20
16

More than 1 year has passed since last update.

Modifier.Node を使いましょう (Part 1: なぜ必要になった)

Last updated at Posted at 2023-09-09

この記事のシリーズでは DroidKaigi 2023 で私が発表した内容をもとに、Compose v1.3.0 あたりから追加された Modifier.NodeAPI について記載しましました。

シリーズのコンテンツ
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 が初めて追加されたのは Android Dev Summit 2022 の頃で、Leland氏 がその改善の思考についてセッションがありますので、ぜひ見てみてください。

Compose ランタイムの Smart Recomposition

Compose の基本的な構成要素は @Composable 関数ですね。この @Composable 関数を「Composition」というツリーのようなデータ構造に追加されます。
Screenshot 2023-09-06 at 8.43.56 PM.png
初フレームでは、Compose Runtime が Composition ツリーの各ノードに訪れて、Box, Text, Button のようなレイアウト@Composable 関数を呼び出します。呼び出された@Composableが発行するUIコンポーネントを使ってレイアウトが構築されます。

その後、次々のフレームからUIを最適化するために、Compose ランタイムが Composition ツリーで「変更が行われたかどうか」のためツリー全体を観察します。ランタイムが「変更が行われた」と判断したノードのみを再呼び出し(Recomposition)して、構築されたUIを更新します。

このように関数の Recomposition を選択的スキップできることを一般的に「Smart Recomposition」と呼ばれてます。

Composable 関数の Recomposition をスキップできるかどうかの判断については ComposableFunctionBodyTransformer に多くの情報が記述されております。この記事のため2点を知ることが必要だと思います:

  • non-Unit 値を返す @Composable 関数の Recomposition をがスキップされない。つまり、各フレームで再呼び出されます。
  • Unit を返す @Composbale 関数の場合は、渡されてるパラメータが変更されていない場合、Recomposition がスキップされます。
@Composable
fun a(): Int { 
    Log.d(.., "a")
    return /* .. */ 
}

@Composable
fun A(value: Int) { 
    Log.d(.., "A: $value")
    /* .. */
}

@Composable
fun B(value: Int) { 
    Log.d(.., "B: $value")
    /* .. */
}

var state by remember { mutableStateOf(10) }
Button(onClick = { state += 1 }){..}
a()
A(4)
B(state)

/*
 ボタンタップし、Recomposition を実行したら output は以下の通り:
 a
 A, value: 4
 B, value: 10

 a
 B, value: 11

 a
 B, value: 12
 ..
 */

Recomposition について公ドキュメントで詳しく書いてありますのでぜひ見てみてください。

旧 Modifier 仕組み

Modifier は Compose UI の重要なものの1つです。複数の Modifier を「連結」して @Composable UIコンポーネントの見た目、位置などを特定なプロパティを提供することができます。

// 赤色の背景と 16dp の padding
Box(modifier = Modifier
    .padding(16.dp)
    .background(Color.Red)
)

基本的に、Modifier.background のような関数が Modifier.then() というメソッドで Modifier.Element のオブジェクトをあるデ-タ構造に連結されます。

Modifier.Element が連結されて作られるデ-タ構造を一般的に「Modifier チェーン」と呼ばれることもあります。

interface Modifier {
    ..
    /**
     * A single element contained within a [Modifier] chain.
     */
    interface Element : Modifier { /* .. */ }

    /**
     * Concatenates this modifier with another.
     * Returns a [Modifier] representing this modifier followed by [other] in sequence.
     */
    infix fun then(other: Modifier): Modifier { /* .. */ }
}

例えば、上記の Modifier の例を以下のように表します。

/* Modifier.padding(16.dp).background(Color.Red) */

Modifier
    .then(Padding(16.dp))
    .then(Background(Color.Red))

class Padding(all: Dp): LayoutModifier { /* .. */ }
class Background(color: Color, ..): DrawModifier { /* .. */ }

// レイアウト調整する用の Modifier.Element
interface LayoutModifier: Modifier.Element { /* .. */ }

// 描画する用の Modifier.Element
interface DrawModifier: Modifier.Element { /* .. */ }

Modifier チェーンの役割

旧 Modifier 仕組みで、この Modifier チェーンの2つの役割ありました。

特定なプロパティーを提供する

Modifier チェーンがレイアウトに特的なプロパティ(サイズ、色、タップイベント時の処理、フォーカス与えるなど)を提供しました。
Screenshot 2023-09-06 at 8.40.08 PM.png
例えば、Modifier.background でレイアウトの背後に背景を描画することができます。

