在上一篇文章中,我們從 AndroidX 原始碼的視角,深入剖析了 Jetpack Compose 的底層渲染管線與動畫硬體加速原理。
不過,讀懂原始碼與自己真正掌握架構之間,往往隔著一層「動手做」的距離。為了驗證這些架構設計在真實運行時的表現,我決定用 Kotlin 從零手刻一個最小化的教育型 Compose 渲染引擎:MiniCompose。
這個專案不依賴任何 AndroidX Compose 函式庫,也不包含複雜的編譯器外掛(Compiler Plugin)或響應式狀態系統,而是專注於實現支撐 Compose 高效能渲染的 5 大關鍵架構決策,並打造了一個左右分割螢幕的微秒級($\mu\text{s}$)即時動畫基準測試 App。
1. 雙向即時效能基準測試(Benchmark)
在深入程式碼前,我們先來看這個實驗 App 的對比設計。
在 Android UI 開發中,移動一個元件通常有兩種常見方式:
Modifier.graphicsLayer:在繪製階段(Draw Phase)透過硬體層做幾何變換。Modifier.offset:在排版階段(Layout Phase)修改座標位置。
為了客觀比較這兩者的極限效能差距,MiniCompose 設計了即時並排的壓力測試畫面:
100 / 500 / 1000 Nodes"] GPULayout["Layout Phase: 0 µs
✓ 0 Passes / 秒(跳過 measureAndLayout)"] GPUDraw["Draw Phase: ~140 µs
✓ 60 FPS 絲滑運作"] GPUCard --> GPULayout --> GPUDraw end subgraph RightSide ["⚠️ 右側:Modifier.offset"] direction TB CPUCard["CPU 排版計算卡片
100 / 500 / 1000 Nodes"] CPULayout["Layout Phase: ~500 – 3,200 µs
⚠️ 60 Passes / 秒(每幀全樹重算)"] CPUDraw["Draw Phase: ~200 – 380 µs
⚠️ CPU 負擔隨節點數激增"] CPUCard --> CPULayout --> CPUDraw end LeftSide ~~~ RightSide end style LeftSide fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46 style RightSide fill:#fff1f2,stroke:#f43f5e,stroke-width:2px,color:#881337 style GPUCard fill:#ffffff,stroke:#34d399,stroke-width:1.5px,color:#065f46 style CPUCard fill:#ffffff,stroke:#fb7185,stroke-width:1.5px,color:#881337 style GPULayout fill:#d1fae5,stroke:#059669,stroke-width:1px,color:#064e3b style CPULayout fill:#ffe4e6,stroke:#e11d48,stroke-width:1px,color:#9f1239 style GPUDraw fill:#d1fae5,stroke:#059669,stroke-width:1px,color:#064e3b style CPUDraw fill:#ffe4e6,stroke:#e11d48,stroke-width:1px,color:#9f1239
實驗設計與量測方式
- 多層級節點樹:支援即時切換 100 節點、500 節點 與 1000 節點 的
LayoutNode階層樹,每個節點包含文字量測(Paint.measureText)與 Flex 排版約束計算。 - 高精度微秒量測:利用
System.nanoTime()分別量測排版階段(Layout Phase)與繪製階段(Draw Phase)消耗的微秒時間。 - 軌跡動態視覺化:繪製運動殘影軌跡點,直觀呈現 sub-pixel 運動路徑。
實測數據與真實日誌分析
在實機運行測試時(兩側元件動畫均穩定運行於 60 FPS),我們透過 Logcat 擷取不同節點規模下的微秒級效能數據:
| 節點樹規模 | 效能指標 | Modifier.graphicsLayer (GPU) |
Modifier.offset (CPU) |
每幀 CPU 節省 (Savings) |
|---|---|---|---|---|
| ~100 Nodes | Layout Phase 耗時 | 0 µs(0 passes/s,跳過 measureAndLayout) | ~450 – 565 µs(60 passes/s) | +500 µs / 幀 |
| Draw Phase 耗時 | ~115 – 150 µs | ~180 – 260 µs | — | |
| 每幀總 CPU 耗時 | ~120 – 160 µs | ~640 – 790 µs | — | |
| ~500 Nodes | Layout Phase 耗時 | 0 µs(0 passes/s,跳過 measureAndLayout) | ~1,580 – 1,800 µs(60 passes/s) | +1,650 µs / 幀 (~1.7 ms) |
| Draw Phase 耗時 | ~120 – 160 µs | ~260 – 360 µs | — | |
| 每幀總 CPU 耗時 | ~140 – 180 µs | ~1,850 – 2,120 µs (~2.0 ms) | — | |
| ~1000 Nodes | Layout Phase 耗時 | 0 µs(0 passes/s,跳過 measureAndLayout) | ~3,000 – 3,300 µs(60 passes/s) | +3,100 µs / 幀 (~3.1 ms) |
| Draw Phase 耗時 | ~108 – 180 µs | ~340 – 380 µs | — | |
| 每幀總 CPU 耗時 | ~125 – 195 µs | ~3,350 – 3,660 µs (~3.5 ms) | — |
📌 關鍵實測日誌洞察與基準測試說明:
- 跳過排版階段:
graphicsLayer在動畫期間完全跳過measureAndLayout()(0 passes/s),Layout Phase 耗時為 0 µs;在 1000 節點規模下,僅需在 Draw Phase 耗時實測約 ~140 µs 進行屬性更新與繪製分發。- 基準測試路徑說明(Caveat):在 MiniCompose 基準測試中,右側
offset每一幀都會呼叫markTreeDirty遍歷整棵樹重算;這是為了演示排版開銷上限而刻意設計的最壞路徑(Worst-case Demo Path),並非真實 Jetpack Compose 中Modifier.offset的局部排版(Localized Relayout)預設行為。- 每幀節省(CPU Savings):在 1000 節點的最壞排版路徑下,
offset單幀排版耗時達 ~3,100 µs(總 CPU 耗時 ~3,500 µs),且頻繁物件量測引發多次背景 GC 暫停(~600–780 µs);而graphicsLayer透過跳過排版與重錄,每幀直接節省了 超過 3,100 µs (3.1 ms) 的 CPU 計算!
2. MiniCompose 的 5 大核心架構實作
MiniCompose 的目標不是重寫整個 Compose,而是用最乾淨、純粹的程式碼還原 Compose 團隊的 5 個核心設計決策。
決策 1:為什麼 ComposeView 與 AndroidComposeView 必須是 ViewGroup?
在傳統 View 系統中,如果我們要客製化一個純粹繪製內容的元件,通常繼承 View 即可。但 Compose 的進入點卻是兩個 ViewGroup:
• 對外公開的 API 容器
• 攔截非法 addView()"] MCV -->|唯一合法子 View| MACV["MiniAndroidComposeView (ViewGroup)
• 內部核心 Bridge & 樹狀結構 Owner"] MACV -->|持有與調度| RootNode["Root LayoutNode
• Compose 元件樹根節點"] MACV -->|持有與管理| AVHandler["AndroidViewsHandler
• 託管 AndroidView 嵌入的原生元件"] style AVTree fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a style MCV fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a style MACV fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#7c2d12 style RootNode fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46 style AVHandler fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
其背後原因有兩個:
- API 封裝:
MiniComposeView對外暴露給開發者使用,它必須是一個ViewGroup才能透過addView()將唯一的內部核心元件MiniAndroidComposeView掛載進去。同時,它覆寫了公開的addView()方法,禁止外部隨意新增一般 View:override fun addView(child: View?) { if (!creatingComposition) { throw UnsupportedOperationException( "Cannot add views to MiniComposeView; use setContent {} instead." ) } super.addView(child) } - 互操作性(Interop):當我們在 Compose 中使用
AndroidView嵌入傳統原生元件(如WebView或MapView)時,這些原生元件必須存在於 Android View 樹狀結構中。MiniAndroidComposeView作為ViewGroup,才能在內部建立一個AndroidViewsHandler來持有並管理這些原生子 View。
決策 2:空的 onDraw() 與攔截 dispatchDraw() 的 Z-Order 奧秘
在 Android 中,一個 View 的完整繪製流程如下:
其中,onDraw() 是用來畫 View 自己的內容,而 dispatchDraw() 則是 ViewGroup 用來派發並繪製所有子 View。
💡 核心洞察:如果 Compose 選擇在
onDraw()中繪製LayoutNode元件樹,那麼隨後在dispatchDraw()繪製的原生嵌入元件(如AndroidView)將會永遠覆蓋在 Compose UI 之上,破壞畫面的圖層順序(Z-ordering)。
因此,MiniAndroidComposeView 採取了明確的繪製順序:
- 將
onDraw()留空,不在這個階段做任何繪製。 - 覆寫
dispatchDraw(canvas):先畫完整棵LayoutNode樹,再調用super.dispatchDraw(canvas)繪製原生子 View(而在真正的 Jetpack Compose 中,則透過更複雜的 Layer 階層進一步支援 Compose UI 與原生 View 的交錯交疊 Interleaving 繪製):
class MiniAndroidComposeView(context: Context) : ViewGroup(context) {
val root = LayoutNode("Root")
private val canvasHolder = CanvasHolder()
// 刻意留空!防止內容被 dispatchDraw 繪製的子 View 覆蓋
override fun onDraw(canvas: Canvas) {}
override fun dispatchDraw(canvas: Canvas) {
// 在 dispatchDraw 中繪製整個 Compose LayoutNode 樹
canvasHolder.drawInto(canvas) {
root.draw(this)
}
// 接著讓 ViewGroup 繪製嵌入的原生 View
super.dispatchDraw(canvas)
}
}決策 3:CanvasHolder 帶來的零物件分配(Zero-Allocation)
在 60 FPS 或 120 FPS 的高頻繪製迴圈中,任何短生命週期物件的頻繁分配,都會給垃圾回收器(Garbage Collector)帶來巨大壓力,導致 Micro-stutter 卡頓。
Android 原生傳入 draw() 的是 android.graphics.Canvas,而 Compose 內部使用的是跨平台的 androidx.compose.ui.graphics.Canvas 封裝。如果每一幀、每個節點都 new CanvasWrapper(canvas),GC 將不堪負荷。
MiniCompose 透過 CanvasHolder 模式解決了這個問題:
class CanvasHolder {
// 預先配置單一可重複使用的 MiniCanvas 實例
val miniCanvas = MiniCanvas()
inline fun drawInto(targetCanvas: Canvas, block: MiniCanvas.() -> Unit) {
miniCanvas.internalCanvas = targetCanvas
try {
miniCanvas.block()
} finally {
miniCanvas.internalCanvas = null
}
}
}透過 inline 函式與內部引用置換,整個繪製管線在每一幀的物件分配數量嚴格為 0。
決策 4:GraphicsLayer 與硬體 RenderNode 的記憶體分離
在 Android 10 (API 29+) 中,Google 開放了原生 C++ android.graphics.RenderNode API。MiniCompose 的 GraphicsLayer 正是封裝了這顆硬體加速的核心。
一個 RenderNode 在記憶體中被精確拆分為兩個獨立部分:
• scaleX, scaleY
• rotationX, rotationY, rotationZ
• alpha, elevation, pivotX, pivotY"] end subgraph DL ["2. Display List (繪製指令,錄製完成後不可變)"] direction TB DLList["• drawRect(0, 0, 100, 100)
• drawText('Hello')
• drawBitmap(...)"] end end HP -.->|硬體矩陣變換| GPU["GPU RenderThread
(直接套用 4x4 矩陣,重播 Display List)"] DL -->|無需重新錄製| GPU style RN fill:#f8fafc,stroke:#334155,stroke-width:2px,color:#0f172a style HP fill:#eff6ff,stroke:#3b82f6,stroke-width:1.5px,color:#1e3a8a style DL fill:#f1f5f9,stroke:#64748b,stroke-width:1.5px,color:#334155 style HPList fill:#ffffff,stroke:#93c5fd,stroke-width:1px,color:#1e3a8a style DLList fill:#ffffff,stroke:#cbd5e1,stroke-width:1px,color:#334155 style GPU fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
當我們在 MiniCompose 中使用 graphicsLayer 更新動畫時:
node.graphicsLayerBlock = { layer ->
// 直接寫入 native RenderNode 的 C++ 記憶體欄位!
// 耗時 < 1 微秒,不觸發 Display List 重錄,不觸發 Re-layout
layer.translationX = animatedOffset
}繪製時,GPU 上的 RenderThread 可以直接套用新的 4×4 矩陣,重播完全沒有變動的 Display List。在動畫期間,graphicsLayer 做到不重排(跳過 Layout Phase)且不重錄 Display List;即使在 1000 節點的規模下,每一幀也僅需在 Draw Phase 消耗實測約 ~140 µs 即可完成全部節點的屬性更新與繪製分發。
決策 5:Modifier.graphicsLayer vs Modifier.offset 的本質對比
Compose 的渲染管線包含三個階段:
$$\text{Composition (組件組合)} \longrightarrow \text{Layout (量測與擺放)} \longrightarrow \text{Draw (畫布繪製)}$$| 比較維度 | Modifier.graphicsLayer |
Modifier.offset |
|---|---|---|
| 執行階段 | Draw Phase(繪製階段) | Layout Phase(排版階段) |
| 底層動作 | 直接更新 native RenderNode 的 Float 欄位 |
標記 needsLayout = true,更新座標 |
| 節點樹負擔 | 0 節點遍歷(跳過全樹重新排版) | 遞迴遍歷整棵樹,重新量測子節點約束 |
| Display List | 完全不重新錄製,Display List 保持重用 | 標記 Dirty,整棵樹重新錄製繪製指令 |
| 1000 節點耗時 | Layout: 0 µs / 總耗時: ~140 µs(節省 96%) | Layout: ~3,100 µs / 總耗時: ~3,500 µs |
3. MiniCompose 專案模組結構
整個 MiniCompose 專案結構精簡,所有核心邏輯集中在以下檔案:
app/src/main/java/com/example/minicompose/
├── CanvasHolder.kt # 零物件分配 Canvas 轉接器
├── GraphicsLayer.kt # 封裝 RenderNode 的硬體繪製圖層與屬性更新
├── LayoutNode.kt # Compose 節點樹結構、Measure/Layout Policy 與繪製回呼
├── MiniComposeView.kt # 對外公開的 MiniComposeView 與內部 Bridge MiniAndroidComposeView
└── MainActivity.kt # 分割螢幕即時基準測試 App(支援 100/500/1000 節點)
4. 結語與學習心得
透過自己動手從零實作 MiniCompose,原本在閱讀 Jetpack Compose 原始碼時許多看似抽象的設計,都變得具體且直觀:
- 架構的取捨皆有原因:
ComposeView繼承ViewGroup與空的onDraw(),是為了在享受現代宣告式 UI 的同時,依然保有與傳統 View 系統 100% 的互操作性與正確的 Z-Order 混合。 - 效能優化藏在細節裡:從
CanvasHolder的零物件配置,到graphicsLayer對RenderNodeHeader Properties 的精準操作,Compose 的高效能來自於對 Android 底層繪製管線(HWUI / RenderThread)的極致理解。
如果你也想親身體驗這些微秒級的效能差異,歡迎下載 MiniCompose 原始碼,在 Android Studio 中打開並跑在實體裝置或模擬器上體驗!