この記事は Goodpatch Advent Calendar 2022 8日目の記事です。
こんにちは!Jetpack Compose と KMM が好きなエンジニアのスージです。
今年開催された Android Dev Summit で Compose チームの @intelligibabble が Compose UI のパフォーマンス向上のため既存のModifier
API の代わりに Compose 1.3.0 で追加されたModifier.Node
API の紹介と使用する理由についてセッションをやりました。ぜひチェックしてみてください!
この記事では私がこのセッションと 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 }
でも、元々この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.Node
object に構成されるように想定されてます。そのModifier.Node
オブジェクトをModifier.Node
の linked-list に追加しLayout
に適用します。
Modifier.Node のメリット
このアプローチのメリットはいくつかあります:
Modifier.Node
は mutable なのでModifier.Node
の linked-list を簡単に比較でき、 Compiler はModifier
が変更されたかどうかを判断できます。Modifier.composed { }
を materialize することがなくなり、Modifier.Node
に入れ替えますので@Composable
の数も減ります。そうなると、処理が必要な Compositionツリーの数も減ります。Modifier.Element
チェーン object の代わりにModifier.Node
object のツリーになりますので全体的にツリーが短くなり、tree traversalも早くなります。
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.Node
API に移行するために、以下のステップでできると思ってます (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 には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!