fun Modifier.background(color: Color, ..)
    = this.then(Background(color = color, ..))
 
class Background(color: Color, ..): DrawModifier { 
    // draw を override して、UIコンポーネントのコンテンツの後ろに
    // 背景を描画すること定義してます
    override fun ContentDrawScope.draw() {
            drawRect(color = color, ..)
            drawContent()
    }
}

少し余談で、Droidcon Berlin'23で Modifier チェーンを定義する順次の重要さについてセッションがあります。興味あれば、ぜひ見てみてください。

Modifier が「変更されたかどうか」の判断のため比較

スマートな Recomposition のため、レイアウト @Composable に渡される Modifier パラメータが変更されたかどうかの判断が必要でした。
変更された場合、LayoutNode に新しい Modifier を設定し Recomposition が行われます。

// ../androidx/compose/ui/layout/Layout.kt
@Composable
inline fun Layout(
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    // Modifier.Element のチェーン
    val materialized = currentComposer.materialize(modifier)
    ..
    ReusableComposeNode<LayoutNode>(
        factory = ..
        update = {
            // 前の materialized インスタンスと異なった場合、LayoutNode の Modifier に
            // 新しい materialized インスタンスを設定され、invalidate されます
            set(materialized, { this.modifier = it })
            ..
        }
    )
}

Stateless Modifier

元々 Modifier.Element を state を保持させないように (stateless) 想定して作られたみたいです。なので、Modifier.then メソッドにいつもそれぞれの Modifier.Element の新しいインスタンスを渡させるように作られました。

Modifier.background(color: Color, ..) 
    = this.then(Background(color..)) // 毎回新しいインスタンス渡された

// color が val になってます
class Background(val color: Color, ..): DrawModifier

つまり、各フレームで新しい生成される Modifier.Element インスタンスとUIコンポーネントの lifespan (寿命) が一致しなかった

Stateless だったので、もし内部的に state を持つようになっても、毎回新しいインスタンスが渡されたので、保持された state もリセットされてしまいます。

class Background(val color: Color, ..): DrawModifier {
+    var isCircleShape = false // 毎回 false として初期化されてしまいます
    override DrawScope.draw() {
        val shape = if (this.isRoundRect) CircleShape else ..
        /* .. */
    }
}

Stateful Modifier

リップルアニメーションやフォーカスのような、Modifierを内部的に state を持たせる (stateful) 、ユースケースが必要になりました。そのきっかけ、Modifier.composed{} が追加されました。

// ../androidx/compose/ui/ComposedModifier.kt
fun Modifier.composed(
    ..
    factory: @Composable Modifier.() -> Modifier
): Modifier

@Composable factory ラムダパラメータの中で Compose ランタイムを活用して state の保持することは大きなメリットでした。

例えば、

fun Modifier.toggleBackground() = composed {
    // 内部的な state
    var selected by remember { mutableStateOf(false) }

    this.clickable { selected = !selected }
        .background(color = if (selected) Color.Red else Color.Blue)
}

でも、Modifier.composed が追加されてパフォーマンスには大きく影響を与えてしまいました。

Modifier.composed のパフォーマンス問題

Statefulな Modifier が必要になったため Modifier.composed が追加されましたが、それでパフォーマンス面でいくつかの問題が発生されました。

不必要な Recomposition

@Composable 関数に @Composable ラムダが渡されれば、Compose ランタイムがそのラムダをキャッシュすることができます。

@Composable
fun Box(
    // contents ラムダがキャッシュされてます
    contents: @Composable BoxScope.() -> Unit
)

Modifier.composed 自体は @Composable 関数ではないので、Compose ランタイムを活用して factory ラムダをキャッシュすることはできないです。つまり、Modifier.composed が返す ComposedModifierequals() で比較を常に失敗されました。
Screenshot 2023-09-06 at 8.57.48 PM.png
Modifier.composed が Modifier チェーンに追加された場合、前の Modifier チェーンと比較されて常に「等しいではない」と判断されます。
その結果は、UIコンポーネントに渡される Modifier が変更されてないにも関わらず、各フレームで不必要な Recomposition が行われました

@Composable
fun A(modifier: Modifier = Modifier) {
    SideEffect { Log.d(.., "Recompose A") }
    Text(modifier = modifier, ..)
}

@Composable
fun B(modifier: Modifier = Modifier) {
    SideEffect { Log.d(.., "Recompose B") }
    Text(modifier = modifier, ..)
}

