mcpField
M
migrating-to-modifier-node

migrating-to-modifier-node

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

关于 migrating-to-modifier-node

面向 Claude 的技能,讲解如何用 `Modifier.Node` 编写 Compose 自定义 Modifier,并从 `Modifier.composed { }` 迁移。内容涵盖节点接口、参数更新、生命周期管理和迁移验证。

主要功能

  1. 01将 `composed` Modifier 迁移为节点
  2. 02选择专用节点接口
  3. 03通过元素更新节点参数
  4. 04管理协程与节点生命周期
  5. 05验证迁移目标

适用场景

  1. 01替换旧式自定义绘制 Modifier
  2. 02构建处理指针输入的 Modifier
  3. 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)。
  • 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 class Element(搜索:class .*Element : ModifierNodeElement 应为 data class)。
  • 编译器报告(composables.txt)显示,使用迁移后修饰符的父可组合项现在是 restartable skippable(不再因 composed 而无法跳过)。
  • 在 Layout Inspector → Recomposition Counts 中,父可组合项的计数在修饰符驱动的动画帧期间趋于稳定;只有节点的绘制/布局失效列表会递增。
  • 在父可组合项上使用 @TraceRecomposition(skydoves/compose-stability-analyzer)确认,在 release + R8 + 真机环境下,父项不会因修饰符的内部动画而重新组合。
  • 在 onAttach 中分配的资源(监听器、观察者)会在 onDetach 中释放——使用泄漏检测工具验证。

参考资料

评论

支持 Markdown