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

- 組合:執行
@Composable函式,建構及更新 UI 樹狀結構。 - 版面配置:測量子項,然後放置子項。
- 繪圖:發出畫布繪圖指令,將像素算繪到畫面上。
每當在任何階段讀取 Compose State 時,Compose 會自動記錄該狀態與對應階段之間的依附元件。
什麼情況下寫入作業會「向後」?
如果在後續階段 (或在下游範圍,也就是在同一次組合傳遞中,稍後執行的可組合項範圍) 修改狀態,就會發生反向寫入,導致 Compose 必須排定較早的階段或可組合項再次執行,強制重新組合。反向寫入是指未經過最佳化的重組迴圈。
反向寫入的後果
反向寫入不一定會造成問題,也不一定會觸發當機,但效率不彰,可能會從多方面影響應用程式效能:
- 額外影格轉譯和捨棄影格:反向寫入會強制 Compose 在連續影格中執行多餘的組合傳遞,浪費 CPU 和 GPU 資源,並可能導致卡頓。
- 第一個影格的正確性問題:如果元件需要向後寫入,才能解決最終尺寸或狀態,第一個影格會使用無效、預設或未結算的資料 (例如大小為零或偏移量不正確) 算繪。這會導致第二個影格算繪時,出現明顯的視覺效果跳動或版面配置閃爍。
- 無限重組迴圈:如果狀態變更會改變版面配置大小,而版面配置大小會持續將新值寫回狀態,您可能會建立無限影格迴圈,導致畫面不斷重組每個影格,但永遠不會穩定。
流程階段
狀態變更應一律向前流動,經過下列階段:
| 閱讀階段 | 撰寫背景資訊 | 可接受? | 原因 |
|---|---|---|---|
版面配置 (Modifier.offset { }) |
組合 | 是 | 組合會更新狀態 → 版面配置稍後會在同一個影格中讀取狀態,而不會重組。 |
繪圖 (graphicsLayer { }、drawBehind { }) |
組合 | 是 | 組合會更新狀態 → 繪製會在最後階段讀取狀態。完全略過可組合項和版面配置。 |
| 繪圖 | 版面配置 | 是 | 版面配置會更新狀態,而繪圖會讀取狀態,這是有效的流程。 |
| 組合 | 事件回呼 (onClick、onValueChange) 導致駕駛狀態變更。注意:版面配置回呼不會計為事件。 |
是 | 事件會變更用於驅動組合的狀態。如果事件發生在影格外 (不在 Composition、Layout 或 Draw 中),則為有效。 |
| 組合 | 協同程式 (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 }
避免向後寫入的關鍵規則
- 如果狀態是在 Composition 中讀取,請勿在
onGloballyPositioned、onSizeChanged或LayoutModifier中寫入狀態,否則會導致第一幀正確性問題。- 如果自訂繪圖只需要版面配置座標或大小,請直接在「繪圖」或「版面配置」階段讀取這些值 (例如使用
Modifier.drawWithCache或Modifier.layout)。 - 如要進行視窗層級大小調整 (
WindowWidthSizeClass):將升降機大小觀察結果提升至視窗層級。組合會在進行本機測量前,根據視窗大小類別分支。 - 保持組合結構統一:使用單一自訂版面配置或
FlowRow或LazyVerticalGrid等元件,在第 2 階段調整測量和放置位置,而不會重新組合或變更組合結構中使用的狀態。 - 使用子項組合:當子項可組合函式必須根據本機寬度或高度分支時,請使用
BoxWithConstraints或SubcomposeLayout。請謹慎使用:子組合會產生效能成本,通常可以避免。 - 最後手段:允許第一個影格錯誤,將大小儲存在
onSizeChanged中,觸發第二個影格重組。這會導致版面配置顯示跳動、卡頓,以及無限迴圈的風險。
- 如果自訂繪圖只需要版面配置座標或大小,請直接在「繪圖」或「版面配置」階段讀取這些值 (例如使用
- 請勿在組合中首次讀取狀態後變更狀態:
- 雖然您可以在
SideEffect以外的組合期間安全地寫入MutableState物件,但請特別注意,確保您不會寫入先前在組合中讀取的狀態。建議在需要於組合中寫入狀態時使用rememberUpdatedState。在組合期間以其他方式寫入狀態,通常表示缺少效果,或是狀態或可組合項設計不當。請注意,組合程序是樂觀的,一律會使用最新的狀態值執行,因此您可能不會在重組程序中看到所有狀態變更。UI 更新不應做為處理一次性事件的方法,因此狀態值通常不需要因重組而更新。 - 請避免變動在組合外部觀察到的狀態 (例如
ViewModel欄位或isVisible標記)。影響組合的狀態寫入作業應位於事件 lambda (onClick)、協同程式 (LaunchedEffect) 或連帶效果 (SideEffect) 中。rememberUpdatedState是例外狀況,因為它會突變僅在@Composable主體中使用的狀態。
- 雖然您可以在
- 將狀態讀取作業延遲到最後階段:
- 在「繪製」(
Modifier.graphicsLayer { alpha = ... }) 或「版面配置」(Modifier.offset { IntOffset(...) }) 中讀取狀態,可確保變更只會使第 2 或第 3 階段失效,完全略過第 1 階段 (組合)。
- 在「繪製」(