Pre-Cooking the First Launch: What Baseline Profiles Buy You, and What They Don’t
DEV Community

Pre-Cooking the First Launch: What Baseline Profiles Buy You, and What They Don’t

Every Android team eventually has the same conversation. Startup looks fine on the developer's phone, the Play Console vitals say otherwise, and someone says, "Isn't there a profile thing for this?"

There is, and it works. It is also routinely oversold, mis-measured, and left to rot. This post covers the mechanism, the generation workflow, honest measurement, and the failure modes that cost real teams real time.

The running example is Tidewatch, a fictional tide-and-fishing-forecast app (com.tidewatch.app) with a Compose UI, a Room-backed repository, and a forecast list that is the first thing users see.

In short: a Baseline Profile lets ART compile your hot startup and scroll code before the user ever opens the app. It shrinks the interpreter and JIT warm-up phase on a fresh install. It does not reduce the amount of work your app does, fix a bloated Application.onCreate, or help much once the process is already alive.

Why a fresh install is slower

When you tap an icon on a freshly installed app, almost none of your code exists as native machine code. Your APK ships DEX bytecode, and ART has to decide how to run it.

ART replaced Dalvik as the default runtime in Android 5.0, where apps were compiled ahead of time at install. Android 7 added a JIT compiler and profile-guided compilation, and that hybrid strategy is what a fresh install runs into today:

  1. Interpret. Bytecode executes instruction by instruction. There is no compile step, so it starts immediately, but it is the slowest way to run code.
  2. JIT-compile hot methods. While the app runs, ART counts method invocations and loop iterations. Methods that cross a threshold get compiled to native code in-process. That compilation costs CPU on a device that is already busy launching your app, and the result is thrown away when the process dies.
  3. Record a profile. ART writes down which methods ran, so it knows what mattered.
  4. Compile ahead of time, later. When the device is idle and charging, a background dexopt job reads that profile and compiles those methods to native code with the speed-profile filter. Subsequent launches load native code directly.

Step 4 is the catch. It needs your app to have already run, and it waits for a quiet moment. A brand-new install, or an update that invalidates previous compilation, starts back at step 1. For a user's first impression, that is exactly the wrong moment to be slow.

Play can also distribute cloud profiles aggregated from other users, but those arrive after enough people have used a given version. They help later adopters, not the first launches of a new release.

The effect on the first launch looks like this: The diagram is deliberately a shape, not data. The size of the gap depends on how much code sits on your launch path, how heavy it is, and how slow the device is.

Google's documentation cites launch-time improvements of up to around 30% for apps using profiles, but treat that as a ceiling from well-suited apps, not a promise. We'll measure ours properly in section 4.

What a profile does not change

Say this out loud on your team before anyone promises a number to a product manager:

  • It does not remove work. If Application.onCreate initializes eleven SDKs on the main thread, they will still all run. They will just run as compiled code.
  • It does not fix I/O, network, or lock contention. A blocking disk read costs the same either way.
  • It does little for hot starts, where the code is already loaded and warm.
  • It fades over time for users who have already run the app for days, because the background dexopt job eventually reaches a similar state on its own.

The profile's value concentrates on first launches after install or update.

What a Baseline Profile is, and how it's packaged

A Baseline Profile is a list of classes and methods, written as human-readable rules, that tells ART which code to compile ahead of time. In your source tree it lives in src/main/baseline-prof.txt:

