关于 deferring-state-reads
面向 Claude 的技能,通过将高频变化的状态读取从 Composition 移到 Layout 或 Draw,减少 Jetpack Compose 重组。它讲解基于 lambda 的 Modifier,以及如何验证动画和滚动界面的改动。
主要功能
- 01识别会触发 Composition 失效的状态读取
- 02将值形式 Modifier 替换为 Layout 或 Draw 的 lambda 形式
- 03在 graphicsLayer 中合并动画变换
- 04通过 lambda 提供器跨可组合项传递热值
- 05用 Layout Inspector 或跟踪验证重组变化
适用场景
- 01减少动画期间的重组
- 02避免滚动时父级可组合项重组
- 03将动画绘制工作移出 Composition
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill deferring-state-reads安装后在 Claude Code 中输入/deferring-state-reads确认已加载。
附带文件references/three-phases.md
详细介绍
延迟读取状态——将高频读取从组合阶段移到绘制阶段
Compose 每帧运行三个阶段:组合 → 布局 → 绘制。在阶段 N 读取状态会使阶段 N 及其后的所有阶段失效。在任何由动画或滚动驱动的 UI 中,提升性能最显著的做法就是通过基于 lambda 的修饰符,将状态读取从组合阶段移到布局或绘制阶段。本技能将教 Claude 如何识别发生在错误阶段的读取,并将其迁移到正确阶段。
何时使用本技能
- 动画导致每帧都对整个子树进行重组(
Modifier.alpha(progress.value)、Modifier.offset(x.dp)、Modifier.padding(state.dp)——padding没有 lambda 重载,因此动画调整内边距时,请改用Modifier.layout { ... }或Modifier.offset { IntOffset(...) })。 - 在可组合函数主体中直接读取滚动位置(
val y = scrollState.value),导致父级在每滚动一个像素时都重组。 - 开发者提到“每帧”“滚动卡顿”“动画卡顿”“掉帧”,或报告整个屏幕在拖动时都会重组。
@TraceRecomposition日志显示父级的重组计数器每个动画刻度都会递增一次。- 开发者将高频变化的值(动画进度、滚动偏移量、拖动增量)作为
Float参数传递给其他可组合函数。
何时不使用本技能
- 状态只会在一次用户交互时改变(点击按钮、打开对话框)——改写为 lambda 修饰符毫无收益,只会增加冗余。
- 状态读取确实需要驱动组合(显示或隐藏不同的可组合项、切换不同的组件树)。lambda 修饰符无法决定要发出哪个可组合项。
- 父级不可跳过的原因不同(参数不稳定)。请先使用
../../stability/diagnosing-compose-stability/SKILL.md进行诊断。 - 开发者想将许多高频输入过滤为一个低频布尔值——这属于
../choosing-derivedstateof/SKILL.md。
前置条件
- 熟悉三阶段模型。如果不确定读取发生在哪个阶段,请先阅读
references/three-phases.md,再进行迁移。 - Compose UI 1.7+,以使用
rememberGraphicsLayer()和新版Modifier.animateItem()形式。所有受支持的 Compose 版本都提供Modifier.offset { }、Modifier.graphicsLayer { }、Modifier.drawBehind { }和Modifier.drawWithCache { }的 lambda 形式。 - 能够在 Layout Inspector 中确认重组次数,或通过
@TraceRecomposition确认(如果此仓库中存在../debugging-recompositions/SKILL.md,请参阅;否则使用 skydovescompose-stability-analyzer运行时)。 - 任何最终测量都应使用 release 构建——debug 构建以解释执行方式运行,无法真实反映开销。
工作流
-
1. 找出状态读取位置和使用它的修饰符。 在可组合函数体中查找
state.value、by state、animatedFloat.value、scrollState.value、dragOffset.value。记录哪个修饰符使用了它。 -
2. 判断修饰符属于哪种形式——值形式还是 lambda 形式。 大多数定位、尺寸和绘制修饰符都有两种形式:
| 值形式(在 Composition 中读取) | Lambda 形式(在 Layout 或 Draw 中读取) |
|---|---|
Modifier.offset(x = dp, y = dp) | Modifier.offset { IntOffset(x, y) } |
Modifier.padding(start = dp, …) | Modifier.layout { measurable, constraints -> … }(padding 没有 lambda 重载;要用热状态进行内缩,请使用自定义布局) |
Modifier.size(dp) | Modifier.layout { … }(自定义) |
Modifier.alpha(float) | Modifier.graphicsLayer { alpha = … } |
Modifier.rotate(float) | Modifier.graphicsLayer { rotationZ = … } |
Modifier.scale(float) | Modifier.graphicsLayer { scaleX = …; scaleY = … } |
Modifier.background(color) | Modifier.drawBehind { drawRect(color) } |
如果值形式使用了热状态,请改用 lambda 形式。每次 Layout(或 Draw)阶段执行时都会调用该 lambda,而无需重新运行 Composition。
-
3. 将值形式的修饰符迁移到 lambda 形式。 状态读取发生变化时,lambda 体会重新执行,但执行阶段是 Layout 或 Draw,不会使 Composition 失效。
-
4. 对多个逐帧变换,将它们提升到同一个
Modifier.graphicsLayer { … }代码块中。 它可以在一个 Draw 阶段节点中处理translationX/translationY、scaleX/scaleY、rotationZ、alpha、cameraDistance、clip和shape,优先于串联使用alpha+offset+rotate。 -
5. 以 lambda provider 的形式跨可组合函数提升热值。 传递
() -> Float而不是Float,这样接收方可将读取推迟到自己的 lambda 修饰符中,而不会使调用方失效。这是 lambda 修饰符本身之后,最重要的跨可组合函数性能技巧。 -
6. 在 Layout Inspector /
@TraceRecomposition中验证。 父可组合函数的重组次数必须停止逐动画帧递增。如果仍在增加,说明其他位置仍存在阶段不匹配的读取——重新在文件中搜索.value和by state。 -
7. 对变换之外的逐帧 Draw 工作,使用
Modifier.drawBehind { }或Modifier.drawWithCache { }。drawBehind会在每次 Draw 阶段执行时重新运行;drawWithCache会缓存构建步骤(例如路径、画刷),仅在状态变化时重新运行onDraw代码块。
模式
模式:动画偏移
// WRONG
val animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset(x = animatedX.value.dp))
// WRONG because: reading .value in Composition phase invalidates the whole subtree on every animation frame.
// RIGHT
val animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset { IntOffset(animatedX.value.toInt(), 0) })
该 lambda 在 Layout 阶段运行,因此不会使 Composition 失效。动画仍会通过仅影响 Layout 的失效来驱动视觉位置。
模式:动画 alpha(以及其他变换)
// WRONG
val animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.alpha(animatedAlpha))
// WRONG because: Modifier.alpha(Float) reads the value in Composition; the entire Box subtree recomposes every frame.
// RIGHT
val animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.graphicsLayer { alpha = animatedAlpha })
graphicsLayer { } 只会在 Draw 阶段读取状态。对于组合变换,请将它们全部放入同一个代码块:
// RIGHT — one Draw-phase node, three transforms
Box(
Modifier.graphicsLayer {
alpha = progress
scaleX = 1f + 0.2f * progress
scaleY = 1f + 0.2f * progress
translationX = progress * 32.dp.toPx()
}
)
模式:由滚动驱动的粘性标题
// WRONG
val offset = scrollState.value
Header(Modifier.offset(y = offset.dp))
// WRONG because: scrollState.value updates every pixel of scroll, and reading it in the parent body recomposes Header (and every sibling) per scroll tick.
// RIGHT
Header(Modifier.offset { IntOffset(0, scrollState.value) })
该 lambda 捕获 scrollState(一个稳定的容器),只在 Layout 运行时读取 .value,并将失效限制在 Header 的 Layout 阶段。
模式:跨可组合函数使用 lambda provider
// WRONG
@Composable
fun Parent(scrollOffset: Float) {
Child(scrollOffset)
}
@Composable
fun Child(scrollOffset: Float) {
Box(Modifier.offset { IntOffset(0, scrollOffset.toInt()) })
}
// WRONG because: scrollOffset is read in Parent's signature each frame; Parent and every sibling of Child recompose per frame even though only Child cares.
// RIGHT
@Composable
fun Parent(scrollOffset: () -> Float) {
Child(scrollOffset)
}
@Composable
fun Child(scrollOffset: () -> Float) {
Box(Modifier.offset { IntOffset(0, scrollOffset().toInt()) })
}
lambda 参数是一个稳定的函数引用。在 Modifier.offset { } 中调用它,会将读取推迟到 Layout 阶段。父级不会重新读取热状态,因此不会发生重组。
模式:逐帧 Draw 工作——drawBehind / drawWithCache
// WRONG
val color = animatedColor.value
Box(Modifier.background(color))
// WRONG because: Modifier.background reads color in Composition; per-frame color animation invalidates the subtree.
// RIGHT — Draw phase only, no caching needed for a solid color
Box(Modifier.drawBehind { drawRect(animatedColor.value) })
// RIGHT — heavier work; cache the Path/Brush across frames
Box(
Modifier.drawWithCache {
val brush = Brush.linearGradient(
colors = listOf(start, end),
start = Offset.Zero,
end = Offset(size.width, size.height),
)
onDrawBehind { drawRect(brush, alpha = animatedAlpha.value) }
}
)
只有当 lambda 捕获的 remember 键发生变化时,drawWithCache 才会重新构建缓存;每次 Draw 阶段都会使用最新状态重新运行 onDrawBehind 代码块。
三阶段参考
Compose 每一帧都会按此顺序运行。**在第 N 阶段读取状态,会使第 N 阶段及其之后的所有阶段失效。**尽可能晚地读取状态。
| 阶段 | 作用 | 失效时的开销 |
|---|---|---|
| 组合 | 运行 @Composable 函数,构建/比较 UI 树 | 最高 — 所有跳过门控都要重新运行,子可组合项可能会重新组合 |
| 布局 | 对每个节点执行 measure() 和 placeRelative() | 中等 — 重新测量并重新放置受影响的子树 |
| 绘制 | 将绘制命令记录到画布中 | 最低 — 单次渲染过程 |
Modifier 阶段速查表(最常见情况)
| Modifier | 读取状态的阶段 |
|---|---|
Modifier.offset(Dp) | 组合 |
Modifier.offset { IntOffset } | 布局 |
Modifier.padding(Dp) / padding(PaddingValues) | 组合(不存在 lambda 重载) |
Modifier.size(Dp) | 组合 |
Modifier.layout { measurable, constraints -> … } | 布局(当高频变化状态必须驱动测量时的逃生口,例如动画内边距 — padding 未提供 lambda 形式) |
Modifier.alpha(Float) | 组合 |
Modifier.rotate(Float) | 组合 |
Modifier.scale(Float) | 组合 |
Modifier.graphicsLayer { … } | 绘制 |
Modifier.background(Color) | 组合 |
Modifier.drawBehind { … } | 绘制 |
Modifier.drawWithCache { … } | 绘制(其捕获的状态发生变化时会重建缓存) |
关于更深入的机制 — 失效传播、反向写入规则以及各 Modifier 的全面细分,请参阅 references/three-phases.md。
强制规则
- 必须优先为变化频率高于每次用户交互一次的状态使用基于 lambda 的 Modifier(动画值、滚动位置、拖动偏移量、手势增量)。
- 必须使用
Modifier.graphicsLayer { … }处理动画alpha、scale、rotate、translation。对于高频变化状态,切勿使用Modifier.alpha(state.value)/Modifier.rotate(state.value)/Modifier.scale(state.value)。 - 不得在可组合函数体内读取频繁变化的状态,而应让 lambda Modifier 稍后读取。每避免在可组合函数体中读取一次
.value,每帧就少一次组合失效。 - 不得在同一组合过程中写入已读取的
MutableState— 这属于反向写入(参见references/three-phases.md);运行时会中止重组并产生开销。 - **优先:**当值频繁变化且跨越可组合项边界时,使用 lambda 提供器(
() -> T),而非普通值。在接收端配合 lambda Modifier 使用。 - **优先:**当每帧工作涉及依赖尺寸或主题、且可重建的资源(路径、画笔、渐变)时,使用
Modifier.drawWithCache { }而非Modifier.drawBehind { }。
验证
- Layout Inspector 显示动画每一帧中父可组合项的重组计数为 0(或接近 0)。只有布局/绘制计数递增。
-
@TraceRecomposition(traceStates = true)(skydoves/compose-stability-analyzer)确认在发布版 + R8 + 真机环境下,包装可组合项不会逐帧重组;只有附加了 lambda Modifier 的子节点会重新运行。 - 对迁移后的文件执行 grep,确认没有由高频变化状态驱动的
Modifier.alpha(/Modifier.rotate(/Modifier.scale(/Modifier.offset(<dp expr>)模式。 - 跨可组合项传递的高频变化值使用
() -> Tlambda 提供器,而非T值。 - 发布版仍能正确渲染 — 只有当 lambda 函数体与原有算术运算一致时,从值形式改为 lambda 形式才不会改变行为(注意
dp与px的区别:Modifier.offset { IntOffset(x, y) }接收的是像素)。
参考资料
- Android Developers — Jetpack Compose 的各个阶段:https://developer.android.com/develop/ui/compose/phases
- Android Developers — 性能:各个阶段:https://developer.android.com/develop/ui/compose/performance/phases
- Android Developers — 图形修饰符:https://developer.android.com/develop/ui/compose/graphics/draw/modifiers
- Android Developers — 自定义修饰符(Modifier.Node):https://developer.android.com/develop/ui/compose/custom-modifiers
- Ben Trengrove — 调试重组:https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- 为什么要在发布版本中测试性能:https://medium.com/androiddevelopers/why-should-you-always-test-compose-performance-in-release-4168dd0f2c71
- Chris Banes — Compose 性能标签页:https://chrisbanes.me/tags/jetpack-compose-performance/
- skydoves — 6 条 Jetpack Compose 指南:https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- skydoves — Jetpack Compose 机制:https://speakerdeck.com/skydoves/jetpack-compose-mechanism
references/three-phases.md— 详细介绍 Composition / Layout / Draw、向下失效、反向写入规则,以及全面的修饰符阶段速查表。