Compose 三階段渲染管線

「想當年寫傳統 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))
}

在標準單趟測量模型下,這是一個邏輯死循環:

  1. VerticalDivider 說:「我要 fillMaxHeight(),你 Row 多高我就多高!」
  2. Row 說:「我是 wrap_content,我得看你們所有人量完才知道我有多高!」
  3. 兩邊互相乾瞪眼。在舊 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
}
  1. IntrinsicMeasurable(探聽偏好):父元件在正式測量前,透過 minIntrinsicHeight 等函式向子元件打聽「在你理想的世界裡,你最少需要多大空間?」,這只是一次回傳整數 Int 的輕量試算,不會建立任何 Placeable 記憶體結構。
  2. Measurable(最終定讞):父元件匯總所有子元件的固有尺寸,算出確切的邊界後,正式下達 measure(constraints) 命令。此時子元件真正執行單趟測量並產出 Placeable。

IntrinsicSize 的四種常用模式

在 Composable 上設定 Modifier 時,可根據需求向子元件查詢不同維度:

  • Modifier.width(IntrinsicSize.Min):在給定高度下,能正確呈現內容所需的最小寬度。
  • Modifier.width(IntrinsicSize.Max):在不發生非預期折行的情況下,內容所需的最大寬度。
  • Modifier.height(IntrinsicSize.Min):常用於 Row 垂直分隔線對齊,以最高內容為基準。
  • Modifier.height(IntrinsicSize.Max):以最大展開為基準的高度偏好。

工位大實話與收工

  1. 分清三階段是效能調校的基石:能用 Modifier.offset { } 解決的動畫,就不要在 Composable 函式本體內直接讀取 State;能用 drawBehind 繪製的視覺效果,就不要開一個新的 Box 元件。
  2. Intrinsic Measurements 是昂貴的救火特例:它雖然保證了單趟測量的語義完整,但背後仍有多跑一次查詢遞迴的算力代價。除非像「垂直分隔線貼齊內容高度」這種不可替代的版面場景,否則不要無腦在全 App 到處套用 IntrinsicSize.Min。

感謝觀看,收工。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *