Lists, keys and staying at 60 fps
Lesson 3 of 51 min read
LazyColumn done properly: keys, stability, and precomputed rows.
LazyColumn only composes what is visible. That is free performance — until four specific mistakes take it away.
1. Missing keys
LazyColumn {
items(products, key = { it.id }) { ProductRow(it) }
}
Without a key, items are identified by index. Reorder or remove from the middle and state sticks to positions instead of items: the wrong row stays expanded, checkboxes appear to jump, animations teleport.
2. Work inside the row
Formatting a date, building a string, parsing a price — per row, per scroll, per frame. Precompute it in the ViewModel and give the row a ready-to-draw model.
data class ProductRowModel(val id: String, val title: String, val priceLabel: String)
3. Unstable parameters
If Compose cannot prove a parameter is stable, it cannot skip the composable. A plain List is treated as unstable. Use an immutable collection type, or mark the model @Immutable when you can guarantee it.
4. Reading state too high
Reading a scroll offset or an animated value in a parent recomposes the parent's whole subtree. Read it in the smallest composable that needs it, or pass a lambda so the read happens later.
// recomposes the screen on every scroll pixel
val offset = listState.firstVisibleItemScrollOffset
// reads only where it is drawn
Header(alphaProvider = { computeAlpha(listState) })
How you prove it
- Layout Inspector shows recomposition counts per composable — a number climbing while you scroll a static area is your bug.
- Macrobenchmark with
FrameTimingMetricgives you a number before and after. - Always measure a release build; debug Compose is meaningfully slower and will mislead you.
The usual mistake: loading a full-resolution image into a 96 dp row. Ask Coil for the display size and the jank disappears before you optimise anything else.
