mcpField
C
compose-state-deferred-reads

compose-state-deferred-reads

github.com/chrisbanes开发1.1k989 次安装1 天前更新
代码仓库

关于 compose-state-deferred-reads

介绍如何将快速变化的状态读取从组合阶段延迟到布局或绘制阶段,帮助开发者减少滚动、动画和手势期间的不必要重组,并避免 Compose 阶段间的回写。

主要功能

  1. 01将状态读取延迟到布局或绘制
  2. 02通过 Provider Lambda 跨组合项传值
  3. 03避免阶段间状态回写
  4. 04在测量阶段应用数值
  5. 05判断状态是否应由组合阶段读取

适用场景

  1. 01减少滚动期间的重组
  2. 02处理动画偏移和变换
  3. 03在延迟列表行之间共享测量尺寸
  4. 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)

何时不应使用

  • 状态用于控制发出哪些可组合项。
  • 动画是一次性的、开销很小,且清晰度更重要。
  • 你正在编写测试,直接断言值更简单。
  • 运行时证据表明重新组合不是瓶颈。

相关内容

评论

支持 Markdown