Free weekly email: the week's new posts, videos and one tip from the books, every Sunday. Get it free →
Homelab the Right Way

Home › Posts

Your app starts slow after every update: generate a Baseline Profile and prove it is applied

After every install and update, Android runs your code interpreted and JIT-compiled until the device compiles it in the background. A Baseline Profile generated with the androidx.baselineprofile plugin makes your critical journeys fast from the first launch, and three checks prove it actually reached the device.

· 4 min read · androidperformancebaseline profilesjetpack composemacrobenchmarkkotlin

Your app starts slow after every update: generate a Baseline Profile and prove it is applied

A Baseline Profile is the single change most likely to make an existing Compose app start faster and scroll more smoothly without touching its logic. It is a list of the classes and methods your app needs on its most important paths, shipped inside the app, so the runtime can compile them ahead of time on install instead of discovering them slowly on the user's phone. This post generates one with the Baseline Profile Gradle plugin and then proves, three ways, that it is applied.

The trap

Your Kotlin compiles to DEX bytecode. The Android Runtime (ART) can interpret it, JIT-compile hot methods while the app runs, or run machine code compiled ahead of time (AOT). ART does not AOT-compile your whole app on install. It records which methods were used into a profile and compiles them later, when the device is idle and charging.

That works eventually, and "eventually" is the problem. After every install and every update, users start in the slow path until the device has recorded enough and found a quiet moment to compile it. Google Play's Cloud Profiles help later installs of a release, but they need time and users to build up, so they do nothing for the first users of each new version.

Jetpack libraries, Compose included, ship their own profiles, which is why a plain Compose app already benefits a little. Library profiles only cover library code, though. Your screens, ViewModels and repositories are in nobody's profile but yours. Google's documentation states that Baseline Profiles improve code execution speed by about 30% from the first launch for the code paths they include; your own gain depends on your app, and Macrobenchmark tells you what it is.

Step 1: the generator module

The Baseline Profile Gradle plugin (androidx.baselineprofile, 1.5.0) does the plumbing. Android Studio's Baseline Profile Generator module template (File > New > New Module, Android Studio Iguana or newer, AGP 8.2 or newer) writes this for you. The generator is a com.android.test module that points at the app:

// baselineprofile/build.gradle.kts
plugins {
    id("com.android.test")
    id("org.jetbrains.kotlin.android")
    id("androidx.baselineprofile")
}
android {
    namespace = "com.example.fieldnotes.baselineprofile"
    compileSdk = 36
    defaultConfig {
        minSdk = 28
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    targetProjectPath = ":app"
}
dependencies {
    implementation("androidx.benchmark:benchmark-macro-junit4:1.5.0")
    implementation("androidx.test.uiautomator:uiautomator:2.4.0")
}

Generation needs root on the device, except on Android 13 (API 33) and newer, where Macrobenchmark can collect profiles without root. A Gradle-managed device with systemImageSource = "aosp" works everywhere because AOSP images allow root.

The app module applies the same plugin, depends on the generator, and includes ProfileInstaller:

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("androidx.baselineprofile")
}
dependencies {
    implementation("androidx.profileinstaller:profileinstaller:1.4.1")
    baselineProfile(project(":baselineprofile"))
}
baselineProfile {
    saveInSrc = true      // commit the generated profile
    mergeIntoMain = true  // one profile for all variants
}

ProfileInstaller matters for installs that do not come from Google Play: sideloaded builds, internal testing installs and other stores. There, Play is not around to hand the profile to ART, so ProfileInstaller writes it on the app's first launch.

Step 2: write the journeys

The generator is an instrumented test with BaselineProfileRule. It drives the app through the paths users take every day, and the rule records every class and method that ran:

@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test fun generate() = rule.collect(
        packageName = "com.example.fieldnotes",
        includeInStartupProfile = true,
    ) {
        pressHome()
        startActivityAndWait()
        device.wait(Until.hasObject(By.res("notes_list")), 5_000)

        val list = device.findObject(By.res("notes_list"))
        list.setGestureMargin(device.displayWidth / 5)
        list.fling(Direction.DOWN)
        device.waitForIdle()

        device.findObject(By.res("note_row")).click()
        device.wait(Until.hasObject(By.res("editor_body")), 5_000)
        device.pressBack()
    }
}

The test finds elements by resource id, so in Compose expose test tags as resource ids once near the root with Modifier.semantics { testTagsAsResourceId = true } and tag the elements the generator needs with Modifier.testTag("notes_list").

Good journeys follow a few rules. Cover what users actually do, most common first. Wait for content, because if the journey moves on before the list loads, the list's code is not in the profile. Keep it deterministic with local or seeded data rather than a live server. And never sign in to real accounts from a generator.

Step 3: generate and commit

Run the plugin's task, not the test directly:

./gradlew :app:generateBaselineProfile

The plugin builds the app, runs the generator and copies the result into your sources:

app/src/main/generated/baselineProfiles/baseline-prof.txt
app/src/main/generated/baselineProfiles/startup-prof.txt

Commit both. Every release build compiles them into the app, and you will find the binary form under assets/dexopt/baseline.prof in the AAB or APK. The rules inside are plain text: HSPLcom/example/fieldnotes/ui/NotesViewModel;->loadNotes()V, where H means hot, S startup and P post-startup.

The second file is a Startup Profile, produced because of includeInStartupProfile = true. It tells R8 to put every class needed at startup into the primary DEX file. It needs R8 enabled for release (isMinifyEnabled = true) and DEX layout optimization, which is on by default from AGP 8.3.

Step 4: prove it is applied

A profile that does not reach the device is just a file in a repository.

Benchmark it. Compare a fresh install with no ahead-of-time compilation against one that requires your profile and fails loudly if the app has none:

@Test fun startupNoCompilation() = startup(CompilationMode.None())

@Test fun startupBaselineProfile() = startup(
    CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))

Run both on the same physical device and compare the median timeToInitialDisplayMs.

Check the device by hand:

PKG=com.example.fieldnotes
adb install app-release.apk
adb shell am broadcast \
  -a androidx.profileinstaller.action.INSTALL_PROFILE \
  $PKG/androidx.profileinstaller.ProfileInstallReceiver
adb shell am force-stop $PKG
adb shell cmd package compile -f -m speed-profile $PKG
adb shell dumpsys package dexopt | grep -A 1 $PKG

The broadcast prints "1" on success, and the dexopt status should read speed-profile.

Ask from inside the app. ProfileVerifier.getCompilationStatusAsync() reports whether the profile was installed and compiled (isCompiledWithProfile, hasProfileEnqueuedForCompilation()). Log it in internal builds; "enqueued" is normal right after an install from outside Play.

Checklist

  • The generator module applies androidx.baselineprofile and targets :app.
  • The app depends on ProfileInstaller and on the generator through baselineProfile(...).
  • Journeys cover launch, the main list, opening an item, and wait for content.
  • baseline-prof.txt and startup-prof.txt are committed.
  • None versus Partial(Require) shows a measured gap on a real device.
  • dumpsys package dexopt reads speed-profile.
  • The profile is regenerated after screen redesigns, major Compose upgrades and before releases.

More articles