Lesson 80 of 83 advanced

Baseline Profiles, Macrobenchmark & Startup Optimization

Eliminate jank and slash startup time with AOT compilation hints and systematic benchmarking

Open interactive version (quiz + challenge)

Real-world analogy

Without baseline profiles, your app's first launch is like a chef who has to read the recipe for every dish while customers are waiting. Baseline profiles are like the chef prepping ingredients the night before — the most common dishes (critical user journeys) are pre-prepared so they are served instantly. Macrobenchmark is your stopwatch to measure exactly how long the chef takes, and Perfetto is the security camera that shows you exactly where the chef wastes time.

What is it?

Baseline profiles are files that tell the Android Runtime (ART) which parts of your app to compile ahead-of-time during installation, rather than just-in-time during execution. Macrobenchmark is a testing library that measures real-world performance metrics (startup time, frame timing) on actual devices. Together, they form the foundation of Android startup optimization — profiles eliminate JIT jank, and benchmarks prove the improvement with hard numbers.

Real-world relevance

A news app had a 2.1-second cold start on mid-range devices. Perfetto traces revealed: 600ms in ContentProvider initialization (6 libraries auto-initializing via separate ContentProviders), 400ms in Dagger setup, 300ms in first Compose frame JIT compilation, and 800ms loading initial data synchronously. The fixes: (1) Migrated to App Startup library — consolidated 6 ContentProviders into 1, saving 450ms. (2) Generated baseline profiles covering startup + first article scroll — eliminated Compose JIT jank, saving 280ms. (3) Parallelized data loading with async/coroutines and showed skeleton UI — TTID dropped to 600ms with TTFD at 1.1s. Total improvement: 2.1s to 0.6s TTID.

Key points

Code example

// 1. Module setup — :macrobenchmark/build.gradle.kts
plugins {
    id("com.android.test")
    id("androidx.baselineprofile")
}

android {
    namespace = "com.example.benchmark"
    targetProjectPath = ":app"
    experimentalProperties["android.experimental.self-instrumenting"] = true
}

dependencies {
    implementation("androidx.benchmark:benchmark-macro-junit4:1.2.3")
    implementation("androidx.test.ext:junit:1.1.5")
}

// 2. Baseline Profile Generator
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generateStartupProfile() {
        rule.collect(
            packageName = "com.example.app",
            maxIterations = 5,
            stableIterations = 3
        ) {
            // Cold start — measures startup path
            pressHome()
            startActivityAndWait()

            // Critical user journey — scroll main feed
            device.findObject(By.res("feed_list"))
                .also { list ->
                    list.setGestureMargin(device.displayWidth / 5)
                    list.fling(Direction.DOWN)
                    list.fling(Direction.DOWN)
                }

            // Navigate to detail screen
            device.findObject(By.res("article_card"))
                .click()
            device.waitForIdle()
        }
    }
}

// 3. Startup Benchmark
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val rule = MacrobenchmarkRule()

    @Test
    fun startupCold() {
        rule.measureRepeated(
            packageName = "com.example.app",
            metrics = listOf(StartupTimingMetric()),
            compilationMode = CompilationMode.DEFAULT(),
            startupMode = StartupMode.COLD,
            iterations = 10
        ) {
            pressHome()
            startActivityAndWait()
        }
    }

    @Test
    fun startupWithBaselineProfile() {
        rule.measureRepeated(
            packageName = "com.example.app",
            metrics = listOf(StartupTimingMetric()),
            compilationMode = CompilationMode.Partial(
                baselineProfiles = listOf("baseline-prof.txt")
            ),
            startupMode = StartupMode.COLD,
            iterations = 10
        ) {
            pressHome()
            startActivityAndWait()
        }
    }

    @Test
    fun scrollPerformance() {
        rule.measureRepeated(
            packageName = "com.example.app",
            metrics = listOf(FrameTimingMetric()),
            compilationMode = CompilationMode.DEFAULT(),
            iterations = 5
        ) {
            pressHome()
            startActivityAndWait()

            val list = device.findObject(By.res("feed_list"))
            list.setGestureMargin(device.displayWidth / 5)
            repeat(3) {
                list.fling(Direction.DOWN)
                device.waitForIdle()
            }
        }
    }
}

// 4. App Startup library — replace multiple ContentProviders
class AnalyticsInitializer : Initializer<Analytics> {

    override fun create(context: Context): Analytics {
        return Analytics.init(context, BuildConfig.ANALYTICS_KEY)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return emptyList() // No dependencies
    }
}

class ImageLoaderInitializer : Initializer<ImageLoader> {

    override fun create(context: Context): ImageLoader {
        return ImageLoader.Builder(context)
            .diskCachePolicy(CachePolicy.ENABLED)
            .memoryCache {
                MemoryCache.Builder(context)
                    .maxSizePercent(0.25)
                    .build()
            }
            .build()
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return listOf(AnalyticsInitializer::class.java)
    }
}

// AndroidManifest.xml — App Startup provider
// <provider
//     android:name="androidx.startup.InitializationProvider"
//     android:authorities="${applicationId}.androidx-startup"
//     android:exported="false"
//     tools:node="merge">
//     <meta-data
//         android:name="com.example.app.AnalyticsInitializer"
//         android:value="androidx.startup" />
//     <meta-data
//         android:name="com.example.app.ImageLoaderInitializer"
//         android:value="androidx.startup" />
// </provider>

