featured.svg

身為 Android 開發者,你可能早已體驗過 Jetpack Compose 帶來的宣告式 UI 開發體驗。特別是使用 Modifier.graphicsLayer 製作位移、旋轉與透明度動畫時,畫面呈現出的 60 / 120 FPS 極致流暢度,常讓人讚嘆不已。

不過,你有沒有在寫程式時突然好奇過:

  • Compose 作為全新設計的 UI 框架,究竟是如何無縫嵌入傳統 Android View 體系的?
  • 當系統刷新畫面時,Compose 是如何「攔截」系統 Canvas 並畫出自己的元件?
  • 為什麼透過 graphicsLayer 跑動畫時,即便主執行緒(Main UI Thread)被複雜邏輯暫時卡住,動畫依然能夠順暢播放?

這篇文章將帶大家一起打開 AndroidX Compose 原始碼(以 ComposeViewAndroidComposeViewCanvasHolderRenderNode 為核心),一層層拆解 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

flowchart TD AV["Android View 樹"] --> CV["ComposeView (ViewGroup)
• 公開 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

這裡的分工非常明確:

  1. 公開 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
}
  1. AndroidComposeView 也是 ViewGroup:因為它不僅要作為 Compose 樹的根節點(Owner),當我們在 Compose 中使用 AndroidView 嵌入傳統 View(如 WebViewMapView)時,AndroidComposeView 必須透過內部的 AndroidViewsHandler 容器來管理這些原生子 View。
  2. ViewTree 依賴與生命週期傳遞ComposeView 透過內部 ComposeViewContext 自動尋找並傳遞 View 樹中的 LifecycleOwnerSavedStateRegistryOwnerViewModelStoreOwner,讓 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
}
flowchart LR A["View.draw(Canvas)"] --> B["1. drawBackground"] B --> C["2. onDraw
(繪製 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
    }
}

其背後的主要考量為:

  1. onDraw() 執行時間太早onDraw() 在繪製任何子 View 之前 就執行完畢。如果 Compose 在 onDraw() 繪製 UI,那麼所有透過 AndroidView 嵌入的原生 View 永遠會蓋在 Compose UI 的上方,無法實現 Compose 元件與原生 View 的動態圖層交錯(Z-Index)。
  2. 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 適配器設計:

sequenceDiagram participant OS as Android OS participant ACV as AndroidComposeView participant CH as CanvasHolder participant AC as AndroidCanvas Wrapper participant Root as Compose LayoutNode Tree OS->>ACV: dispatchDraw(systemCanvas) ACV->>CH: drawInto(systemCanvas) CH->>AC: 暫時將 internalCanvas 指向 systemCanvas CH->>Root: root.draw(androidCanvas) Root->>AC: 執行繪圖指令 (drawRect, drawText...) AC->>OS: 直接委派給 systemCanvas 執行 CH->>AC: 繪製結束,恢復 internalCanvas

AndroidCanvas.android.kt 中:

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)。

原始碼中主要有兩種圖層實現:

  1. RenderNodeLayer(早期實現):直接包裝 Android 系統的 RenderNodeApi29 / RenderNodeApi23,內部手動透過 OutlineResolver 來處理裁切與陰影。
  2. GraphicsLayerOwnerLayer(現代 1.7+ 標準實現):Compose 將圖層抽象化為統一的 GraphicsLayer API。在 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 命令並渲染到螢幕。
flowchart TD subgraph UIThread["1. Main UI Thread (Kotlin)"] direction TB M1["Composition & Layout 測量"] --> M2["RenderNode.beginRecording()"] M2 --> M3["寫入 DisplayList 繪圖指令集 (C++)"] M3 --> M4["RenderNode.endRecording()"] end subgraph SyncStep["2. VSYNC 幀同步"] M4 -->|SyncFrameState 資料同步| R1 end subgraph RenderStep["3. RenderThread (Native C++)"] direction TB R1["取得 DisplayList & RenderNode 4x4 矩陣屬性"] --> R2["Skia 引擎生成 GPU Shaders / Vulkan / OpenGL 指令"] end subgraph GPUStep["4. GPU 硬體加速繪製"] R2 --> G1["輸出至螢幕 Surface Buffer 顯示 (60/120 FPS)"] end style UIThread fill:#f1f5f9,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a style SyncStep fill:#faf5ff,stroke:#a855f7,stroke-width:1.5px,color:#581c87 style RenderStep fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46 style GPUStep fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#7c2d12 style M1 fill:#ffffff,stroke:#93c5fd,color:#0f172a style M2 fill:#ffffff,stroke:#93c5fd,color:#0f172a style M3 fill:#ffffff,stroke:#93c5fd,color:#0f172a style M4 fill:#ffffff,stroke:#93c5fd,color:#0f172a style R1 fill:#ffffff,stroke:#6ee7b7,color:#0f172a style R2 fill:#ffffff,stroke:#6ee7b7,color:#0f172a style G1 fill:#ffffff,stroke:#fdba74,color:#0f172a

關鍵區分:Display List (繪圖指令) vs. RenderNode Header Properties (矩陣屬性)

許多人以為動畫改變 translationX 時,系統需要重新錄製 Display List,但事實並非如此!

一個 RenderNode 在記憶體中分為兩個獨立部分:

flowchart TD RN["android.graphics.RenderNode (記憶體內部結構)"] RN --> Header["1. RenderNode Header Properties
(可變 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
  1. Display List(繪圖指令集):儲存 drawRectdrawPathdrawText 等靜態繪畫命令。錄製後即不可變(Immutable)
  2. RenderNode Header Properties(標頭屬性):包含 translationX, scaleX, rotationZ, alpha, shadowElevation 等 C++ float 欄位。

當你更新 graphicsLayer 的動畫數值時:

// 在 Compose 動畫中變更平移位置
Modifier.graphicsLayer {
    translationX = animatedOffset // 每一幀都在改變
}

背後發生的事情是:

  1. 不需要 Re-composition:不會重新執行 @Composable 函數。
  2. 不需要 Re-layout:不會重新計算 LayoutNode 的尺寸與位置。
  3. 不需要重新錄製 Display List:完全不呼叫 beginRecording(),也不重新產生 C++ 繪圖指令。
  4. 僅更新 C++ 原生欄位:Compose 僅在 < 1 微秒 內直接修改 native RenderNode 物件上的 translationX float 數值!

在下一個 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 的革新,在底層渲染架構上更展現了極致的效能考究:

  • 透過 AbstractComposeViewAndroidComposeView 無縫銜接傳統 View System。
  • 透過覆寫 dispatchDraw 實現與原生 View 的完美 Z-Order 混合繪製。
  • 透過 CanvasHolder 達成零記憶體配置的繪圖轉譯。
  • 透過 RenderNode 將動畫變形完全委派給 RenderThread 與 GPU,實現極致流暢的動畫體驗。

了解這些底層細節後,下次在寫 Compose UI 時,你就知道為什麼動畫推薦優先使用 Modifier.graphicsLayer 搭配 Lambda 傳值了!

(本文關於 Compose 的其他優秀架構設計,如 Slot Table、3-Phase Invalidation、Modifier.Node 等,將在後續文章中陸續為大家拆解!)