Shaheer Malliyil

always learning, always coding.

How we measured our Android cold start (and got 15% back from a text file)

ONCOassist is used by oncology clinicians, often in corridors, between consultations, next to patients. Our cold start was around half a second. That is not a disaster, but we had never actually measured it, so we had no idea whether it was 500ms of unavoidable work or 500ms with easy wins hiding inside.

So I did the boring thing first and built the measurement rig, then let the data pick the optimizations.

Macrobenchmark, briefly

Microbenchmarks measure a function inside your process. Macrobenchmark measures the app the way a user experiences it. A separate test APK cold launches your real, R8 minified, release-signed build and times it from process creation to first frame, collecting a Perfetto trace for every iteration.

It lives in its own Gradle module of type com.android.test.

File tree of the macrobenchmark module showing build.gradle, the empty manifest, the generator and benchmark test classes, and the generated baseline profile files under app.

Two details trip people up. The tests live in src/main, because the whole module is the test. The module also declares targetProjectPath ':app' plus a mirror of your app’s product flavors, so variant matching resolves to the right app build.

The part that confused me: two release builds

Applying the androidx.baselineprofile plugin to :app quietly creates two new build types.

  • nonMinifiedRelease has R8 off and is used to generate profiles.
  • benchmarkRelease has R8 on, is release-signed, and is used for measurement.

My first reaction was suspicion. If we generate the profile on an unminified build, are we optimizing the app users actually get?

We are, and the mechanism is worth knowing. Baseline profiles are recorded as source-level method signatures. On every release build, R8 maps those readable names through its own obfuscation mapping and writes a binary profile into the AAB.

A profile recorded on a minified build would only be valid for that one R8 run and useless after it, which is why generation happens on the unminified variant.

Three-step diagram showing a profile recorded on the non-minified build, translated by R8 into every release build, then measured on the minified benchmark build.

Measurement always runs on the minified build, so the numbers below come from an APK that is very close to what ships.

Three setup gotchas

  1. Plugin 1.3.4 does not inject targetAppId. The common template reads the target package from an instrumentation argument that never arrives, and every test throws in under a second. I baked it in per flavor with buildConfigField instead.
  2. Benchmark variants inherit release signing. Ours fails hard without the keystore, so benchmark builds only work on machines that have it. Worth knowing before you plan CI.
  3. With mergeIntoMain set to true and product flavors in play, the per-variant generation tasks disappear. There is one :app:generateBaselineProfile task, and it scoped itself to our production flavor.

The benchmark itself

One test class holds the whole before and after comparison, because Macrobenchmark can control how the app is compiled.

CompilationMode.None()
CompilationMode.Partial(BaselineProfileMode.Require)
  • The first wipes the profile so everything is JIT compiled. That is the worst case first launch a real user can hit.
  • The second installs your profile the way Play would.

Ten cold starts each, on a physical device. Emulator numbers are useless here. Same APK, two compilation states, so the difference isolates the profile’s effect and nothing else.

The result

Median of 10 cold starts, measured as timeToInitialDisplay:

  • No profile: 500.8 ms
  • With baseline profile: 427.1 ms

That is 73.7ms faster, or 14.7%, from committing a generated text file. It sits in the lower half of Google’s advertised 15 to 30% range, and for a reason. 500ms was already a fast start, and profiles help most where there is a lot of JIT work to remove.

Bar chart scoreboard showing cold start falling from 500.8ms with no profile to 427.1ms with a baseline profile, plus the later stages of the series.

Next

That is the setup and the first win. Getting the profile generated took eight attempts and most of a day.

That included a signed, installable APK that was missing 2,598 of its own classes, and a corrupted build cache entry that survived ./gradlew clean. That is Part 2.

Measured on a Pixel 7 Pro running Android 16, productionBenchmarkRelease, Jetpack Macrobenchmark 1.4.1, medians of 10 cold start iterations.

Leave a Reply

Your email address will not be published. Required fields are marked *