向後寫入

Compose 會依嚴格的順序,在三個階段中執行影格:

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

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


什麼情況下寫入作業會「向後」?

如果在後續階段 (或在下游範圍,也就是在同一次組合傳遞中,稍後執行的可組合項範圍) 修改狀態,就會發生反向寫入,導致 Compose 必須排定較早的階段或可組合項再次執行,強制重新組合。反向寫入是指未經過最佳化的重組迴圈。

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

反向寫入的後果

反向寫入不一定會造成問題,也不一定會觸發當機,但效率不彰,可能會從多方面影響應用程式效能:

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

流程階段

狀態變更應一律向前流動,經過下列階段:

閱讀階段 撰寫背景資訊 可接受? 原因
版面配置 (Modifier.offset { }) 組合 組合會更新狀態 → 版面配置稍後會在同一個影格中讀取狀態,而不會重組。
繪圖 (graphicsLayer { }drawBehind { }) 組合 組合會更新狀態 → 繪製會在最後階段讀取狀態。完全略過可組合項和版面配置。
繪圖 版面配置 版面配置會更新狀態,而繪圖會讀取狀態,這是有效的流程。
組合 事件回呼 (onClickonValueChange) 導致駕駛狀態變更。注意:版面配置回呼不會計為事件。 事件會變更用於驅動組合的狀態。如果事件發生在影格外 (不在 Composition、Layout 或 Draw 中),則為有效。
組合 協同程式 (LaunchedEffect) 可以,但請務必謹慎操作 非同步更新狀態,以回應生命週期/事件。效果的寫入作業可能有效,但可能指向效率不彰的狀態分層。請盡可能避免發生這種情況。
刊登位置 (版面配置內) 測量 (在版面配置中) 在版面配置中,更新狀態,然後在刊登位置中讀取該狀態是可以接受的做法。
測量 (在版面配置中) 刊登位置 (版面配置內) 否 - 倒寫 在放置作業中寫入狀態,然後進一步讀取狀態,會導致重新測量迴圈。
組合 版面配置 (onSizeChangedLayoutModifier) - 向後寫入 版面配置會使組合失效 → 重組迴圈。
組合 繪圖 (drawWithContentCanvas) 否 - 向後寫入 繪製會使組合結構失效 → 重新組合迴圈。

相位組合:向後與向前

以下是 Compose 中向後寫入的範例,以及如何解決這些問題。

反向:在「組合」中閱讀,在「版面配置」中撰寫

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