Jetpack Compose のフェーズ

他のほとんどの UI ツールキットと同様に、Compose は複数の異なるフェーズを介したフレームをレンダリングします。Android View システムには、測定、レイアウト、描画の 3 つの主要なフェーズがあります。Compose はよく似ていますが、開始時にコンポジションという重要な追加のフェーズがあります。

コンポジションは、Compose の思想および State や Jetpack Compose など、Compose のドキュメント全体で記述されています。

フレームの 3 つのフェーズ

Compose には、次の 3 つの主要なフェーズがあります。

  1. コンポジション: 表示する UI。Compose はコンポーズ可能な関数を実行し、UI の説明を作成します。
  2. レイアウト: UI を配置する場所。このフェーズは、測定と配置の 2 段階で構成されます。レイアウト要素は、レイアウト ツリー内の各ノードについて、自身と子要素を 2D 座標で測定して配置します。
  3. 描画: レンダリングの方法。UI 要素は、キャンバス(通常はデバイス画面)に描画されます。
Compose がデータを UI に変換する 3 つのフェーズ(データ、コンポジション、レイアウト、描画、UI の順に)の画像。
図 1. Compose がデータを UI に変換する 3 つのフェーズ

このフェーズの順序は基本的に同じで、データはコンポジションからレイアウト、描画へと一方向に移行し、単方向データフローとしてフレームが生成されます。子のコンポジションが親のレイアウト フェーズに依存している場合、BoxWithConstraintsLazyColumnLazyRow は例外です。

これらの 3 つのフェーズは各フレームで仮想的に行われると想定できますが、パフォーマンスの観点から、Compose はこれらのすべてのフェーズで同じ入力から同じ結果を計算する反復処理を回避します。以前の結果を再利用できる場合、Compose はコンポーズ可能な関数の実行をスキップします。必要がない場合、Compose UI でツリー全体の再レイアウトや再描画は行われません。Compose は、UI の更新に必要な最小限の作業のみを実行します。Compose は異なるフェーズ内で状態の読み取りを追跡するため、この最適化が可能です。

各フェーズについて理解する

このセクションでは、コンポーザブルに対して 3 つの Compose フェーズがどのように実行されるかについて詳しく説明します。

合成

コンポジション フェーズでは、Compose ランタイムはコンポーズ可能な関数を実行し、UI を表すツリー構造を出力します。次の動画に示すように、この UI ツリーは、次のフェーズに必要なすべての情報を含むレイアウト ノードで構成されています。

図 2. コンポジション フェーズで作成される UI を表すツリー。

コードと UI ツリーのサブセクションは次のようになります。

5 つのコンポーザブルと、その親ノードから分岐した子ノードを持つ UI ツリーを含むコード スニペット。
図 3. UI ツリーのサブセクションと、対応するコード。

以下の例では、コード内のコンポーズ可能な関数はそれぞれ、UI ツリー内の単一のレイアウト ノードにマッピングされています。より複雑な例では、コンポーザブルにロジックと制御フローを含め、さまざまな状態に応じて別のツリーを生成できます。

レイアウト

レイアウト フェーズでは、Compose はコンポジション フェーズで生成された UI ツリーを入力として使用します。レイアウト ノードのコレクションには、2D 空間での各ノードのサイズと位置を決定するために必要なすべての情報が含まれています。

図 4. レイアウト フェーズでの UI ツリー内の各レイアウト ノードの測定と配置。

レイアウト フェーズでは、次の 3 ステップのアルゴリズムを使用してツリーが走査されます。

  1. 子を測定する: 子が存在する場合、ノードは子を測定します。
  2. 独自のサイズを決定する: これらの測定値に基づいて、ノードが自身のサイズを決定します。
  3. 子を配置する: 各子ノードは、ノード独自の位置を基準として配置されます。

このフェーズを終えると、各レイアウト ノードには次のものが含まれます。

  • 割り当てられた widthheight
  • 描画位置の x、y 座標

前のセクションの UI ツリーを思い出してください。

5 つのコンポーザブルと、その親ノードから分岐した子ノードがある UI ツリーを含むコード スニペット

このツリーでは、アルゴリズムは次のように機能します。

  1. Row はその子である ImageColumn を測定します。
  2. Image が測定されます。子がないため、独自のサイズを決定し、そのサイズを Row に報告します。
  3. 次に Column が測定されます。まず自身の子(2 つの Text コンポーザブル)を測定します。
  4. 最初の Text が測定されます。子がないため、独自のサイズを決定し、そのサイズを Column に報告します。
    1. 2 番目の Text が測定されます。子がないため、独自のサイズを決定して Column に報告します。
  5. Column は、子の測定値を使用して自身のサイズを決定します。子の最大幅とその高さの合計を使用します。
  6. Column は、子をそれ自体に対して相対的に配置し、垂直方向に互いに下に配置します。
  7. Row は、子の測定値を使用して自身のサイズを決定します。子の高さの最大値とその幅の合計を使用します。その後、子を配置します。

