featured.svg

上一篇文章中,我們從 AndroidX 原始碼的視角,深入剖析了 Jetpack Compose 的底層渲染管線與動畫硬體加速原理。

不過,讀懂原始碼與自己真正掌握架構之間,往往隔著一層「動手做」的距離。為了驗證這些架構設計在真實運行時的表現,我用 Kotlin 從零手刻了一個最小化的教育型 Compose 渲染引擎:MiniCompose

這個專案不依賴任何 AndroidX Compose 函式庫,也不包含編譯器外掛(compiler plugin)或複雜的響應式狀態系統,而是專注於實現支撐 Compose 高效能渲染的 5 大關鍵架構決策。更進一步地,為了徹底排除同一執行緒排程干擾與虛擬機垃圾回收(GC)對效能比較的污染,MiniCompose 引入了 Android 跨進程畫面嵌入技術(Multi-Process Embedded Rendering),在單一視窗中左右並排運行兩個完全隔離的 OS 獨立進程,進行千節點規模的微秒級(µs)即時基準測試。

1. 雙進程(Multi-Process)即時效能基準測試與實機展示

在深入底層實作前,我們先來看這個實驗 App 的對比設計與最新實機動態演示。

在 Android UI 開發中,移動一個元件通常有兩種常見方式:

  1. Modifier.graphicsLayer:在繪製階段(Draw Phase)透過硬體層做幾何變換。
  2. Modifier.offset:在排版階段(Layout Phase)修改座標位置。

以往若在同一個 Activity 或同一個進程內同時跑兩種極端負載的動畫,右側高密度的排版計算與頻繁產生的短生命週期物件,容易引發全進程的 ART 虛擬機 GC 暫停(Stop-The-World),或者霸佔主執行緒的 Choreographer,進而拖累左側的幀率。

為了解決這個干擾,MiniCompose 將左右兩側拆分為兩個獨立的 Linux OS 進程

