身為 Android 開發者,你可能早已體驗過 Jetpack Compose 帶來的宣告式 UI 開發體驗。特別是使用 Modifier.graphicsLayer 製作位移、旋轉與透明度動畫時,畫面呈現出的 60 / 120 FPS 極致流暢度,常讓人讚嘆不已。
不過,你有沒有在寫程式時突然好奇過:
- Compose 作為全新設計的 UI 框架,究竟是如何無縫嵌入傳統 Android View 體系的?
- 當系統刷新畫面時,Compose 是如何「攔截」系統
Canvas並畫出自己的元件? - 為什麼透過
graphicsLayer跑動畫時,即便主執行緒(Main UI Thread)被複雜邏輯暫時卡住,動畫依然能夠順暢播放?
這篇文章將帶大家一起打開 AndroidX Compose 原始碼(以 ComposeView、AndroidComposeView、CanvasHolder 與 RenderNode 為核心),一層層拆解 Compose 的底層渲染架構與動畫加速!
1. Compose 在 View System 中的宿主:ComposeView 與 AndroidComposeView
在傳統 XML 佈局或 View 體系中引進 Compose 時,我們的進入點通常是 ComposeView:
val composeView = ComposeView(context).apply {
setContent {
Text("Hello Compose!")
}
}為什麼 ComposeView 繼承自 ViewGroup 而不是 View?
在 Android View 框架中,標準的 View 只能繪製自己,無法包含子 View;唯有 ViewGroup 才能透過 addView() 管理與擺放子視圖。
當我們呼叫 ComposeView.setContent 時,AbstractComposeView 會在內部建立一個關鍵的子 View——AndroidComposeView:
• 公開 API 容器 & 生命週期管理"] CV -->|唯一子 View| ACV["AndroidComposeView (ViewGroup)
• 內部橋樑 & 實現 Owner 介面"] ACV -->|管理| Root["Root LayoutNode
• Compose 樹根節點"] ACV -->|管理| AVH["AndroidViewsHandler
• 用於內嵌傳統 Android View"] style AV fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a style CV fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a style ACV fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#7c2d12 style Root fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46 style AVH fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
這裡的分工非常明確:
- 公開 API 與內部實現解耦:
ComposeView負責處理生命週期策略 (ViewCompositionStrategy) 與 XML 佈局防護。它透過覆寫checkAddView()防止外部誤加原生 View:
// ComposeView.android.kt
private fun checkAddView() {
if (!creatingComposition) {
throw UnsupportedOperationException(
"Cannot add views to ${javaClass.simpleName}; only Compose content is supported"
)
}
}當呼叫 createComposition() 時,AbstractComposeView 會經由 Wrapper.android.kt 實例化 AndroidComposeView 並將其掛載為唯一的子 View:
// Wrapper.android.kt
internal fun AbstractComposeView.setContent(
composeViewContext: ComposeViewContext,
content: @Composable () -> Unit
): Composition {
GlobalSnapshotManager.ensureStarted()
val composeView = if (childCount > 0) {
(getChildAt(0) as? AndroidComposeView)
} else {
removeAllViews()
null
} ?: AndroidComposeView(context, composeViewContext).also {
addView(it.view, DefaultLayoutParams)
}
val wrapped = composeView.getTag(R.id.wrapped_composition_tag) as? WrappedComposition
?: WrappedComposition(
composeView,
Composition(UiApplier(composeView.root), composeViewContext.compositionContext)
).also { composeView.setTag(R.id.wrapped_composition_tag, it) }
wrapped.setContent(content)
return wrapped
}AndroidComposeView也是ViewGroup:因為它不僅要作為 Compose 樹的根節點(Owner),當我們在 Compose 中使用AndroidView嵌入傳統 View(如WebView或MapView)時,AndroidComposeView必須透過內部的AndroidViewsHandler容器來管理這些原生子 View。- ViewTree 依賴與生命週期傳遞:
ComposeView透過內部ComposeViewContext自動尋找並傳遞 View 樹中的LifecycleOwner、SavedStateRegistryOwner與ViewModelStoreOwner,讓 Compose 能夠在 View 樹中正確注入CompositionLocal與狀態回復機制。
2. 繪圖攔截的魔法:為什麼是 dispatchDraw 而不是 onDraw?
當 Android 系統刷新畫面時,View 類別的 draw(Canvas) 函式會依序執行以下步驟:
// Standard android.view.View / ViewGroup draw pipeline:
public void draw(Canvas canvas) {
drawBackground(canvas); // 1. 繪製背景
onDraw(canvas); // 2. 繪製 View 本身內容
dispatchDraw(canvas); // 3. 繪製子 View 們 (ViewGroup 專屬)
onDrawForeground(canvas); // 4. 繪製滾動條與前景 Overlay
}(繪製 View 本身)"] C --> D["3. dispatchDraw
(繪製子 View 們)"] D --> E["4. onDrawForeground
(繪製前景 Overlay)"] style A fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a style B fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155 style C fill:#fff5f5,stroke:#ef4444,stroke-width:2px,color:#991b1b style D fill:#ecfdf5,stroke:#10b981,stroke-width:2.5px,color:#065f46 style E fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
你可能會以為 Compose 是在 onDraw(Canvas) 中把元件印到畫面上,但如果你查閱 AndroidComposeView.android.kt 的原始碼,會發現一個有趣的現象:
override fun onDraw(canvas: android.graphics.Canvas) {
// 竟然是空的!
}關鍵原因:Z-Order 排序與原生 View 混合繪製
Compose 選擇在 dispatchDraw(Canvas) 階段攔截 Canvas,原始碼中的關鍵實現如下:
// AndroidComposeView.android.kt
override fun dispatchDraw(canvas: android.graphics.Canvas) {
if (!isAttachedToWindow) {
invalidateLayers(root)
}
// 1. 確保繪圖前的測量與佈局已完成
measureAndLayout()
Snapshot.notifyObjectsInitialized()
isDrawingContent = true
try {
trace("AndroidOwner:draw") {
// 2. 將系統 Canvas 丟入 CanvasHolder 進行 Zero-Allocation 轉譯
canvasHolder.drawInto(canvas) {
// 3. 從 Compose 根節點遞迴繪製整棵 LayoutNode 樹
root.draw(canvas = this, graphicsLayer = null)
}
}
} finally {
isDrawingContent = false
}
}其背後的主要考量為:
onDraw()執行時間太早:onDraw()在繪製任何子 View 之前 就執行完畢。如果 Compose 在onDraw()繪製 UI,那麼所有透過AndroidView嵌入的原生 View 永遠會蓋在 Compose UI 的上方,無法實現 Compose 元件與原生 View 的動態圖層交錯(Z-Index)。- 在
dispatchDraw()中精確控制圖層:dispatchDraw()是ViewGroup繪製子 View 的時間點。Compose 在這裡接管Canvas,就能精確控制 Compose 的LayoutNode樹與原生 View 之間的繪圖順序。
3. Zero-Allocation 繪圖適配器:CanvasHolder 與 AndroidCanvas
在 Android 系統中,dispatchDraw 傳進來的是 android.graphics.Canvas;而 Compose 內部使用的是跨平台的 androidx.compose.ui.graphics.Canvas 介面。
如果每一幀(每秒 60 ~ 120 次)繪圖時,Compose 都 new 一個包裝物件來轉換 Canvas,將會引發嚴重的記憶體抖動(Memory Churn)與 GC 卡頓。
為了避免這個問題,Compose 採用了 CanvasHolder 適配器設計:
public class CanvasHolder {
@PublishedApi internal val androidCanvas: AndroidCanvas = AndroidCanvas()
public inline fun drawInto(targetCanvas: android.graphics.Canvas, block: Canvas.() -> Unit) {
val previousCanvas = androidCanvas.internalCanvas
androidCanvas.internalCanvas = targetCanvas // 替換內部參考
androidCanvas.block() // 執行 Compose 繪圖
androidCanvas.internalCanvas = previousCanvas // 復原
}
}透過這個簡單而精妙的設計,Compose 實現了 0 記憶體配置(Zero-Allocation) 的 Canvas 轉譯!
4. 繪圖圖層的硬體加速:RenderNodeLayer vs GraphicsLayerOwnerLayer
在 Compose 中,當我們為元件加上 Modifier.graphicsLayer 時,Compose 會為該節點建立一個獨立的繪圖圖層(OwnedLayer)。
原始碼中主要有兩種圖層實現:
RenderNodeLayer(早期實現):直接包裝 Android 系統的RenderNodeApi29/RenderNodeApi23,內部手動透過OutlineResolver來處理裁切與陰影。GraphicsLayerOwnerLayer(現代 1.7+ 標準實現):Compose 將圖層抽象化為統一的GraphicsLayerAPI。在 Android 10 (API 29+) 上,它底層會建立GraphicsLayerV29,並直接使用 Android 系統公開的android.graphics.RenderNode。
在 AndroidGraphicsContext.android.kt 原始碼中,我們可以看到系統如何根據 API 版本動態切換圖層實現:
// AndroidGraphicsContext.android.kt
override fun createGraphicsLayer(): GraphicsLayer {
synchronized(lock) {
val ownerId = getUniqueDrawingId(ownerView)
val layerImpl = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: 使用官方 public RenderNode API
GraphicsLayerV29(ownerId)
} else if (isRenderNodeCompatible && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
try {
// Android 6.0~9.0: 使用隱藏的 framework RenderNode stubs
GraphicsLayerV23(ownerView, ownerId)
} catch (_: Throwable) {
isRenderNodeCompatible = false
GraphicsViewLayer(obtainViewLayerContainer(ownerView), ownerId)
}
} else {
// 舊版或相容性降級: 使用 ViewLayer 容器
GraphicsViewLayer(obtainViewLayerContainer(ownerView), ownerId)
}
return GraphicsLayer(layerImpl)
}
}不論是哪一種實現,在 Android 10 (API 29) 以上的版本,兩者最終都會指向系統核心的 android.graphics.RenderNode!例如在 GraphicsLayerV29 中,屬性賦值會直接轉發給原生 RenderNode:
// GraphicsLayerV29.android.kt
override var translationX: Float = 0f
set(value) {
field = value
renderNode.translationX = value // 直接寫入 Native RenderNode
}此外,GraphicsLayer 還支援彈性的 CompositingStrategy:
CompositingStrategy.Auto:系統自動判斷是否需要分配 GPU 離屏緩衝區(Offscreen Buffer)。CompositingStrategy.Offscreen:強制建立 GPU 離屏緩衝區,用於複雜的BlendMode混色或遮罩渲染。CompositingStrategy.ModulateAlpha:避開離屏緩衝區,直接將 Alpha 乘以子圖層繪製指令,大幅節省 GPU 記憶體頻寬。
5. 終極解密:為什麼 RenderNode 能讓動畫超流暢?
要理解 RenderNode 動畫流暢的秘密,我們必須了解 Android 的 雙執行緒繪圖架構:
- Main UI Thread(主執行緒 / Kotlin):執行 Compose 的 Composition、Layout 測量與
dispatchDraw。 - RenderThread(原生 C++ / HWUI / GPU 執行緒):負責處理 GPU 指令、發送 Vulkan / OpenGL ES 命令並渲染到螢幕。
關鍵區分:Display List (繪圖指令) vs. RenderNode Header Properties (矩陣屬性)
許多人以為動畫改變 translationX 時,系統需要重新錄製 Display List,但事實並非如此!
一個 RenderNode 在記憶體中分為兩個獨立部分:
(可變 C++ 原生標頭屬性 — 變形與矩陣)
• translationX, translationY
• scaleX, scaleY
• rotationX, rotationY, rotationZ
• alpha, shadowElevation, pivotX, pivotY"] RN --> DisplayList["2. Display List
(不可變 C++ 繪圖指令集串流 — 畫面內容)
• drawRect(0, 0, 100, 100)
• drawText('Hello World')
• drawBitmap(...)"] style RN fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#ffffff style Header fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a style DisplayList fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
- Display List(繪圖指令集):儲存
drawRect、drawPath、drawText等靜態繪畫命令。錄製後即不可變(Immutable)。 - RenderNode Header Properties(標頭屬性):包含
translationX,scaleX,rotationZ,alpha,shadowElevation等 C++ float 欄位。
當你更新 graphicsLayer 的動畫數值時:
// 在 Compose 動畫中變更平移位置
Modifier.graphicsLayer {
translationX = animatedOffset // 每一幀都在改變
}背後發生的事情是:
- ❌ 不需要 Re-composition:不會重新執行
@Composable函數。 - ❌ 不需要 Re-layout:不會重新計算 LayoutNode 的尺寸與位置。
- ❌ 不需要重新錄製 Display List:完全不呼叫
beginRecording(),也不重新產生 C++ 繪圖指令。 - ✅ 僅更新 C++ 原生欄位:Compose 僅在
< 1 微秒內直接修改 nativeRenderNode物件上的translationXfloat 數值!
在下一個 VSYNC 週期,RenderThread 在 GPU 上準備繪製時,它的 C++ 執行邏輯如下:
// RenderThread 內部的繪製虛擬碼
void drawRenderNode(RenderNode* node, Canvas* canvas) {
canvas->save();
// 1. 直接將 RenderNode Header 屬性套用為 4x4 變形矩陣 (GPU 矩陣運算)
canvas->translate(node->getTranslationX(), node->getTranslationY());
canvas->scale(node->getScaleX(), node->getScaleY());
canvas->rotate(node->getRotation());
canvas->setAlpha(node->getAlpha());
// 2. 播放完全沒變過的 Display List!
canvas->drawDisplayList(node->getDisplayList());
canvas->restore();
}因為 Display List 完全不需要重寫,所有的位移與旋轉矩陣運算都直接丟給 GPU 處理,所以即便 Main UI Thread 剛好被複雜的業務邏輯卡住,RenderThread 依然能在 GPU 上以極致流暢的 60/120 FPS 播放動畫!
實務建議:Modifier.offset vs. Modifier.graphicsLayer
這也是為什麼在 Compose 效能最佳化實踐中,強烈推薦使用 graphicsLayer 跑動畫:
| 特性 | Modifier.offset(x = 10.dp) |
Modifier.graphicsLayer { translationX = 10f } |
|---|---|---|
| 改變時觸發流程 | Re-layout 佈局重新計算 + 重新錄製 Display List | 僅更新原生 C++ RenderNode 標頭矩陣屬性 |
| 主執行緒耗時 | 隨 UI 樹深度呈 $O(N)$ 增加 | $< 1\,\mu\text{s}$ (微秒) |
| GPU 加速 | 每幀重新產生繪圖指令集 | GPU 視訊記憶體硬件加速變形 |
結語
Jetpack Compose 不僅僅在語法層面帶來了宣告式 UI 的革新,在底層渲染架構上更展現了極致的效能考究:
- 透過
AbstractComposeView與AndroidComposeView無縫銜接傳統 View System。 - 透過覆寫
dispatchDraw實現與原生 View 的完美 Z-Order 混合繪製。 - 透過
CanvasHolder達成零記憶體配置的繪圖轉譯。 - 透過
RenderNode將動畫變形完全委派給 RenderThread 與 GPU,實現極致流暢的動畫體驗。
了解這些底層細節後,下次在寫 Compose UI 時,你就知道為什麼動畫推薦優先使用 Modifier.graphicsLayer 搭配 Lambda 傳值了!
(本文關於 Compose 的其他優秀架構設計,如 Slot Table、3-Phase Invalidation、Modifier.Node 等,將在後續文章中陸續為大家拆解!)