关于 migrating-to-modifier-node
面向 Claude 的技能,讲解如何用 `Modifier.Node` 编写 Compose 自定义 Modifier,并从 `Modifier.composed { }` 迁移。内容涵盖节点接口、参数更新、生命周期管理和迁移验证。
主要功能
- 01将 `composed` Modifier 迁移为节点
- 02选择专用节点接口
- 03通过元素更新节点参数
- 04管理协程与节点生命周期
- 05验证迁移目标
适用场景
- 01替换旧式自定义绘制 Modifier
- 02构建处理指针输入的 Modifier
- 03将协程工作移入节点生命周期
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill migrating-to-modifier-node安装后在 Claude Code 中输入/migrating-to-modifier-node确认已加载。
附带文件references/modifier-node-anatomy.md
详细介绍
迁移到 Modifier.Node — 用持久化节点替代 composed { }
Modifier.composed { } 会为每个修饰符在每次组合时分配一个新的可组合作用域:无法跳过、无法提升,还会强制父级在每次重组时运行。Modifier.Node 是一个持久化节点,通过 ModifierNodeElement.equals() 进行差异比较——首次应用时创建,后续应用时原地更新。不会在每次重组时分配对象,不会创建新的可组合作用域,也不会形成父级失效链。因此,Android Developers 将该系统描述为“从根本上就是为了比旧版 composed { } 工厂有更高的性能而设计的”(参见 developer.android.com/develop/ui/compose/custom-modifiers)。本技能将教 Claude 如何使用 Modifier.Node 编写新修饰符,以及如何迁移旧版 composed { } 工厂。
何时使用此技能
- 自定义修饰符当前使用
Modifier.composed { }(在模块中搜索Modifier.composed)。 - 从头编写新的自定义修饰符——不要从
composed { }开始。 - 代码审查发现了
composed { }工厂。 - 自定义修饰符需要
CoroutineScope(动画循环、防抖器)、读取CompositionLocal、参与布局/绘制/指针输入,或跟踪布局坐标。 @TraceRecomposition日志显示,由于修饰符链中包含composed { }修饰符,父级可组合函数在每一帧都会重组。
何时不使用此技能
- 这个“修饰符”实际上是一个单行可组合包装函数,可以继续作为
@Composable函数——无需改动。 - 内置修饰符组合(
Modifier.padding(...).clickable(...))已足够,无需自定义节点行为。 - 开发者需要的修复是调整现有修饰符链的顺序,而非编写新节点——请参阅
../ordering-modifier-chains/SKILL.md。 - 自定义修饰符仅通过
Modifier.composed { }读取高频动画值,以便传给下方的值形式修饰符——根本问题是状态读取阶段不正确;请参阅../../recomposition/deferring-state-reads/SKILL.md。
前置条件
- Compose UI 1.5+ —
Modifier.Node和专用节点接口在此版本中已稳定。 - Kotlin 2.0+,并应用了
org.jetbrains.kotlin.plugin.compose。Strong Skipping 默认启用;不可跳过的修饰符在滚动速度较快时会叠加影响。 - 最终测量请使用 release 构建(skydoves 的热门观点 #5:debug 构建以解释执行方式运行,结果不可信)。
- 如需了解各接口完整的重写范围和生命周期图示,请在编写
DrawModifierNode以外的内容前阅读references/modifier-node-anatomy.md。
工作流
-
1. 找出模块中所有
Modifier.composed { }工厂。 使用 grep 搜索Modifier.composed。每个匹配项都是一个迁移目标。记录函数体的作用——remember、drawBehind、LaunchedEffect、读取CompositionLocal,还是指针处理器——因为这决定了需要哪些专用节点接口。 -
2. 梳理每次 Modifier.Node 迁移都会产生的三个部分。
- (a) 公共扩展函数 —
fun Modifier.foo(...): Modifier = this then FooElement(...)。名称和签名与旧的composed { }工厂相同。 - (b)
ModifierNodeElement<T>data class— 保存参数;实现create()(首次应用时调用一次)和update(node: T)(后续应用时调用)。 - (c)
Modifier.Node子类 — 保存可变状态(var字段),实现一个或多个专用节点接口,并运行生命周期钩子(onAttach、onDetach、onReset)。
- (a) 公共扩展函数 —
-
3. 将 Element 设为
data class。 Compose 通过编译器合成的equals()/hashCode()来决定调用update()还是保留节点不变。必须使用data class。普通的class会退回到引用相等判断,差异比较会认为每次应用都是新的修饰符,因此永远不会调用update()——节点会悄无声息地保留过期参数。 -
4. 选择合适的专用节点接口。
Modifier.Node本身是空的;行为来自它实现的接口。常用接口如下:
| 接口 | 在修饰符需要执行以下操作时使用…… |
|---|---|
DrawModifierNode | 绘制(替代 drawBehind/drawWithCache) |
LayoutModifierNode | 测量/放置(替代 layout { } 和自定义 Layout) |
SemanticsModifierNode | 提供无障碍信息 |
PointerInputModifierNode | 处理指针/手势输入(替代 pointerInput) |
CompositionLocalConsumerModifierNode | 在节点内部读取 CompositionLocal |
LayoutAwareModifierNode | 在此节点的尺寸/坐标发生变化时收到通知 |
GlobalPositionAwareModifierNode | 在节点于窗口/根节点中的位置发生变化时收到通知 |
ObserverModifierNode | 使用自定义观察器观察任意状态读取(observeReads { ... }) |
DelegatingNode | 通过委托给子节点组合多种节点行为 |
TraversableNode | 遍历修饰符链(父子节点遍历) |
节点可以同时实现多个接口,例如 DrawModifierNode + CompositionLocalConsumerModifierNode + LayoutAwareModifierNode。对于行为复杂的多功能修饰符,**推荐:**组合较小的 DelegatingNode 子节点,而不是让一个庞大的节点实现五个接口。
-
5. 异步任务使用内置的
coroutineScope。 每个Modifier.Node都公开了一个coroutineScope: CoroutineScope,它会延迟创建,并与节点的附加/分离生命周期绑定。在onAttach()中使用它启动动画、观察器和防抖任务。不得在onAttach中创建自己的CoroutineScope——否则它会在onDetach后继续存在,造成泄漏。 -
6. 在需要时显式触发重新执行。 在
update()或协程中修改节点状态后,如果需要重绘/重新测量/重新放置,请调用invalidateDraw()、invalidateMeasurement()或invalidatePlacement()。默认情况下,update()会触发自动失效;如需精细控制,可重写shouldAutoInvalidate = false并手动触发失效。 -
7. 为资源生命周期实现
onAttach/onDetach/onReset。 节点加入树时运行onAttach;节点离开树时运行onDetach;节点被复用时运行onReset(仅适用于惰性布局)。必须在onDetach中释放监听器、观察器和外部订阅。 -
8. 验证迁移:不再存在
Modifier.composed { }。 再次使用 grep 搜索模块。如果仍有composed { }调用,请列出并说明原因;否则迁移即已完成。
模式
模式:将 composed { drawBehind } 迁移到 DrawModifierNode
// WRONG (legacy)
fun Modifier.circle(color: Color): Modifier = composed {
val computed = remember(color) { color.copy(alpha = 0.5f) }
drawBehind { drawCircle(computed) }
}
// WRONG because: composed { } opens a fresh composable scope per parent recomposition; the modifier can never be skipped and forces the parent to recompose on every read it does inside.
// RIGHT
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create(): CircleNode = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
private class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
drawCircle(color.copy(alpha = 0.5f))
drawContent()
}
}
fun Modifier.circle(color: Color): Modifier = this then CircleElement(color)
data class Element 会自动提供 equals() / hashCode()。调用方传入相同的 color 时,equals() 会返回 true,节点保持不变。color 变化时,update() 会原地修改 node.color——不会分配新对象,也不会使父级 Composition 失效。
模式:在节点中执行协程任务(替换 composed { LaunchedEffect })
// WRONG (legacy)
fun Modifier.pulse(period: Long): Modifier = composed {
val alpha = remember { Animatable(1f) }
LaunchedEffect(period) {
while (true) { alpha.animateTo(0.3f); alpha.animateTo(1f); delay(period) }
}
graphicsLayer { this.alpha = alpha.value }
}
// WRONG because: every parent recomposition allocates a new composable scope, and the LaunchedEffect's keying logic re-evaluates inside that scope.
// RIGHT
private data class PulseElement(val period: Long) : ModifierNodeElement<PulseNode>() {
override fun create(): PulseNode = PulseNode(period)
override fun update(node: PulseNode) { node.period = period }
}
private class PulseNode(var period: Long) : Modifier.Node(), DrawModifierNode {
private var alpha by mutableFloatStateOf(1f)
override fun onAttach() {
coroutineScope.launch {
while (true) {
animate(1f, 0.3f) { value, _ -> alpha = value; invalidateDraw() }
animate(0.3f, 1f) { value, _ -> alpha = value; invalidateDraw() }
delay(period)
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
drawRect(Color.Black.copy(alpha = 1f - alpha), blendMode = BlendMode.DstIn)
}
}
fun Modifier.pulse(period: Long): Modifier = this then PulseElement(period)
节点内置的 coroutineScope 会在 onDetach 时自动取消。不会泄漏,也无需手动使用 DisposableEffect。
模式:在节点内部读取 CompositionLocal
// WRONG (legacy)
fun Modifier.themedBorder(width: Dp): Modifier = composed {
val tokens = LocalThemeTokens.current
drawBehind { drawRect(tokens.outline, style = Stroke(width.toPx())) }
}
// WRONG because: every CompositionLocal read inside composed { } pins the modifier to a fresh scope per parent recomposition.
// RIGHT
private data class ThemedBorderElement(val width: Dp) : ModifierNodeElement<ThemedBorderNode>() {
override fun create(): ThemedBorderNode = ThemedBorderNode(width)
override fun update(node: ThemedBorderNode) { node.width = width }
}
private class ThemedBorderNode(
var width: Dp,
) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode {
override fun ContentDrawScope.draw() {
val tokens = currentValueOf(LocalThemeTokens)
drawContent()
drawRect(tokens.outline, style = Stroke(width.toPx()))
}
}
fun Modifier.themedBorder(width: Dp): Modifier = this then ThemedBorderElement(width)
CompositionLocalConsumerModifierNode 可在任何节点回调中通过 currentValueOf(local) 获取值。读取会由 Draw 失效列表跟踪,而非 Composition 重启作用域跟踪,因此更改 LocalThemeTokens 会重绘节点,而无需重新组合父级。
模式:忘记使用 data class(静默的节点过时问题)
// WRONG
private class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
// WRONG because: not a data class -> equals() is referential -> every apply looks like a different element -> Compose tears down and recreates the node every time, OR the diff fails and update() is never called, leaving the node with the original color forever.
// RIGHT
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
模式:持有对调用方可组合项的引用
// WRONG
private class HostingNode(val composer: Composer) : Modifier.Node() { /* ... */ }
// WRONG because: a Modifier.Node outlives any single composition pass; holding a Composer/composition-scoped object leaks it and invokes undefined behavior.
// RIGHT — accept primitive/stable parameters; read CompositionLocals via CompositionLocalConsumerModifierNode if you need composition context.
private data class HostingElement(val tag: String) : ModifierNodeElement<HostingNode>() {
override fun create() = HostingNode(tag)
override fun update(node: HostingNode) { node.tag = tag }
}
private class HostingNode(var tag: String) : Modifier.Node() { /* ... */ }
模式:使用 DelegatingNode 组合多个行为
// RIGHT — one public modifier, three small nodes delegated under one element
private data class CardEffectsElement(
val color: Color,
val onClick: () -> Unit,
) : ModifierNodeElement<CardEffectsNode>() {
override fun create() = CardEffectsNode(color, onClick)
override fun update(node: CardEffectsNode) {
node.update(color, onClick)
}
}
private class CardEffectsNode(
color: Color,
onClick: () -> Unit,
) : DelegatingNode() {
private val background = delegate(BackgroundNode(color))
private val click = delegate(ClickNode(onClick))
fun update(color: Color, onClick: () -> Unit) {
background.color = color
click.onClick = onClick
}
}
DelegatingNode 是组装多行为修饰符的规范方式,无需让单个节点实现所有接口。委托的子节点共享宿主的生命周期。
专用节点接口
速查表——完整的重写接口和“适用场景”指南请参阅 references/modifier-node-anatomy.md:
DrawModifierNode— 实现ContentDrawScope.draw()。用于自定义修饰符时替代drawBehind/drawWithCache。LayoutModifierNode— 实现MeasureScope.measure(...)。替代Modifier.layout { }。SemanticsModifierNode— 实现SemanticsPropertyReceiver.applySemantics()。PointerInputModifierNode— 实现onPointerEvent(...)和onCancelPointerInput()。CompositionLocalConsumerModifierNode— 可在任何节点回调中通过currentValueOf(local)获取值。LayoutAwareModifierNode—onPlaced(coordinates)/onRemeasured(size)。GlobalPositionAwareModifierNode—onGloballyPositioned(coordinates)。ObserverModifierNode— 使用observeReads { ... }包装状态读取,并在onObservedReadsChanged()中响应。DelegatingNode— 通过delegate(otherNode)组合行为。TraversableNode— 通过DelegatableNode上的顶层扩展函数遍历父节点/子节点/后代节点:traverseAncestors(key, block)、traverseChildren(key, block)、traverseDescendants(key, block)。key参数用于选择参与遍历的可遍历节点;后代节点重载的代码块会返回TraverseDescendantsAction(继续 / 跳过 / 取消)。
生命周期(简版——完整示意图见参考资料)
ModifierNodeElement.create() // first apply only
↓
Modifier.Node.onAttach() // node joins the tree; coroutineScope becomes valid
↓ // (lives here across many parent recompositions)
ModifierNodeElement.update(node) // each subsequent apply with !equals previous
↓ // mutate node.var fields; auto-invalidates by default
Modifier.Node.onReset() // optional: lazy-layout reuse
↓
Modifier.Node.onDetach() // node leaves the tree; coroutineScope is cancelled
强制规则
- 必须优先为任何新的自定义修饰符使用
Modifier.Node——Modifier.composed { }已过时。 - 必须将
ModifierNodeElement<T>声明为data class,以便使用自动生成的equals()/hashCode()执行差异比较。普通class会悄无声息地破坏update()。 - 必须重写
update(node: T),以原地修改节点状态。不得在update()中重新创建节点。 - 必须在
onDetach()中释放订阅、监听器和外部资源。coroutineScope会自动取消;手动管理的资源不会。 - 不得在
Modifier.Node中持有对Composer、调用方可组合项、父组合或任何作用域限定于组合的对象的引用。 - 不得在
onAttach中分配新的CoroutineScope——应使用内置的coroutineScope属性。 - 不得为新代码推荐
Modifier.composed { }。(SPEC §8 中的仓库级规则。) - **首选:**相比直接使用
Modifier.Node,优先使用专用节点接口(DrawModifierNode、LayoutModifierNode等)。 - **首选:**相比在单个节点上实现超过约 3 个专用接口,优先使用
DelegatingNode。 - **首选:**在需要精细控制时,重写
shouldAutoInvalidate = false并显式调用invalidateDraw()/invalidateMeasurement()/invalidatePlacement();否则,依赖update()触发的自动失效。
验证
- 在迁移后的模块中运行
grep -R "Modifier.composed" <module>/src,结果为零匹配项。 - 每个迁移后的修饰符都暴露一个
data classElement(搜索:class .*Element : ModifierNodeElement应为data class)。 - 编译器报告(
composables.txt)显示,使用迁移后修饰符的父可组合项现在是restartable skippable(不再因composed而无法跳过)。 - 在 Layout Inspector → Recomposition Counts 中,父可组合项的计数在修饰符驱动的动画帧期间趋于稳定;只有节点的绘制/布局失效列表会递增。
- 在父可组合项上使用
@TraceRecomposition(skydoves/compose-stability-analyzer)确认,在 release + R8 + 真机环境下,父项不会因修饰符的内部动画而重新组合。 - 在
onAttach中分配的资源(监听器、观察者)会在onDetach中释放——使用泄漏检测工具验证。
参考资料
- Android Developers — 自定义修饰符(Modifier.Node):https://developer.android.com/develop/ui/compose/custom-modifiers
- Android Developers — 图形修饰符:https://developer.android.com/develop/ui/compose/graphics/draw/modifiers
- Android Developers — 性能概述:https://developer.android.com/develop/ui/compose/performance
- Android Developers — Jetpack Compose 的阶段:https://developer.android.com/develop/ui/compose/phases
- Ben Trengrove — 调试重新组合:https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- 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/modifier-node-anatomy.md— 各接口完整的重写范围、生命周期图、DelegatingNode组合方案和手动失效指南。