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 は例外です。

概念的には、これらの各フェーズはフレームごとに行われますが、パフォーマンスを最適化するために、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. 子ノードを配置する: 各子ノードは、ノード自身の位置を基準として配置されます。

このフェーズが終了すると、各レイアウトノードには次のものがあります。

  • 指定された高さ
  • 描画する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. 最初の Text と 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 つだけ保持することです。適切なレイアウト プリミティブを使用したり、カスタム レイアウトを作成したりすると、複数の要素間の関係を調整する際の信頼できる情報源として、最低限に共有された親を利用できます。動的な状態を導入すると、原則に反することになります。