跳到正文
DEV Community· Android Expert·· 17 小時前AI 評分46

深入拆解 LazyColumn:LazyLayout、槽位重用及上線後的失效模式

LazyColumn Under the Hood: LazyLayout, Slot Reuse, and the Failure Modes That Ship to Production

AI 導讀

LazyColumn 的運作取決於視窗所需內容、預取策略及 LazyLayout 的槽位重用機制,項目只會在進入視窗或預取範圍時組合。文章説明 key、contentType 如何影響項目身份、狀態保存與重用,並指出昂貴的項目組合會阻塞測量階段,造成畫面卡頓。

正文

LazyColumn looks like a simple function. You pass it a list, it scrolls. But if you have ever debugged a checkbox that jumped to the wrong row, a text field that cleared itself mid-scroll, or a feed that stutters only when ads appear, you already know there is a machine behind it.

This post walks through that machine: what LazyColumn composes, when, and what happens to your state when an item leaves the screen. Along the way we will pin down what key and contentType really do. A popular explanation of contentType is slightly off, and the difference matters for performance work.

Version note:LazyLayout internals (prefetch strategies, reuse policies, pausable composition) have changed between Compose releases and some remain experimental. Treat numbers such as pool sizes as implementation details and verify against the Compose version you ship.

1. The mental model: composition follows the viewport

LazyColumn does not compose every item. It composes what the current viewport needs, plus a little work done ahead of time.

lazy-viewport-window

Four populations of items exist at any moment:

  1. Visible items are composed, measured, and placed during the measure pass. How many there are depends on the viewport size, the actual (often variable) item sizes, content padding, spacing, and partially visible edge items. It does not depend on list.size.
  2. A prefetched item is composed ahead of time, in the scroll direction, using idle time in the frame. If it is not ready when the next frame needs it, the layout composes it synchronously during measure, and that is where jank comes from.
  3. Recently departed items are not necessarily destroyed. Their composition slots may be retained in a reuse pool, bucketed by contentType.
  4. Everything else does not exist. No composable runs, no LayoutNodes are allocated, nothing is remembered. A 10,000-item list costs roughly what a 20-item list costs.

You will often see this described as “visible items plus prefetchDistance". There is no prefetchDistance parameter on LazyColumn. Prefetching is driven by LazyListState and its prefetch strategy: by default, the next item in the scroll direction and, for a LazyRow nested in a LazyColumn, the first few items of the upcoming row. Recent Compose versions expose the strategy as an experimental API, and pausable composition lets a prefetch span frames instead of blowing one frame's budget.

2. The stack: from LazyColumn to LayoutNode

lazylayout-stack

Each layer explains a behavior you will meet later:

  • LazyColumn { items(...) } is only a DSL. The lambda records intervals (index ranges, each with a key, a contentType, and an item lambda) into an item provider. It does not compose anything, so never put per-item side effects in it.
  • LazyListState and the measure policy own scroll position and, on every measure pass, work out which indices the incoming constraints require.
  • LazyLayout is the shared foundation. LazyColumn, LazyRow, LazyVerticalGrid, LazyVerticalStaggeredGrid, and pagers are policies on top of it. It connects an item provider, a measure policy, and prefetch state.
  • SubcomposeLayout is what LazyLayout is built on, and it makes laziness possible. Normal composition happens before layout. SubcomposeLayout lets measure-time code call subcompose(slotId, content), so composition can depend on layout results ("how many items fit?"). It also owns the slot pool used for reuse; LazyLayout plugs in its own reuse policy (a SubcomposeSlotReusePolicy that is aware of contentType) and wraps item content through an internal content factory.
  • Composer and Applier do the real work, producing LayoutNodes that are measured and placed in the same pass.

The practical consequence: item composition happens inside the measure phase. An expensive item composable is not “slow to scroll”. It is slow to measure, and it blocks the frame.

3. Life of an item

item-lifecycle

As you scroll, every item follows the same path:

  1. The measure pass decides an index is needed, because it entered the viewport or the prefetch window.
  2. LazyLayout asks SubcomposeLayout for a slot. If a free slot of the same contentType exists it is reused; otherwise a new composition is created.
  3. The item is measured, placed, and drawn.
  4. It scrolls out of bounds.
  5. Its slot goes to the reuse pool if there is room (roughly seven slots by default). If the pool is full, or no compatible item arrives, the slot is disposed.