各ノードに 1 回だけアクセスしていることに注意してください。Compose ランタイムでは、UI ツリーを 1 回パスするだけですべてのノードを測定して配置できるため、パフォーマンスが向上します。ツリー内のノード数が増加すると、ツリーをたどるのに要する時間は線形に増加します。一方、各ノードが複数回アクセスされると、走査時間は指数関数的に増加します。

図面

描画フェーズでは、ツリーが再び上から下にトラバースされ、各ノードが画面上に順番に描画されます。

図 5. 描画フェーズでは、画面上のピクセルを描画します。

前の例を使用すると、ツリーのコンテンツは次のように描画されます。

  1. Row は、背景色など、含まれる可能性のあるコンテンツを描画します。
  2. Image は自身を描画します。
  3. Column は自身を描画します。
  4. 1 番目と 2 番目の Text は、それぞれ自身を描画します。

図 6. UI ツリーとその描画表現。

状態の読み取り

上のリストで示したいずれかのフェーズ中にスナップショットの状態の値を読み取ると、Compose は値の読み取り時に行われた処理を自動的に追跡します。この追跡により Compose は状態の値が変更されたときにリーダーを再実行できるようになり、Compose の状態に関するオブザーバビリティの基礎となります。

状態は、通常は mutableStateOf() を使用して作成され、その後 value プロパティに直接アクセスする方法または Kotlin プロパティのデリゲートを使用する方法のいずれかでアクセスされます。詳細については、コンポーザブル内の状態をご覧ください。このガイドでは、「状態の読み取り」とは、これらの同等のアクセス方法のいずれかを指します。

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

プロパティ デリゲートでは、「getter」関数と「setter」関数を使用して、状態の value へのアクセスと更新を行います。これらの getter 関数と setter 関数は、プロパティが作成されたときではなく、プロパティを参照したときにのみ呼び出されます。そのため、上の 2 つの方法は同等です。

読み取り状態が変更されたときに再実行できるコードの各ブロックは、再起動スコープです。Compose は、状態の値の変化を追跡し、さまざまなフェーズでスコープを再起動します。

段階的な状態の読み取り

前述のように、Compose には 3 つの主要なフェーズがあり、Compose は各フェーズ内で読み取られる状態を追跡します。これにより、Compose は影響を受ける UI の要素ごとに作業を実行する必要がある特定のフェーズにのみ通知できます。

各フェーズを確認して、そのフェーズ内で状態値が読み取られた場合の動作について説明します。

フェーズ 1: コンポジション

@Composable 関数またはラムダブロック内の状態の読み取りはコンポジションに影響を与え、場合によっては後続のフェーズに影響します。状態の値が変更されると、recomposer は、その状態の値を読み取るすべてのコンポーズ可能な関数の再実行をスケジュール設定します。入力が変更されていない場合、ランタイムがコンポーズ可能な関数の一部またはすべてをスキップすることを決定する場合があります。詳しくは、入力が変更されていない場合にスキップするをご覧ください。

コンポジションの結果に応じて、Compose UI はレイアウトと描画のフェーズを実行します。コンテンツが同じで、サイズとレイアウトが変更されない場合は、これらのフェーズをスキップできます。

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

フェーズ 2: レイアウト

レイアウト フェーズは、測定と配置の 2 つのステップで構成されます。測定のステップでは、Layout コンポーザブルに渡される測定ラムダ、LayoutModifier インターフェースの MeasureScope.measure メソッドなどが実行されます。配置のステップでは、layout 関数のプレースメント ブロック、Modifier.offset { … } のラムダブロックなどが実行されます。

各ステップでの状態の読み取りは、レイアウトと描画のフェーズに影響します。状態の値が変更されると、Compose UI がレイアウト フェーズをスケジュール設定します。また、サイズや位置が変更された場合も、描画フェーズが実行されます。

より正確に言うと、測定ステップと配置ステップには別々の再起動のスコープがあります。つまり、配置ステップでの状態の読み取りでは、それより前の測定ステップは呼び出されません。ただし、これら 2 つのステップは互いに絡み合っていることが多いため、配置ステップの状態の読み取りは、測定ステップに属する他の再起動のスコープに影響を与える場合があります。

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

フェーズ 3: 描画

描画コード中の状態の読み取りは、描画フェーズに影響します。一般的な例には、Canvas()Modifier.drawBehindModifier.drawWithContent などがあります。状態の値が変更されると、Compose UI は描画フェーズのみを実行します。

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

状態の読み取りの最適化

Compose がローカライズされた状態の読み取りの追跡を行う際、適切なフェーズで各状態を読み取ることで、実行される作業量を最小限に抑えることができます。

例を見てみましょう。ここでは、オフセット修飾子を使用して最終のレイアウト位置をオフセットする Image() を作成し、ユーザーがスクロールしたときのパララックス効果を実現しています。

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

このコードは機能しますが、最適なパフォーマンスが得られません。記述されているように、このコードは firstVisibleItemScrollOffset 状態の値を読み取り、Modifier.offset(offset: Dp) 関数に渡します。ユーザーがスクロールすると、firstVisibleItemScrollOffset の値が変化します。あご存じのとおり、Compose はあらゆる状態の読み取りを追跡するため、読み取りコードの再起動(ここでは再呼び出し)が可能になります。この例では、Box のコンテンツです。

