他のほとんどの UI ツールキットと同様に、Compose は複数の異なるフェーズを介したフレームをレンダリングします。Android View システムには、測定、レイアウト、描画の 3 つの主要なフェーズがあります。Compose はよく似ていますが、開始時にコンポジションという重要な追加のフェーズがあります。
コンポジションは、Compose の思想および State や Jetpack Compose など、Compose のドキュメント全体で記述されています。
フレームの 3 つのフェーズ
Compose には、次の 3 つの主要なフェーズがあります。
- コンポジション: 表示する UI。Compose はコンポーズ可能な関数を実行し、UI の説明を作成します。
- レイアウト: UI を配置する場所。このフェーズは、測定と配置の 2 段階で構成されます。レイアウト要素は、レイアウト ツリー内の各ノードについて、自身と子要素を 2D 座標で測定して配置します。
- 描画: レンダリングの方法。UI 要素は、キャンバス(通常はデバイス画面)に描画されます。
このフェーズの順序は基本的に同じで、データはコンポジションからレイアウト、描画へと一方向に移行し、単方向データフローとしてフレームが生成されます。子のコンポジションが親のレイアウト フェーズに依存している場合、BoxWithConstraints
、LazyColumn
、LazyRow
は例外です。
これらの 3 つのフェーズは各フレームで仮想的に行われると想定できますが、パフォーマンスの観点から、Compose はこれらのすべてのフェーズで同じ入力から同じ結果を計算する反復処理を回避します。以前の結果を再利用できる場合、Compose はコンポーズ可能な関数の実行をスキップします。必要がない場合、Compose UI でツリー全体の再レイアウトや再描画は行われません。Compose は、UI の更新に必要な最小限の作業のみを実行します。Compose は異なるフェーズ内で状態の読み取りを追跡するため、この最適化が可能です。
状態の読み取り
上のリストで示したいずれかのフェーズ中にスナップショットの状態の値を読み取ると、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.drawBehind
、Modifier.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 つだけ保持することです。適切なレイアウト プリミティブを使用したり、カスタム レイアウトを作成したりすると、複数の要素間の関係を調整する際の信頼できる情報源として、最低限に共有された親を利用できます。動的な状態を導入すると、原則に反することになります。