---
name: android-performance
description: "Optimize Android app performance with Baseline Profiles for startup, Compose stability annotations (@Stable, @Immutable, strong skipping), recomposition debugging (Layout Inspector, recomposition counts), R8 optimization, LeakCanary memory leak detection, Macrobenchmark startup tracing, Coil image loading best practices, and LazyColumn/LazyGrid key-based performance. Use when diagnosing slow startup, janky scrolling, excessive recomposition, memory leaks, or optimizing release builds."
---

# Android Performance

Performance optimization patterns for Android apps targeting Jetpack Compose,
R8, Baseline Profiles, and modern profiling tools. Covers startup, runtime,
memory, and image loading optimization.

## Contents

- [Baseline Profiles](#baseline-profiles)
- [Compose Stability](#compose-stability)
- [Recomposition Debugging](#recomposition-debugging)
- [Lazy Layout Performance](#lazy-layout-performance)
- [R8 Optimization](#r8-optimization)
- [Memory Leak Detection](#memory-leak-detection)
- [Startup Optimization](#startup-optimization)
- [Image Loading with Coil](#image-loading-with-coil)
- [General Performance Patterns](#general-performance-patterns)
- [Do's and Don'ts](#dos-and-donts)
- [Troubleshooting](#troubleshooting)
- [Review Checklist](#review-checklist)

## Baseline Profiles

Baseline Profiles pre-compile critical code paths at install time, improving
startup time and reducing jank on first run.

### Setup

```kotlin
// build.gradle.kts (:app)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.baselineprofile)
}

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

baselineProfile {
    automaticGenerationDuringBuild = true
}
```

### Generator Module

```kotlin
// :baselineprofile/build.gradle.kts
plugins {
    alias(libs.plugins.android.test)
    alias(libs.plugins.baselineprofile)
}

android {
    namespace = "com.example.baselineprofile"
    targetProjectPath = ":app"
}

baselineProfile {
    useConnectedDevices = true
}
```

### Profile Generator

```kotlin
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generateBaselineProfile() {
        rule.collect(
            packageName = "com.example.app",
            includeInStartupProfile = true,
        ) {
            // Cold start
            pressHome()
            startActivityAndWait()

            // Critical user journeys
            device.findObject(By.text("Search")).click()
            device.waitForIdle()

            device.findObject(By.res("search_field")).text = "Istanbul"
            device.waitForIdle()

            device.findObject(By.res("flight_card")).click()
            device.waitForIdle()
        }
    }
}
```

### Generate and Apply

```bash
# Generate baseline profile
./gradlew :app:generateBaselineProfile

# Profile is written to app/src/main/baseline-prof.txt
# Startup profile to app/src/main/startup-prof.txt
```

## Compose Stability

Compose skips recomposition of composables whose parameters have not changed.
For this to work, parameters must be **stable** (immutable or observable).

### Stability Rules

| Type | Stable? | Why |
|------|---------|-----|
| Primitive (`Int`, `String`, `Boolean`) | Yes | Immutable value types |
| `data class` (all stable fields) | Yes | Compose infers stability |
| `data class` with `List<T>` field | No | `List` is an interface; could be mutable |
| `class` (non-data) | No | Compose cannot infer stability |
| Lambda `() -> Unit` | Unstable by default | Captured variables may change |

### @Immutable and @Stable Annotations

```kotlin
// Mark a class as truly immutable (all properties never change after construction)
@Immutable
data class FlightUiModel(
    val id: String,
    val route: String,
    val price: String,
    val status: String,
)

// Mark a class as stable (Compose can track changes via equals)
@Stable
data class FilterState(
    val origin: String = "",
    val destination: String = "",
    val date: LocalDate? = null,
)
```

### Use kotlinx.collections.immutable

Replace `List`, `Set`, `Map` with immutable variants for stable parameters:

```kotlin
// build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")

// Usage
@Immutable
data class FlightListUiState(
    val flights: ImmutableList<FlightUiModel> = persistentListOf(),
    val isLoading: Boolean = false,
)

// Convert in ViewModel
val uiState: StateFlow<FlightListUiState> = repository.getFlights()
    .map { flights ->
        FlightListUiState(
            flights = flights.map(::toUiModel).toImmutableList(),
        )
    }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FlightListUiState())
```

### Strong Skipping Mode (Kotlin 2.0+)

With the Compose Compiler plugin for Kotlin 2.0+, strong skipping is enabled
by default. This makes more composables skippable:

- Unstable parameters are compared by instance equality (`===`)
- Lambdas are memoized by default
- Less need for manual `@Stable` / `@Immutable` annotations

Verify in Compose compiler reports:

```properties
# gradle.properties
composeCompiler.reportsDestination=build/compose-reports
composeCompiler.metricsDestination=build/compose-metrics
```

```bash
./gradlew assembleRelease
# Check build/compose-reports/app_release-composables.txt for skippability
```

## Recomposition Debugging

### Layout Inspector (Android Studio)

1. Run app in debug mode
2. Open Layout Inspector: View > Tool Windows > Layout Inspector
3. Enable "Show Recomposition Counts" in the toolbar
4. Interact with the app and observe which composables recompose

High recomposition counts on composables that should not change indicate
unnecessary invalidation.

### Recomposition Highlighter

```kotlin
// Debug utility -- remove before release
@Composable
fun RecompositionCounter(label: String) {
    val count = remember { mutableIntStateOf(0) }
    count.intValue++
    SideEffect {
        Log.d("Recomposition", "$label: ${count.intValue}")
    }
}
```

### Compose Compiler Metrics

Generate stability reports to identify unstable composables:

```bash
./gradlew assembleRelease \
    -PcomposeCompiler.reportsDestination=build/compose-reports \
    -PcomposeCompiler.metricsDestination=build/compose-metrics
```

Key files:
- `*-composables.txt`: Lists all composables with restartable/skippable status
- `*-classes.txt`: Lists all classes with stability status
- `*-module.json`: Summary metrics

Look for composables marked `restartable` but NOT `skippable` -- these are
recomposition hotspots.

## Lazy Layout Performance

### LazyColumn/LazyRow Keys

Always provide stable keys for lazy layouts. Without keys, Compose cannot
efficiently diff items.

```kotlin
// CORRECT: stable key from item ID
LazyColumn {
    items(
        items = flights,
        key = { flight -> flight.id },
    ) { flight ->
        FlightCard(flight = flight)
    }
}

// WRONG: no key -- full list diff on every change
LazyColumn {
    items(flights) { flight ->
        FlightCard(flight = flight)
    }
}
```

### contentType for Heterogeneous Lists

```kotlin
LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { item ->
            when (item) {
                is FeedItem.Flight -> "flight"
                is FeedItem.Hotel -> "hotel"
                is FeedItem.Ad -> "ad"
            }
        },
    ) { item ->
        when (item) {
            is FeedItem.Flight -> FlightCard(item)
            is FeedItem.Hotel -> HotelCard(item)
            is FeedItem.Ad -> AdBanner(item)
        }
    }
}
```

`contentType` enables Compose to reuse view holders of the same type,
similar to RecyclerView's view type system.

### Avoid Heavy Computation in Item Scope

```kotlin
// WRONG: formatting on every recomposition
items(flights, key = { it.id }) { flight ->
    val formatted = SimpleDateFormat("HH:mm", Locale.getDefault())
        .format(Date(flight.departureTime))
    Text(formatted)
}

// CORRECT: precompute in ViewModel or UiModel
data class FlightUiModel(
    val id: String,
    val formattedDeparture: String, // precomputed
)
```

### Prefetch Configuration

```kotlin
LazyColumn(
    state = rememberLazyListState(),
    // Default prefetch is usually sufficient
    // For custom behavior:
    flingBehavior = ScrollableDefaults.flingBehavior(),
) {
    items(flights, key = { it.id }) { flight ->
        FlightCard(flight = flight)
    }
}
```

## R8 Optimization

R8 performs code shrinking, obfuscation, and optimization in release builds.

### Enable Full Optimization

```kotlin
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}
```

### R8 Full Mode

R8 full mode enables more aggressive optimizations:

```properties
# gradle.properties
android.enableR8.fullMode=true
```

Full mode may require additional keep rules for reflection-based code.

### Measuring R8 Impact

```bash
# Before R8 (debug APK)
./gradlew assembleDebug
ls -la app/build/outputs/apk/debug/app-debug.apk

# After R8 (release APK)
./gradlew assembleRelease
ls -la app/build/outputs/apk/release/app-release.apk

# Compare sizes
```

## Memory Leak Detection

### LeakCanary Setup

```kotlin
// build.gradle.kts
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
```

LeakCanary runs automatically in debug builds. It detects:
- Activity leaks
- Fragment leaks
- ViewModel leaks
- Service leaks
- Custom watched objects

### Common Leak Patterns

```kotlin
// LEAK: Activity reference in singleton
object Analytics {
    private var context: Context? = null // holds Activity reference
    fun init(context: Context) { this.context = context }
}

// FIX: Use application context
object Analytics {
    private var context: Context? = null
    fun init(context: Context) { this.context = context.applicationContext }
}

// LEAK: Coroutine scope outlives lifecycle
class MyActivity : ComponentActivity() {
    init {
        GlobalScope.launch { // outlives Activity
            // long-running work
        }
    }
}

// FIX: Use lifecycleScope
class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            // cancelled when Activity is destroyed
        }
    }
}

// LEAK: Anonymous inner class holds reference
class MyViewModel : ViewModel() {
    val callback = object : Callback {
        override fun onResult(data: Data) {
            // this holds implicit reference to MyViewModel
        }
    }
}
```

### Manual Object Watching

```kotlin
// Watch custom objects for leaks
val watcher = LeakCanary.objectWatcher
watcher.expectWeaklyReachable(myObject, "MyObject should be GC'd")
```

## Startup Optimization

### Macrobenchmark for Startup Tracing

```kotlin
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startupCompilation() {
        benchmarkRule.measureRepeated(
            packageName = "com.example.app",
            metrics = listOf(StartupTimingMetric()),
            iterations = 5,
            startupMode = StartupMode.COLD,
        ) {
            pressHome()
            startActivityAndWait()
        }
    }

    @Test
    fun startupWithScrolling() {
        benchmarkRule.measureRepeated(
            packageName = "com.example.app",
            metrics = listOf(
                StartupTimingMetric(),
                FrameTimingMetric(),
            ),
            iterations = 5,
            startupMode = StartupMode.COLD,
        ) {
            pressHome()
            startActivityAndWait()

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

### Startup Best Practices

```kotlin
@HiltAndroidApp
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // Only initialize essentials here
        // Defer non-critical init to background
    }
}

// Use App Startup library for lazy initialization
class AnalyticsInitializer : Initializer<Analytics> {
    override fun create(context: Context): Analytics {
        return Analytics.init(context)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
```

### Defer Heavy Initialization

```kotlin
// WRONG: blocking startup
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        Database.initialize(this)      // slow
        ImageLoader.setup(this)        // slow
        CrashReporting.setup(this)     // slow
    }
}

// CORRECT: defer to background
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
            Database.initialize(this@MyApplication)
            ImageLoader.setup(this@MyApplication)
        }
        // Crash reporting can stay on main (usually lightweight)
        CrashReporting.setup(this)
    }
}
```

## Image Loading with Coil

### Basic Setup

```kotlin
// build.gradle.kts
implementation("io.coil-kt:coil-compose:2.7.0")

// Composable
@Composable
fun FlightImage(imageUrl: String, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(imageUrl)
            .crossfade(true)
            .memoryCacheKey(imageUrl)
            .diskCacheKey(imageUrl)
            .build(),
        contentDescription = "Flight image",
        modifier = modifier,
        contentScale = ContentScale.Crop,
    )
}
```

### Global Image Loader Configuration

```kotlin
@HiltAndroidApp
class MyApplication : Application(), ImageLoaderFactory {

    override fun newImageLoader(): ImageLoader =
        ImageLoader.Builder(this)
            .memoryCache {
                MemoryCache.Builder(this)
                    .maxSizePercent(0.25) // 25% of app memory
                    .build()
            }
            .diskCache {
                DiskCache.Builder()
                    .directory(cacheDir.resolve("image_cache"))
                    .maxSizePercent(0.02) // 2% of disk
                    .build()
            }
            .crossfade(true)
            .respectCacheHeaders(true)
            .build()
}
```

### Image Size Optimization

```kotlin
// Downscale to view size -- prevents loading full-resolution images
AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(imageUrl)
        .size(Size(200, 200)) // Request specific size
        .scale(Scale.FILL)
        .build(),
    contentDescription = null,
    modifier = Modifier.size(100.dp), // Display size
)
```

### Placeholder and Error States

```kotlin
AsyncImage(
    model = imageUrl,
    contentDescription = "Airline logo",
    placeholder = painterResource(R.drawable.placeholder_airline),
    error = painterResource(R.drawable.error_airline),
    fallback = painterResource(R.drawable.default_airline),
    modifier = Modifier
        .size(48.dp)
        .clip(CircleShape),
)
```

## General Performance Patterns

### derivedStateOf for Expensive Computations

```kotlin
@Composable
fun FlightList(flights: List<Flight>) {
    val listState = rememberLazyListState()

    // Only recalculates when the derived condition changes
    val showScrollToTop by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }

    Box {
        LazyColumn(state = listState) {
            items(flights, key = { it.id }) { FlightCard(it) }
        }

        if (showScrollToTop) {
            FloatingActionButton(
                onClick = { /* scroll to top */ },
                modifier = Modifier.align(Alignment.BottomEnd),
            ) {
                Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
            }
        }
    }
}
```

### remember for Expensive Objects

```kotlin
// WRONG: recreated every recomposition
@Composable
fun DateDisplay(timestamp: Long) {
    val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
    Text(formatter.format(Date(timestamp)))
}

// CORRECT: remembered across recompositions
@Composable
fun DateDisplay(timestamp: Long) {
    val formatter = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
    Text(formatter.format(Date(timestamp)))
}
```

### Debounce for Search

```kotlin
@HiltViewModel
class SearchViewModel @Inject constructor(
    private val searchUseCase: SearchFlightsUseCase,
) : ViewModel() {

    private val searchQuery = MutableStateFlow("")

    val searchResults: StateFlow<List<Flight>> = searchQuery
        .debounce(300) // Wait 300ms after last keystroke
        .distinctUntilChanged()
        .filter { it.length >= 2 }
        .flatMapLatest { query -> searchUseCase(query) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun onQueryChange(query: String) {
        searchQuery.value = query
    }
}
```

## Do's and Don'ts

### Do's
- Generate and ship Baseline Profiles for startup optimization
- Use `key` parameter in `LazyColumn`/`LazyRow` items
- Use `@Immutable` / `@Stable` or `kotlinx-collections-immutable` for stable parameters
- Use `derivedStateOf` for computed values that change less frequently than their inputs
- Use `remember` for expensive objects (formatters, regex, etc.)
- Profile release builds on real devices (not debug on emulator)
- Use Coil's `size()` to request images at display size
- Enable R8 full mode for maximum shrinking

### Don'ts
- Do not allocate objects inside composable body (formatters, lists)
- Do not use `mutableListOf()` as a composable parameter (unstable)
- Do not skip `key` in LazyColumn items
- Do not use `GlobalScope` -- use structured concurrency
- Do not hold Activity/Fragment references in singletons
- Do not block the main thread with I/O or heavy computation
- Do not leave LeakCanary in release builds (it is `debugImplementation`)
- Do not ignore Compose compiler stability reports

## Troubleshooting

| Problem | Cause | Fix |
|---------|-------|-----|
| Slow startup (> 1s cold start) | Heavy `Application.onCreate` or no Baseline Profile | Defer init; generate Baseline Profile |
| Janky scrolling in LazyColumn | Missing keys, unstable items, or heavy item composition | Add `key`; use `@Immutable` models; precompute formatted data |
| Excessive recomposition | Unstable parameters or overly broad state reads | Check compiler reports; narrow state scope; use `derivedStateOf` |
| Memory leak detected | Activity reference in singleton or GlobalScope | Use `applicationContext`; use `lifecycleScope` |
| Large APK size | R8 disabled or unused resources | Enable `isMinifyEnabled` and `isShrinkResources` |
| Images loading slowly | Full-resolution images loaded | Use Coil `size()` to request at display size |
| Compose compiler reports "unstable" | Class has mutable or interface-typed fields | Use `@Immutable`/`@Stable` or immutable collections |
| Baseline Profile not applied | Missing `baselineProfile` plugin or not generated | Run `./gradlew :app:generateBaselineProfile` |

## Review Checklist

- [ ] Baseline Profiles generated and included in release builds
- [ ] All `LazyColumn`/`LazyRow` items have stable `key`
- [ ] `contentType` specified for heterogeneous lazy lists
- [ ] Compose compiler reports show critical composables as skippable
- [ ] No object allocations inside composable body (formatters, regex)
- [ ] `derivedStateOf` used for computed scroll/visibility state
- [ ] `remember` used for expensive objects
- [ ] R8 enabled with `isMinifyEnabled = true` for release
- [ ] LeakCanary included as `debugImplementation` only
- [ ] Coil configured with memory and disk cache limits
- [ ] No heavy work in `Application.onCreate` -- deferred to background
- [ ] Search input debounced (300ms+)
- [ ] Profiling done on release build, real device