HSPLcom/tidewatch/forecast/TideRepository;->loadForecast(Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow;
HSPLcom/tidewatch/ui/ForecastListKt;->ForecastList(Ljava/util/List;Landroidx/compose/runtime/Composer;I)V
Lcom/tidewatch/app/TidewatchApplication;

Each line is a flag prefix, a class descriptor, and optionally a method name with its JVM signature: H marks a method as hot, S as startup, and P as post-startup. A bare class descriptor asks for the class to be preloaded. Names and signatures must match what ships in the final DEX.

At build time, the Android Gradle Plugin merges your rules with rules shipped by your dependencies (more on those in section 6), lets R8 rewrite names to match any obfuscation or optimization, and emits a binary baseline.prof plus a baseline.profm metadata file. These end up under assets/dexopt/ in the APK, or in the bundle metadata for an AAB.

On the device, who applies it depends on how the app arrived:

  • Installed through Play (on supporting Android versions), the installer hands the profile to ART so the compile happens as part of install.
  • Sideloaded, or on versions where the platform doesn't do this for you, the androidx.profileinstaller library detects the packaged profile on first run and schedules its installation.

That second path is why you should keep ProfileInstaller in your dependencies even if "everyone installs from Play." Internal test builds, enterprise distribution and some stores don't take the Play path.

Generating one with Macrobenchmark

You don't write these files by hand. You describe the user journeys that matter, record them on a device, and let tooling emit the rules.

The Gradle plugin androidx.baselineprofile standardizes this into two modules: your app (the consumer) and a com.android.test module (the producer) that contains the generator. Android Studio has a "Baseline Profile Generator" module template that scaffolds both.

Producer module (:baselineprofile/build.gradle.kts):

plugins {
  id("com.android.test")
  id("org.jetbrains.kotlin.android")
  id("androidx.baselineprofile")
}
android {
  namespace = "com.tidewatch.baselineprofile"
  targetProjectPath = ":app"
}
baselineProfile {
  // Use a real, connected device rather than a managed emulator.
  useConnectedDevices = true
}
dependencies {
  implementation(libs.androidx.test.ext.junit)
  implementation(libs.androidx.test.uiautomator)
  implementation(libs.androidx.benchmark.macro.junit4)
}

Consumer module (:app/build.gradle.kts):

plugins {
  id("com.android.application")
  id("org.jetbrains.kotlin.android")
  id("androidx.baselineprofile")
}
dependencies {
  implementation(libs.androidx.profileinstaller)
  baselineProfile(project(":baselineprofile"))
}
baselineProfile {
  saveInSrc = true // commit the result so reviewers can diff it
  dexLayoutOptimization = true
}

Now the part that determines whether the profile is any good: the journey. The generator should exercise what users actually do in their first minute, not a synthetic tour of every screen. Here is a pattern that avoids the most common silent failure, a generator that "passes" while interacting with nothing.

import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

private const val TARGET = "com.tidewatch.app"

/** Waits for a view or fails the test loudly instead of returning null. */
fun UiDevice.awaitObject(selector: BySelector, timeoutMs: Long = 5_000): UiObject2 =
  checkNotNull(wait(Until.findObject(selector), timeoutMs)) {
    "Timed out after ${timeoutMs}ms waiting for $selector"
  }

/** One shared definition of "what users do first", used by generator and benchmarks alike. */
fun MacrobenchmarkScope.forecastJourney() {
  pressHome()
  startActivityAndWait()
  val list = device.awaitObject(By.res(TARGET, "forecast_list"))
  list.setGestureMargin(device.displayWidth / 5)
  list.fling(Direction.DOWN)
  device.waitForIdle()
  device.awaitObject(By.res(TARGET, "tide_chart_first_item")).click()
  device.waitForIdle()
}

@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
  @get:Rule val rule = BaselineProfileRule()
  @Test fun generate() = rule.collect(
    packageName = TARGET,
    includeInStartupProfile = true,
  ) {
    forecastJourney()
  }
}

Two things worth calling out:

  • Compose and By.res. UI Automator can only find Compose test tags if the root opts in with Modifier.semantics { testTagsAsResourceId = true }. Without it, By.res("forecast_list") finds nothing. If your generator uses a nullable lookup, the fling never happens, the test still passes, and your profile quietly omits every scroll path. awaitObject above turns that into a failing test.
  • Sharing the journey. Defining forecastJourney() once and reusing it in benchmarks means you measure the paths you optimized, and when someone changes the UI, both break together.

