Improving our Android app's startup time by 20%

How Baseline Profiles helped the Todoist phone and Wear OS apps get faster easily

By Afzal Najam

In his excellent Android Dev Summit ’22 talk about Baseline Profiles, Rahul Ravikumar convinced me to adopt them and reap the benefits. In the process, we also set up Macrobenchmark to measure the performance of our apps. For the most part, it was as easy to do as I thought, but throwing our Wear OS app into the mix added a few complications.

What are Baseline Profiles?

A baseline profile is a file containing a list of classes and methods downloaded alongside your app when you install it from the Google Play Store. It is then used to compile those classes and methods ahead of time, resulting in startup, frame timing, and general performance improvements.

Ahead-of-time compilation was introduced back in Android 5.0 (Lollipop), but before Baseline Profiles, there were always caveats with each strategy:

  1. Complete ahead-of-time compilation took up too much installation time and disk space, since all methods and classes were compiled ahead of time.
  2. Profile Guided Optimization had poor first launch performance and was specific only to the installed device and app version (since Android 7.0 with ART ).
  3. Play Cloud Profiles alone can take weeks to collect and generate an aggregated profile. It also requires profile regeneration with each app version (since Android 9.0).

The talk from Android Dev Summit ’22 mentioned earlier is a must-watch to learn about the history and trade-offs of these strategies!

So what’s new?

Well, with this new way of creating Baseline Profiles, these caveats are no longer applicable because now, you, the developer, can ship a profile with the app from day 1. We tried this for the Todoist app last month and the results certainly put a smile on our faces.

The improvements

We have two Android apps for Todoist , the phone/tablet app and the Wear OS app. We measured the time to initial display (the startup time), duration of each frame (frame duration), and time by which a given frame missed its deadline (frame overrun time). Frame overrun time is available from API 31+ (Android 12), but the Google Pixel Watch runs Android 11, so we weren’t able to measure it for the Wear OS app.

We used the very useful Benchart tool to convert the benchmark results into a much more readable format, and further verified our results using the Google Spreadsheet template from Py’s blog post on Statistically Rigorous Android Macrobenchmarks .

Startup time

Left: Without Baseline Profiles, Right: With Baseline Profiles

For the Todoist phone app, we saw a median improvement of about 23% in startup time, shaving off 328ms. The maximum startup time for the build with Baseline Profile was less than the minimum time of the build without it, ensuring a faster startup no matter what.

Todoist phone app startup times
Todoist phone app startup times
✅ min : After performed 20.6% better (-283.05ms)
✅ median : After performed 22.95% better (-328.05ms)
✅ max : After performed 27.94% better (-436.4ms)

Left: Without Baseline Profiles, Right: With Baseline Profiles

At almost 14%, the improvement for the Wear OS app wasn’t as drastic, but we were still able to save about 267 ms in the median startup time. Now, our worst startup time is better than the times most users were previously experiencing!

Todoist Wear OS app startup times
Todoist Wear OS app startup times
✅ min : After performed 19.96% better (-364.9ms)
✅ median : After performed 13.94% better (-267.7ms)
✅ max : After performed 8.5% better (-173.85ms)

Frame duration

We saw some improvements in frame duration for the Todoist phone app. The longest 95th percentile frames improved by 19.96% (11.15ms) but otherwise the median duration was unnoticeably better.

Todoist phone app frame durations
Todoist phone app frame durations
✅ P50 : After performed 3.38% better (-0.35ms)
✅ P90 : After performed 4.05% better (-1.2ms)
✅ P95 : After performed 19.96% better (-11.15ms)
✅ P99 : After performed 7.22% better (-12.3ms)

On the Wear OS app, we observed a similar level of improvement. The median frame duration only improved by 1.6%, but we saw a significant uptick in the extreme — frames took almost 35% less time in the 99th percentile.

Todoist Wear OS app frame durations
Todoist Wear OS app frame durations
✅ P50 : After performed 1.61% better (-0.3ms)
❌ P90 : After performed 5.16% worse (+2.45ms)
✅ P95 : After performed 10.45% better (-10.7ms)
✅ P99 : After performed 34.62% better (-141.5ms)

Frame overrun time

Android documentation describes frame overrun time as:

How much time a given frame missed its deadline by. Positive numbers indicate a dropped frame and visible jank / stutter, negative numbers indicate how much faster than the deadline a frame was.

