「想當年寫傳統 Android View,自訂元件最怕在
onMeasure裡跟MeasureSpec大戰三百回合——AT_MOST、EXACTLY 算到頭禿,還得防範巢狀 RelativeLayout 引起的 $O(2^n)$ 測量指數爆炸。直到切到 Jetpack Compose,官方直接定了一條鐵律:『單趟測量(Single-Pass Layout),誰敢量兩次就當場崩潰給你看』。」

很多開發者剛寫 Compose 時,以為它只是把 XML 換成 Kotlin 函式而已。
但只要稍微遇到複雜動畫或高頻滑動,UI 幀率立刻從 60fps 暴跌到個位數。原因很簡單:你不懂 Compose 的三階段渲染管線(Pipeline),把本該在繪製階段解決的狀態讀取,無腦丟在組合階段觸發瘋狂重組(Recomposition)。
這篇不講玄學,直接拆解 Compose 底層的渲染三部曲、狀態讀取階段跳過最佳化,以及看似打破單趟測量鐵律的 Intrinsic Measurements(固有尺寸) 物理本質。
為什麼 View 會卡,而 Compose 要定「單趟測量」死線?
在傳統 View 系統裡,最常見的效能毒瘤是巢狀 RelativeLayout 或帶有 layout_weight 的 LinearLayout。
父容器為了確定自身尺寸,必須先用無限制約束測量子元件一次;子元件量完回報後,父容器再用具體尺寸量第二次。一旦階層巢狀嵌套 5 層:
$$2^5 = 32 \text{ 次測量}$$
若巢狀 10 層,一幀之內就會噴出 $2^{10} = 1024$ 次遞迴測量!CPU 算力直接被耗盡,16.6ms 的渲染時間預算當場爆表。
為了終結這種指數級災難,Compose 在架構底層立下鐵則:單趟版面配置(Single-Pass Layout),演算法複雜度嚴格壓在 $O(n)$。
在一次渲染週期中,每個節點有且僅有一次測量機會,絕對禁止父元件對同一個子元件呼叫兩次 measure()。
核心三階段:Composition ➔ Layout ➔ Draw
Compose 將 UI 的生成與像素輸出精確拆解為三個依序執行的獨立階段:
flowchart LR
State["State 變更\n(如 count++)"] --> Phase1
subgraph Phase1 ["1. 組合 (Composition)"]
direction TB
C1["執行 @Composable 函式"]
C2["產生 / 更新 Slot Table (UiNode 樹)"]
C3["『決定顯示什麼』"]
C1 --> C2 --> C3
end
subgraph Phase2 ["2. 版面配置 (Layout)"]
direction TB
L1["測量 (Measure):\n父傳遞 Constraints,子回報 Size"]
L2["放置 (Place):\n決定 (x, y) 座標並擺放 Placeable"]
L3["『決定大小與位置』"]
L1 --> L2 --> L3
end
subgraph Phase3 ["3. 繪製 (Drawing)"]
direction TB
D1["遍歷 LayoutNode 樹"]
D2["轉譯為 RenderNode / GPU 指令"]
D3["『像素繪製到螢幕』"]
D1 --> D2 --> D3
end
Phase1 --> Phase2 --> Phase3
1. 組合階段 (Composition) ——「決定顯示什麼」
- 核心任務:執行標記為
@Composable的函式程式碼,產出或更新記憶體中的樹狀資料結構(內部由 Slot Table 與LayoutNode組成)。 - 物理邊界:這個階段完全不知道任何螢幕像素寬高或座標!它只負責把資料轉換為 UI 描述節點。
2. 版面配置階段 (Layout) ——「決定大小與位置」
版面配置由兩道不可逆的單向工序組成:
- 步驟 A:測量 (Measure):父節點向子節點傳遞
Constraints(包含寬高之 min/max 邊界);子節點根據約束測量自己,並將尺寸打包回傳成Placeable。這是一個由上而下的約束傳遞、由下而上的尺寸回報過程。 - 步驟 B:放置 (Place):父節點根據自身的版面排列邏輯(如 Row、Column、Box),呼叫
placeable.placeRelative(x, y)將子節點錨定在特定座標。
3. 繪製階段 (Drawing) ——「把像素打在螢幕上」
- 核心任務:系統遍歷放置完成的
LayoutNode樹,將繪製節點轉換為硬體加速的 Canvas 與 GPU 繪圖指令(RenderNode),最終在螢幕上轉化為實際像素。
效能調校核心:State 讀取位置決定跳過哪些階段
這是 Compose 效能最關鍵的「分水嶺」:在哪個階段讀取 State,就只會觸發該階段及其後續管線的重新執行!
┌─────────────────────────────────────────────────────────────┐
│ 狀態讀取位置與管線開銷對照 │
├──────────────────┬─────────────────┬────────────────────────┤
│ 狀態讀取位置 │ 觸發階段 │ 運算開銷與畫面影響 │
├──────────────────┼─────────────────┼────────────────────────┤
│ 1. 組合階段直接讀│ Composition 重組│ 重新執行 Composable 函式 (最昂貴)│
│ 2. Layout 區塊讀 │ Layout 重排 │ 跳過重組,僅重新測量放置 (中等)│
│ 3. Draw 區塊讀 │ Draw 重繪 │ 跳過重組與版面排版,直接重繪 (極快)│
└──────────────────┴─────────────────┴────────────────────────┘
慘痛反例:把高頻滑動值放在組合階段讀取
// 災難反例:每滑動 1px 就觸發一次全元件 Recomposition
val scrollOffset = scrollState.value // 在組合階段直接讀取 State
Box(
modifier = Modifier
.offset(y = scrollOffset.dp) // 每次 offset 變更,整個 Box 函式重跑一遍!
.background(Color.Red)
)
頂級最佳化:延遲到 Layout / Draw 階段讀取
Compose 提供了一系列的 Lambda 修飾符,把 State 的讀取延後到版面配置或繪製階段:
// 最佳化寫法:利用 Lambda 將讀取推遲到 Layout 階段
Box(
modifier = Modifier
.offset {
// 閉包只在 Layout/Place 階段被呼叫!
// 完美跳過 Composition 階段,重組次數直接歸零!
IntOffset(x = 0, y = scrollState.value)
}
.drawBehind {
// 在 Draw 階段直接讀取狀態繪製,連 Layout 都跳過
drawCircle(color = if (scrollState.value > 100) Color.Green else Color.Red)
}
)
透過 Lambda 封裝,當 scrollState 劇烈變化時,Compose 智慧地辨識出結構沒有改變,完全跳過組合階段,直接進入 Layout 或 Draw 階段,輕鬆維持 120fps 絲滑更新。
固有尺寸(Intrinsic Measurements):單趟測量模型下的救火悖論
單趟測量雖然高效,但立刻在工位現場引發了一個經典死穴:當父元件的尺寸必須依賴子元件的內容時,該怎麼辦?
最經典的案發現場就是帶有分隔線的文字列:
// 經典慘劇:Divider 想要跟最高的那行 Text 一樣高
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Text("標題文字", modifier = Modifier.weight(1f))
VerticalDivider(
modifier = Modifier
.fillMaxHeight() // 致命問題:在無邊界 Row 裡,fillMaxHeight 是多高?
.width(1.dp)
)
Text("落落長的詳細內容說明\n第二行註解文字\n第三行備註", modifier = Modifier.weight(1f))
}
在標準單趟測量模型下,這是一個邏輯死循環:
VerticalDivider說:「我要fillMaxHeight(),你 Row 多高我就多高!」Row說:「我是wrap_content,我得看你們所有人量完才知道我有多高!」- 兩邊互相乾瞪眼。在舊 View 系統裡只能量兩次;但在 Compose 嚴禁呼叫兩次
measure()的鐵律下,如果直接執行,Divider 會當場把 Row 撐到無窮大或直接報錯。
救贖方案:獨立的預先查詢通道(Query Pass)
為了在不破壞單趟測量前提下解開悖論,Compose 設計了 Intrinsic Measurements(固有尺寸查詢)。
這是在正式執行 measure() 之前,父元件被允許開闢的一條「輕量級問詢通道」:
sequenceDiagram
autonumber
participant P as 父元件 (Row)
participant C1 as 子元件 1 (Text)
participant C2 as 子元件 2 (VerticalDivider)
Note over P,C2: 階段一:預先查詢通道 (Query Pass - 不分配 Placeable)
P->>C1: minIntrinsicHeight(width)? (你最少需要多高?)
C1-->>P: 回報 60dp (三行文字高度)
P->>C2: minIntrinsicHeight(width)? (你最少需要多高?)
C2-->>P: 回報 0dp
Note over P: 父元件確定自身高度 Constraints: min=60dp, max=60dp
Note over P,C2: 階段二:正式單趟測量 (Final Measure Pass - 僅此一次!)
P->>C1: measure(Constraints(maxHeight=60dp))
C1-->>P: 回傳 Placeable
P->>C2: measure(Constraints(maxHeight=60dp))
C2-->>P: VerticalDivider 根據 60dp 滿幅展開,回傳 Placeable
Note over P: 兩者皆僅被 measure() 一次,嚴格守護 O(n) 單趟鐵則!
底層雙介面分工:Measurable vs. IntrinsicMeasurable
在自訂 Layout 或 Modifier.layout 的底層,傳進來的 measurable 物件實際上實作了兩個不同角色的介面:
// Compose 內部架構抽象簡化
interface IntrinsicMeasurable {
fun minIntrinsicWidth(height: Int): Int
fun maxIntrinsicWidth(height: Int): Int
fun minIntrinsicHeight(width: Int): Int
fun maxIntrinsicHeight(width: Int): Int
}
interface Measurable : IntrinsicMeasurable {
// 最終權威指令:只能呼叫一次,呼叫兩次直接拋出 IllegalStateException!
fun measure(constraints: Constraints): Placeable
}
IntrinsicMeasurable(探聽偏好):父元件在正式測量前,透過minIntrinsicHeight等函式向子元件打聽「在你理想的世界裡,你最少需要多大空間?」,這只是一次回傳整數Int的輕量試算,不會建立任何 Placeable 記憶體結構。Measurable(最終定讞):父元件匯總所有子元件的固有尺寸,算出確切的邊界後,正式下達measure(constraints)命令。此時子元件真正執行單趟測量並產出Placeable。
IntrinsicSize 的四種常用模式
在 Composable 上設定 Modifier 時,可根據需求向子元件查詢不同維度:
Modifier.width(IntrinsicSize.Min):在給定高度下,能正確呈現內容所需的最小寬度。Modifier.width(IntrinsicSize.Max):在不發生非預期折行的情況下,內容所需的最大寬度。Modifier.height(IntrinsicSize.Min):常用於 Row 垂直分隔線對齊,以最高內容為基準。Modifier.height(IntrinsicSize.Max):以最大展開為基準的高度偏好。
工位大實話與收工
- 分清三階段是效能調校的基石:能用
Modifier.offset { }解決的動畫,就不要在 Composable 函式本體內直接讀取 State;能用drawBehind繪製的視覺效果,就不要開一個新的 Box 元件。 - Intrinsic Measurements 是昂貴的救火特例:它雖然保證了單趟測量的語義完整,但背後仍有多跑一次查詢遞迴的算力代價。除非像「垂直分隔線貼齊內容高度」這種不可替代的版面場景,否則不要無腦在全 App 到處套用
IntrinsicSize.Min。
感謝觀看,收工。

發佈留言