flowchart LR subgraph SingleWindow ["單一視窗:雙進程隔離即時基準測試"] direction LR subgraph LeftProc ["⚡ 左側進程 (:left_gpu / PID X)"] direction TB GPUCard["GPU 硬體繪製卡片
100 / 500 / 1000 Nodes"] GPULayout["Layout Phase: 0 µs
✓ 0 Passes / 秒(跳過 measureAndLayout)"] GPUDraw["Draw Phase: ~280 µs
✓ 鎖定 60~62 FPS 絲滑運作"] GPUCard --> GPULayout --> GPUDraw end subgraph RightProc ["⚠️ 右側進程 (:right_cpu / PID Y)"] direction TB CPUCard["CPU 排版計算卡片
100 / 500 / 1000 Nodes"] CPULayout["Layout Phase: ~14,700 µs
⚠️ 44 Passes / 秒(每幀全樹重排重測)"] CPUDraw["Draw Phase: ~6,580 µs
⚠️ 總幀耗時 ~21 ms(幀率掉至 44 FPS)"] CPUCard --> CPULayout --> CPUDraw end LeftProc ~~~ RightProc end style LeftProc fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46 style RightProc 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

實驗設計與即時控制維度

  • OS 級進程隔離(Process Isolation):左側運行於 :left_gpu(PID 32691),右側運行於 :right_cpu(PID 32714),兩者擁有獨立的 ART 虛擬機堆積、Main Looper 與 RenderThread。
  • 多層級節點樹(Tree Complexity):支援即時切換 100 節點500 節點1000 節點LayoutNode 階層樹,每個節點包含文字量測(Paint.measureText)與 Flex 排版約束計算。
  • 排版延遲注入(Layout Delay):支援注入 0ms / 8ms / 20ms 的主執行緒排版負載,模擬重度業務計算。
  • 繪製負載注入(Draw Load):支援 Normal / +150 DL / +300 DL(Display List 繪製路徑),測試 GPU / CPU 繪製極限。
  • 微秒級遙測抬頭顯示(HUD):利用 System.nanoTime() 分別量測排版階段(Layout Phase)與繪製階段(Draw Phase)消耗的微秒時間,並跨進程透過 Binder IPC 即時匯總至主畫面。

實測數據與真實日誌分析

在開啟 1000 節點、8ms Layout Delay 與 +300 DL 的重度壓力測試下,我們從實機 HUD 與 Logcat 擷取到微秒級效能數據:

評測維度 Modifier.graphicsLayer (:left_gpu / PID 32691) ⚠️ Modifier.offset (:right_cpu / PID 32714) 效能差異與架構洞察
運作幀率 (FPS) 62 FPS(穩定維持滿幀) 44 FPS(掉幀、明顯卡頓) 左側完全不受右側進程卡頓影響
Layout Phase 耗時 0 µs(0 passes/s,完全跳過) 14,733 µs (~14.7 ms)(44 passes/s) graphicsLayer 節省 100% 排版開銷
Draw Phase 耗時 ~275 – 280 µs ~6,581 µs (~6.6 ms) graphicsLayer 重用 Display List,無額外重錄開銷
單幀總 CPU 耗時 ~280 µs (0.28 ms) ~21,314 µs (21.3 ms) 每幀節省超過 21,000 µs (21 ms)

📌 關鍵實測日誌洞察與基準測試說明

  • 跳過排版階段graphicsLayer 在動畫期間完全跳過 measureAndLayout()(0 passes/s),Layout Phase 耗時嚴格為 0 µs;在 1000 節點規模下,僅需在 Draw Phase 耗時實測約 ~280 µs 進行屬性更新與繪製分發。
  • 基準測試路徑說明(Caveat):在 MiniCompose 基準測試中,右側 offset 每一幀都會標記整棵子樹 Dirty 並遍歷重算;這是為了演示極限排版開銷上限而刻意設計的最壞路徑(Worst-case Demo Path),並非真實 Jetpack Compose 中 Modifier.offset 的局部排版(Localized Relayout)預設行為
  • 進程隔離帶來的純粹性:當右側進程被 1000 節點重排與延遲塞滿、單幀耗時飆至 21.3 ms(幀率跌至 44 FPS)時,左側進程依然絲滑地以 62 FPS 高速旋轉與移動,驗證了硬體加速圖層在多進程/多執行緒下的抗干擾能力。

2. 核心技術解析:如何在單一視窗中以雙進程/多 Activity 隔離渲染畫面?

許多讀者可能會好奇:Android 原生介面中,如何讓兩個完全不同的 Linux 進程,把各自的 UI 同時渲染在同一個 Activity 的同一個視窗畫面上?

MiniCompose 結合了 Android 11 (API 30+) 引入的 SurfaceControlViewHost 與傳統的 SurfaceView + Binder IPC,實現了這套跨進程畫面無縫嵌入架構。

2.1 為什麼需要雙進程隔離?

在常規 Android 開發中,所有 View 都運行在同一個主執行緒(UI Thread)與同一個 ART 虛擬機進程中。若要公正地對比「高負載 CPU 計算」與「GPU 硬體加速」:

  1. 共享 UI Looper 污染:CPU 端的繁重排版會延遲 Choreographer.doFrame(),使同一畫面上的 GPU 動畫也被迫延遲掉幀。
  2. 共享 GC 暫停:大量短命物件的頻繁分配會觸發全進程的垃圾回收暫停(GC Pause),干擾微秒級量測。

將兩者拆分至 :left_gpu:right_cpu 獨立進程後,每個進程擁有自己的 Linux PID、獨立的虛擬機堆積、專屬的 Main Looper 與 RenderThread,達成了物理級別的效能隔離。

2.2 跨進程畫面嵌入架構:SurfaceControlViewHost

AndroidManifest.xml 中,我們宣告了兩個獨立進程:

<!-- Process 1: 主介面與左側 GPU Activity (:left_gpu) -->
<activity
    android:name=".MainActivity"
    android:process=":left_gpu"
    android:hardwareAccelerated="true" />

<!-- Process 2: 右側 CPU 渲染服務與獨立 Activity (:right_cpu) -->
<service
    android:name=".RightCpuService"
    android:process=":right_cpu"
    android:exported="false" />

<activity
    android:name=".RightCpuActivity"
    android:process=":right_cpu"
    android:resizeableActivity="true" />

整個跨進程渲染與控制的 IPC 握手流程如下:

sequenceDiagram autonumber participant Host as MainActivity (:left_gpu) participant SF as SurfaceFlinger (System Compositor) participant Remote as RightCpuService (:right_cpu) Note over Host: 1. 建立 SurfaceView 並取得 hostToken Host->>Remote: bindService() + Binder.transact(TRANSACTION_CREATE_SURFACE, hostToken, w, h) Note over Remote: 2. 初始化 SurfaceControlViewHost(display, hostToken) Note over Remote: 3. 將 MiniComposeView 設為 Root View Note over Remote: 4. 取出 SurfacePackage (封裝 Remote SurfaceControl) Remote-->>Host: Binder Parcel 回傳 (PID, SurfacePackage) Note over Host: 5. surfaceView.setChildSurfacePackage(surfacePackage) Host->>SF: 註冊跨進程圖層混合 Remote->>SF: :right_cpu RenderThread 直接送幀 Host->>SF: :left_gpu RenderThread 直接送幀 SF-->>Host: SurfaceFlinger 同步合成至單一螢幕! rect rgb(240, 249, 255) Note over Host, Remote: 6. 跨進程雙向互動與遙測同步 (Binder IPC) Host->>Remote: transact(TRANSACTION_SET_COMPLEXITY / DELAY / LOAD) Host->>Remote: transact(TRANSACTION_GET_STATS) -> 回傳 (FPS, LayoutUs, DrawUs) end

2.3 核心程式碼實作

步驟 1:主端(:left_gpu)配置 SurfaceView 並發送 hostToken

MainActivity 中,右半部配置一個 SurfaceView,當 Surface 就緒後,將它的 hostToken 透過 Binder 傳給遠端服務:

// MainActivity.kt (:left_gpu process)
rightSurfaceView = SurfaceView(this).apply {
    setZOrderMediaOverlay(true)
    holder.setFormat(PixelFormat.TRANSLUCENT)
    holder.addCallback(object : SurfaceHolder.Callback {
        override fun surfaceCreated(holder: SurfaceHolder) {
            attachSurfaceIfReady()
        }
        override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
            attachSurfaceIfReady()
        }
        override fun surfaceDestroyed(holder: SurfaceHolder) {}
    })
}

