---
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."
metadata:
  source: multi-agent-pipeline
---

# 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.

Detailed code examples live in `references/patterns.md`, organized under the
same section headings used below. Load the matching section when you implement
or review that area; the guidance here is enough to decide what to do.

## Contents

- [Baseline Profiles](#baseline-profiles)
- [Compose Stability](#compose-stability)
- [Deferred State Reads](#deferred-state-reads)
- [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. Apply the `baselineprofile` plugin
to `:app`, add a `:baselineprofile` test module that targets `:app`, and write a
`BaselineProfileRule` generator that exercises cold start plus the critical user
journeys. Run `./gradlew :app:generateBaselineProfile` to produce
`baseline-prof.txt` and `startup-prof.txt` under `app/src/main/`.

See `references/patterns.md` -> "Baseline Profiles" for the `:app` and
generator `build.gradle.kts`, the `BaselineProfileGenerator` test, and the
generate command.

## 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 |
| Class from a module not compiled with the Compose compiler (e.g. a domain module) | No | Compose cannot see its fields; map it to a UI model in the feature module, annotate it, or list it in a stability configuration file |

Mark truly immutable models `@Immutable` and observable-but-stable state
`@Stable`. Replace `List`/`Set`/`Map` parameters with
`kotlinx.collections.immutable` variants (`ImmutableList`, `persistentListOf()`)
so list-carrying UI state stays stable. On Kotlin 2.0+ the Compose Compiler
enables strong skipping by default (unstable params compared by `===`, lambdas
memoized), reducing the need for manual annotations. Verify results in the
Compose compiler reports.

See `references/patterns.md` -> "Compose Stability" for the annotation
examples, the immutable-collections `StateFlow` mapping, and the
`gradle.properties` report settings.

## Deferred State Reads

Where a state is read decides which phase re-runs when it changes. An animated
value read during composition recomposes the whole enclosing scope every frame.
The same value read in a layout or draw lambda re-runs only that phase.

| Phase the read happens in | Example | Re-runs |
|---|---|---|
| Composition | `Modifier.offset(x = v)`, `Modifier.alpha(v)`, `Modifier.scale(v)` | composition, layout, draw |
| Layout | `Modifier.offset { IntOffset(v.roundToPx(), 0) }` | layout, draw |
| Draw | `Modifier.graphicsLayer { alpha = v; translationX = px }`, `drawBehind { }` | draw only |

- Delegate animation state with `by` (`val offset by animateDpAsState(...)`) and
  read it inside the latest phase that can use it.
- Animate colors through `drawBehind { drawRect(color) }` rather than a
  `background(color)` read in composition.
- Hoist a `rememberInfiniteTransition` above a list instead of creating one per
  item.
- Applies to `animate*AsState`, `Animatable`, `InfiniteTransition` and any state
  that changes every frame (scroll offsets, drag positions).

## Recomposition Debugging

Use Android Studio's Layout Inspector with "Show Recomposition Counts" enabled
to find composables that recompose more than they should. For targeted logging,
a small `RecompositionCounter` debug composable prints per-label counts (remove
before release). For a build-wide view, generate Compose compiler metrics and
look for composables marked `restartable` but NOT `skippable` -- those are the
recomposition hotspots.

See `references/patterns.md` -> "Recomposition Debugging" for the Layout
Inspector steps, the `RecompositionCounter` snippet, the metrics command, and
the report files to read.

## Lazy Layout Performance

Always provide a stable `key` for `LazyColumn`/`LazyRow` items; without it
Compose cannot diff efficiently and re-renders the whole list on any change.

```kotlin
LazyColumn {
    items(items = orders, key = { it.id }) { order ->
        OrderCard(order = order)
    }
}
```

For heterogeneous lists, add `contentType` so Compose reuses layouts of the
same type (like RecyclerView view types). Never format or allocate inside item
scope -- precompute display strings in the `UiModel`. Default prefetch is
usually sufficient.

See `references/patterns.md` -> "Lazy Layout Performance" for the keyed vs
unkeyed contrast, the `contentType` example, the precompute-in-UiModel pattern,
and prefetch configuration.

## R8 Optimization

R8 performs code shrinking, obfuscation, and optimization in release builds.
Enable it with `isMinifyEnabled = true` and `isShrinkResources = true` on the
`release` build type, using `proguard-android-optimize.txt` plus your keep
rules. Turn on `android.enableR8.fullMode=true` for more aggressive
optimization (may require extra keep rules for reflection-based code). Measure
impact by comparing debug and release APK sizes.

See `references/patterns.md` -> "R8 Optimization" for the `buildTypes` block,
the full-mode property, and the size-comparison commands.

## Memory Leak Detection

Add LeakCanary as `debugImplementation` only; it runs automatically in debug
builds and reports Activity, Fragment, ViewModel, Service, and custom watched
leaks. The recurring leak sources are: Activity/Context references held in
singletons (fix with `applicationContext`), `GlobalScope` coroutines that
outlive a lifecycle (fix with `lifecycleScope`/`viewModelScope`), and anonymous
inner classes capturing an implicit outer reference. For custom objects, watch
them explicitly with `objectWatcher.expectWeaklyReachable(...)`.

See `references/patterns.md` -> "Memory Leak Detection" for the dependency
line, the leak-vs-fix pairs, and the manual object-watching snippet.

## Startup Optimization

Measure cold start with a Macrobenchmark `StartupTimingMetric` (add
`FrameTimingMetric` to catch scroll jank during startup), running against a
release build on a real device. Keep `Application.onCreate` minimal: initialize
only essentials, defer database, image loader, and other heavy setup to a
background dispatcher (or the App Startup library). Crash reporting can usually
stay on main.

See `references/patterns.md` -> "Startup Optimization" for the
`StartupBenchmark` tests, the App Startup `Initializer`, and the
blocking-vs-deferred `Application.onCreate` contrast.

## Image Loading with Coil

Load images with Coil's `AsyncImage`, enabling `crossfade` and explicit
cache keys. Configure a global `ImageLoader` via `ImageLoaderFactory` with
memory and disk cache limits (e.g. 25% of app memory, 2% of disk). Always
request images at display size with `.size(...)` to avoid decoding
full-resolution bitmaps, and supply `placeholder`/`error`/`fallback` painters.

See `references/patterns.md` -> "Image Loading with Coil" for the basic
`AsyncImage`, the `ImageLoaderFactory` configuration, the size-optimization
request, and the placeholder/error states.

## General Performance Patterns

- **`derivedStateOf`** for values computed from state that change less often
  than their inputs (e.g. a scroll-to-top flag derived from
  `firstVisibleItemIndex`), so recomposition triggers only on the derived
  change.
- **`remember`** expensive objects (formatters, regex) so they survive
  recomposition instead of being reallocated each pass.
- **Debounce** search input in the ViewModel (`debounce(300)` +
  `distinctUntilChanged()` + `flatMapLatest`) to avoid a request per keystroke.

See `references/patterns.md` -> "General Performance Patterns" for the
`derivedStateOf` list example, the `remember` formatter contrast, and the
debounced search `StateFlow`.

## 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
- [ ] Animated values read in `offset { }` / `graphicsLayer { }` / `drawBehind { }`, not in composition
- [ ] No cross-module domain type in a composable parameter or UI state
- [ ] 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
