mcpField
M
migrating-to-modifier-node

migrating-to-modifier-node

github.com/skydoves開発520129 回インストール19 週間前に更新
リポジトリ

migrating-to-modifier-node について

Compose のカスタム Modifier を `Modifier.Node` で作成し、`Modifier.composed { }` から移行する方法を Claude に案内するスキルです。ノードインターフェース、パラメーター更新、ライフサイクル管理、移行の検証を扱います。

主な機能

  1. 01`composed` Modifier をノードへ移行
  2. 02専用ノードインターフェースを選択
  3. 03Element 経由でノードの値を更新
  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 を参照)。このスキルでは、新しい修飾子を Modifier.Node として作成する方法と、従来の composed { } ファクトリーを移行する方法を Claude に教えます。

このスキルを使う場面

  • カスタム修飾子が現在 Modifier.composed { } を使用している場合(モジュール内で Modifier.composed を検索してください)。
  • 新しいカスタム修飾子を一から作成する場合 — composed { } から始めないでください。
  • コードレビューで composed { } ファクトリーが見つかった場合。
  • カスタム修飾子が CoroutineScope を必要とする場合(アニメーションループ、デバウンサー)、CompositionLocal を読み取る場合、レイアウト/描画/ポインター入力に関与する場合、またはレイアウト座標を追跡する場合。
  • @TraceRecomposition のログに、composed { } 修飾子がチェーンにあるため、親コンポーザブルが毎フレーム再コンポーズされていると示される場合。

このスキルを使わない場面

  • 「修飾子」が実際には1行のコンポーザブルラッパーで、@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 はデフォルトで有効です。スキップできない修飾子は、スクロール速度が上がるほど影響が累積します。
  • 最終的な測定にはリリースビルドを使用してください(skydoves のホットテイク #5:デバッグビルドはインタープリター実行されるため、実態を反映しません)。
  • インターフェースごとのオーバーライド一覧とライフサイクル図については、DrawModifierNode を超えるものを作成する前に references/modifier-node-anatomy.md を読んでください。

ワークフロー

  • 1. モジュール内のすべての Modifier.composed { } ファクトリーを特定します。 Modifier.composed を検索します。一致するものがそれぞれ移行対象です。本文で何を行っているか(remember、drawBehind、LaunchedEffect、CompositionLocal の読み取り、ポインターハンドラーなど)を確認してください。必要となる特殊化ノードインターフェースは、それによって決まります。

  • 2. Modifier.Node への移行で作成する3つの要素を整理します。

    • (a) パブリック拡張関数 — fun Modifier.foo(...): Modifier = this then FooElement(...)。古い composed { } ファクトリーと同じ名前とシグネチャにします。
    • (b) ModifierNodeElement<T> の data class — パラメーターを保持し、create()(初回適用時に1回呼び出されます)と update(node: T)(以降の適用時に呼び出されます)を実装します。
    • (c) Modifier.Node のサブクラス — 可変状態(var フィールド)を保持し、1つ以上の特殊化ノードインターフェースを実装して、ライフサイクルフック(onAttach、onDetach、onReset)を実行します。
  • 3. Element を data class にします。 コンパイラーが生成する equals()/hashCode() によって、Compose は 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 のようにできます。複雑で複数の動作を持つモディファイアでは、5つのインターフェースを実装する巨大なノード1つではなく、小さな 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 { } が残っていないことを確認します。 モジュールを再度検索します。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 を返し、ノードはそのまま維持されます。色が変わると、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) を利用できます。読み取りは Composition の再起動スコープではなく、Draw の無効化リストによって追跡されるため、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 は、1 つのノードにすべてのインターフェースを実装させずに、複数の振る舞いを持つ修飾子を組み立てる標準的な方法です。委譲された子ノードは、ホストのライフサイクルを共有します。

特殊なノードインターフェース

早見表 — オーバーライド可能な API の全一覧と「使用する場面」のガイダンスは 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(続行/スキップ/キャンセル)を返します。

ライフサイクル(概要 — 完全な図は references を参照)

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 など)を使用してください。
  • 推奨: 1 つのノードに約 3 つを超える特化インターフェースを実装するよりも、DelegatingNode を使用してください。
  • 推奨: 細かい制御が重要な場合は shouldAutoInvalidate = false をオーバーライドし、invalidateDraw()/invalidateMeasurement()/invalidatePlacement() を明示的に呼び出してください。それ以外の場合は、update() によってトリガーされる自動無効化に任せてください。

検証

  • grep -R "Modifier.composed" <module>/src を実行すると、移行したモジュールで一致が 0 件になります。
  • 移行したすべての修飾子で 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 で解放されることを確認します。リークカナリアによる検査を実施してください。

参考資料

コメント