6
5

More than 1 year has passed since last update.

Modifier.Node を使いましょう (Part 2: Modifier.Node API の紹介)

Last updated at Posted at 2023-09-09

前の記事では、Modifier.composed が Compose ランタイムのパフォーマンスに影響を与えてしまって、そのきっかけで Modifier.Node APIの開発に繋がった話を記載してます。

シリーズのコンテンツ
Part 1: なぜ Modifier.Node が必要になった
Part 2: Modifier.Node API の紹介(この記事)
Part 3: Modifier.Node に移行する基本手順
Part 4: @Composable 関数の実装を Modifier.Node に書き換える
Part 5: Modifier.Node を委任する、Delegate パターン
Part 6: Modifier.Node 移行しようと思ってまだ残ってる課題

この記事では実際の Modifier.Node 仕組みそして、この新しい仕組みの内部的な実装について少し書いてみました。

Modifier.Node仕組み

前の記事でも書いてますが、新しい Modifier.Node 仕組みで2つのチェーンが存在してます。

  • Node チェーン
  • Element チェーン

まずは、それぞれの Modifier 関数がそれぞれの ModifierNodeElement オブジェクトを発行して、Element チェーンが生成されてます。
この Element チェーンを UI コンポーネントに設定されてる時、それぞれの ModifierNodeElement が対応してるそれぞれの Modifier.Node オブジェクトを発行して Node チェーンが作られてます。
そして、生成された Node チェーンが UI コンポーネントに Modifier のそれぞれのプロパティを提供してます。
Screenshot 2023-09-06 at 10.05.06 PM.png
この2つのチェーンについて2つの違いを知ることは大事かなと思います:

  • Element チェーンはUIコンポーネントに触接的に影響を与えないです。それは Node チェーンの役割です。
  • 毎回 Element チェーンの新しいインスタンスが生成されますが、Node チェーンの場合は初回生成されたインスタンスが UI コンポーネントにずっと持ち続けられます。

では、各チェーンのもっと具体的に見ましょう。

Node チェーン と Modifier.Node

先ほども記載しましたが、Node チェーンが UI コンポーネントにそれぞれの特定なプロパティを提供します。

そして、UI コンポーネント毎に1つのインスタンスを持ち続けられるので、このチェーンの各 Modifier.Node も UI コンポーネントと同じ lifespan (寿命) を持ちます。この長生きできてるため、Modifier.Node が state を保持する役割も持ってます。

interface Modifier {
    interface Element: Modifier{ /* .. */ }
    ..
    abstract class Node { 
        var isAttached: Boolean = false
            private set

        open fun onAttach() {}
        open fun onDetach() {}

        val coroutineScope: CoroutineScope
            get() = ..
    }
}
  • onAttach() : このコールバックは、Modifier.Nodeが初めてUIコンポーネントに定義されたときに呼び出されます。
    Screenshot 2023-09-06 at 10.42.22 PM.png
  • onDetach() : このコールバックは、Modifier.Nodeが初めてUIコンポーネントから外されたときに呼び出されます。
    Screenshot 2023-09-06 at 10.44.55 PM.png
  • isAttached : 後ほど紹介しますが、幾つかの API を onAttachonDetach の間でしか利用することはできない。このライフサイクルに縛られてる API を安全に呼び出すために、このフラグを利用します。
  • coroutineScope: この CouroutineScope を使って Modifier.Node の中で coroutine を実行させることができます。

Modifier.Node の coroutineScope を使って onAttach と onDetach の間で coroutine を実行することができます。
onDetach が呼び出された後、実行中の coroutine が中止されます。

class FocusableNode(..): Modifier.Node() {
    init {
-        coroutineScope.launch {..} // エラーが発生!
    }
    override fun onAttach() {
+        couroutineScope.launch {..} // 問題なし!
    }
    fun update(..) {
+        couroutineScope.launch {..} // 問題なし!
    }
}

Element チェーン と ModifierNodeElement

先ほど記載しましたが、Element チェーンは「Modifierが変更された」を判断するために比較される役割を持ってます。

Element チェーンのアイテム、ModifierNodeElement が単なる Modifier.Node に対応してる Modifier.Element です。
なので、Modifier.Element と同様で、各フレームで新しいインスタンスが Modifier.then に渡され、UI コンポーネントと同じ寿命を持たず、state を保持することはできないです。

abstract class ModifierNodeELement<N: Modifier.Node>(): Modifier.Element {
    abstract fun create(): N
    abstract fun update(node: N)

