Jetpack Compose のフェーズ

他のほとんどの UI ツールキットと同様に、Compose はいくつかの異なるフェーズを経てフレームをレンダリングします。たとえば、Android ビューシステムには、測定、レイアウト、描画という 3 つの主要なフェーズがあります。Compose はこれとよく似ていますが、最初にコンポジションと呼ばれる重要なフェーズが追加されています。

Compose のドキュメントでは、Compose の思想と状態と Jetpack Compose でコンポジションについて説明しています。

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

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

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

通常、これらのフェーズの順序は変わりません。コンポジション、レイアウト、描画の順にデータが一方向に流れて、フレームを作成します(単方向データフローとも呼ばれます)。BoxWithConstraints、LazyColumn、LazyRow は例外で、子のコンポジションは親のレイアウトのフェーズに左右されます。

概念的には、これらのフェーズはフレームごとに発生しますが、パフォーマンスを最適化するために、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 は、その子である Image と Column を測定します。
  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 ツリーとその描画表現。

状態の読み取り

前述のフェーズのいずれかで snapshot state の value を読み取ると、Compose は value を読み取ったときに行っていた処理を自動的に追跡します。このトラッキングにより、Compose は状態の value が変化したときにリーダーを再実行できます。これは、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)
)

プロパティ委譲の内部では、State の value にアクセスして更新するために「ゲッター」関数と「セッター」関数が使用されます。これらのゲッター関数とセッター関数は、プロパティを値として参照するときにのみ呼び出され、プロパティの作成時には呼び出されません。そのため、前述の 2 つの方法は同等です。

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

段階的な状態の読み取り

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

以降のセクションでは、各フェーズについて説明し、そのフェーズ内で状態値が読み取られた場合の動作について説明します。

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

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

コンポジションの結果に応じて、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 { … } のラムダブロック、および同様の関数が実行されます。

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

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.drawBehind、Modifier.drawWithContent などがあります。状態の value が変更されると、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 状態の value を読み取り、Modifier.offset(offset: Dp) 関数に渡します。ユーザーがスクロールすると、firstVisibleItemScrollOffset の value が変化します。前述のように、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 の value が変更された場合、Compose はレイアウト フェーズと描画フェーズを再開するだけで済みます。

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

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

このガイドでは、Compose のフェーズは常に同じ順序で呼び出され、同じフレーム内で逆方向に進むことはできないと説明しました。ただし、異なるフレーム間でアプリがコンポジション ループに入ることは禁止されていません。次の例を考えてみましょう。

Box {
    var imageHeightPx by remember { mutableIntStateOf(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 は、次のフレームの再コンポーズをスケジュール設定します。ただし、現在の描画フェーズでは、更新された imageHeightPx 値がまだ反映されていないため、テキストは 0 のパディングでレンダリングされます。

2 番目のフレームの構成

Compose は、imageHeightPx の値の変更によってトリガーされる 2 番目のフレームを開始します。このフレームのコンポジション フェーズでは、Box コンテンツ ブロック内で状態が読み取られます。テキストに、画像の高さと正確に一致するパディングが追加されました。レイアウト フェーズ中に imageHeightPx が再度設定されますが、値は一貫しているため、それ以上の再コンポーズはスケジュールされません。

レイアウト フェーズでのサイズ変更が再コンポーズをトリガーし、それによってレイアウトが再度発生する再コンポーズ ループを示す図。

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

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

上記のサンプルの修正方法は、適切なレイアウト プリミティブを使用することです。上記の例は Column() で実装できますが、カスタム レイアウトの作成が必要な、より複雑な例もあります。詳しくは、カスタム レイアウトのガイドをご覧ください。

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

再コンポーズ ループと、フェーズをまたいで状態に書き込むことを回避する方法については、Compose での後方書き込みをご覧ください。