关于 compose-state-deferred-reads
介绍如何将快速变化的状态读取从组合阶段延迟到布局或绘制阶段,帮助开发者减少滚动、动画和手势期间的不必要重组,并避免 Compose 阶段间的回写。
主要功能
- 01将状态读取延迟到布局或绘制
- 02通过 Provider Lambda 跨组合项传值
- 03避免阶段间状态回写
- 04在测量阶段应用数值
- 05判断状态是否应由组合阶段读取
适用场景
- 01减少滚动期间的重组
- 02处理动画偏移和变换
- 03在延迟列表行之间共享测量尺寸
- 04诊断跨阶段的重复失效
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-deferred-reads安装后在 Claude Code 中输入/compose-state-deferred-reads确认已加载。
详细介绍
Compose 状态延迟读取
核心原则
读取状态会使读取它的阶段失效。如果在可组合项主体中读取 State<T>,发生变化时组合会失效。如果在布局或绘制阶段读取,发生变化时则只会使布局或绘制失效。滚动偏移、动画和拖动位置等帧率状态通常应在布局/绘制阶段读取,而不是在组合阶段读取。
反向写入是对称的失效模式:在某个阶段写入可观察状态,导致更早的阶段失效。Compose 阶段按组合 → 布局 → 绘制的顺序执行。从布局或绘制阶段写入由组合阶段读取的快照支持状态,会使组合失效;在组合期间写入同一轮组合中更早读取的状态,也会产生同样的结果。这两种情况都会安排额外工作,且往往会连带影响相邻的惰性列表项。
解决方法在于结构设计:保留 State<T> 或提供者 lambda,并在布局/绘制回调中读取值;在回调中捕获测量结果,并在测量阶段应用,而不是在相邻可组合项的主体中读取测量状态。
何时使用此技能
- 将
val x by animate*AsState(...)传递给Modifier.offset(x = ...)、Modifier.size(...)、Modifier.graphicsLayer(...)或其他值形式的修饰符。 - 在可组合项主体中读取
LazyListState.firstVisibleItemScrollOffset、ScrollState.value、Animatable.value或手势状态。 - 可组合项接收
scrollOffset: Int、progress: Float、dragOffset: Offset或类似的帧率值。 - 即使数据稳定,滚动、动画或手势期间的重组计数仍持续增加。
- 可组合项主体在每次重组时调用
stateMap[key] = …、list.addAll(…)或类似操作(组合 → 组合反向写入)。 - 一个惰性列表项通过
onSizeChanged/onGloballyPositioned捕获尺寸,而相邻项在组合阶段读取该高度(Modifier.height(state.dp))——布局 → 组合反向写入。
0. 反向写入
反向写入 = 在一个阶段写入可观察状态,导致更早的阶段(或当前阶段)失效。Compose 按组合 → 布局 → 绘制的顺序执行,因此:
- 在组合期间写入快照状态,而该状态会在同一轮组合中被读取。
- 在布局期间写入快照状态(例如通过
Modifier.layout、onSizeChanged、onGloballyPositioned),而该状态会在组合期间被读取。 - 在绘制期间写入快照状态,而该状态会在组合或布局期间被读取。
所有情况下,写入操作都会安排额外的失效处理轮次,且往往会连带影响相邻的惰性列表项。
不要在每次执行时都从可组合项主体写入 mutableStateOf、mutableStateListOf、mutableStateMapOf 或其他快照支持的状态:
// ❌ BAD — mutates observable map during composition; siblings recompose repeatedly
@Composable
fun MergeOverlay(parent: Map<Key, ViewState>, overlay: Map<Key, ViewState>): Map<Key, ViewState> {
val merged = remember { mutableStateMapOf<Key, ViewState>() }
merged.clear()
merged.putAll(parent)
merged.putAll(overlay) // back-writing composition → composition
return merged
}
// ✅ GOOD — read-only merge; no composition-time writes
@Composable
fun MergeOverlay(parent: Map<Key, ViewState>, overlay: Map<Key, ViewState>): Map<Key, ViewState> =
remember(parent, overlay) {
if (overlay.isEmpty()) parent else parent + overlay
}
对于派生的只读快照,优先使用 remember(keys) { … }。mutableState* 写入应留给事件回调(onClick)或副作用,不要在每次组合时用它们重建派生数据。
onSizeChanged 之类的回调会在布局期间写入。只有在更早的阶段不会读取由此产生的状态时,这样做才安全——请参阅下文的跨行测量。
跨行测量(布局 → 组合反向写入)
当行 A 进行测量,而行 B 必须与 A 高度一致时,不要在 B 的可组合项主体中读取 A 捕获的尺寸。onSizeChanged 会在布局期间写入;如果 B 在组合期间读取该值,布局便反向写入了组合:
var anchorHeightPx by remember { mutableIntStateOf(0) }
// ❌ BAD — B reads measurement state in composition; insertion/focus can double-recompose B
RowA(Modifier.onSizeChanged { anchorHeightPx = it.height })
RowB(Modifier.height(with(LocalDensity.current) { anchorHeightPx.toDp() })) // composition read
// ✅ GOOD — capture on A; apply on B in measure phase only
RowA(Modifier.onSizeChanged { if (it.height != anchorHeightPx) anchorHeightPx = it.height })
RowB(
Modifier.decorateMeasureConstraints { incoming ->
if (anchorHeightPx > 0) incoming.copy(minHeight = anchorHeightPx, maxHeight = anchorHeightPx)
else incoming
},
)
decorateMeasureConstraints 是一个小型布局辅助工具(请参阅 compose-modifier-and-layout-style)。在高度尚未确定时,相邻项在组合期间使用固定的回退值;高度确定后,只会使布局失效,不会引发额外的组合级联。
1. 优先使用块形式修饰符
一些修饰符有值形式和块形式。值形式接收组合阶段已读取的值;块形式可以在布局或绘制阶段读取。
// Before: animated value read in composition by the `by` delegate
@Composable
fun SelectionPill(selectedIndex: Int) {
val offsetX by animateDpAsState(120.dp * selectedIndex)
Box(Modifier.offset(x = offsetX))
}
// After: State is kept, value is read in the layout-phase offset block
@Composable
fun SelectionPill(selectedIndex: Int) {
val offsetX = animateDpAsState(120.dp * selectedIndex)
Box(
Modifier.offset {
IntOffset(offsetX.value.roundToPx(), 0)
},
)
}
常见替换:
| 组合阶段读取 | 延迟读取 |
|---|---|
Modifier.offset(x = animatedX) | Modifier.offset { IntOffset(animatedX.value.roundToPx(), 0) } |
Modifier.graphicsLayer(translationY = y) | Modifier.graphicsLayer { translationY = yProvider() } |
val radius by animateFloatAsState(...); drawBehind { drawCircle(radius = radius) } | val radius = animateFloatAsState(...); drawBehind { drawCircle(radius = radius.value) } |
drawBehind 块本身已处于绘制阶段;关键在于 State.value 也要在该块内读取。
2. 跨可组合项边界传递提供器
如果快速变化的值需要跨越可组合项边界,请传递提供器 lambda,而不是快照值:
// Before: HomeScreen reads scroll offset in composition and passes the value down
@Composable
fun HomeScreen() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item { HeroImage(scrollOffset = listState.firstVisibleItemScrollOffset) }
}
}
@Composable
fun HeroImage(scrollOffset: Int, modifier: Modifier = Modifier) {
AsyncImage(
model = "...",
modifier = modifier.graphicsLayer(translationY = -scrollOffset / 2f),
)
}
// After: the only read happens inside graphicsLayer
@Composable
fun HomeScreen() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item {
HeroImage(
scrollOffsetProvider = {
if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset
} else {
0
}
},
)
}
}
}
@Composable
fun HeroImage(scrollOffsetProvider: () -> Int, modifier: Modifier = Modifier) {
AsyncImage(
model = "...",
modifier = modifier.graphicsLayer {
translationY = -scrollOffsetProvider() / 2f
},
)
}
如果有助于明确延迟读取约定,请将提供器参数命名为以 Provider 结尾。
3. 其他布局/绘制读取位置
也可以在以下位置延迟读取状态:
Modifier.layout { measurable, constraints -> ... }- 自定义
Alignment.align(...) drawWithContent、drawBehind和其他绘制修饰符- 块形式的图层/布局修饰符,例如
graphicsLayer { ... }和offset { ... }
当状态会改变某个元素的放置或绘制位置时,请使用这些方式。如果状态决定哪些可组合项会存在,则应在组合阶段读取。
快速参考
| 症状 | 诊断 | 修复 |
|---|---|---|
val x by animateFloatAsState(...) 后接 Modifier.offset(...) | by 会在组合阶段读取 | 保留 State<Float>,并在 offset {} 中读取 .value |
Modifier.graphicsLayer(translationY = animatedY) | 属性参数形式使用组合阶段的值 | 使用 graphicsLayer { translationY = ... } |
Child(scrollOffset = listState.firstVisibleItemScrollOffset) | 快速变化的值跨越边界 | Child(scrollOffsetProvider = { ... }) |
| 绘制块每帧仍会重新组合 | 值在进入绘制块前就已读取 | 将 State.value 的读取移到绘制块内 |
| 状态用于在不同 UI 分支间选择 | 组合决策 | 保持在组合阶段读取 |
可组合项主体中的 mergedMap.putAll(overlay) | 从组合阶段回写到组合阶段 | remember(parent, overlay) { parent + overlay } |
同级项上的 Modifier.height(measuredPx.toDp()) | 从布局阶段回写到组合阶段 | 使用测量阶段约束装饰器 |
| 用于只读合并的身份缓存 | 存在覆盖项过时的风险 | 对不可变结果使用 remember(keys) |
何时不应使用
- 状态用于控制发出哪些可组合项。
- 动画是一次性的、开销很小,且清晰度更重要。
- 你正在编写测试,直接断言值更简单。
- 运行时证据表明重新组合不是瓶颈。
相关内容
compose-state-authoring— 何时应在组合阶段而非回调中使用mutableState*。compose-state-hoisting— 跨边界传递提供器/lambda 时,状态持有者与纯 UI 的拆分适用位置。compose-stability-diagnostics— 参数稳定性和编译器报告。compose-modifier-and-layout-style— 测量阶段约束装饰器辅助工具。