この記事は Goodpatch Advent Calendar 2022 8日目の記事です。
こんにちは!Jetpack Compose と KMM が好きなエンジニアのスージです。
今年開催された Android Dev Summit で Compose チームの @intelligibabble が Compose UI のパフォーマンス向上のため既存のModifierAPI の代わりに Compose 1.3.0 で追加されたModifier.NodeAPI の紹介と使用する理由についてセッションをやりました。ぜひチェックしてみてください!
この記事では私がこのセッションと cs.android.com でコードを調べて理解した上でざっくりModifier.Nodeの解釈と使い方について書いてみました。
1.3.0 までの Modifier API
Composeの スマートRecompositionを活用するために、Compose Compilerは渡されたパラメーターが変更されたかどうかを判断できるようにします。
コンポーズ可能な関数を新しいデータで再度呼び出します。すると、関数が再コンポーズされます。..
Compose フレームワークでは、変更されたコンポーネントのみをインテリジェントに再コンポーズできます。
Compose UI コンポーネントの見た目・動作を調整するためmodifier = Modifier..パラメータを渡します。Compose 1.3.0以下ではModifierはModifier.then()とModifier.composed{}を用いて複数のstatelessとstatefulModifier.Elementのチェーンで構成されています。
@Composable fun Box( modifier = Modifier .background(..) .size(..) .clickable(..) )
その中で.clickable()のようなModifierが、また複数のstatelessとstatefulModifier.Elementのチェーンで構成されています。
fun Modifier.clickable(): Modifier = composed { .. return this .then(semanticModifier) .indication(interactionSource, indication) .then(gestureModifiers) }
LayoutにこのModifierチェーンを適用する前に Composer.materialize() を使ってcomposed {}で作られたComposedModifierをシンプルなModifier.Elementチェーンに拡大させます。
/** * Materialize any instance-specific [composed modifiers][composed] for applying to a raw tree node. * Call right before setting the returned modifier on an emitted node. * You almost certainly do not need to call this function directly. */ @Suppress("ModifierFactoryExtensionFunction") fun Composer.materialize(modifier: Modifier): Modifier { val result = modifier.foldIn<Modifier>(Modifier) { acc, element -> acc.then( val factory = element.factory as Modifier.(Composer, Int) -> Modifier val composedMod = factory(Modifier, this, 0) materialize(composedMod) ) .. } return result }

materialize()を使ってComposedModifierを拡大
でも、元々このModifier.composed{}自体がパフォーマンス問題がありました。
Modifier.composed{} の問題
Modifier.clickable()を調べてみると、
fun Modifier.clickable(..onClick: () -> Unit): Modifier = composed { val onClickState = rememberUpdatedState(onClick) val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) } .. return this .. .composed { remember { FocusRequesterModifier() } } }
- まずは、
Modifier.composed{}はバリューを返す関数なので、Recomposition の時は呼び出しをスキップしないです。なので、中に state あればそれを保特するためremember { }しないといけなくなります。 Modifier.composed{}は@Composable関数じゃないので、Compiler が返されるModifier.Elementチェーンを比較できないです。
つまり、返されるModifier.Elementチェーンが変えられなくても Compiler が比較できず新しいModifierパラメータとして扱いし、Recomposition で UI@Composableを再呼び出しします。この再呼び出しで、中身のremember { }コールも無駄に増えてしまいます。
Compiler が比較できない問題を解決できれば、Modifierチェーンが変えられない場合は Compiler が渡されるパラメータの変更を判断でき、Recomposition で UI@Composableの再呼び出しをスキップできますね。
このニーズから生み出したのはModifier.Nodeです。
Modifier.Node
Compose 1.3.0 からModifierの中で新しい Nodeクラスが追加されてます。
interface Modifier { /** * The longer-lived object that is created for each [Modifier.Element] applied to a * [androidx.compose.ui.layout.Layout].. */ abstract class Node : DelegatableNode, OwnerScope { final override var node: Node = this private set internal var parent: Node? = null internal var child: Node? = null .. } }
今後のアップデートで、.background()、.padding()そして.clickable()のようなModifierもModifier.Elementのチェーン object より1つのModifier.Nodeobject に構成されるように想定されてます。そのModifier.NodeオブジェクトをModifier.Nodeの linked-list に追加しLayoutに適用します。

Modifier.Nodeを使うと想定してる構成
Modifier.Node のメリット
このアプローチのメリットはいくつかあります:
Modifier.Nodeは mutable なのでModifier.Nodeの linked-list を簡単に比較でき、 Compiler はModifierが変更されたかどうかを判断できます。Modifier.composed { }を materialize することがなくなり、Modifier.Nodeに入れ替えますので@Composableの数も減ります。そうなると、処理が必要な Compositionツリーの数も減ります。Modifier.Elementチェーン object の代わりにModifier.Nodeobject のツリーになりますので全体的にツリーが短くなり、tree traversalも早くなります。