If this number is greater than 0, the overall smoothness of the app is affected negatively. Thankfully, in our case the median frame overrun time was already negative and didn’t see much change (only 0.7ms) but even here, we saw improvements for the frames taking too long.

Todoist phone app frame overrun times
Todoist phone app frame overrun times
✅ P50 : After performed 2.08% better (-0.1ms)
✅ P90 : After performed 20.53% better (-3.85ms)
✅ P95 : After performed 45.86% better (-34.9ms)
✅ P99 : After performed 3.43% better (-9.05ms)

Implementing it for Todoist and Wear

We have separate modules for the Todoist phone and Wear OS apps. After experimenting with having a single benchmark module for both, we ended up creating a dedicated module for each app to make it simpler to specify the targetProjectPath that Android Test modules require. It was also a better approach to use Macrobenchmark instrumentation arguments for generating a baseline profile from the terminal.

The official documentation to set up a Macrobenchmark module and create Baseline Profiles came in very handy. Since we were already creating a new benchmark build type, it was easy to switch the network environment for these benchmarks to our staging server in that build type. This was very useful because, on the phone, we’re going through the sign-up, onboarding, and new task user flows.

For structuring the tests, we used the Robot pattern .

We also found that using collectStableBaselineProfile with a maxIterations of 7 for both apps yielded slightly better results for startup time than just using collectBaselineProfile.

Creating Baseline Profiles and measuring the performance on the phone app was straight-forward for the most part. The Wear OS app came with some challenges, though.

Challenges

Wear OS emulator on Apple Silicon

The phone app uses Gradle Managed Devices to generate the profile using a headless emulator. However, the Wear app needs a manually launched emulator, since there is no support for Wear Gradle Managed Devices yet.

Yet another challenge with generating Baseline Profiles on a Wear OS emulator is that it doesn’t work if the host machine is an M1 Mac. This is a reported issue . Our workaround was to use a separate x86 computer to host the Wear OS emulator, using a technique described in How to use Android’s x86 emulators on M1 Macs .

Typing with UI Automator on Wear OS

Typing on Wear OS is different from the phone. When you want to prompt the user to type something, you send an Intent with android.support.wearable.input.action.REMOTE_INPUT action and the system shows these screens.

Wear OS input screens
Wear OS input screens

So we need to find these Views without having their IDs, including the keyboard. It turns out that the content description of the keyboard button is Keyboard, of course. For typing itself, we have to find the EditText using a By.clazz selector, and finally to tap the “send” button, we need to use the Send content description. Here’s what that looks like in code:

fun submitText(text: String) {
    val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
    device.findObject(By.descContains("Keyboard")).click()

    val editText = device.findObject(By.clazz("android.widget.EditText"))
    editText.text = text
    device.findObject(By.descContains("Send")).click()
}

Running benchmarks on a Google Pixel watch

While the above issues were with the emulator, the benchmark was also very flaky on my Google Pixel watch, even when it was working perfectly on the emulator. Here are some ways to avoid that.

  1. Enable the “Stay awake when charging” option in Developer Options.
  2. Account for accidental double-taps. There might be times when a previous click goes through as a double-tap and UI Automator isn’t able to find the element you’re looking for anymore. Allowing the test to continue if some UI elements aren’t found made it more resilient for us.

Running benchmarks over Wi-Fi

Wireless debugging did not work very well for running the benchmark on the phone, due to connection drops, whereas wired debugging worked perfectly.

When to update Baseline Profiles

While work is ongoing to add support for CI, it isn’t strictly necessary to generate Baseline Profiles very often. Since the profile contains a list of classes and methods, unless there’s some refactor or new screens in the signup, onboarding, or the main content screen, there won’t be a significant difference in performance.

For now, we have decided to generate them manually at every release, but only if necessary based on the changes. In the future, we plan to run it automatically in CI.

Future

Baseline Profiles pushed us to start measuring our performance using the Macrobenchmark library. Now that we have that set up, we can work towards continuously monitoring the performance of our apps.

In the future, we’d like to set up an automated pipeline that stores the benchmark results from the last update, generates a new baseline, runs benchmarks, and compiles a comparison of the benchmark results between releases. There’s a sample from Google showing exactly how to do it using GitHub Actions and Firebase Test Lab, so it’s just a matter of time.