private fun attachSurfaceIfReady() {
    val service = rightServiceBinder ?: return
    val hostToken = rightSurfaceView.hostToken ?: return
    val w = rightSurfaceView.width
    val h = rightSurfaceView.height
    if (w <= 0 || h <= 0) return

    val data = Parcel.obtain()
    val reply = Parcel.obtain()
    try {
        data.writeStrongBinder(hostToken)
        data.writeInt(w)
        data.writeInt(h)
        service.transact(RightCpuService.TRANSACTION_CREATE_SURFACE, data, reply, 0)
        reply.readException()
        rightPid = reply.readInt()
        val hasPackage = reply.readInt()
        if (hasPackage != 0) {
            val surfacePackage = SurfaceControlViewHost.SurfacePackage.CREATOR.createFromParcel(reply)
            // 將遠端進程的畫面包掛載進本地 SurfaceView!
            rightSurfaceView.setChildSurfacePackage(surfacePackage)
        }
    } finally {
        data.recycle()
        reply.recycle()
    }
}

步驟 2:遠端(:right_cpu)建立 SurfaceControlViewHost 並回傳 SurfacePackage

RightCpuService 內部,收到 hostToken 後建立 SurfaceControlViewHost,並將耗費 CPU 計算的 MiniComposeView 掛載進去:

// RightCpuService.kt (:right_cpu process)
@RequiresApi(Build.VERSION_CODES.R)
private fun createEmbeddedViewHierarchy(
    hostToken: IBinder,
    width: Int,
    height: Int
): SurfaceControlViewHost.SurfacePackage? {
    val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
    val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)

    // 建立跨進程 View 宿主
    val newHost = SurfaceControlViewHost(this, display, hostToken)
    this.host = newHost

    val newComposeView = MiniComposeView(this)
    this.composeView = newComposeView

    // 將 Compose 樹掛載至遠端 Host
    newHost.setView(newComposeView, width, height)

    setupComposeTree(width, height)
    startAnimation()

    // 取得可跨進程序列化傳輸的 SurfacePackage
    return newHost.surfacePackage
}

