この記事のシリーズでは DroidKaigi 2023 で私が発表した内容をもとに、Compose v1.3.0 あたりから追加された 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 が初めて追加されたのは Android Dev Summit 2022 の頃で、Leland氏 がその改善の思考についてセッションがありますので、ぜひ見てみてください。
Compose ランタイムの Smart Recomposition
Compose の基本的な構成要素は @Composable
関数ですね。この @Composable
関数を「Composition」というツリーのようなデータ構造に追加されます。
初フレームでは、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 チェーンがレイアウトに特的なプロパティ(サイズ、色、タップイベント時の処理、フォーカス与えるなど)を提供しました。
例えば、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 が返す ComposedModifier が equals()
で比較を常に失敗されました。
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 ツリーのサイズを大きくしてしまいました。
つまり、Modifier.composed で Compositionツリーが大きくなって、Compose ランタイムのツリーを観察するコストが増加されました。
インパクト
よく見かけるアプリのユースケース「アイテム一覧をタップして詳細画面開く」を Composeに実装すると、
LazyColumn {
items(models) { model ->
ListItem(
modifier = Modifier.clickable { /* 繊維する */ },
model = model, ..
)
}
}
この実装をよく考えてみれば、タップ処理を行うためアイテムレイアウトに設定された Modifier.clickable が、先ほど記載した2つのパフォーマンス問題をリストのサイズの倍数で大きくしてしまいますね。
このような色んなユースケースで 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 チェーン |
---|---|
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 については次の記事で書いてみましたので良ければ読んでみてください。