What happens to your state

Reuse changes the answer to “is my state still there?”, which is why this trips up so many teams:

  • remember { } is lost when the item is disposed, and also reset when its slot is reused for a different item. Never assume it survives a scroll.
  • rememberSaveable { } is stored per key in a SaveableStateHolder and restored when the same key scrolls back in. The LazyColumn documentation asks that the key type be saveable via a Bundle on Android (String, Long, Int, a Parcelable, and so on) so the saved state can survive process death. Within a single process, the registry keeps it in memory.
  • State hoisted by item id (a ViewModel or state holder) survives scrolling, reordering, and rotation. Anything a user would be upset to lose belongs here.

4. key: identity, not just state

By default an item’s identity is its index. That is fine for append-only lists and wrong for anything that reorders, inserts, or deletes.

@Immutable
sealed interface FeedItem {
    val id: Long
    val stableKey: String // unique across ALL types, saveable in a Bundle

    data class Post(override val id: Long, val title: String, val body: String) : FeedItem {
        override val stableKey: String = "post-$id" // computed once, not per read
    }
    data class Ad(override val id: Long, val sponsor: String) : FeedItem {
        override val stableKey: String = "ad-$id"
    }
    data class Story(override val id: Long, val frames: List<String>) : FeedItem {
        override val stableKey: String = "story-$id"
    }
}

A key buys you three things:

  • State follows the item. rememberSaveable state is attached to the key, not the position, so a checkbox stays with its row when the list changes.
  • Scroll position is anchored. LazyListState tracks the first visible item by key, so inserting items above the viewport does not shove the user's content around.
  • Modifier.animateItem() works. Item animations need identity to tell "moved" from "removed and added".

Note the "post-$id" / "ad-$id" prefixes. Raw ids from different tables often collide, and a duplicate key is a hard crash, not a warning.

5. contentType: what it really does

Here is the version of the story you will find in many posts, and the correction:

  • Often claimed: without contentType, every item is unique, so nothing is recycled.
  • Actually true: contentType defaults to null, and null is a valid type. Items without one are all considered compatible with each other. Reuse still happens, but it happens across items whose composition structure may have nothing in common.

contenttype-reuse

Reuse is only cheap when the new item has the same shape as the old one. When a slot built for an Ad receives a Story, Compose has to diff the groups, dispose the ad's nodes, and create the story's nodes. You paid for the slot bookkeeping and still got something close to a fresh composition. During a fast fling through a mixed feed, that is exactly the pattern that produces dropped frames.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.unit.dp

