# Android Performance Patterns

Full code examples for the Android Performance skill. Each section mirrors a
heading in `SKILL.md`. Load the section named in the guide when implementing or
reviewing that area.

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

## Baseline Profiles

### 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 = "London"
            device.waitForIdle()

            device.findObject(By.res("order_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

### @Immutable and @Stable Annotations

```kotlin
// Mark a class as truly immutable (all properties never change after construction)
@Immutable
data class OrderUiModel(
    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 store: String = "",
    val region: 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 OrderListUiState(
    val orders: ImmutableList<OrderUiModel> = persistentListOf(),
    val isLoading: Boolean = false,
)

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

### 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 = orders,
        key = { order -> order.id },
    ) { order ->
        OrderCard(order = order)
    }
}

// WRONG: no key -- full list diff on every change
LazyColumn {
    items(orders) { order ->
        OrderCard(order = order)
    }
}
```

### contentType for Heterogeneous Lists

```kotlin
LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { item ->
            when (item) {
                is FeedItem.Order -> "order"
                is FeedItem.Hotel -> "hotel"
                is FeedItem.Ad -> "ad"
            }
        },
    ) { item ->
        when (item) {
            is FeedItem.Order -> OrderCard(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(orders, key = { it.id }) { order ->
    val formatted = SimpleDateFormat("HH:mm", Locale.getDefault())
        .format(Date(order.placedAt))
    Text(formatted)
}

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

### Prefetch Configuration

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

## R8 Optimization

### 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("order_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 OrderImage(imageUrl: String, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(imageUrl)
            .crossfade(true)
            .memoryCacheKey(imageUrl)
            .diskCacheKey(imageUrl)
            .build(),
        contentDescription = "Order 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 = "Brand logo",
    placeholder = painterResource(R.drawable.placeholder_brand),
    error = painterResource(R.drawable.error_brand),
    fallback = painterResource(R.drawable.default_brand),
    modifier = Modifier
        .size(48.dp)
        .clip(CircleShape),
)
```

## General Performance Patterns

### derivedStateOf for Expensive Computations

```kotlin
@Composable
fun OrderList(orders: List<Order>) {
    val listState = rememberLazyListState()

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

    Box {
        LazyColumn(state = listState) {
            items(orders, key = { it.id }) { OrderCard(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: SearchOrdersUseCase,
) : ViewModel() {

    private val searchQuery = MutableStateFlow("")

    val searchResults: StateFlow<List<Order>> = 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
    }
}
```
