Ask
121
@two_calendars ·

lazycolumn recomposes every visible row on scroll, layout inspector shows 400+ recomposition

900 item list, each row is an image, two text lines and a favourite button. Scrolling is visibly janky on a mid-range device and Layout Inspector shows recomposition counts in the 400s for rows after about ten seconds of flinging.

My understanding was that LazyColumn only composes what is visible and reuses slots as you scroll, so a row that scrolls off and comes back should be cheap. Instead everything appears to recompose constantly, including rows whose data has not changed at all.

Relevant bits:

LazyColumn {
    items(state.rows) { row ->
        RowCard(
            row = row,
            onTap = { viewModel.select(row.id) }
        )
    }
}

data class Row(val id: String, val title: String, val tags: List<String>)

The state comes from a StateFlow collected with collectAsStateWithLifecycle. What am I looking at?

10 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @kraut_corner · 19h ago · 3 replies

    You have three separate problems and they are all visible in that snippet.

    1. No key. Without key, an item's identity is its index. Any insertion, removal or reorder shifts everything and Compose throws away the state for every item after the change point. Add it:

    items(state.rows, key = { it.id }) { row -> ... }
    

    While you are there add contentType if you have more than one row layout - it lets Compose reuse the right kind of slot instead of composing from scratch.

    2. A new lambda per item per composition. onTap = { viewModel.select(row.id) } allocates a fresh lambda that captures row. New instance, different parameter, RowCard cannot skip. Hoist a single stable callback that takes the id:

    val onSelect = remember { { id: String -> viewModel.select(id) } }
    

    and have RowCard call onSelect(row.id) internally.

    3. List<String> is an unstable type. The compiler cannot prove a List will not be mutated under it, so any composable taking one is not skippable. Either mark the class @Immutable, or use a genuinely immutable collection type. Marking @Immutable is a promise you are making - if you break it, you get stale UI instead of slow UI, which is worse.

    Fix all three. Fixing one and remeasuring will disappoint you, because they compound.

    To verify rather than hope, turn on composition tracing in the profiler, or drop a recomposition-counting modifier on the row in a debug build. Counts should drop to single digits for a full scroll.

    98
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @two_calendars · 23h ago

      All three, and the counts went from 400+ to 3. The lambda one is the one I would never have found - it looks like the most idiomatic possible code.

      26
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @durable_ines · yesterday

      It is idiomatic, which is why it is such a good trap. The rule that helped me remember: any lambda you write inside the item block is created fresh on every composition of that block. If it captures something from the loop it cannot be remembered as-is, so pass the identifier as a parameter instead of capturing it.

      21
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @durable_ines · 6h ago · 2 replies

    Adding a fourth cause that is not in your snippet but is the most common one in real apps: reading state too high up.

    If the parent of your LazyColumn reads a frequently-changing value - scroll offset, an animation, anything per-frame - the entire parent recomposes, and every child goes with it. Sixty times a second.

    The fix is deferred reads. Instead of reading the value and passing the result down, pass a lambda that reads it at the point of use:

    // invalidates composition every frame
    Modifier.offset(x = scrollState.value.dp)
    
    // reads during layout, skips composition entirely
    Modifier.offset { IntOffset(scrollState.value, 0) }
    

    Same for graphicsLayer. The lambda overloads exist precisely so the read happens in the layout or draw phase rather than composition. If you have a collapsing toolbar anywhere near this list, go check it, because that pattern is where I have seen this most often.

    63
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @kraut_corner · yesterday

      This is the one that survives all the other fixes. You can have perfect keys and stable parameters and still recompose the world because a parent reads a scroll offset. Worth learning the three phases - composition, layout, draw - just so you can ask which one a given read belongs in.

      24
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @dartfit_dana · 3d ago

    Check what compiler version you are on before you rewrite everything. Strong skipping changes the rules here - it makes composables with unstable parameters skippable when the arguments are referentially equal, and it remembers lambdas for you. That kills the lambda problem in your snippet and softens the List one.

    What it does not fix is the missing key. Identity in a lazy list is not a stability question, it is a correctness question, and no compiler feature will guess your ids for you.

    So: still add the key, still be deliberate about stability at your API boundaries, but do not go add @Immutable to two hundred data classes if the compiler is already handling it.

    41
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @whetstone_wu · 9h ago

    Also stop passing the ViewModel into the row. It is an unstable type by definition, and it means the row can now do anything, which makes it impossible to reason about and impossible to preview.

    A row should take data and callbacks. That constraint alone prevents most of these problems by making the unstable things impossible to reach from inside the item block.

    27
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @hemline_hank · 2d ago · 2 replies

    Recomposition count is a red herring most of the time - recomposition is cheap by design and 400 of them across ten seconds is nothing. If scrolling is janky I would look at your image loading and whether you are decoding on the main thread, not at the composition counts.

    12
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @whetstone_wu · 2d ago

      Individually cheap, yes. 400 per row across a screen of rows at 60fps is not, and a row that cannot skip is re-running its whole subtree including layout. Image decoding is worth checking too, but "counts do not matter" is how you end up with a list that recomposes the world and nobody knows why.

      9
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @couchto5kagain · 3h ago

    The favourite button on each row is doing more work than my entire previous app.

    5
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report