Melnikov

Screen startup performance

Intro

Mobile applications usually request some data when opening a new screen (Activity/Fragment). It could be a database query, network request, content provider query, or any other IO operation like reading assets or files. These operations are time-consuming, especially IO operations like network requests or reading large files.

To simplify maintenance and support we create our apps with architecture in mind. The main idea of an exemplary architecture is to split the application into logical layers to simplify code reading and modification.

The main approaches to build a presentation architectural layer are MVVM, MVP, MVC, MVI. They are very similar in that all have Model and View layers. The difference is the way they transfer data from Model to View and back.

Opening a new screen usually contains such steps:

  1. some action triggers new screen creation (click, notification tap, app creation)
  2. we create Activity/Fragment
  3. launch transition animation
  4. inflate layout (XML/Compose) with a progress indicator
  5. onStart create ViewModel/Presenter/Controller and subscribe view to it
  6. start data requests to server/database/assets from ViewModel/Presenter/Controller
  7. wait for data requests to finish and process them
  8. inflate fetched data to the UI
  9. remove a progress indicator

We are used to this approach, and it's quite robust and reliable from the architectural point of view. But can we do better in terms of performance?

Problem

Activity and Fragment creation are expensive operations from the performance perspective, especially Activities. Transition animation also takes some time to render. Layout inflation takes some time to instantiate views.

Notice we start our data requests only on the point 6 (!). Even before the further analysis it's obvious that if points 2-5 take some time, we delay our screen startup at least for this time.

This time could take from tens to hundreds of milliseconds depending on the application complexity and structure. It could be satisfactory for a rarely used screen or app. But for the developers who care about the user's experience, we definitely should do better.

⚠️ todo | Provide some stats

Solution

There are different technics to improve startup. The most explicit is to warm up the cache before the screen opens. For example, Google Chrome and Android APIs let us warmup browser itself and likely to load pages. I suppose the majority of the developers would agree it's a good idea to cache and warmup the data needed for the most often opening screens.

As a downside of this approach, it could be quite resource-consuming to preload everything before the direct action of the user. Battery, traffic, and memory will be the most vulnerable indicators in this case.

As a backup option to get the screen opened faster we could just start the data fetch as soon as possible. At the point 1 of the mentioned above list to be precise.

Assuming that the screen data fetching is an async background process, imagine we start some data fetching process right after the button click. And while the UI thread instantiates fragments, layouts and views we are already doing our job to fetch the data. If the connection is good enough and your server responds quite fast the data could be ready just on time when the UI thread finished its work. It's quite an unusual approach for the most of the apps, but often used apps follow the mentioned approach one way or another.

What if we move our data requests from the point 6 straight to the point 1 where the action in triggered. How could we implement this logic? There are many ways, let's discuss the most straight forward one. So, less theory, more practice.

Simple approach (the idea)

We need to start the data request in parallel with UI instantiation. It's obvious that the most probable place to hold the data is Application Context as it would live outside the UI flow.

When action is triggered (e.g., button click to open the screen), we create our request, schedule it to fetch the data and return some requestId to get the result later.

Let RequestCache be our interface to schedule such requests and store response data.

interface RequestCache {
    fun <T : Any> schedule(call: () -> T): Int
    fun <T : Any> get(requestId: Int): Deferred<T>?
    fun invalidate(requestId: Int)
}

When an action to start a new screen is triggered, we schedule our request and pass requestId as a parameter to the new screen.

button.setOnClickListener { view ->
    val requestId = requestsCache.schedule { /* Make a request call */ }
    screenNavigator.goToScreenB(requestId)
}

Then points 2-6 from the above list happen. After we pass this requestCache to our ViewModel/Presenter/Controller and get our deferred result from cache. For the case when Android recreates app's state after the process death we should fall back to our initial logic to fetch the data.

val requestId: Int = readFromArguments()
val deferredResult = requestCache.get<String>(requestId)
val result = if (deferredResult != null) {
    deferredResult.await()
} else {
    /* Make a request call */
}
/* Update UI with result */

We shouldn't forget about invalidating cache when user closes the screen. Jetpack Lifecycle could help us to detect that the screen is closed and not going to be recreated (configChanges). On this callback we should call invalidate not to leak our memory.

val requestId: Int = readFromArguments()
requestsCache.invalidate(requestId)

⚠️ todo | Downsides to mention:

  • we have to inject code responsible for the request to initial screen
  • we have to remember about the data invalidation
  • could be a bit excessive for rarely used screens

Architectural approach

Now you got the idea. Let's now scale it to our common presentation architectural patterns. Previous approach is quite imperative, and it obliges us to handle everything manually. Let's think of a solution to integrate such approach into all screens. What could we do to generalize the approach mentioned above?

  1. extract screen business logic from UI lifecycle logic
  2. call it in parallel with UI instantiation
  3. bind them together when ready
  4. invalidate cache when screen is closed
  5. keep process kill by Android and recreation in mind

This way all screens will work the same way, and you can generalize it in a common case.

If you use MVP or similar approach, I would suggest to extract presenters from the UI scope. It's quite easy to create and cache them outside lifecycle, and when UI is ready to bind them together.

It's a bit different if you prefer MVVM. MVVM enforces you to work inside UI Lifecycle. It's easy with official guidelines approach, but it doesn't keep performance and sophisticated caching in mind.

MVVM is tightly coupled with Context and UI lifecycle. It was actually created to handle configuration changes. So, it's not the best candidate for our job to start something before UI is ready. For MVVM we should extract our business logic (use cases / interactors / services, name it as you will) and cache it outside. Then, when MVVM is ready to be used we bind them together. I would call it extracting business logic controllers from UI logic.

⚠️ todo | Describe common architectural approach

We should be careful about releasing resources in the cache as soon as we don't need them anymore. For the main screen of the app it could live forever with some refresh mechanism inside, but for the rest of the app we should dispose its data to prevent memory leaks. Lifecycle could help us to be informed when activity is stopped or destroyed.

This way we could achieve performance boost on every screen we open. It would save us tens to hundreds milliseconds each screen.