Run it with the generated task (the exact name follows your build variants), for example ./gradlew :app:generateBaselineProfile. The plugin runs the journey repeatedly until results are stable, then writes baseline-prof.txt into your source set.

Verifying the profile actually reached the device

"The file exists in git" is not evidence. Check each hop:

# 1. Is it in the artifact?
unzip -l app-release.apk | grep -E "baseline\.prof"
# 2. What compilation state did ART record for the installed app?
adb shell dumpsys package dexopt | grep -A2 com.tidewatch.app
# look for: [status=speed-profile]

For a runtime check, such as in a debug diagnostics screen or a log line in QA builds, ProfileVerifier reports what happened. getCompilationStatusAsync() takes no arguments; the Context-taking method is writeProfileVerification(context), which ProfileInstallerInitializer calls for you at startup. If you disabled that initializer, call writeProfileVerification yourself a few seconds after launch, or the future will hang or time out. The verifier also needs API 28 or higher and returns an "unsupported API version" code below that.

With kotlinx-coroutines-guava, the returned future becomes a suspend call. await() is non-blocking, so no dispatcher switch is needed:

import androidx.profileinstaller.ProfileVerifier
import androidx.profileinstaller.ProfileVerifier.CompilationStatus
import kotlinx.coroutines.guava.await

suspend fun describeProfileState(): String {
  val status = ProfileVerifier.getCompilationStatusAsync().await()
  return when (status.profileInstallResultCode) {
    CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled with profile"
    CompilationStatus.RESULT_CODE_PROFILE_ENQUEUED_FOR_COMPILATION -> "Profile installed, compilation pending"
    CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING -> "Compiled, but the profile was smaller than expected"
    CompilationStatus.RESULT_CODE_NO_PROFILE -> "No profile in this build"
    CompilationStatus.RESULT_CODE_ERROR_UNSUPPORTED_API_VERSION -> "Verifier needs API 28+"
    else -> "Other state: ${status.profileInstallResultCode}"
  }
}

The "enqueued" state matters: a sideloaded install can be correct but not yet compiled, which will look like "the profile did nothing" if you benchmark too early.

Measuring honestly

A profile is a performance change, so it gets the same standard as any other: a controlled comparison, enough repetitions, and a metric that matches what users feel.

The benchmark setup Macrobenchmark runs in its own module against a non-debuggable, release-like build of your app. The plugin template creates a benchmark build type that inherits from release, is signed with the debug key for local runs, and keeps the app profileable so tracing still works.

The central trick is CompilationMode, which controls what compilation state the app is forced into before each run:

The honest experiment is two arms of the same test, differing only in compilation mode:

@RunWith(AndroidJUnit4::class)
class StartupBenchmarks {
  @get:Rule val rule = MacrobenchmarkRule()
  @Test fun coldStartNoProfile() = coldStart(CompilationMode.None())
  @Test fun coldStartWithProfile() = coldStart(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
  private fun coldStart(mode: CompilationMode) = rule.measureRepeated(
    packageName = TARGET,
    metrics = listOf(StartupTimingMetric()),
    compilationMode = mode,
    startupMode = StartupMode.COLD,
    iterations = 15,
    setupBlock = { pressHome() },
  ) {
    startActivityAndWait()
  }
}

Using BaselineProfileMode.Require is deliberate. If the profile is missing or failed to install, the test errors instead of silently falling back to a "with profile" number that is really the no-profile number.

Choosing metrics

StartupTimingMetric reports timeToInitialDisplayMs (first frame) and, if you call reportFullyDrawn(), timeToFullDisplayMs. Initial display alone can flatter you: a splash that paints fast while data loads in the background looks great. Report fully-drawn time too. In Compose, ReportDrawnWhen { state is ForecastState.Loaded } ties it to real content.

FrameTimingMetric reports frameDurationCpuMs and, on newer API levels, frameOverrunMs. Use it for the scroll journey, where precompiled code reduces jank from first-time execution of list and animation paths.

@Test fun scrollForecast() = rule.measureRepeated(packageName = TARGE
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.