Modifier.Nodeのメリット | Compose Modifiers deep dive
Modifier.Node に移行する方法
例えば、以下のような色付けたRoundedRectangeを描画するModifierがあります。色を変えると、RoundRectangleの色も変えたいので、colorで 返すModifierをremember{ }してます。
fun Modifier.roundRectangle( color: Color ) = composed { // 色変わると新しい色の RoundRectanleModifier が作られる val modifier = remember(color) { RoundRectangleModifier(color = color) } return modifier } // 描画するようなカスタム DrawModifier class RoundRectangleModifier( private val color: Color, ): DrawModifier { override fun ContentDrawScope.draw() { drawRoundRect( color = color, cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx()) ) } }
Modifier.composedOf {}で連結するこのModifierをModifier.NodeAPI に移行するために、以下のステップでできると思ってます (LayoutModifierNodeSampleのようなサンプルコードを参照にしました) 。
新しい Modifier.Node を作る
まずは、Modifier.Nodeを implement して新しいRoundRectangleModifierNodeを作って、以前のRoundRectangleModifierと同じ色のパラメータを渡します。
@OptIn(ExperimentalComposeUiApi::class) class RoundRectangleNodeModifier( var color: Color, ): Modifier.Node()
colorをvalじゃなくてvarにした理由がありますが、後で説明します。
持たせたい役割の DelegatableNode を implementする
このModifierに描画する役割を 'delegate' (持たせる) するためにDrawModifierNodeを implement して、RoundRectangleModifierと同じonDraw()を実装します。
class RoundRectangleNodeModifier( var color: Color, ): DrawModifierNode, .. { override fun ContentDrawScope.draw() { drawRoundRect( color = color, cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx()) ) } }
このDrawModifierNodeはModifier.Nodeを wrap して役割を持たせるDelegatableNodeです。
/** * Represents a [Modifier.Node] which can be a delegate of another [Modifier.Node]. Since * [Modifier.Node] implements this interface, in practice any [Modifier.Node] can be delegated. */ @ExperimentalComposeUiApi interface DelegatableNode { val node: Modifier.Node } interface DrawModifierNode : DelegatableNode { fun ContentDrawScope.draw() .. }
すでに様々なDelegatableNodeが用意されてますのでぜひチェックしてみてください。
* * .. * @see androidx.compose.ui.node.LayoutModifierNode * @see androidx.compose.ui.node.DrawModifierNode * @see androidx.compose.ui.node.SemanticsModifierNode * @see androidx.compose.ui.node.PointerInputModifierNode * @see androidx.compose.ui.node.ParentDataModifierNode * @see androidx.compose.ui.node.LayoutAwareModifierNode * @see androidx.compose.ui.node.GlobalPositionAwareModifierNode * @see androidx.compose.ui.node.IntermediateLayoutModifierNode */ @ExperimentalComposeUiApi abstract class Node : DelegatableNode, OwnerScope { .. }
modifierElementOf を使ってチェーンに追加
最後に、modifierElementOfを使ってModifier.Nodeのチェーンに追加します。
@OptIn(ExperimentalComposeUiApi::class) fun Modifier.roundRectangle( color: Color ) = this then modifierElementOf( key = color, create = { RoundRectangleNodeModifier(color) }, update = { currentNode -> currentNode.color = color }, definitions = .. )
modifierElementOfにいくつかパラメータ渡さないといけないです。
- key:
Modifierを変更したい key です。このパラメータ変わったら、updateコールバックが呼び出されます。以前のRoundRectableModifierの場合だとrememberの key と同じような用途です。 - create:新しい
Modifier.Nodeインスタンスを作成する用のコールバックです。Modifier.Nodeの初期化をここで書きます。 - update:既存の
Modifier.Nodeインスタンスを更新する用のコールバックです。現在のModifier.Nodeインスタンスがここでパラメータとして渡され、更新されたModifier.Nodeを返します。colorがここで再設定され(varにした理由)、onDraw()がまた呼び出される時変更した色で描画されます。
今作ったModifier.roundRectangle()を実際に使うと、想定通り描画されますね。
val color by animateColorAsState(..) Box(modifier = Modifier..roundRectangleNode(color = color)) { .. }

Compose UI の Modifier.Node に移行の状態
この記事を書くとき、Compose 1.3.0 で、LayoutにModifier.ElementのチェーンをModifier.Nodeに入れ替えることがすでに対応されてます。でも、セッションで想定した.clickable {}がClickableNodeを返すような対応は、今Modifier.focusModifier()のModifier.Node移行 PRで始まったので見てみてください。
その間は、BackwardsCompatNodeで既存のModifierを組み合わせて対応されてます。
/** * This entity will end up implementing all of the entity type interfaces.. */ @Suppress("NOTHING_TO_INLINE") @OptIn(ExperimentalComposeUiApi::class) internal class BackwardsCompatNode(element: Modifier.Element) : .. LayoutModifierNode, IntermediateLayoutModifierNode, DrawModifierNode, SemanticsModifierNode, PointerInputModifierNode, LayoutAwareModifierNode, GlobalPositionAwareModifierNode, Modifier.Node() { .. override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints ): MeasureResult { return with(element as LayoutModifier) { measure(measurable, constraints) } } override fun ContentDrawScope.draw() { val element = element with(element as DrawModifier) { draw() .. } } }
最後に
Compose 1.3.0 でModifier.Node以外も気になるAPI沢山追加されまして、この記事でまとめてみました。
そして Goodpatch Advent Calendar 2022 の6日目の記事として、今年海外 Android カンファレンスで Compose の面白かったセッションをまとめてみましたので、ぜひチェックしてみてください。🙂
Goodpatch には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!