Backwards Write

Compose は、厳密に順序付けられた 3 つのフェーズでフレームを実行します。

3 つの順方向のフェーズ: 1. 構成」、「2. レイアウト」、「3. 描画
図 1. Compose フレームの 3 つのフェーズ
  1. コンポジション: @Composable 関数を実行して、UI ツリーをビルドして更新します。
  2. レイアウト: 子を測定してから配置します。
  3. 描画: キャンバスの描画コマンドを発行して、ピクセルを画面にレンダリングします。

いずれかのフェーズで Compose の State読み取られると、Compose はその状態と対応するフェーズの間の依存関係を自動的に記録します。


書き込みが「逆方向」になるのはなぜですか?

後方書き込みは、状態が読み取られた場所よりも後のフェーズ(またはダウンストリーム スコープ、つまり同じコンポジション パス内で後で実行されるコンポーザブル スコープ)で変更されるたびに発生します。これにより、Compose は、前のフェーズまたはコンポーザブルを再度実行するようにスケジュールして、再コンポーズを強制します。後方書き込みは最適化されていない再構成ループです。

再コンポーズ ループのフェーズを逆方向に書き込む例
図 2. 再構成ループのフェーズでの逆方向の書き込みの例

後方書き込みの結果

後方書き込みは必ずしも悪いことではなく、常にクラッシュを引き起こすわけではありませんが、非効率的であり、アプリのパフォーマンスに次のような悪影響を及ぼす可能性があります。

  • 余分なフレーム レンダリングとドロップされたフレーム: 後方書き込みにより、Compose は連続するフレーム間で冗長なコンポジション パスを実行し、CPU と GPU のリソースを無駄にし、ジャンクを引き起こす可能性があります。
  • 最初のフレームの正確性に関する問題: コンポーネントで最終的なサイズや状態を解決するために後方書き込みが必要な場合、最初のフレームは無効なデータ、デフォルトのデータ、または未確定のデータ(サイズがゼロ、オフセットが正しくないなど)でレンダリングされます。これにより、2 番目のフレームがレンダリングされるときに、視覚的なポップアップやレイアウトの点滅が目に見える形で発生します。
  • 無限の再コンポジション ループ: 状態の変化によってレイアウトのサイズが変更され、レイアウトのサイズ変更によって新しい値が状態に継続的に書き戻されると、画面が安定することなくフレームごとに再コンポジションを繰り返す無限のフレームループが発生する可能性があります。

フェーズ間のフォワード フロー

状態の変更は常にフェーズを前進する必要があります。

読み取りフェーズ 書き込みコンテキスト 使用可能か? 背景
レイアウトModifier.offset { } 楽曲 コンポジションが状態を更新 → レイアウトが同じフレーム内で再コンポーズせずに状態を読み取る。
抽選graphicsLayer { }drawBehind { } 楽曲 コンポジションが状態を更新 → 描画が最終フェーズで状態を読み取る。コンポジションとレイアウトが完全にスキップされます。
描画 レイアウト レイアウトが状態を更新し、描画がそれを読み取る有効なフロー。
楽曲 イベント コールバック(onClickonValueChange)が状態の変更を駆動します。: レイアウト コールバックはイベントとしてカウントされません。 イベントは、コンポジションの駆動に使用される状態を変更します。イベントがフレーム外(コンポジション、レイアウト、描画以外)で発生した場合、これは有効です。
楽曲 コルーチン(LaunchedEffect はい - 注意が必要です ライフサイクル/イベントに応じて状態を非同期で更新します。効果からの書き込みは有効な場合がありますが、非効率的な状態レイヤリングを指している可能性があります。可能な限り避けるべきです。
配置(レイアウト内) Measure (In Layout) レイアウトでは、状態を更新してからプレースメントでその状態を読み取ることは許容されます。
Measure (In Layout) 配置(レイアウト内) No - backwards write 配置で状態に書き込み、その状態を読み取り状態にすると、再測定ループが発生します。
楽曲 レイアウトonSizeChangedLayoutModifier No - Backwards write レイアウトが無効になり、コンポジション → 再コンポジションのループが発生します。
楽曲 抽選drawWithContentCanvas No - Backwards write Draw が Composition → Recomposition ループを無効にします。

フェーズの組み合わせ: 後方と前方

以下は、Compose 内での逆方向書き込みの例と、その解決方法です。

後方: コンポジションで読み取り、レイアウトで書き込み

  • 動作: コンポジションは componentHeight を読み取って、出力する UI を決定します。フレームの後半で、レイアウト フェーズはビューを測定または配置し、componentHeight に新しい値を書き込みます(たとえば、onSizeChangedonGloballyPositioned、またはカスタム LayoutModifier を使用します)。
  • 結果: レイアウトで componentHeight を変更すると、完了したばかりのコンポジション フェーズが無効になります。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)
        }
    }
}

