Compose は、厳密に順序付けられた 3 つのフェーズを順に実行してフレームを処理します。
- Composition:
@Composable関数を実行して UI ツリーをビルド、更新します。 - Layout: 子を測定して配置します。
- 描画: キャンバス描画コマンドを出力して、画面にピクセルをレンダリングします。
いずれかのフェーズで Compose State が読み取られると、Compose はその状態と対応するフェーズの間の依存関係を自動的に記録します。
「逆方向」の書き込みとは何ですか?
後方書き込みは、状態が読み取られたフェーズよりも後のフェーズ(またはダウンストリーム スコープ。つまり、同じコンポジション パス内で後で実行されるコンポーザブル スコープ)で状態が変更されるたびに発生します。これにより、Compose は、前のフェーズまたはコンポーザブルを再度実行するようにスケジュールすることで、再コンポーズを強制します。後方書き込みは、最適化されていない再コンポーズ ループです。
後方書き込みの影響
逆方向の書き込みは必ずしも悪いことではなく、クラッシュをトリガーするとは限りませんが、非効率的であり、いくつかの点でアプリのパフォーマンスに悪影響を及ぼす可能性があります。
- 余分なフレーム レンダリングとフレーム落ち: 後方書き込みにより、Compose は連続するフレームにわたって冗長なコンポジション パスを実行せざるを得なくなり、CPU リソースと GPU リソースが無駄になり、ジャンクが発生する可能性があります。
- 最初のフレームの正確性の問題: コンポーネントが最終的なサイズや状態を解決するために後方書き込みを必要とする場合、最初のフレームは無効なデータ、デフォルトのデータ、または未解決のデータ(サイズがゼロ、オフセットが正しくないなど)でレンダリングされます。これにより、2 番目のフレームがレンダリングされるときに、視覚的なポップアップやレイアウトのフラッシュが発生します。
- 無限の再コンポーズ ループ: 状態の変更によってレイアウトのサイズが変更され、レイアウトのサイズが新しい値を状態に継続的に書き戻す場合、画面が安定することなく、すべてのフレームを常に再コンポーズする無限のフレームループが作成されるリスクがあります。
フェーズを順方向に進む
状態の変更は、常にフェーズを順方向に進む必要があります。
| 読み取りフェーズ | 書き込みコンテキスト | 許容可能か | 背景 |
|---|---|---|---|
レイアウト(Modifier.offset { }) |
楽曲 | ○ | Composition が状態を更新 → レイアウトが同じフレーム内で再コンポーズせずに状態を読み取る。 |
引き分け(graphicsLayer { }、drawBehind { }) |
楽曲 | ○ | Composition が状態を更新 → 描画が最終フェーズで状態を読み取る。Composition と Layout は完全にスキップされます。 |
| 描画 | レイアウト | ○ | レイアウトが状態を更新し、描画がそれを読み取る。有効なフロー。 |
| 楽曲 | イベント コールバック(onClick、onValueChange)が運転状態を変更します。注: レイアウト コールバックはイベントとしてカウントされません。 |
○ | イベントは、コンポジションの駆動に使用される状態を変更します。イベントがフレーム外(コンポジション、レイアウト、描画以外)で発生する場合は、有効です。 |
| 楽曲 | コルーチン(LaunchedEffect) |
はい - 注意が必要 | ライフサイクル/イベントに応じて状態を非同期で更新します。エフェクトからの書き込みは有効な場合もありますが、非効率的な状態のレイヤリングを指している可能性があります。可能な限り、このような状況は避ける必要があります。 |
| プレースメント(レイアウト内) | メジャー(レイアウト内) | ○ | レイアウトでは、状態を更新してから、その状態を配置で読み取ることができます。 |
| メジャー(レイアウト内) | プレースメント(レイアウト内) | いいえ - 逆方向に書き込む | 配置で状態に書き込み、さらに読み取り状態で書き込むと、再測定ループが発生します。 |
| 楽曲 | レイアウト(onSizeChanged、LayoutModifier) |
いいえ - 逆方向の書き込み | レイアウトが無効化される → Composition → 再コンポーズのループ。 |
| 楽曲 | 引き分け(drawWithContent、Canvas) |
いいえ - 逆方向に書く | 描画によって Composition が無効化 → 再コンポーズのループ。 |
フェーズの組み合わせ: 後方と前方
Compose 内での後方書き込みの例と、その解決方法を以下に示します。
逆方向: Composition で読み取り、Layout で書き込み
- 動作: Composition は
componentHeightを読み取り、出力する UI を決定します。フレームの後半で、レイアウト フェーズがビューを測定または配置し、componentHeightに新しい値を書き込みます(たとえば、onSizeChanged、onGloballyPositioned、カスタムLayoutModifierを使用)。 - 結果: レイアウトで
componentHeightを変更すると、完了したばかりの Composition フェーズが無効になります。onSizeChangedは、レイアウト測定パスの完了後にサイズを報告します。更新された状態値が次のパスで安定すると、再コンポーズは 1 つの追加フレームの後に停止する可能性があります。ただし、新しい値がサイズを変更し続けると、無限フレームループが発生します。さらに、onGloballyPositionedはレイアウトと配置の両方の後に実行されるため、内部での状態の書き込みは、連続するフレームにわたる継続的な再コンポーズと再レイアウトのループの影響をさらに受けやすくなります。
// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged) @Composable fun BadAspectRatioImage(painter: Painter) { var calculatedHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current // State read during COMPOSITION: Image( painter = painter, contentDescription = "Dynamic Image", modifier = Modifier .fillMaxWidth() .height(calculatedHeight) .onSizeChanged { size -> // State write during LAYOUT phase! // Triggers backwards write and recomposition pass val aspectRatio = 16f / 9f val widthDp = with(density) { size.width.toDp() } calculatedHeight = widthDp / aspectRatio } ) } // ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition @Composable fun GoodAspectRatioImage( painter: Painter, aspectRatio: Float = 16f / 9f, modifier: Modifier = Modifier ) { Layout( content = { Image( painter = painter, contentDescription = "Dynamic Image" ) }, modifier = modifier ) { measurables, constraints -> val width = constraints.maxWidth val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio() val imageConstraints = constraints.copy( minWidth = width, maxWidth = width, minHeight = height, maxHeight = height ) val placeable = measurables.first().measure(imageConstraints) layout(width, height) { placeable.placeRelative(0, 0) } } }
逆方向: コンポジションで読み取り、描画で書き込み
- 動作: 状態はコンポーザブルの本文(コンポジション フェーズ)で読み取られますが、
Modifier.drawWithContent、Modifier.drawBehind、Canvas(描画フェーズ)内で変更されます。 - 結果: 描画フェーズで状態が変更 → Composition が無効化 → 無限ループ。
// ❌ BAD: Read in Composition, Written in Draw () @Composable fun BadBackwardsWriteDraw() { var componentHeight by remember { mutableStateOf(0.dp) } // State read during COMPOSITION: Text( text = "Height is: $componentHeight", modifier = Modifier.drawBehind { // State write during the DRAW phase! // Invalidates Composition -> triggers recomposition loop! componentHeight = size.height.dp } ) }
後方: コンポジションでの読み取り、コンポジションでの書き込み(同じフェーズ)
- 動作: コンポーズ可能な関数で
countを読み取り、読み取り後に別のコンポーザブルのコンテンツ スロットでcountを直接変更します。 - 結果: スナップショット システムは、同じコンポジション パス内で読み取りと後続の書き込みを記録し、現在のスコープを直ちに無効にします。
// ❌ BAD: Direct write in Composable body after read @Composable fun BadCounter() { var count by remember { mutableIntStateOf(0) } Text("Count: $count") // State read in Composition Button(onClick = {}) { count++ // State write in Composition (Backwards write!) } } // Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read @Composable fun OkCounter() { var count by remember { mutableIntStateOf(0) } Button(onClick = {}) { count++ // State write in Composition } Text("Count: $count") // State read in Composition }
逆方向書き込みを回避するための主なルール
onGloballyPositioned、onSizeChanged、onPlaced、onLayoutRectChanged、LayoutModifierで状態に書き込まないでください。その状態が Composition で読み取られる場合、最初のフレームの正確性の問題が発生します。- レイアウトの座標またはサイズがカスタム描画にのみ必要な場合は、描画フェーズまたはレイアウト フェーズで直接読み取ります(たとえば、
Modifier.drawWithCacheまたはModifier.layoutを使用します)。 - ウィンドウ レベルのサイズ設定(
WindowWidthSizeClass)の場合: サイズの監視をウィンドウ レベルにホイスティングします。ローカル測定が行われる前に、ウィンドウ サイズクラスでコンポジションが分岐します。 - Composition を均一に保つ: 単一のカスタム レイアウトまたは
FlowRowやLazyVerticalGridなどのコンポーネントを使用します。これらは、Composition で使用される状態を再コンポーズまたは変更することなく、フェーズ 2 で測定と配置を調整します。 - サブコンポジションを使用する: 子コンポーザブルをローカルの幅または高さに基づいて分岐させる必要がある場合は、
BoxWithConstraintsまたはSubcomposeLayoutを使用します。注意: サブコンポジションはパフォーマンスに影響するため、通常は避ける必要があります。 - 最後の手段として: 最初のフレームが間違っていることを許容し、サイズを
onSizeChangedに保存して、2 番目のフレームの再コンポーズをトリガーします。これにより、目に見えるレイアウトのポップ、ジャンク、無限ループのリスクが発生します。
- レイアウトの座標またはサイズがカスタム描画にのみ必要な場合は、描画フェーズまたはレイアウト フェーズで直接読み取ります(たとえば、
- コンポジションで最初に読み取られた後に状態を変更しない:
SideEffectの外部でコンポジション中にMutableStateオブジェクトに安全に書き込むことはできますが、コンポジションで以前に読み取った可能性のある状態に書き込まないように、特別な注意を払ってください。コンポジションで状態の書き込みが必要になった場合は、rememberUpdatedStateを使用することをおすすめします。別の方法で Composition 中に状態に書き込むことは、通常、エフェクトが欠落しているか、状態またはコンポーズ可能な関数が不適切に設計されていることを示しています。コンポジションはオプティミスティックであり、常に状態の最新の値で実行されるため、再コンポーズですべての状態変化が表示されるとは限りません。UI の更新は、1 回限りのイベントを処理する方法として使用すべきではありません。そのため、再コンポーズの結果として状態の値を更新する必要があることはまれです。- コンポジションの外部で監視される状態(
ViewModelフィールドやisVisibleフラグなど)を変更しないでください。コンポジションに影響する状態の書き込みは、イベント ラムダ(onClick)、コルーチン(LaunchedEffect)、副作用(SideEffect)に属します。rememberUpdatedStateは例外で、@Composable本文でのみ使用される状態を変更するように設計されています。
- 状態の読み取りを可能な限り後のフェーズに延期する:
- 描画(
Modifier.graphicsLayer { alpha = ... })またはレイアウト(Modifier.offset { IntOffset(...) })で状態を読み取ると、変更によってフェーズ 2 または 3 のみが無効になり、フェーズ 1(Composition)は完全にスキップされます。
- 描画(