// 5. reportFullyDrawn() for TTFD tracking
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val viewModel: MainViewModel by viewModels()

        setContent {
            val uiState by viewModel.uiState.collectAsState()

            when (uiState) {
                is UiState.Loading -> SkeletonScreen()
                is UiState.Success -> {
                    MainScreen(data = (uiState as UiState.Success).data)
                    // Report fully drawn after content is ready
                    LaunchedEffect(Unit) {
                        reportFullyDrawn()
                    }
                }
                is UiState.Error -> ErrorScreen()
            }
        }
    }
}

Line-by-line walkthrough

  1. 1. BaselineProfileRule() — JUnit rule that manages device setup, app installation, and trace collection for profile generation
  2. 2. rule.collect(packageName, maxIterations = 5, stableIterations = 3) — runs the profile collection; stops after results stabilize for 3 consecutive iterations or reaches 5 max
  3. 3. pressHome(); startActivityAndWait() — simulates cold start by going home first, then launching the app and waiting for the first frame to render
  4. 4. device.findObject(By.res('feed_list')).fling(Direction.DOWN) — exercises the scroll path so those methods get included in the baseline profile
  5. 5. MacrobenchmarkRule().measureRepeated(...) — runs the benchmark multiple times and reports statistical results (median, P50, P90, P99)
  6. 6. StartupTimingMetric() — measures time-to-initial-display (TTID) and time-to-fully-drawn (TTFD) in milliseconds with high precision
  7. 7. CompilationMode.DEFAULT() — uses whatever profiles are installed, simulating real-world conditions; CompilationMode.None() is fully interpreted (worst case)
  8. 8. Initializer.create(context) — App Startup calls this lazily when the component is first needed; dependencies() declares initialization ordering
  9. 9. tools:node='merge' — merges App Startup's ContentProvider with any library-declared providers; use tools:node='remove' to disable a library's auto-initialization
  10. 10. reportFullyDrawn() — signals to the system (and Macrobenchmark) that all async content is loaded and the screen is fully interactive; tracked as TTFD in Play Vitals
  11. 11. LaunchedEffect(Unit) { reportFullyDrawn() } — calls reportFullyDrawn when the Success composable first enters composition; Unit key ensures it runs only once

Spot the bug

// Startup benchmark — always shows 0ms
@Test
fun benchmarkStartup() {
    rule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(FrameTimingMetric()),  // Wrong metric
        compilationMode = CompilationMode.Full(),  // Unrealistic
        startupMode = StartupMode.HOT,  // Not cold start
        iterations = 1  // Too few iterations
    ) {
        startActivityAndWait()  // Missing pressHome()
    }
}

// App Startup — circular dependency
class AInitializer : Initializer<A> {
    override fun create(context: Context): A = A()
    override fun dependencies() = listOf(BInitializer::class.java)
}

class BInitializer : Initializer<B> {
    override fun create(context: Context): B = B()
    override fun dependencies() = listOf(AInitializer::class.java)
}
Need a hint?
Five issues in the benchmark: wrong metric, unrealistic compilation mode, wrong startup mode, too few iterations, and missing pressHome(). Plus a circular dependency in App Startup.
Show answer
Bug 1: FrameTimingMetric measures frame duration, not startup time — use StartupTimingMetric() for measuring startup. Bug 2: CompilationMode.Full() AOT compiles everything, which is unrealistic — real users have partial compilation. Use CompilationMode.DEFAULT() or CompilationMode.Partial(). Bug 3: StartupMode.HOT measures bringing an existing Activity to front, not actual cold startup — use StartupMode.COLD. Bug 4: iterations = 1 gives unreliable data — use at least 5-10 iterations for statistical significance. Bug 5: Missing pressHome() before startActivityAndWait() means the app may already be in the foreground, making the cold start measurement invalid. Bug 6: AInitializer depends on BInitializer which depends on AInitializer — circular dependency causes a stack overflow at startup. Fix: restructure so one initializer does not depend on the other, or merge them.

Explain like I'm 5

Imagine it is your first day at a new school. Without baseline profiles, you have to figure out where every classroom is while running between classes — you are late to everything! With baseline profiles, someone gave you a map the night before showing the 5 most important rooms you will visit. You practiced the routes, so on your first day you walk straight there without getting lost. Macrobenchmark is your friend with a stopwatch timing how fast you get to each class. Perfetto is a drone following you and recording every wrong turn so you can improve tomorrow.

Fun fact

Google reported that apps with baseline profiles see a 30% improvement in time-to-initial-display on average. The Google Maps app was one of the first to extensively use baseline profiles — they measured a 40% reduction in cold start time and a 60% reduction in jank frames during first map render. The Perfetto tracing tool (used for startup analysis) was named after the Italian word for 'perfect' — fitting, since it provides near-perfect nanosecond-resolution visibility into what your app is doing during startup.

Hands-on challenge

Set up a complete performance optimization pipeline for an existing app: (1) Create a :macrobenchmark module with proper build.gradle configuration. (2) Write a BaselineProfileGenerator test that covers cold start, main feed scrolling, and detail screen navigation. (3) Write benchmark tests measuring cold/warm/hot start with StartupTimingMetric and scroll performance with FrameTimingMetric. (4) Implement the App Startup library to consolidate 3 library initializations (analytics, image loader, feature flags) into a single ContentProvider with proper dependency ordering. (5) Add reportFullyDrawn() to your main Activity after async data loads. (6) Document how to capture and analyze a Perfetto trace for the startup path.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery