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

- 組合結構:執行
@Composable函式,建構及更新 UI 樹狀結構。 - 版面配置:測量子項,然後放置子項。
- 繪圖:發出畫布繪圖指令,將像素算繪到螢幕上。
只要在任何階段讀取 Compose State,Compose 就會自動記錄該狀態與對應階段之間的依附元件。
什麼是「反向」寫入?
只要狀態在後續階段 (或下游範圍,即在同一組合階段中依序執行的可組合項範圍) 遭到修改,就會發生向後寫入,強制 Compose 安排較早的階段或可組合項再次執行,以重新組合。反向寫入是未最佳化的重組迴圈。
反向寫入的後果
反向寫入不一定是壞事,也不一定會觸發當機,但效率不佳,而且可能會在多方面損害應用程式效能:
- 額外影格轉譯和捨棄影格:向後寫入會強制 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 }
避免向後寫入的重要規則
- 如果狀態是在組合結構中讀取,請勿在
onGloballyPositioned、onSizeChanged、onPlaced、onLayoutRectChanged或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 階段 (組合結構)。
- 在繪圖 (