@Composable
fun FeedList(
    feed: List<FeedItem>,
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
) {
    LazyColumn(
        modifier = modifier,
        state = state,
        contentPadding = PaddingValues(vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(
            items = feed,
            key = { it.stableKey },
            contentType = { item ->
                when (item) {
                    is FeedItem.Post -> "post"
                    is FeedItem.Ad -> "ad"
                    is FeedItem.Story -> "story"
                }
            },
        ) { item ->
            when (item) {
                is FeedItem.Post -> PostCard(item)
                is FeedItem.Ad -> AdCard(item)
                is FeedItem.Story -> StoryRow(item)
            }
        }
    }
}

With this in place, a post that leaves the viewport parks its slot in the post bucket. The next post to arrive takes that slot, the node structure already matches, and only parameters that actually changed recompose.

Two rules of thumb:

  • Use one contentType per distinct layout shape, not per data class. Two data classes that render the same composable can share a type, and one data class that renders two layouts (say, Post with and without media) needs two.
  • Prefer cheap constants. contentType is evaluated frequently during measure. Return a string literal or enum, never something allocated per call.

Because reuse resets remember, per-item UI state must be keyed, saved, or hoisted. Reuse is not a bug that "loses" your state. It is the designed behavior that your state model has to survive.

6. Failure modes in production

failure-modes

Let’s expand the ones that cost the most debugging time.

6.1 State lands on the wrong row

Without a key, identity is the index. Insert one item at the top and the state that lived at index 3 now belongs to a different item.

// Bug: identity is the index
items(feed) { PostCard(it) }

// Fix: identity is the domain id
items(feed, key = { it.stableKey }) { PostCard(it) }

6.2 IllegalArgumentException: Key "…" was already used

Common with paging, where adjacent pages overlap, and with ids that are only unique per type. Deduplicate upstream and namespace keys by type, as in section 4. Do not “fix” this by appending the index to the key, since that silently reintroduces index identity.

6.3 Nested vertical scrolling

This one throws an IllegalStateException (not the IllegalArgumentException of duplicate keys):

// Crash: "Vertically scrollable component was measured with an infinity maximum height"
Column(Modifier.verticalScroll(rememberScrollState())) {
    Header()
    LazyColumn { items(feed) { PostCard(it) } }
}

A lazy list needs a bounded height to know what fits. Put the header inside the list instead:

LazyColumn {
    item(key = "header", contentType = "header") { Header() }
    items(feed, key = { it.stableKey }, contentType = { "post" }) { PostCard(it) }
    item(key = "footer", contentType = "footer") { Footer() }
}

6.4 UI state that resets on scroll

Expanded cards and half-typed text are classic victims. Scroll away, scroll back, and they are gone.

// Resets whenever the slot is disposed or reused
var expanded by remember { mutableStateOf(false) }

// Survives scrolling and process death, given a stable key on the item
var expanded by rememberSaveable { mutableStateOf(false) }

For anything more valuable than a toggle, hoist by id:

class FeedViewModel : ViewModel() {
    private val _expanded = MutableStateFlow<Set<String>>(emptySet())
    val expanded: StateFlow<Set<String>> = _expanded

    fun toggle(key: String) = _expanded.update { if (key in it) it - key else it + key }
}

6.5 Reading scroll state in composition

// Bug: recomposes the whole parent each time the first visible index changes
val showButton = state.firstVisibleItemIndex > 3

// Fix: only recompose when the boolean actually flips
val showButton by remember {
    derivedStateOf { state.firstVisibleItemIndex > 3 }
}

To be precise, firstVisibleItemIndex changes only when the first visible item changes, so intra-item pixel scrolling (firstVisibleItemScrollOffset) does not trigger it. In a fast fling it still changes many times per second, and every change recomposes the parent for no visible effect.

The same applies to infinite scrolling. Do it as a side effect, not a composition read:

LaunchedEffect(state) {
    snapshotFlow {
        val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
        last >= state.layoutInfo.totalItemsCount - 5
    }
        .distinctUntilChanged()
        .filter { it }
        .collect { onLoadMore() }
}

The snapshotFlow block re-runs whenever layoutInfo changes, but distinctUntilChanged() ensures downstream code only sees the boolean flipping, and nothing recomposes.

6.6 Unstable parameters defeating skipping

Item composables can only skip recomposition when their inputs are stable and equal. A List<String> parameter on a non-strong-skipping setup, or a lambda recreated on every parent recomposition, forces every visible item to recompose. Prefer immutable models, @Immutable/@Stable where honest, kotlinx.collections.immutable for collections, and lambdas that capture stable values such as the item id.

7. A checklist before you ship a feed

  • Every items block has a stable, unique, type-namespaced key.
  • Every items block has a contentType per distinct layout shape, as cheap constants.
  • No per-item UI state lives in plain remember unless losing it is acceptable.
  • Scroll-derived UI uses derivedStateOf or snapshotFlow, not direct reads in composition.
  • No lazy list sits inside an unbounded vertical scroll container.
  • You have profiled a fast fling on a mid-range device in a release build, with R8 and baseline profiles. Debug builds exaggerate lazy-list jank considerably.

🔚 Takeaways

  • LazyColumn composes the viewport plus a prefetched item, and does so inside the measure pass.
  • Slots left behind are pooled by contentType, not destroyed immediately, and reuse resets remember.
  • key gives items identity. contentType gives slots compatibility. Without contentType, reuse still happens, just between items that may not match, which costs you most of its benefit.
  • Most production bugs here are not Compose bugs. They are state models that quietly assumed items live forever.

📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps

If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend “Mastering Android AI: App to Intelligence” It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.


Originally published on Medium.

來源:DEV Community · dev.to