Your app launches in 3 seconds. Users see a white screen. They close the app. Your retention drops.

Performance is not optional. Google Play ranks apps by performance metrics. Users uninstall apps that feel slow. The difference between a 1-second and 3-second startup can be a 50% drop in engagement.

In this tutorial — the final one in the series — you will learn how to measure and optimize your app’s performance using Baseline Profiles, Macrobenchmark, and modern profiling tools.

Prerequisites: You should have a release-ready app. See Android Tutorial #16: App Security for R8 configuration.


Why Performance Matters

Google tracks your app’s performance in Android Vitals:

MetricBad ThresholdImpact
Startup time (cold)> 5 secondsLower search ranking
ANR rate> 0.47%Warning in Play Console
Crash rate> 1.09%Warning in Play Console
Excessive wakeups> 10/hourBattery warning

Apps that exceed these thresholds get warnings in the Play Console and may be ranked lower in search results.


App Startup Types

Android has three types of app starts:

Cold Start

The app is not in memory. The system creates the process, initializes the Application class, creates the activity, inflates the layout, and draws the first frame.

This is the slowest start. Optimize it first.

Warm Start

The app is in memory but the activity was destroyed. The system recreates the activity but skips process initialization.

Hot Start

The app and activity are in memory. The system just brings the activity to the foreground. This is nearly instant.


Measuring Startup Time

reportFullyDrawn()

Tell the system when your app is ready to use:

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

        setContent {
            MyApp(
                onReady = {
                    // Report when the first meaningful content is shown
                    reportFullyDrawn()
                }
            )
        }
    }
}

@Composable
fun MyApp(onReady: () -> Unit) {
    val viewModel: HomeViewModel = hiltViewModel()
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(state.isLoading) {
        if (!state.isLoading && state.items.isNotEmpty()) {
            onReady()
        }
    }

    // App content
}

This shows up in Android Vitals as your “Time to fully drawn” metric.

Tracing API

Add custom trace sections to understand where time is spent:

class MyApp : Application() {
    override fun onCreate() {
        Trace.beginSection("App.onCreate")
        super.onCreate()

        Trace.beginSection("Hilt.init")
        // Hilt initialization happens here
        Trace.endSection()

        Trace.beginSection("Channels.create")
        NotificationChannels.createAll(this)
        Trace.endSection()

        Trace.endSection() // App.onCreate
    }
}

View traces in Android Studio Profiler or in system trace files.

Logcat Timing

Quick check without tools:

# Filter for startup metrics
adb logcat -s ActivityTaskManager | grep "Displayed"
# Output: Displayed com.example.myapp/.MainActivity: +1s234ms

Baseline Profiles

Baseline Profiles tell the Android runtime (ART) which code paths to compile ahead of time. This improves startup by 30% and reduces jank in critical user journeys.

How They Work

Without Baseline Profiles, ART interprets your code at first launch. Over time, it JIT-compiles hot methods. After a few hours, it AOT-compiles them during idle charging.

With Baseline Profiles, ART AOT-compiles the specified methods at install time. First launch is already optimized.

Setting Up Macrobenchmark

Create a separate module for benchmarks:

// settings.gradle.kts
include(":benchmark")
// benchmark/build.gradle.kts
plugins {
    id("com.android.test")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.example.benchmark"
    compileSdk = 36

    defaultConfig {
        minSdk = 28
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    targetProjectPath = ":app"

    testOptions.managedDevices.localDevices {
        create("pixel6Api34") {
            device = "Pixel 6"
            apiLevel = 34
            systemImageSource = "aosp-atd"
        }
    }
}

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

Generating Baseline Profiles

@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

    @get:Rule
    val baselineProfileRule = BaselineProfileRule()

    @Test
    fun startup() = baselineProfileRule.collect(
        packageName = "com.example.myapp"
    ) {
        // Cold start
        pressHome()
        startActivityAndWait()

        // Navigate through critical user journeys
        device.findObject(By.text("Tasks")).click()
        device.waitForIdle()

        device.findObject(By.text("Settings")).click()
        device.waitForIdle()
    }
}

Run the generator:

./gradlew :benchmark:pixel6Api34BenchmarkAndroidTest \
    -P android.testInstrumentationRunnerArguments.class=com.example.benchmark.BaselineProfileGenerator

Adding to Your App

