featured.svg

身為 Android 開發者,你可能早已體驗過 Jetpack Compose 帶來的宣告式 UI 開發體驗。特別是使用 Modifier.graphicsLayer 製作位移、旋轉與透明度動畫時,畫面能穩定維持在 60 / 120 FPS 的流暢度。

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

  • Compose 作為全新設計的 UI 框架,究竟是如何無縫嵌入傳統 Android View 體系的?
  • 當系統刷新畫面時,Compose 是如何「攔截」系統 Canvas 並畫出自己的元件?
  • 為什麼透過 graphicsLayer 跑動畫時,能夠將主執行緒(Main UI Thread)的負擔降到最低並維持順暢的 60 / 120 FPS?

這篇文章將帶大家一起打開 AndroidX Compose 原始碼(以 ComposeViewAndroidComposeViewCanvasHolderRenderNode 為核心),一層層拆解 Compose 的底層渲染架構與動畫加速!

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

compose-view-hierarchy.svg

這裡的分工非常明確:

  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. Compose 與原生 View 系統橋接AndroidComposeView 繼承自 ViewGroup 並實作了 OwnerViewRootForTest 等內部核心介面。它將 Android 系統的輸入事件(Touch、Key)、無障礙輔助(Accessibility)、剪貼簿、焦點管理等,全數轉換為 Compose 內部的事件模型。

繪圖進入點:dispatchDraw 與 Z-Order 混合渲染

既然 AndroidComposeView 是個 ViewGroup,那麼當 Android 系統要求它畫在螢幕上時,它是如何接管繪圖流程的?

Android View 的繪圖管線

在傳統 Android View 系統中,View.draw(Canvas) 的標準管線依序執行四個步驟:

// View.java (簡化邏輯)
public void draw(Canvas canvas) {
    drawBackground(canvas);   // 1. 繪製背景
    onDraw(canvas);           // 2. 繪製 View 本身內容
    dispatchDraw(canvas);     // 3. 繪製子 View 們 (ViewGroup 專屬)
    onDrawForeground(canvas); // 4. 繪製滾動條與前景 Overlay
}
view-draw-pipeline.svg

你可能會以為 Compose 是在 onDraw(Canvas) 中把元件印到畫面上,但如果你查閱 AndroidComposeView.android.kt 的原始碼,會發現一個有趣的現象:

override fun onDraw(canvas: android.graphics.Canvas) {
    // 竟然是空的!
}

關鍵原因:Z-Order 排序與原生 View 混合繪製

為什麼 AndroidComposeView 不在 onDraw 繪圖?

因為 Compose 支援透過 AndroidView 嵌入傳統原生視圖(例如 WebViewMapView 或自訂 View)。如果 Compose 的宣告式節點在 onDraw() 繪製,那麼隨後在 dispatchDraw() 中繪製的所有原生子 View,將會永遠覆蓋在 Compose 元件之上,無法實現任何「Compose 浮層覆蓋在原生 View 上」的階層混排!

因此,Compose 選擇在 dispatchDraw(Canvas) 階段才真正繪圖:

  1. 執行 Compose 測量與佈局:在繪圖前呼叫 measureAndLayout(),確保所有 LayoutNode 的幾何數據已處於最新狀態。
  2. 混合交錯繪圖:在 dispatchDraw 內部,Compose 遍歷所有節點,根據 zIndex 與樹狀階層,交錯調度 Compose 元件繪製與原生子 View(透過 AndroidViewsHandler)繪製。

零物件配置的畫布適配器:CanvasHolder

在 Android 系統中,dispatchDraw 傳進來的是 android.graphics.Canvas;而 Compose 內部使用的是跨平台的 androidx.compose.ui.graphics.Canvas 介面。

如果每一幀(每秒 60 ~ 120 次)繪圖時,Compose 都 new 一個包裝物件來轉換 Canvas,將會引發嚴重的記憶體抖動(Memory Churn)與 GC 卡頓。

為了避免這個問題,Compose 採用了 CanvasHolder 適配器設計:

canvas-holder-sequence.svg

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 轉譯!

繪圖圖層的硬體加速: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 記憶體頻寬。

架構核心:為什麼 RenderNode 能讓動畫如此流暢?

要理解 RenderNode 動畫流暢的秘密,我們必須了解 Android 的 雙執行緒繪圖架構

  • Main UI Thread(主執行緒 / Kotlin):執行 Compose 的 Composition、Layout 測量與 dispatchDraw
  • RenderThread(原生 C++ / HWUI / GPU 執行緒):負責處理 GPU 指令、發送 Vulkan / OpenGL ES 命令並渲染到螢幕。
dual-thread-rendering-pipeline.svg

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

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

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

rendernode-memory-structure.svg
  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 完全不需要重寫,所有的位移與旋轉矩陣運算都直接交由 RenderThread 與 GPU 處理。主執行緒(Main UI Thread)在動畫期間只需進行極速的屬性寫入($< 1\,\mu\text{s}$),大幅釋放了主執行緒的運算資源,從根本上避免了 CPU 排版負擔過重造成的掉幀!

實務建議: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 等,將在後續文章中陸續為大家拆解!)