步驟 3:跨進程 Binder 遙測與互動控制

為了讓主畫面的控制按鈕(如 100/500/1000 節點切換、延遲注入等)與 HUD 統計數據即時同步,兩進程間定義了一組輕量的 Binder Transaction:

// 跨進程控制碼定義
const val TRANSACTION_CREATE_SURFACE = 1
const val TRANSACTION_SET_COMPLEXITY = 2
const val TRANSACTION_SET_LAYOUT_DELAY = 3
const val TRANSACTION_GET_STATS = 4
const val TRANSACTION_SET_DRAW_LOAD = 5
const val TRANSACTION_SET_ANIMATING = 6

MainActivity 每秒定期呼叫 TRANSACTION_GET_STATS,跨進程讀取 :right_cpu 的 FPS 與微秒耗時,並繪製在主螢幕的 HUD 面板上。

補充:多 Activity 原生分割畫面(Split-Screen)

除了 SurfaceControlViewHost 視窗內嵌入外,專案中也提供了獨立的 RightCpuActivity。透過設定 android:resizeableActivity="true"launchMode="singleTask",在 Android 平板或多重視窗模式下,系統可以同時以左右分割畫面運行 MainActivity (:left_gpu) 與 RightCpuActivity (:right_cpu),同樣享有 100% 的進程與繪製隔離。

3. MiniCompose 的 5 大核心架構實作

除了雙進程隔離技術,MiniCompose 的核心價值在於用最精簡的 Kotlin 程式碼,完整還原 Jetpack Compose 團隊在渲染管線上的 5 大關鍵設計決策。

決策 1:為什麼 ComposeViewAndroidComposeView 必須是 ViewGroup

在傳統 View 系統中,如果我們要客製化一個純粹繪製內容的元件,通常繼承 View 即可。但 Compose 的進入點卻是兩個 ViewGroup

flowchart TD AVTree["Android 原生 View 樹狀結構"] --> MCV["MiniComposeView (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

其背後原因有兩個:

  1. 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)
    }
  2. 互操作性(Interop):當我們在 Compose 中使用 AndroidView 嵌入傳統原生元件(如 WebViewMapView)時,這些原生元件必須存在於 Android View 樹狀結構中。MiniAndroidComposeView 作為 ViewGroup,才能在內部建立一個 AndroidViewsHandler 來持有並管理這些原生子 View。

決策 2:空的 onDraw() 與攔截 dispatchDraw() 的 Z-Order 奧秘

在 Android 中,一個 View 的完整繪製流程如下:

$$\text{drawBackground()} \longrightarrow \text{onDraw()} \longrightarrow \text{dispatchDraw()} \longrightarrow \text{onDrawForeground()}$$

其中,onDraw() 是用來畫 View 自己的內容,而 dispatchDraw() 則是 ViewGroup 用來派發並繪製所有子 View。

💡 核心洞察:如果 Compose 選擇在 onDraw() 中繪製 LayoutNode 元件樹,那麼隨後在 dispatchDraw() 繪製的原生嵌入元件(如 AndroidView)將會永遠覆蓋在 Compose UI 之上,破壞畫面的圖層順序(Z-ordering)。