後方: 読み取りは Composition、書き込みは Drawing

  • 発生する事象: 状態はコンポーザブルの本体(コンポジション フェーズ)で読み取られますが、Modifier.drawWithContentModifier.drawBehindCanvas(描画フェーズ)内で変更されます。
  • 結果: 描画フェーズで状態が変更される → コンポジションが無効になる → 無限ループ。

// ❌ 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
}


逆方向書き込みを回避するための主なルール

  1. コンポーザブルで状態が読み取られる場合、onGloballyPositionedonSizeChangedLayoutModifier で状態に書き込まないでください。これは、最初のフレームの正確性の問題を引き起こすためです。
    • レイアウトの座標やサイズがカスタム描画にのみ必要な場合は、描画フェーズまたはレイアウト フェーズで直接読み取ります(Modifier.drawWithCacheModifier.layout などを使用)。
    • ウィンドウ レベルのサイズ設定(WindowWidthSizeClass)の場合: ホイスト サイズの観測をウィンドウ レベルに引き上げます。構成は、ローカル測定が行われる前にウィンドウ サイズクラスで分岐します。
    • コンポジションを均一に保つ: フェーズ 2 で、コンポジションで使用される状態を再コンポーズしたり変更したりすることなく、測定と配置を調整する単一のカスタム レイアウトまたは FlowRowLazyVerticalGrid などのコンポーネントを使用します。
    • サブコンポジションを使用する: 子コンポーザブルがローカルの幅または高さに基づいて分岐する必要がある場合は、BoxWithConstraints または SubcomposeLayout を使用します。注意: サブコンポジションにはパフォーマンス コストがかかり、通常は回避できます。
    • 最後の手段として: 最初のフレームが間違っていても許容し、onSizeChanged にサイズを保存して 2 番目のフレームの再構成をトリガーします。これにより、レイアウトのポップ、ジャンク、無限ループのリスクが発生します。
  2. コンポジションで状態を初めて読み取った後に状態を変更しない:
    • SideEffect の外部のコンポジション中に MutableState オブジェクトに安全に書き込むことができますが、コンポジションで以前に読み取った状態に書き込まないように特に注意してください。コンポジションで状態の書き込みが必要になった場合は、rememberUpdatedState を使用することをおすすめします。コンポジション中に別の方法で状態に書き込むことは、通常、エフェクトが欠落しているか、状態またはコンポーザブルが適切に設計されていないことを示しています。コンポーズは楽観的で、常に状態の最新の値で実行されるため、再コンポーズですべての状態の変化を確認できるとは限りません。UI の更新は 1 回限りのイベントを処理する方法として使用すべきではありません。そのため、再コンポーズの結果として状態の値を更新する必要があることはあまりありません。
    • コンポジションの外部で監視される状態(ViewModel フィールドや isVisible フラグなど)を変更しないでください。コンポジションに影響する状態の書き込みは、イベント ラムダ(onClick)、コルーチン(LaunchedEffect)、または副作用(SideEffect)に属します。rememberUpdatedState は、@Composable 本体でのみ使用される状態を変更するように設計されているため、例外です。
  3. 状態の読み取りを可能な限り遅いフェーズまで延期する:
    • Draw(Modifier.graphicsLayer { alpha = ... })または Layout(Modifier.offset { IntOffset(...) })で状態を読み取ると、変更によってフェーズ 2 または 3 のみが無効になり、フェーズ 1(コンポジション)は完全にスキップされます。