Modifier.Node のプチ解釈

この記事は 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以下ではModifierModifier.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()のようなModifierModifier.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 のメリット
Modifier.Nodeのメリット | Compose Modifiers deep dive

Modifier.Node に移行する方法

例えば、以下のような色付けたRoundedRectangeを描画するModifierがあります。色を変えると、RoundRectangleの色も変えたいので、colorで 返すModifierremember{ }してます。

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 {}で連結するこのModifierModifier.NodeAPI に移行するために、以下のステップでできると思ってます (LayoutModifierNodeSampleのようなサンプルコードを参照にしました) 。

新しい Modifier.Node を作る

まずは、Modifier.Nodeを implement して新しいRoundRectangleModifierNodeを作って、以前のRoundRectangleModifierと同じ色のパラメータを渡します。

@OptIn(ExperimentalComposeUiApi::class)
class RoundRectangleNodeModifier(
    var color: Color,
): Modifier.Node()

colorvalじゃなくて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())
        )
    }
}

このDrawModifierNodeModifier.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にいくつかパラメータ渡さないといけないです。

  • keyModifierを変更したい 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 で、LayoutModifier.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 には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!

【Design Div】Androidデベロッパー