Baseline Profiles and R8: Advanced Android Performance Tuning

Eliminate cold start jank with Baseline Profiles for AOT compilation and R8 for code shrinking.

Introduction

Baseline Profiles specify which classes and methods to compile ahead-of-time. R8 shrinks and obfuscates code. Together they reduce APK size and improve runtime performance. This guide covers profile generation and R8 optimization.

Understanding Baseline Profiles

ART uses profiles to decide what to compile. Baseline Profiles force AOT compilation of critical paths during install. This eliminates JIT compilation during startup. Profiles are app-specific and version-dependent.

Generating Baseline Profiles

Add Macrobenchmark module. Write benchmark tests covering critical user flows. Run benchmarks on physical device. Extract baseline profile from output. Add to main module.

R8 Optimization Rules

R8 shrinks unused code, obfuscates names, and optimizes bytecode. Write ProGuard rules for reflection, JNI, and serialization. Use -keep rules sparingly. Test obfuscated builds.

Measuring Impact

Measure startup time before and after. Check APK size reduction. Profile runtime performance. Monitor ANR rates in production. Baseline Profiles typically improve startup 30-50%.

Frequently Asked Questions

Do Baseline Profiles increase APK size?

Minimally, typically 50-200KB. The startup improvement far outweighs the size increase. R8 optimization often reduces overall APK size, offsetting profile size.

How often should I update profiles?

Update profiles for each major release. Regenerate when adding significant features. Profile changes don't require user updates - profiles apply at next install.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:29
UP00:01
MOD · ARTICLE · PERFORMANCES/N · AX-BASELIPUBLISHED
PerformanceMar 27, 202614 MIN

Baseline Profiles and R8: Advanced Android Performance Tuning

Eliminate cold start jank and reduce APK size with Baseline Profiles for AOT compilation and R8 for aggressive code shrinking, obfuscation, and optimization.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

The Two Performance Levers You're Probably Not Using

Most Android performance optimization focuses on what happens inside your app: reducing recompositions, caching network responses, lazy-loading images. But two powerful tools operate below your application code, at the compilation and packaging level, and most teams never configure them beyond the defaults. **Baseline Profiles** tell the Android runtime which code paths to compile ahead of time (AOT) during app installation, instead of waiting for just-in-time (JIT) compilation at runtime. This eliminates jank during cold starts and critical user journeys -- Google reports 30-40% improvement in time-to-initial-display for apps that adopt them. **R8** (the successor to ProGuard) shrinks, obfuscates, and optimizes your bytecode during release builds. With proper configuration, R8 can remove 40-60% of unused code from your APK, inline methods for faster execution, and rewrite class hierarchies for smaller dex files. Together, these tools deliver faster startup, smoother interactions, and smaller downloads -- all without changing a single line of application code.

Generating Baseline Profiles with Macrobenchmark

Baseline Profiles are generated by running your app's critical user journeys in a Macrobenchmark test. The test records which classes and methods are touched, producing a profile file that ships inside your APK/AAB. When the user installs the app, the ART runtime reads this profile and AOT-compiles those hot paths immediately. The setup requires a separate `:benchmark` module with Macrobenchmark dependencies. The profile generator extends `BaselineProfileRule` and uses UI Automator to navigate through your app's most important flows.
kotlin
// :benchmark/build.gradle.kts
plugins {
    id("com.android.test")
    id("org.jetbrains.kotlin.android")
    id("androidx.baselineprofile")
}

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

baselineProfile {
    useConnectedDevices = true
}

dependencies {
    implementation("androidx.benchmark:benchmark-macro-junit4:1.3.3")
    implementation("androidx.test.uiautomator:uiautomator:2.3.0")
}

// :app/build.gradle.kts
plugins {
    id("androidx.baselineprofile")
}

dependencies {
    baselineProfile(project(":benchmark"))
}

baselineProfile {
    automaticGenerationDuringBuild = true    // Generate on every release build
    saveInSrc = true                         // Commit profile to source control
}

Writing Profile Generator Rules

The profile generator simulates your app's critical user journeys. Focus on the flows that matter most: cold start to first meaningful content, navigation between main screens, and any interaction where jank is most noticeable. Each `rule.collect()` call produces a profile that covers the code executed during that journey. The framework merges all profiles into a single `baseline-prof.txt` file that ships with your release build.
kotlin
@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: measure from launch to first frame
            pressHome()
            startActivityAndWait()

            // Wait for content to load
            device.wait(
                Until.hasObject(By.res("article_list")),
                10_000
            )
        }
    }

    @Test
    fun generateCriticalJourneyProfile() {
        rule.collect(
            packageName = "com.example.app",
            maxIterations = 5,
            stableIterations = 3
        ) {
            startActivityAndWait()

            // Navigate to article detail
            device.wait(Until.hasObject(By.res("article_list")), 10_000)
            device.findObject(By.res("article_card")).click()
            device.wait(Until.hasObject(By.res("article_content")), 5_000)

            // Scroll through article
            device.findObject(By.res("article_scroll")).also {
                it.scroll(Direction.DOWN, 1.0f)
                it.scroll(Direction.DOWN, 1.0f)
            }

            // Navigate to search
            device.pressBack()
            device.findObject(By.res("search_button")).click()
            device.wait(Until.hasObject(By.res("search_field")), 5_000)
            device.findObject(By.res("search_field")).text = "kotlin"
            device.wait(Until.hasObject(By.res("search_results")), 5_000)
        }
    }
}