因此,MiniAndroidComposeView 採取了明確的繪製順序:

  • onDraw() 留空,不在這個階段做任何繪製。
  • 覆寫 dispatchDraw(canvas)先畫完整棵 LayoutNode 樹,再調用 super.dispatchDraw(canvas) 繪製原生子 View
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) {
        // 1. 若節點樹 Dirty,執行排版與量測計時
        if (root.needsLayout || root.hasDirtyDescendants) {
            val startNs = System.nanoTime()
            val didWork = root.measureAndLayout(width, height)
            if (didWork) {
                layoutPassCount++
                lastLayoutTimeUs = (System.nanoTime() - startNs) / 1000L
            }
        }

        // 2. 在 dispatchDraw 中繪製整個 Compose LayoutNode 樹
        val drawStartNs = System.nanoTime()
        canvasHolder.drawInto(canvas) {
            root.draw(this)
        }
        lastDrawTimeUs = (System.nanoTime() - drawStartNs) / 1000L
        
        // 3. 接著讓 ViewGroup 繪製嵌入的原生 View
        super.dispatchDraw(canvas)
    }
}

決策 3:CanvasHolder 帶來的零物件分配(Zero-Allocation)

在 60 FPS 或 120 FPS 的高頻繪製迴圈中,任何短生命週期物件的頻繁分配,都會給垃圾回收器(Garbage Collector)帶來巨大壓力,導致 micro-stutter 卡頓。

Android 原生傳入 draw() 的是 android.graphics.Canvas,而 Compose 內部使用的是跨平台的 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 在記憶體中被精確拆分為兩個獨立部分:

flowchart TD subgraph RN ["RenderNode (Native C++ 物件結構)"] direction TB subgraph HP ["1. Header Properties (可變資料,更新耗時 < 1 µs)"] direction TB HPList["• translationX, translationY
• 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.translationY = currentY
    layer.rotationZ = animationProgress * 360f
}

繪製時,GPU 上的 RenderThread 可以直接套用新的 4×4 矩陣,重播完全沒有變動的 Display List。在動畫期間,graphicsLayer 做到不重排(跳過 Layout Phase)且不重錄 Display List;即使在 1000 節點的規模下,每一幀也僅需在 Draw Phase 消耗實測約 ~280 µ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 / 總耗時: ~280 µs(節省 98%) Layout: ~14.7 ms / 總耗時: ~21.3 ms

4. MiniCompose 專案模組結構

整個 MiniCompose 專案結構清晰,模組分工如下:

app/src/main/
├── AndroidManifest.xml      # 宣告 :left_gpu 與 :right_cpu 雙獨立進程
└── java/com/example/minicompose/
    ├── CanvasHolder.kt      # 零物件分配 Canvas 轉接器
    ├── GraphicsLayer.kt     # 封裝 RenderNode 的硬體繪製圖層與 header 屬性更新
    ├── LayoutNode.kt        # Compose 節點樹結構、Measure/Layout Policy 與繪製分發
    ├── MiniComposeView.kt   # 對外公開的 MiniComposeView 與內部 Bridge MiniAndroidComposeView
    ├── MainActivity.kt      # 雙進程協調器(運行於 :left_gpu,透過 SurfaceControlViewHost 嵌入右側畫面)
    ├── RightCpuService.kt   # 遠端渲染服務(運行於 :right_cpu,提供 SurfacePackage 與 IPC 遙測數據)
    └── RightCpuActivity.kt  # 獨立 CPU 測試 Activity(支援 Android 原生多重視窗分割畫面)

5. 結語與學習心得

透過從零手刻 MiniCompose 並整合 Android 雙進程跨視窗渲染架構,我們獲得了兩個層面的深刻體悟:

  1. Compose 架構設計的精準取捨ComposeView 繼承 ViewGroup 與清空 onDraw(),是為了在享受宣告式 UI 開發體驗的同時,兼顧與傳統 View 系統 100% 的 Z-Order 互操作性;而 CanvasHolderGraphicsLayer 則是對 Android HWUI / RenderThread 底層管線的高效利用。
  2. 進程隔離帶來的純粹基準測試:透過 SurfaceControlViewHost,我們得以在單一視窗內將兩個截然不同的渲染策略隔絕在獨立的 Linux 進程中。無論右側的排版負載多麼沉重、引發多少 GC 暫停,左側的硬體加速動畫依然能以 60+ FPS 絲滑運轉。

如果你也想親自把玩這套雙進程架構並體驗微秒級的效能數據,歡迎造訪 GitHub - p47t/minicompose,將專案 clone 下來在 Android Studio 中打開並運行於實機上!