これは、コンポジションのフェーズ内で読み取られる状態の例です。これは必ずしも悪いことではなく、実際には再コンポジションのベースであり、データ変更によって新しい UI が出力できるようになります。

ただし、この例は最適ではありません。コンポーザブル コンテンツ全体がすべてのスクロール イベントによって再評価され、測定、配置されてから最後に描画されるためです。スクロールごとに Compose フェーズがトリガーされます。ただし、表示されている内容は変更されておらず、表示されている場所のみが変更されています。状態の読み取りを最適化して、レイアウト フェーズを再度トリガーできます。

オフセット修飾子の別のバージョンとして Modifier.offset(offset: Density.() -> IntOffset) を使用できます。

このバージョンはラムダ パラメータを取ります。ここで、結果のパラメータがラムダブロックによって返されます。これを使用するようにコードを更新しましょう。

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

では、なぜパフォーマンスが向上したのでしょうか?修飾子に提供するラムダブロックは、レイアウト フェーズ(具体的にはレイアウト フェーズの配置ステップ中)で呼び出されます。つまり、firstVisibleItemScrollOffset 状態がコンポジション中に読み取られることはなくなります。Compose は状態の読み取りのタイミングを追跡するため、この変更により、firstVisibleItemScrollOffset 値が変更された場合、Compose はレイアウトと描画のフェーズを再開するだけで済みます。

この例では、別のオフセット修飾子に依存して結果のコードを最適化できますが、一般的な考え方は誤りではありません。状態の読み取りを可能な限り低いフェーズへローカライズすることを試み、Compose による最小限の作業の実行を可能にします。

もちろん、多くの場合、コンポジション フェーズでの状態の読み取りは絶対に不可欠です。そのような場合でも、状態の変更をフィルタリングすることで再コンポジションの回数を最小限に抑えることができます。詳細については、derivedStateOf: 1 つ以上の状態オブジェクトを別の状態に変換するをご覧ください。

再コンポジション ループ(循環型フェーズの依存関係)

前述したように、Compose のフェーズは常に同じ順序で呼び出され、同じフレーム内で逆方向に進む方法はありません。ただし、複数のフレーム間でコンポジション ループに入るアプリは禁止されていません。以下の例で考えてみましょう。

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

ここでは、(不適切な)垂直方向の列を実装し、上部に画像、その下にテキストを配置しています。Modifier.onSizeChanged() を使用して画像の解決済みサイズを把握し、テキストで Modifier.padding() を使用して画像の下方にシフトさせます。Px から Dp への不自然な変換は、すでにコードになんらかの問題があることを意味しています。

この例での問題は、1 つのフレーム内の「最終」レイアウトに到達しないことです。このコードでは複数のフレームの発生に依存しているため、不要な処理が行われ、UI がユーザーの画面上でジャンプします。

何が起きているかをそれぞれのフレームで順を追って見ていきましょう。

最初のフレームのコンポジション フェーズでは、imageHeightPx の値は 0 となり、テキストには Modifier.padding(top = 0) が提供されます。その後、レイアウト フェーズが続いてから、onSizeChanged 修飾子のコールバックが呼び出されます。これは、imageHeightPx が画像の実際の高さに更新された時点です。Compose は、次のフレームの再コンポジションをスケジュール設定します。描画フェーズでは、値の変更がまだ反映されていないため、パディング 0 でテキストがレンダリングされています。

次に、Compose は、imageHeightPx の値の変更によってスケジュール設定された 2 番目のフレームを開始します。状態は Box のコンテンツ ブロックで読み取られ、コンポジション フェーズで呼び出されます。この場合、テキストには画像の高さと一致するパディングが提供されます。レイアウト フェーズでは、コードに imageHeightPx の値が再度設定されますが、値が同じままであるため、再コンポジションはスケジュールされません。

最終的には、テキストに目的のパディングが追加されていますが、パディング値を別のフェーズに戻すために追加のフレームを使用することは最適ではなく、その結果、コンテンツが重なるフレームが作成されます。

このサンプルは不自然に感じられるかもしれませんが、次の一般的なパターンには注意が必要です。

  • Modifier.onSizeChanged()onGloballyPositioned() などのレイアウト オペレーション
  • 一部の状態を更新する
  • その状態をレイアウト修飾子(padding()height() など)への入力として使用する
  • 繰り返される可能性がある

上のサンプルの修正では、適切なレイアウト プリミティブを使用します。上の例は、シンプルな Column() で実装できますが、より複雑な例でカスタムされたものが必要な場合は、カスタム レイアウトの記述が必要になります。詳細については、カスタム レイアウト ガイドをご覧ください。

ここでの一般的な原則は、互いに測定して配置すべき複数の UI 要素について、信頼できる情報源を 1 つだけ保持することです。適切なレイアウト プリミティブを使用したり、カスタム レイアウトを作成したりすると、複数の要素間の関係を調整する際の信頼できる情報源として、最低限に共有された親を利用できます。動的な状態を導入すると、原則に反することになります。