A(modifier = Modifier
        .background(Color.Green)
        .composed { this } // ただ Modifier.composed を追加しただけ
        .padding(4.dp)
)
    
B(modifier = Modifier
        .background(Color.Green)
        .padding(4.dp)
)
 
/*
 Recomposition を実行したら output は以下の通り:
 Recompose A
 Recompose B

 Recompose B

 Recompose B
 ..
 */

Compositionツリーの観察コストの増加

@Composable factory ラムダは Modifier を返します。

// ../androidx/compose/ui/ComposedModifier.kt
factory: @Composable Modifier.() -> Modifier, ..

Smart Recomposition でも話しましたが、non-Unit 値を返す @Composable 関数の再呼び出しがスキップされないです。なので、各フレームで必ず factory ラムダが呼び出されます。

Modifier.composed の factory ラムダが @Composable コンテキスト持ってるので、その中で呼び出される @Composable 関数が Composition ツリーに追加されます。

上記の Android Dev Summit'22 セッションからのスライドで Modifier.clickable のようなよく使われる Modifier も Composition ツリーのサイズを大きくしてしまいました。
スクリーンショット 2023-08-03 21.11.11.png
Screenshot 2023-09-06 at 8.51.02 PM.png
つまり、Modifier.composed で Compositionツリーが大きくなって、Compose ランタイムのツリーを観察するコストが増加されました

インパクト

よく見かけるアプリのユースケース「アイテム一覧をタップして詳細画面開く」を Composeに実装すると、

LazyColumn {
    items(models) { model ->
        ListItem(
            modifier = Modifier.clickable { /* 繊維する */ },
            model = model, ..
        )
    }
}

この実装をよく考えてみれば、タップ処理を行うためアイテムレイアウトに設定された Modifier.clickable が、先ほど記載した2つのパフォーマンス問題をリストのサイズの倍数で大きくしてしまいますね。
clickable追加されただけでのインパクト
このような色んなユースケースで Modifier.composed が使われていて Compose ランタイムのパフォーマンスが落ちてしまいました。

Modifier.composed 問題の解決

Introduce Modifier.Node APIs, refactor Modifier/Wrapper/Entity strategy というタイトルの Modifier.Node API 改修に向けて最初 change list があります。この change list の説明は以下のように記述されてます。

Change Summary
===
The added experimental Modifier.Node APIs provide an abstraction that allows for state to be maintained on an instance that will be retained with the lifecycle of the layout node, and will be allocated per-layout-node and per-usage of the corresponding Modifier.Element that produced it.

Broadly speaking, this abstraction provides an alternative mechanism to produce stateful modifiers without relying on the mechanics of the Modifier.composed API.

気になったら、ぜひ見てみてください。

新しい Modifier.Node 仕組みで Modifier チェーンがを2つのチェーンに分担されてます。

  • Node チェーン
  • Element チェーン
    Screenshot 2023-09-06 at 8.53.02 PM.png
    次の記事でもこの2つについて書きましたが、2つのチェーンの違いを軽くまとめてみました。
Node チェーン Element チェーン
Modifier.Node が連結されて作られてます ModifierNodeElement が連結されて作られてます
UIコンポーネントに特定なプロパティを提供します UIコンポーネントに設定された Modifier に変更があったかどうかの判断のために比較されます
UIコンポーネントはずっとNode チェーンの1つのインスタンスを持ち続けられます 前の仕組みの Modifier.Element チェーンと同様で各フレームで新しいインスタンスが生成されます。
Modifier.Node がUIコンポーネントの寿命と一致していて state を保持することができます 毎回ModifierNodeElementの新しいインスタンスが生成されるので、stateを持たせない

なので、前の仕組みで Modifier チェーンに持たされた役割を2つのチェーンに分けられて、Modifier.composed で発生した問題が解決されてます:

  • 「Modifier に state を保持させる」ニーズを Modifier.Nodeという長生きさせたオブジェクトで満たせてます。
    State を保持するために利用された @Composable コンテキストも不要になってるので、Composition ツリーのサイズが増える問題を解決できてます。
  • Modifierに変更があったかどうかの判断を ModifierNodeElement というオブジェクトに任せてます。
    ラムダオブジェクトより、class オブジェクトを比較することになっていて不要な Recomposition が避けられてます。

この Modifier.Node API については次の記事で書いてみましたので良ければ読んでみてください。

20
16
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
20
16