Copy the generated profile to app/src/main/baseline-prof.txt (the Baseline Profile Gradle plugin can automate this).

Or use the plugin:

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

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

Then generate and install:

./gradlew :app:generateBaselineProfile

Measuring the Impact

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startupWithoutBaseline() = benchmarkRule.measureRepeated(
        packageName = "com.example.myapp",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        compilationMode = CompilationMode.None()
    ) {
        pressHome()
        startActivityAndWait()
    }

    @Test
    fun startupWithBaseline() = benchmarkRule.measureRepeated(
        packageName = "com.example.myapp",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        compilationMode = CompilationMode.Partial(
            baselineProfileMode = BaselineProfileMode.Require
        )
    ) {
        pressHome()
        startActivityAndWait()
    }
}

Typical results:

  • Without Baseline Profile: 1200ms cold start
  • With Baseline Profile: 800ms cold start (33% improvement)

Avoiding ANRs

An ANR (Application Not Responding) occurs when the main thread is blocked for more than 5 seconds.

Common ANR Causes

  1. Network calls on the main thread
  2. Heavy database queries without coroutines
  3. File I/O on the main thread
  4. Slow ContentProvider operations
  5. Lock contention between threads

StrictMode

Enable StrictMode in debug builds to catch main thread violations:

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .penaltyDeath() // Crash on violation
                    .build()
            )

            StrictMode.setVmPolicy(
                StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .detectLeakedClosableObjects()
                    .detectActivityLeaks()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

Moving Work Off the Main Thread

// Bad — blocks main thread
fun loadSettings(): Settings {
    val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
    return Settings(
        theme = prefs.getString("theme", "system")!!,
        language = prefs.getString("language", "en")!!
    )
}

// Good — uses coroutines
suspend fun loadSettings(): Settings = withContext(Dispatchers.IO) {
    val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
    Settings(
        theme = prefs.getString("theme", "system")!!,
        language = prefs.getString("language", "en")!!
    )
}

Compose Performance

Stable Types

Compose skips recomposition for composables with stable parameters. Make your state classes stable:

// Unstable — Compose always recomposes
data class TaskState(
    val tasks: List<Task>,  // List is unstable
    val isLoading: Boolean
)

// Stable — Compose can skip recomposition
@Immutable
data class TaskState(
    val tasks: ImmutableList<Task>,  // kotlinx-collections-immutable
    val isLoading: Boolean
)

Add the dependency:

implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")

Compose Compiler Reports

Generate reports to find non-skippable composables:

// build.gradle.kts
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_reports")
}
./gradlew assembleRelease
# Check app/build/compose_reports/

The report shows:

  • Which composables are skippable
  • Which parameters are stable vs unstable
  • Where recomposition cannot be skipped

Key Performance Rules for Compose

  1. Use key() in LazyColumn items
  2. Avoid creating lambdas in the composition (use remember)
  3. Use derivedStateOf for computed values
  4. Don’t read state in places where you don’t need it

For more details, see Compose Tutorial #17: Performance.


Memory Leaks

LeakCanary

LeakCanary detects memory leaks automatically in debug builds:

dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

That’s it. No code needed. LeakCanary shows a notification when it detects a leak, with the full reference chain.

Common Android Memory Leaks

  1. Holding Activity reference in ViewModel — never pass Context to ViewModel
  2. Static references to Views — don’t store Views in companion objects
  3. Unregistered listeners — always unregister in onDestroy/onDispose
  4. Inner classes holding outer reference — use WeakReference or extract to top-level class

Image Loading Optimization

Images are often the biggest performance bottleneck. Use Coil for efficient loading:

// With Coil
AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(imageUrl)
        .crossfade(true)
        .size(300, 300)  // Don't load full-size image
        .memoryCachePolicy(CachePolicy.ENABLED)
        .diskCachePolicy(CachePolicy.ENABLED)
        .build(),
    contentDescription = null,
    modifier = Modifier.size(150.dp)
)

Key optimizations:

  • Size constraints — don’t load a 4000px image for a 150dp thumbnail
  • Memory cache — avoid re-decoding images
  • Disk cache — avoid re-downloading images
  • Crossfade — smooth loading transition

16 KB Page Size Compatibility

Android 15 introduced support for 16 KB memory page sizes on compatible hardware. As of November 1, 2025, Google Play requires 16 KB page size compatibility for new apps and updates that use native code (NDK) and target Android 15+.

