mcpField
D
deferring-state-reads

deferring-state-reads

github.com/skydoves开发520139 次安装19 周前更新
代码仓库

关于 deferring-state-reads

面向 Claude 的技能,通过将高频变化的状态读取从 Composition 移到 Layout 或 Draw,减少 Jetpack Compose 重组。它讲解基于 lambda 的 Modifier,以及如何验证动画和滚动界面的改动。

主要功能

  1. 01识别会触发 Composition 失效的状态读取
  2. 02将值形式 Modifier 替换为 Layout 或 Draw 的 lambda 形式
  3. 03在 graphicsLayer 中合并动画变换
  4. 04通过 lambda 提供器跨可组合项传递热值
  5. 05用 Layout Inspector 或跟踪验证重组变化

适用场景

  1. 01减少动画期间的重组
  2. 02避免滚动时父级可组合项重组
  3. 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,请参阅;否则使用 skydoves compose-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>) 模式。
  • 跨可组合项传递的高频变化值使用 () -> T lambda 提供器,而非 T 值。
  • 发布版仍能正确渲染 — 只有当 lambda 函数体与原有算术运算一致时,从值形式改为 lambda 形式才不会改变行为(注意 dp 与 px 的区别:Modifier.offset { IntOffset(x, y) } 接收的是像素)。

参考资料

评论

支持 Markdown