// Generate profiles:
// ./gradlew :app:generateBaselineProfile
// Output: app/src/main/baseline-prof.txt

R8: Beyond Default Shrinking

R8 is enabled by default in release builds, but most projects use the bare minimum configuration: the default ProGuard rules and a few `-keep` rules for reflection-heavy libraries. With additional configuration, R8 can deliver dramatically better results. The key optimization modes are: **shrinking** (removes unused classes, methods, and fields), **obfuscation** (renames identifiers to shorter names, reducing dex size), **optimization** (inlines methods, removes dead branches, propagates constants), and **repackaging** (moves classes into fewer packages, reducing dex overhead).
properties
// build.gradle.kts -- release build type
android {
    buildTypes {
        release {
            isMinifyEnabled = true           // Enable R8 shrinking + optimization
            isShrinkResources = true         // Remove unused resources too
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

// proguard-rules.pro -- production-grade configuration

# ---- Kotlin Serialization ----
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
    *** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
    kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.example.app.model.**$$serializer { *; }
-keepclassmembers class com.example.app.model.** {
    *** Companion;
}

# ---- Retrofit ----
-keepattributes Signature, Exceptions
-keepclassmembers,allowshrinking,allowobfuscation interface * {
    @retrofit2.http.* <methods>;
}
-dontwarn retrofit2.**

# ---- Room ----
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

# ---- Compose: keep Stability metadata for compiler optimizations ----
-keep class androidx.compose.runtime.** { *; }

# ---- Aggressive optimizations ----
-optimizationpasses 5
-repackageclasses ''
-allowaccessmodification
-mergeinterfacesaggressively

Measuring Impact: Before and After

Performance work without measurement is guesswork. Use Macrobenchmark to capture hard numbers for startup time, frame timing, and app size before and after your optimizations. Baseline Profiles typically deliver 20-40% improvement in startup time and a noticeable reduction in jank during the first few seconds of use. R8 shrinking typically reduces APK size by 30-60% and can improve runtime performance through method inlining and dead code elimination. The benchmark results below show a typical before/after for a medium-sized app with 50+ screens. Run benchmarks on a physical device for production-representative numbers -- emulator results don't reflect real-world ART behavior.
kotlin
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val rule = MacrobenchmarkRule()

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

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

    @Test
    fun startupCompilationFull() = startup(CompilationMode.Full())

    private fun startup(compilationMode: CompilationMode) {
        rule.measureRepeated(
            packageName = "com.example.app",
            metrics = listOf(
                StartupTimingMetric(),
                TraceSectionMetric("firstComposition"),
                TraceSectionMetric("dataLoaded")
            ),
            iterations = 10,
            startupMode = StartupMode.COLD,
            compilationMode = compilationMode
        ) {
            pressHome()
            startActivityAndWait()
            device.wait(Until.hasObject(By.res("article_list")), 10_000)
        }
    }
}

// Typical results (Pixel 7, release build):
//
// CompilationMode      | TTID (median) | TTFD (median)
// ---------------------|---------------|---------------
// None (JIT only)      | 847ms         | 1,423ms
// Partial (Baseline)   | 512ms (-39%)  | 891ms (-37%)
// Full (AOT all)       | 478ms (-43%)  | 824ms (-42%)
//
// APK size (before R8):  28.4 MB
// APK size (after R8):   11.7 MB (-59%)
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Baseline Profiles AOT-compile your critical code paths during installation, delivering 30-40% faster cold starts with zero application code changes.
  2. 2Generate profiles with Macrobenchmark by scripting your most important user journeys -- cold start, main navigation, and search.
  3. 3R8 with aggressive configuration (repackaging, optimization passes, resource shrinking) can reduce APK size by 40-60% beyond default settings.
  4. 4Always benchmark before and after: use MacrobenchmarkRule with StartupTimingMetric for cold, warm, and hot start measurements.
  5. 5Commit baseline-prof.txt to source control and regenerate it when critical user flows change to keep profiles accurate.
  6. 6Baseline Profiles and R8 are complementary: profiles speed up runtime execution, R8 shrinks and optimizes the bytecode itself.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

Do Baseline Profiles increase APK size?

Minimally, typically 50-200KB. The startup improvement far outweighs the size increase. R8 optimization often reduces overall APK size, offsetting profile size.

How often should I update profiles?

Update profiles for each major release. Regenerate when adding significant features. Profile changes don't require user updates - profiles apply at next install.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA