Files
vnidrop/.codex/skills/compose-skill/references/performance.md
Hammed Abass fd4fff2c86 docs: add compose development guidelines and skills
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory.

Co-authored-by: Junie <junie@jetbrains.com>
2026-07-07 16:55:43 +02:00

7.5 KiB

Performance & Recomposition

Three Phases and Primitive Specializations

Compose executes Composition, Layout, and Drawing phases per frame. State reads in later phases skip earlier phases — moving reads from Composition to Layout/Drawing eliminates recomposition for those reads. Use Modifier.offset { } (lambda) instead of Modifier.offset(). Use mutableIntStateOf()/mutableFloatStateOf() instead of mutableStateOf<Int>() to avoid boxing. See compose-essentials.md for full explanation and code examples.

Performance Mistakes and Fixes

# Issue Fix
1 Unstable parameters (MutableList, lambdas in state models, anonymous objects) Immutable data classes + immutable collections
2 Broad state observation — parent reads whole state, ripples through tree Collect once at route, slice aggressively for leaves
3 Large state passed everywhere — many nodes observe unused fields Pass only what each child renders
4 Callback recreation in hot paths (large lazy lists, nested rows) remember(key, callback) for repeated rows
5 Expensive calculations during composition (parse, sort, filter, format) Move upstream to ViewModel/domain
6 remember misuse — caching business state, hiding architecture issues Use only for local UI state, expensive local objects, hot callback adaptation
7 derivedStateOf misuse — wrapping cheap expressions Use only when derived from rapidly changing Compose state with coarse output
8 rememberSaveable misuse — entire screen state, large graphs Use only for tiny UI-local values surviving recreation
9 State reads too high in tree (LazyListState, animation, keyboard state) Read close to use
10 List recomposition — missing keys, unstable items, inline filters/sorts Stable keys, immutable models, pre-computed data
11 Reducer emits excessive updates — same state, rebuilds on every keystroke Guard identical transitions, emit only on semantic change
12 Ephemeral visual state in global screen state (shimmer alpha, pulse phase) Keep visual-only state local
13 Equality pitfalls — lambdas in data classes, random IDs, mutable collections No lambdas/mutables in data classes, stable IDs
14 Abusing @Immutable/@Stable to silence compiler Use only to describe truth — @Immutable for truly immutable, @Stable rare in app code
15 Raw text input in MVI causing stutter (25+ fields) TextFieldState/BasicTextField2, group fields into nested data classes, isolate read scopes
16 State reads in Composition phase for layout/draw values Lambda modifiers: Modifier.offset { IntOffset(scrollOffset, 0) }

API Decision Table

API Use it for Do not use it for
remember local objects/state across recompositions business state, repo results, derived domain data
rememberSaveable small UI-local state needing restoration whole screen state, large graphs, domain objects
derivedStateOf reducing downstream updates from fast-changing Compose state cheap string concatenation, reducer-owned derivations
key preserving identity in dynamic children/lists hiding bad state models
LaunchedEffect collecting UI effects, startup event, one-shot route work screen business logic in leaves
DisposableEffect register/unregister listeners with cleanup long-running business jobs
produceState bridging external async/callback source to local Compose state replacing a real ViewModel
snapshotFlow turning Compose state reads into Flow operators normal state rendering
collectAsState collect StateFlow into Compose collecting everywhere in the tree
lifecycle-aware collection Lifecycle host integration (multiplatform since lifecycle 2.8+) common leaf components
stable callbacks hot repeated UI paths every single callback everywhere

Code Examples

BAD: calculating derived results in a composable

@Composable
fun CalculatorResult(state: CalculatorState) {
    val area = state.input.areaText.toDoubleOrNull() ?: 0.0
    val materialRate = state.input.materialRateText.toDoubleOrNull() ?: 0.0
    val subtotal = (area * materialRate)
    Text("Subtotal: $subtotal")
}

GOOD: derive upstream, narrow reads

@Composable
fun CalculatorScreen(state: CalculatorState, onEvent: (CalculatorEvent) -> Unit) {
    Header(title = "Estimator")
    CalculatorForm(
        input = state.input,
        validation = state.validation,
        enabled = !state.isRefreshingQuote,
        onAreaChanged = { onEvent(CalculatorEvent.FieldChanged(FormField.Area, it)) },
    )
    ResultCard(derived = state.derived, isRefreshing = state.isRefreshingQuote)
}

@Composable
fun CalculatorResult(derived: CalculatorDerived?) {
    Text(text = derived?.subtotal?.toString() ?: "—")
}

BAD: unstable list items

data class HistoryRowState(
    val id: String, val title: String,
    val tags: MutableList<String>,   // unstable
    val onClick: () -> Unit,         // lambda in data class
)

GOOD: immutable models, stable keys, callback stability

@Immutable
data class HistoryRowUi(val id: String, val title: String, val subtitle: String)

@Composable
fun HistoryList(items: ImmutableList<HistoryRowUi>, onOpen: (String) -> Unit) {
    LazyColumn {
        items(items = items, key = { it.id }) { item ->
            val onClick = remember(item.id, onOpen) { { onOpen(item.id) } }
            ListItem(
                headlineContent = { Text(item.title) },
                supportingContent = { Text(item.subtitle) },
                modifier = Modifier.clickable(onClick = onClick),
            )
        }
    }
}

GOOD: correct derivedStateOf

val listState = rememberLazyListState()
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }

BAD: unnecessary derivedStateOf

val text by remember { derivedStateOf { if (canSubmit) "Submit" else "Fix errors" } }
// Just write: Text(if (canSubmit) "Submit" else "Fix errors")

GOOD: guard identical transitions

private fun onAreaEdited(raw: String) {
    val old = _state.value
    if (old.input.areaText == raw) return
    _state.value = old.copy(input = old.input.copy(areaText = raw))
}

Compiler and Build Optimizations

  • Strong Skipping Mode — enable via compiler flags; allows composables with unstable parameters to skip based on instance equality (===)
  • Stability config — use stability_config.conf to mark external classes as stable: com.example.network.dto.*, kotlinx.datetime.Instant
  • Compose Compiler Metrics — audit restartable/skippable characteristics regularly

Baseline Profiles (Android)

Pre-compile hot code paths via Jetpack Macrobenchmark to reduce startup time and jank:

@RunWith(AndroidBenchmarkRunner::class)
class StartupBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(StartupTimingMetric()),
        iterations = 10,
        setupBlock = { pressHome(); startActivityAndWait() }
    ) { /* interact with app */ }
}

Target <16.67ms per frame for 60fps. Use FrameTimingMetric() for scroll/interaction benchmarks.

R8/ProGuard Rules for Compose (Android only)

-keep @androidx.compose.runtime.Stable class **
-keep @androidx.compose.runtime.Immutable class **