Check if your app is compatible:

# Check for 4KB-aligned native libraries
zipinfo -l app-release.aab | grep "\.so"

If you use NDK or native libraries, ensure they are built with 16 KB page alignment:

# CMakeLists.txt
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=16384")

Most pure-Kotlin apps are unaffected.


Profiling in Production

Android Vitals

Monitor your app’s real-world performance in Play Console:

  1. Go to Play Console → Your App → Android Vitals
  2. Check: Crash rate, ANR rate, startup time, permission denials
  3. Set up alerts for threshold breaches

Firebase Performance Monitoring

For custom performance tracking:

class NetworkRepository @Inject constructor(
    private val api: ApiService
) {
    suspend fun fetchData(): Result<Data> {
        val trace = Firebase.performance.newTrace("fetch_data")
        trace.start()

        return try {
            val result = api.getData()
            trace.putAttribute("status", "success")
            trace.putMetric("item_count", result.size.toLong())
            Result.success(result)
        } catch (e: Exception) {
            trace.putAttribute("status", "error")
            trace.putAttribute("error", e.message ?: "unknown")
            Result.failure(e)
        } finally {
            trace.stop()
        }
    }
}

Cross-link: See Android Tutorial #17: Firebase for Firebase Performance Monitoring setup.


Performance Optimization Checklist

Before releasing:

  • Baseline Profile generated and included
  • Cold startup under 2 seconds
  • No StrictMode violations
  • No memory leaks detected by LeakCanary
  • Compose Compiler reports checked for non-skippable composables
  • Images loaded with size constraints and caching
  • R8 enabled with resource shrinking
  • ANR rate below 0.47% in testing
  • reportFullyDrawn() called when content is ready
  • 16 KB page size compatibility checked (if using native code)

Practical Example: Optimizing a Slow App

Here is a before/after example:

Before (3.2 second cold start)

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        // Initializing everything at startup
        Firebase.initialize(this)           // 200ms
        NotificationChannels.createAll(this)  // 50ms
        loadFeatureFlags()                  // 800ms (network call!)
        initializeAnalytics()               // 100ms
        preloadImages()                     // 400ms
    }
}

After (1.1 second cold start)

@HiltAndroidApp
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        // Only essential initialization
        NotificationChannels.createAll(this) // 50ms
    }
}

// Firebase initializes lazily through Hilt
// Feature flags load in ViewModel (not blocking startup)
// Images load on demand (not preloaded)
// Analytics initializes on first event

Changes made:

  1. Moved network call out of Application.onCreate — feature flags load in the ViewModel
  2. Removed image preloading — Coil handles caching
  3. Lazy Firebase init — Hilt provides Firebase instances when first needed
  4. Added Baseline Profile — 30% startup improvement

Result: 3.2s to 1.1s cold start (66% improvement).


Common Mistakes

1. Not Measuring Before Optimizing

Don’t guess where the bottleneck is. Use Macrobenchmark, Android Studio Profiler, or system traces to find the actual problem.

2. Ignoring Baseline Profiles

Baseline Profiles are the highest-impact, lowest-effort optimization. They give 30% improvement for a few hours of setup.

3. Heavy Application.onCreate

Every millisecond in Application.onCreate adds to every cold start. Initialize lazily. Move network calls to ViewModels.

4. Not Using ImmutableList in Compose

Compose treats List<T> as unstable. Every recomposition rebuilds composables that take List parameters. Use ImmutableList from kotlinx-collections-immutable.

5. Ignoring Android Vitals

Android Vitals data is free and shows real-world performance. Check it weekly. Fix issues before they affect your store ranking.


Series Complete!

Congratulations! You completed all 20 tutorials in the Android Development series.

Here is what you learned:

Part 1: Architecture — MVVM, MVI, Clean Architecture, Hilt, Repository pattern, Use Cases, Multi-module

Part 2: Data & Networking — Retrofit, Room advanced, DataStore, WorkManager, Paging 3

Part 3: Android Platform — Notifications, Foreground Services, Content Providers, Deep Links, Glance Widgets

Part 4: Production — R8/ProGuard, Firebase, CI/CD, Play Store Publishing, Performance

You now have the knowledge to build, test, and ship production-ready Android apps. The next step is to build something real. Pick a project, apply these patterns, and publish it.



This is part 20 of the Android Development Tutorial series.