    abstract override fun equals(other: Any?): Boolean
    ..
}
  • create() : このメソッドは、Modifier が Modifier チェーンに初めて連結されるときに呼び出されます。
    ここで返される Modifier.Node の新しいインスタンスがUIコンポーネントの Node チェーンについかされます。
    Screenshot 2023-09-06 at 10.47.21 PM.png
  • update() : 現在の Element チェーンが前のチェーンと比較されて「変更が行われた」が判断されたらこのメソッドが呼び出されます。
    この関数で渡される Modifier.Node のパラメータはレイアウトの Node チェーンにある既存のインスタンスです。このインスタンスの state を更新し、UIコンポーネントを invalidate する実装を行います。
    Screenshot 2023-09-06 at 10.48.58 PM.png
  • equals()hashCode() : 「UI コンポーネントの Modifier が変わりました」という判断は Element チェーンの各 ModifierNodeElement のこのメソッドで行います。
    上記の update メソッドが呼び出されるかどうかの判断もこのメソッドで行われます。
    Screenshot 2023-09-06 at 10.55.19 PM.png
    class SampleModifierNodeElement(
        val color: Color, 
        val size: Dp
    ): ModifierNodeElement<..> {
        ..
        override fun equals(other: Any?): Boolean {
            val otherModifier = other as? BackgroundElement ?: return false
            return other.color == color &&
               other.size == size
        }
    
        override fun hashCode(): Int {
            var result = color.hashCode()
            result = result * 31 + size.hashCode()
            return result
        }
    }
    
    • equalshashCode を実装する代わりにクラスを data class に定義しても大丈夫ですが、ある change list のコメントで equals メソッドを実装するメリットについて話してます。
      スクリーンショット 2023-08-14 13.30.25.png

    • コンストラクタパラメータがない ModifierNodeElement の場合はオブジェクトのタイプを比較すれば大丈夫です。

      class NoParameterModifierNodeElement(): ModifierNodeElement {
          override fun equals(other: Any?): Boolean {
              return other is NoParameterModifierNodeElement
          }
          
          override fun hashCode(): Int = System.hashCode(this)
      }
      

Modifier.Node の内部的な実装

NodeChain は LayoutNode に適用される Modifier.Node チェーンを作成や変更するクラスです。各 LayoutNode はこの NodeChain の1つインスタンスを持ってます。

// ../androidx/compose/ui/node/LayoutNode.kt
internal class LayoutNode {
    ..
    internal val nodes = NodeChain(this)
}

// ../androidx/compose/ui/node/NodeChain.kt
internal class NodeChain(val layoutNode: LayoutNode) {

    // 現在提供されてる Modifier に属する ModifierNodeElements のコレクション
    private var current: MutableVector<Modifier.Element>? = null
    ..
}

LayoutNode で Modifier が設定されたとき NodeChain.updateFrom() が呼び出されます。このメソッドでNode チェーンが作成または更新が行われます。

// ../androidx/compose/ui/node/LayoutNode.kt
internal class LayoutNode {
    ..
    /**
     * The [Modifier] currently applied to this node.
     */
    override var modifier: Modifier = Modifier
        set(value) {
            nodes.updateFrom(value)
            ..
        }
}

updateFrom に渡される Modifier パラメータが ModifierNodeElement のコレクション (Element チェーン)に拡大され、現在と新しいコレクションのサイズが比較されます。比較された結果は、下記の4つのケースの可能性があります:

  • 両チェーンのサイズが同じ
  • 現在のチェーンは空
  • 新しいチェーンは空
  • どちらのチェーンもサイズが異なってる
fun updateFrom(modifier: Modifier) {
    var before = current // 現在のコレクション
    var beforeSize = before?.size ?: 0
    var after = modifier.fillVector(..) // 新しいコレクションに拡大
    ..
    if (after.size == beforeSize) { /* .. */ }
    else if(beforeSize == 0) { /* .. */ }
    else if(after.size == 0) { /* .. */ }
    else { /* .. */ }
}

両チェーンのサイズが同じ

現在と新しい Element チェーンのサイズが同じ場合は、2つのチェーンは構造的に等しいかどうかをアイテム毎に順次比較されます。

// ../androidx/compose/ui/node/NodeChain.kt
if (after.size == before.size) {
    var i = 0
    while (i < before.size) {
        val prev = before[i] // 現在のコレクションの ModifierNodeElement
        val next = after[i] // 新しいコレクションの ModifierNodeElement

        when (actionForModifiers(prev, next)) { /* .. */ }
    }
}
..

