向後寫入

Compose 會按照嚴格順序,以向前流動的方式執行影格,共分為三個階段:

三個向前推進的階段:1. 組合,2. 版面配置,3. 繪圖
圖 1. Compose 影格的三個階段
  1. 組合結構:執行 @Composable 函式,建構及更新 UI 樹狀結構。
  2. 版面配置:測量子項,然後放置子項。
  3. 繪圖:發出畫布繪圖指令,將像素算繪到螢幕上。

只要在任何階段讀取 Compose State,Compose 就會自動記錄該狀態與對應階段之間的依附元件。


什麼是「反向」寫入?

只要狀態在後續階段 (或下游範圍,即在同一組合階段中依序執行的可組合項範圍) 遭到修改,就會發生向後寫入,強制 Compose 安排較早的階段或可組合項再次執行,以重新組合。反向寫入是未最佳化的重組迴圈。

透過重組迴圈階段向後寫入的範例
圖 2 透過重組迴圈階段向後寫入的範例

反向寫入的後果

反向寫入不一定是壞事,也不一定會觸發當機,但效率不佳,而且可能會在多方面損害應用程式效能:

  • 額外影格轉譯和捨棄影格:向後寫入會強制 Compose 在連續影格中執行多餘的組合傳遞,浪費 CPU 和 GPU 資源,並可能導致卡頓。
  • 第一個影格正確性問題:如果元件需要向後寫入才能解析最終尺寸或狀態,則第一個影格會以無效、預設或不穩定的資料 (例如零大小或不正確的偏移) 算繪。這會導致第二個影格算繪時,出現明顯的視覺彈出或版面配置閃爍。
  • 無限重組迴圈:如果狀態變更會改變版面配置大小,且版面配置大小會持續將新值寫回狀態,則可能會建立無限框架迴圈,導致畫面不斷重組每個框架,而無法穩定。

階段推進流程

狀態變更應一律向前流經各階段:

讀取階段 寫入內容 是否可接受? 原因
版面配置 (Modifier.offset { }) 組成 是 組合結構更新狀態 → 版面配置稍後在同一個影格中讀取狀態,不進行重新組合。
平手 (graphicsLayer { },drawBehind { }) 組成 是 組合結構更新狀態 → 繪圖在最後階段讀取狀態。系統會完全略過組合結構和版面配置階段。
繪圖 版面配置 是 版面配置更新狀態,繪圖讀取狀態,有效流程。
組合結構 事件回呼 (onClick、onValueChange) 驅動狀態變更。注意:版面配置回呼不計為事件。 是 事件會改變用於驅動組合結構的狀態。如果事件發生在影格外 (不在組合、版面配置或繪圖中),則此為有效。
組成 協同程式 (LaunchedEffect) 是,但請謹慎使用 根據生命週期/事件,以非同步方式更新狀態。從效果寫入可能是有效的,但可能指向無效率的狀態分層。請盡可能避免發生這種情況。
刊登位置 (在版面配置中) 測量 (在版面配置中) 是 在版面配置階段,可以先更新狀態,然後在放置階段讀取該狀態。
測量 (在版面配置中) 刊登位置 (在版面配置中) 否 - 反向寫入 在放置階段寫入狀態,然後在讀取狀態中進一步寫入,會導致重新測量迴圈。
組合結構 版面配置 (onSizeChanged、LayoutModifier) 否 - 反向寫入 版面配置會使組合結構失效 → 重組迴圈。
組合結構 平手 (drawWithContent,Canvas) 否 - 倒寫 繪圖會使組合結構 → 重新組合迴圈失效。

相位組合:反向與正向

以下是 Compose 中反向寫入的範例,以及解決方法。

反向:在組合結構中讀取,在版面配置中寫入

  • 發生情況:組合結構會讀取 componentHeight,判斷要發出哪些 UI。在影格的後續階段,版面配置階段會測量或放置檢視畫面,並將新值寫入 componentHeight (例如使用 onSizeChanged、onGloballyPositioned 或自訂 LayoutModifier)。
  • 結果:在版面配置階段修改 componentHeight 會使剛完成的組合結構階段失效。請注意,onSizeChanged 會在版面配置測量階段完成後回報大小。如果更新後的狀態值在下一次傳遞時穩定下來,重組可能會在額外一幀後停止;不過,如果新值繼續改變大小,就會導致無限幀迴圈。此外,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 (繪圖階段) 中變動狀態。
  • 結果:繪製階段會變更狀態 → 組合結構失效 → 無限迴圈。

// ❌ 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. 如果狀態是在組合結構中讀取,請勿在 onGloballyPositioned、onSizeChanged、onPlaced、onLayoutRectChanged 或 LayoutModifier 中寫入狀態,因為這會導致第一幀正確性問題。
    • 如果只有自訂繪圖需要版面配置座標或大小,請直接在繪圖或版面配置階段讀取 (例如使用 Modifier.drawWithCache 或 Modifier.layout)。
    • 視窗層級大小調整 (WindowWidthSizeClass):將大小觀測值提升至視窗層級。在進行本機測量之前,組合結構會根據視窗大小類別進行分支。
    • 保持組合結構一致:使用單一自訂版面配置或 FlowRow 或 LazyVerticalGrid 等元件,在第 2 階段調整測量和位置,而不重新組合或變更組合結構中使用的狀態。
    • 使用子組合:當子項可組合函式必須根據本機寬度或高度分支時,請使用 BoxWithConstraints 或 SubcomposeLayout。請謹慎使用:子組合會產生效能成本,通常可以避免。
    • 最後手段:允許第一個影格出錯,將大小儲存在 onSizeChanged 中,觸發第二個影格重組。這會導致版面配置明顯跳動、卡頓,並可能造成無限迴圈。
  2. 在組合中首次讀取狀態後,請勿變更狀態:
    • 雖然您可以在 SideEffect 以外的組合期間安全地寫入 MutableState 物件,但請特別注意,請勿寫入您先前在組合中讀取的狀態。如果需要在組合中寫入狀態,建議使用 rememberUpdatedState。在組合期間以其他方式寫入狀態,通常代表缺少效果,或狀態/可組合項設計不當。請記住,組合結構會樂觀執行,且一律會使用狀態的最新值,因此您可能不會在重組中看到所有狀態變更。UI 更新不應用於處理一次性事件,因此狀態值不常需要因重組而更新。
    • 避免在組合外部觀察到的狀態 (例如 ViewModel 欄位或 isVisible 標記) 發生變化。影響組合的狀態寫入作業屬於事件 lambda (onClick)、協同程式 (LaunchedEffect) 或副作用 (SideEffect)。rememberUpdatedState 是例外狀況,因為其設計目的是變更僅在 @Composable 主體中使用的狀態。
  3. 盡可能延遲狀態讀取:
    • 在繪圖 (Modifier.graphicsLayer { alpha = ... }) 或版面配置 (Modifier.offset { IntOffset(...) }) 中讀取狀態,可確保變更只會使第 2 或第 3 階段失效,完全略過第 1 階段 (組合結構)。