migrating-to-modifier-node について
Compose のカスタム Modifier を `Modifier.Node` で作成し、`Modifier.composed { }` から移行する方法を Claude に案内するスキルです。ノードインターフェース、パラメーター更新、ライフサイクル管理、移行の検証を扱います。
主な機能
- 01`composed` Modifier をノードへ移行
- 02専用ノードインターフェースを選択
- 03Element 経由でノードの値を更新
- 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 を参照)。このスキルでは、新しい修飾子を 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)を実行します。
- (a) パブリック拡張関数 —
-
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で解放されることを確認します。リークカナリアによる検査を実施してください。
参考資料
- 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 — Jetpack Compose の 6 つのガイドライン: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の構成例、手動での無効化に関するガイダンスを網羅しています。