// 2つのコレクションを ModifierNodeElement ごとに順次比較します
internal fun actionForModifiers(prev: ModifierNodeElement, next: ModifierNodeElement): Int {
    return if (prev == next)
        // 両アイテムのタイプとプロパティが同じ。つまり、update が必要なくそのまま
        // 使用されます
        ActionReuse
    else if (areObjectsOfSameType(prev, next))
        // 両アイテムのタイプが同じだけど、プロパティが異なってる。
        // つまり、update が必要です
        ActionUpdate
    else
        // アイテムのタイプが異なってるので update より差し替えが必要です
        ActionReplace
} 

チェーン全体を渡って、差分が検出されなかった場合、両チェーンが構造的に同じだということ判断され、updateFrom メソッドがそれ以上何もせずに終了されます。

if (after.size == before.size) {
    while (i < before.size) {
        when (actionForModifiers(prev, next)) {
            is ActionReuse -> { 
                // no need to do anything, this is "the same" modifier
            }
            ..
        }
    }
}

しかし差分が検出された場合、検出された index から始まって、最小限の追加や削除が行われるように比較処理が実行されます。

if (after.size == before.size) {
    while (i < before.size) {
        when (actionForModifiers(prev, next)) {
            is ActionReplace -> {
                // break the loop at the index where difference was encountered for the first time
                break 
            }
            ..
        }
    }

    // リスト末尾が来る前に差分が検出されました
    if (i < before.size) {
        structuralUpdate(offset = i, before, after)
    }
}

/**
 * This method utilizes a modified Myers Diff Algorithm which will diff the two modifier chains
 * and execute a minimal number of insertions/deletions. ..
 */
private fun structuralUpdate(
    offset: Int, // the index of both collections from where diff is to be calculated
    ..
) {
    val differ = getDiffer(..) // modified version of DiffUtils
    executeDiff(differ, offset, ..)
    /* invalidate */
}

ここで使用されてる差分検出アルゴリズムは、RecyclerView の場合よく使う DiffUtils の改変版です。

現在のチェーンが空

現在の ModifierNodeElements コレクションが空ということは、レイアウトに初めて Modifier が適用されてることを示してます。

この場合は、ModifierNodeElement.create() を呼び出し、返されてる Modifier.Node のインスタンスをノードのチェーンに追加されます。

// ../androidx/compose/ui/node/NodeChain.kt
fun updateFrom(modifier: Modifier) {
    ..
    else if (beforeSize == 0) {
        var node = /* linked list の頭部 */
        while (i < after.size) {
            node = createAndInsertNodeAsChild(
                element = next, 
                parent = node
            )
            i++
        }
    }
}

fun createAndInsertNodeAsChild(
    element: ModifierNodeElement,
    parent: Modifier.Node
): Modifier.Node {
    val node = element.create() // ModifierNodeElement.create() が呼び出される
    ..
    // linked list で親の後に子を追加する
    val theChild = parent.child
    if (theChild != null) {
        theChild.parent = node
        node.child = theChild
    }
    parent.child = node
    node.parent = parent
    return node
}

新しいチェーンが空

新しいコレクションが空の場合ということは、レイアウトから Modifier が削除されてること表してます。

この場合は、ノードのチェーンから外されます。

// ../androidx/compose/ui/node/NodeChain.kt
fun updateFrom() {
    ..
    else if (after.size == 0) {
        var node = /* linked list の頭部 */
        while (node != null && i < before.size) {
            node = detachAndRemoveNode(node)
            ..
        }
    }
}

fun detachAndRemoveNode(node: Modifier.Node) {
    ..
    node.markAsDetached()
    ..
    val child = node.child
    val parent = node.parent
    if (child != null) {
        child.parent = parent
        node.child = null
    }
    if (parent != null) {
        parent.child = child
        node.parent = null
    }
    return parent!!
}

どちらのチェーンもサイズが異なってる

現在と新しいコレクションのサイズが異なる場合、構造的に異なってること見なされます。

この場合は、チェーンの最初から(index = 0)から始まって、上記の「両チェーンのサイズが同じ」のケースで差分が検出された時と同様で最小限の追加や削除が行われるように比較処理が実行されます。

fun updateFrom() {
    if (after.size == before.size) { /* .. */ }
    else if (before.size == 0) { /* .. */ }
    else if (after.size == 0) { /* .. */ }
    else {
        // diff is run from the index 0 itself
        structuralUpdate(offset = 0, ..)
    }
}

次の記事では、既存の Modifier.composed を Modifier.Node に移行するために 実際の change list を参照しながら基本手順を紹介してます。気になったらぜひ良ければ読んでみてください。

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5