MiniCompose:手刻最小化 Jetpack Compose 渲染引擎與 1000 節點微秒級動態基準測試
在上一篇文章 中,我們從 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 設計了即時並排的壓力測試畫面:
flowchart LR
subgraph SplitScreen ["MiniCompose 即時雙向基準測試畫面"]
direction LR
subgraph LeftSide ["⚡ 左側:Modifier.graphicsLayer"]
direction TB
GPUCard["GPU 硬體繪製卡片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
Your browser does not support the video tag.
實驗設計與量測方式
多層級節點樹 :支援即時切換 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:
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
其背後原因有兩個:
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 的完整繪製流程如下:
$$\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 (而在真正的 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 在記憶體中被精確拆分為兩個獨立部分:
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.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 對 RenderNode Header Properties 的精準操作,Compose 的高效能來自於對 Android 底層繪製管線(HWUI / RenderThread)的極致理解。
如果你也想親身體驗這些微秒級的效能差異,歡迎下載 MiniCompose 原始碼 ,在 Android Studio 中打開並跑在實體裝置或模擬器上體驗!
在 Rust 中整合 Gemma-4 多模態模型、WebRTC APM 與 openWakeWord 實驗本機語音助理 (Rust 52 Projects #52)
在桌面上運行一個簡易的語音助理,一直是我很想嘗試的學習專案。市面上的商業語音助理(如 Alexa 或 Siri)背後有極為龐大的雲端基礎設施與複雜的工程團隊;但作為個人學習 Rust 與本地 AI 技術的練習,我們也可以用幾百行 Rust 程式碼,把麥克風音訊擷取、WebRTC 降噪、喚醒詞辨識、多模態 LLM 與語音合成組裝成一個本機運行的終端機實驗原型。
這就是 voice-assistant 專案的由來,也是 Rust 52 Projects 挑戰的第 52 篇專案(完結篇)。
這個專案的主要目的是學習如何整合各個獨立的 Rust Crate 與 C/C++ FFI 綁定 。特別的是,為了實驗多模態模型的可能性,專案嘗試繞過了傳統「語音轉文字 (STT, Whisper) $\to$ 文字 LLM $\to$ 文字轉語音 (TTS)」的三階段流程,改為將麥克風錄製的語音音訊直接作為多模態向量(Audio Tensor Embedding)餵給 Gemma-4 Multimodal Audio 模型,進行端到端推論。
這篇文章記錄了這個實驗專案的架構設計、模組組合與學習心得。
專案學習重點與組件構成
作為一個學習練習,這個專案將幾個常見的語音處理組件整合在一起:
本機流程實驗 (Local Pipeline) :全程在本地電腦執行麥克風採樣、降噪、喚醒詞辨識與模型推論,方便在沒有網路連線時進行測試與調試。
WebRTC APM 音訊預處理 (Noise Suppression & AGC2) :利用 sonora 封裝的 WebRTC 引擎,將麥克風採樣率重採樣至 16kHz 單聲道,並進行基礎的背景雜音消除與自動增益調整。
喚醒詞偵測 (openWakeWord) :使用 oww-rs 在背景持續監聽 1280 樣本 (Sample) 的音訊視窗,判定是否觸發喚醒詞(預設支援 Alexa 或 Hey Mycroft 測試模型)。
基礎 RMS 能量 VAD (Voice Activity Detection) :喚醒後進入錄音模式,以 10ms (160 樣本) 視窗計算音訊方均根 (RMS) 能量,當持續低於門檻 1.5 秒或達到 8 秒上限時停止錄音。
多模態語音推論嘗試 :透過 llama-cpp-4 與 Vulkan GPU 加速加載 Gemma-4 多模態模型 (gemma-4-E4B-it + mmproj-gemma-4-E4B-it-BF16.gguf),將 WAV 音訊轉為向量直接由 LLM 進行解碼與串流生成。
原生 PowerShell TTS 與簡單迴路防護 :呼叫 Windows PowerShell 內建的 System.Speech 進行語音朗讀;並在朗讀結束前清理輸入緩衝區,避免麥克風收錄到喇叭播放的聲音而造成誤觸發。
系統狀態機架構 (State Machine)
程式的主體架構是一個簡單的狀態機(State Machine),負責在不同的音訊處理階段之間進行切換:
%%{init: { 'themeVariables': { 'fontSize': '16px', 'subGraphTitleFontSize': '18px' } }}%%
flowchart TD
subgraph Listening ["1. State::Listening (背景監聽)"]
direction TD
A1["cpal 麥克風音訊擷取 (16kHz 重採樣)"] --> A2["WebRTC APM (降噪 NS + AGC2 增益)"]
A2 --> A3["openWakeWord (1280 樣本視窗比對)"]
end
subgraph Recording ["2. State::Recording (使用者口述錄音)"]
direction TD
B1["10ms 視窗計算 RMS 音訊能量"] --> B2["終端機即時繪製彩色音量波形條"]
B2 --> B3["VAD 判定: 靜音 > 1.5s 或 時間 > 8.0s"]
end
subgraph Processing ["3. State::Processing (Gemma-4 多模態推論)"]
direction TD
C1["寫入錄音至 temp_query.wav"] --> C2["Gemma-4 Multimodal Projector (Audio Embedding)"]
C2 --> C3["llama-cpp-4 (Vulkan GPU 加速串流生成)"]
end
subgraph Speaking ["4. State::Speaking (語音反饋與重置)"]
direction TD
D1["Windows PowerShell System.Speech 語音朗讀"] --> D2["清空音訊緩衝區 & 防範自激迴音"]
end
A3 -->|"偵測到喚醒詞 (High Beep)"| Recording
B3 -->|"錄音完成 (Low Beep)"| Processing
C3 -->|"推論完成"| Speaking
D2 -->|"恢復背景監聽"| Listening
核心模組實現拆解
1. 音訊擷取與 WebRTC APM 降噪預處理 (preprocessor.rs)
不同麥克風設備的預設採樣率(例如 44.1kHz 或 48kHz)與聲道數各不相同。AudioPreprocessor 的作用是將這些異構音訊轉化為 openWakeWord 與 LLM 模型所需的 16,000 Hz 單聲道格式 ,並經過 WebRTC 的降噪與增益模組處理:
pub struct AudioPreprocessor {
input_sample_rate: u32 ,
apm: Option< AudioProcessing> ,
mic_samples_accumulator: Vec< f32 > ,
apm_input_buffer: Vec< f32 > ,
}
impl AudioPreprocessor {
pub fn feed_and_process (& mut self, raw_samples: & [f32 ], channels: usize ) -> Result< Vec< f32 >> {
if raw_samples.is_empty() { return Ok(Vec::new()); }
// 1. 單聲道混音 (Channel Mixing)
let mono_samples = convert_channels_to_mono(raw_samples, channels);
self.mic_samples_accumulator.extend_from_slice(& mono_samples);
// 2. 線性重採樣至 16,000 Hz
let mic_samples_needed = (160.0 * (self.input_sample_rate as f32 / 16000.0 )).ceil() as usize ;
while self.mic_samples_accumulator.len() >= mic_samples_needed {
let chunk_to_resample = self.mic_samples_accumulator.drain(0 .. mic_samples_needed).collect::< Vec< _>> ();
let resampled_16k = resample_linear(& chunk_to_resample, self.input_sample_rate, 16000 );
self.apm_input_buffer.extend_from_slice(& resampled_16k);
}
// 3. WebRTC APM 處理 10ms 影格 (160 樣本)
let apm_frame_size = 160 ;
let mut processed_samples = Vec::new();
while self.apm_input_buffer.len() >= apm_frame_size {
let frame: Vec< f32 > = self.apm_input_buffer.drain(0 .. apm_frame_size).collect();
let processed_frame = if let Some(ref mut apm_engine) = self.apm {
let mut dest = vec! [0.0 f32 ; apm_frame_size];
apm_engine.process_capture_f32(& [& frame], & mut [& mut dest])? ;
dest
} else {
frame
};
processed_samples.extend_from_slice(& processed_frame);
}
Ok(processed_samples)
}
}
2. openWakeWord 喚醒詞偵測 (detector.rs)
openWakeWord 是一個輕量級的開源喚醒詞模型。這裡使用 oww-rs 將處理後的 16kHz 音訊累積至 1280 樣本(約 80ms)的固定區塊後傳入模型進行推論:
pub struct WakewordDetector {
oww_model: OwwModel ,
oww_chunk_buffer: Vec< f32 > ,
}
impl WakewordDetector {
pub fn feed_and_detect (& mut self, samples: & [f32 ]) -> bool {
self.oww_chunk_buffer.extend_from_slice(samples);
let mut triggered = false ;
while self.oww_chunk_buffer.len() >= OWW_MODEL_CHUNK_SIZE {
let chunk: Vec< f32 > = self.oww_chunk_buffer.drain(0 .. OWW_MODEL_CHUNK_SIZE ).collect();
let result = self.oww_model.detection(chunk);
if result.detected {
triggered = true ;
}
}
triggered
}
}
3. 基礎 RMS 靜音檢測 (VAD) (vad.rs)
專案中採用了較簡單的能量門檻法作為 VAD 實作。每 10ms 影格計算 Root Mean Square (RMS) 音訊能量:
$$RMS = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2}$$
若 RMS 持續低於設定門檻(預設 0.003)達到指定影格數(1.5 秒),或總錄音長度超過最大上限(8 秒),則終止錄音。雖然簡單的 RMS 門檻在吵雜環境或說話停頓較長時可能會誤判,但作為教學概念驗證已經足夠直觀:
pub fn process_frame (& mut self, frame: & [f32 ]) -> (bool , f32 ) {
self.recording_samples.extend_from_slice(frame);
self.total_frames_count += 1 ;
let mut sum_squares = 0.0 f32 ;
for & sample in frame {
sum_squares += sample * sample;
}
let rms = (sum_squares / frame.len() as f32 ).sqrt();
if rms < self.vad_threshold {
self.silence_frames_count += 1 ;
} else {
self.silence_frames_count = 0 ;
}
let is_silent = self.silence_frames_count >= self.silence_limit_frames
&& self.total_frames_count >= self.min_recording_frames;
let hit_max = self.total_frames_count >= self.max_recording_frames;
(is_silent || hit_max, rms)
}
4. Gemma-4 多模態音訊直通推論 (engine.rs)
在這個學習實驗中,最有趣的部分是嘗試用 llama-cpp-4 的 MtmdContext(多模態上下文)將音訊檔案 (temp_query.wav) 編碼為 Embeddings(MtmdBitmap),並直接傳給 Gemma-4 模型進行推論:
pub fn run_multimodal (
& mut self,
prompt: & str ,
audio_path: & Path ,
max_tokens: u32 ,
seed: Option< u32 > ,
mut stream_callback: impl FnMut(& str ) + Send,
) -> Result< String> {
let marker = MtmdContext::default_marker();
let full_prompt = format! (" {} {} " , prompt, marker);
// 1. 將音訊轉換為多模態 Bitmap
let bitmap = MtmdBitmap::from_file(& self.mtmd_ctx, audio_path)? ;
// 2. 切分文字 Prompt 與多模態標記
let text = MtmdInputText::new(& full_prompt, true , true );
let bitmaps = [& bitmap];
let mut chunks = MtmdInputChunks::new();
self.mtmd_ctx.tokenize(& text, & bitmaps, & mut chunks)? ;
// 3. 一次性評估音訊 Tokens
let mut lctx = self.session.model.new_context(& self.session.backend, self.loaded_context_params.clone())? ;
let mut n_past = 0 i32 ;
self.mtmd_ctx.eval_chunks(lctx.as_ptr(), & chunks, 0 , 0 , lctx.n_batch() as i32 , true , & mut n_past)? ;
// 4. 自迴歸串流生成回應文字
let mut sampler = LlamaSampler::chain_simple([
LlamaSampler::dist(seed.unwrap_or(42 )),
LlamaSampler::greedy(),
]);
// ...逐 Token 解碼並觸發 stream_callback(&piece)...
}
這個方式省去了整合 STT 模型的步驟,讓我們能直接在 Rust 中實驗多模態模型對語音輸入的回應效果。
5. Windows PowerShell TTS 整合 (speech.rs)
為了保持專案輕量、避免引進額外的 C/C++ 語音合成依賴,語音輸出部分選擇直接透過 std::process::Command 呼叫 Windows 內建的 PowerShell System.Speech 進行朗讀:
pub fn speak (text: & str ) {
if text.trim().is_empty() { return ; }
let script = format! (
"Add-Type -AssemblyName System.Speech; \
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; \
$synth.Speak([Console]::In.ReadToEnd())"
);
let child = std::process::Command::new("powershell" )
.args(["-NoProfile" , "-Command" , & script])
.stdin(std::process::Stdio::piped())
.spawn().ok();
if let Some(mut c) = child {
if let Some(mut stdin) = c.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = c.wait();
}
}
在測試過程中發現,如果 TTS 播放時麥克風仍處於接收狀態,喇叭發出的聲音很容易再次觸發喚醒或錄音邏輯。因此,程式在 Speaking 狀態下會阻塞等待 TTS 結束,並在重新進入 Listening 前呼叫 input_buffer.lock().unwrap().clear() 清空累積的音訊緩衝區。
測試與執行方式
準備好 Gemma-4 GGUF 模型與 Multimodal Projector 檔案後,即可執行此練習專案:
cargo run --release -- --wakeword alexa --threshold 0.5
可用選項參數:
Usage: voice-assistant [OPTIONS]
Options:
-m, --model <MODEL> GGUF 模型路徑
-p, --mmproj <MMPROJ> Multimodal Projector GGUF 路徑
-w, --wakeword <WAKEWORD> 喚醒詞模型 [default: alexa] [alexa, mycroft]
-t, --threshold <THRESHOLD> 喚醒詞信心門檻 (0.0 - 1.0) [default: 0.5]
--no-apm 停用 WebRTC APM 降噪預處理
-v, --vad-threshold <THRESHOLD> VAD 靜音門檻 (RMS) [default: 0.003]
-d, --max-duration <SECONDS> 最長單次錄音秒數 [default: 8.0]
-s, --silence-duration <SECONDS> 靜音結束判定秒數 [default: 1.5]
Rust 52 Projects 學習之旅總結 💡
完成這個專案,也代表著 Rust 52 Projects 個人學習挑戰告一段落。
當初發起這個挑戰,目的只是希望能強迫自己每週透過動手寫一個小專案,從實務中學習 Rust 的不同領域。回看這 52 個練習專案,涵蓋了許多過去不曾涉足的方向:
系統與模擬器學習 :嘗試練習了 6502 CPU 與 NES 主機模擬器的邏輯解構。
圖形與 UI 框架 :接觸了 wgpu (WGSL Shaders)、GPUI 與 Egui 等不同的繪圖與桌面 GUI 框架。
FFI 與電腦視覺 :學習如何在 Rust 中呼叫 OpenCV 5、MediaPipe ONNX 模型與系統級輸入 API。
機器學習與本地 LLM :從解析 GGUF 格式、手寫基礎 Tensor 算子,到調用多模態大模型。
這 52 個專案絕大多數都只是簡單的概念驗證 (PoC) 或學習實驗,距離成熟或生產級的軟體還有很長的距離。但過程中深刻體會到了 Rust 強大的型別安全、零成本抽象以及豐富的社群 Ecosystem。
特別想感嘆與感謝的是,能夠順利堅持並完成這整整 52 個專案,AI 輔助開發 (AI-Assisted Coding) 的進步絕對功不可沒。不論是快速搭建範例原型、除錯 C/C++ FFI 綁定、理解音訊與機器學習演算法,還是探索陌生的套件,AI 都扮演了隨時隨地的 Pair Programming 夥伴,大幅降低了跨領域學習的門檻,讓我也能一步步把這個原本看似遙遠的 52 篇系列挑戰圓滿完成。
專案的原始碼都已整理並開源在 GitHub 上,希望能給同樣在學習 Rust 的朋友提供一些參考與啟發:
👉 p47t/rust-52-projects (voice-assistant)
深入剖析 Jetpack Compose 渲染架構:從 ComposeView 到 RenderNode 的動畫加速
身為 Android 開發者,你可能早已體驗過 Jetpack Compose 帶來的宣告式 UI 開發體驗。特別是使用 Modifier.graphicsLayer 製作位移、旋轉與透明度動畫時,畫面呈現出的 60 / 120 FPS 極致流暢度,常讓人讚嘆不已。
不過,你有沒有在寫程式時突然好奇過:
Compose 作為全新設計的 UI 框架,究竟是如何無縫嵌入傳統 Android View 體系的?
當系統刷新畫面時,Compose 是如何「攔截」系統 Canvas 並畫出自己的元件?
為什麼透過 graphicsLayer 跑動畫時,能夠將主執行緒(Main UI Thread)的負擔降到最低並實現極致順暢的 60 / 120 FPS?
這篇文章將帶大家一起打開 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 :
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
這裡的分工非常明確:
公開 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
}
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 (!is AttachedToWindow) {
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 適配器設計:
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)。
原始碼中主要有兩種圖層實現:
RenderNodeLayer(早期實現) :直接包裝 Android 系統的 RenderNodeApi29 / RenderNodeApi23,內部手動透過 OutlineResolver 來處理裁切與陰影。
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
許多人以為動畫改變 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
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 微秒 內直接修改 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 的革新,在底層渲染架構上更展現了極致的效能考究:
透過 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 等,將在後續文章中陸續為大家拆解!)
在 Rust 中用 OpenCV 5 與 Axum 7 打造跨平台高效能 IP Camera 視訊串流伺服器
架設一台網路監控攝影機(IP Camera)或即時影像串流伺服器,是許多人在學習物聯網(IoT)與電腦視覺時的經典入門專案。但在實務上,最常遇到的技術瓶頸莫過於:硬體影像解碼與電腦視覺(CV)影像處理解析度太高,一不小心就把 Web 伺服器的非同步事件迴圈(Event Loop)給卡死 。
在這次的 Rust 52 Projects 挑戰中,我用 Rust + OpenCV 5 + Axum (v0.7) + Tokio 打造了一個能跨平台運行於 Windows (MSVC) 與 Linux / 樹莓派 4(Raspberry Pi 4 ARM64)的高效能 IP Camera 視訊串流伺服器:ip-camera 。
這篇文章將全面拆解其架構設計,包含硬體擷取與 Web 異步解耦 、Worker 執行緒 JPEG 壓縮 、tokio::sync::watch 廣播通知 、OpenCV 5 圖像處理管線 ,以及無鏡頭環境下的雷達模擬備援機制 !
專案亮點與技術特色
獨立多執行緒擷取迴圈 (spawn_blocking) :硬體影像擷取、CV 圖形繪製與 JPEG 壓縮隔離在獨立 Worker 執行緒,保持 Axum/Tokio 非同步 Web 事件迴圈極速回應。
OpenCV 5 即時視覺管線 :
EMA 平滑即時 FPS 計數器。
高精度微秒級時間戳記 Overlay。
互動式 HUD 掃描框:含四角綠色標記、動態雷射掃描紅線與特定 ROI 區域動態高斯模糊(Gaussian Blur)。
Worker 執行緒內建 JPEG 壓縮 :在背景 Worker 執行緒直接完成 cv::Mat 到 JPEG 的 imencode 壓縮,Web 串流任務只需廣播 Raw Bytes,大幅降低多用戶同時連線時的 CPU 開銷。
tokio::sync::watch 異步喚醒機制 :Web 串流任務在沒有新影格時完全處於休眠狀態(Zero Overhead),唯有 Worker 產出新影格時才被非同步喚醒發送,防止 Busy Polling。
韌性離線備援 (Mock Radar Scope) :當無實體 Camera 連線時(如 Headless VM、CI 環境),自動切換至動態旋轉雷達掃描儀模擬視訊源。
現代 Glassmorphism Web 儀表板 :內建暗黑玻璃擬物化 Web 介面,支援全螢幕、快照截圖與播放/暫停控制。
系統串流架構:視訊處理與 Web 伺服器解耦
為避免耗時的 OpenCV 矩陣運算與 JPEG 壓縮阻塞 Axum 處理 HTTP 請求的 Tokio Worker Threads,整個系統在架構上進行了徹底的分工解耦:
%%{init: { 'themeVariables': { 'fontSize': '18px', 'subGraphTitleFontSize': '20px' } }}%%
flowchart TD
subgraph CaptureWorker ["1. Capture & CV Worker Thread (spawn_blocking)"]
direction TD
A["VideoCapture / Mock Radar Source"] --> B["CV imgproc Pipeline (FPS, Timestamp, Scan Laser)"]
B --> C["Worker JPEG imencode (85% Quality)"]
C --> D["Update Shared Memory & Trigger Watch Signal"]
end
subgraph StateBridge ["2. Thread-Safe State & Notification Bridge"]
direction TD
E["Shared Frame State: Arc(Mutex(Vec(u8)))"]
F["Notification Channel: tokio::sync::watch"]
end
subgraph AxumServer ["3. Axum 0.7 Async Web Server (Tokio Event Loop)"]
direction TD
G["Client GET /stream Request"] --> H["watch_rx.changed().await (Async Sleep / Wake)"]
H --> I["Read JPEG Bytes from Shared Mutex"]
I --> J["futures_util stream::unfold"]
J --> K["Yield multipart/x-mixed-replace MJPEG Chunk"]
end
D -->|"1. Broadcast Frame Update"| StateBridge
StateBridge -->|"2. Wake Client Stream Task"| H
核心技術一:非同步解耦與 tokio::task::spawn_blocking
在 src/main.rs 中,我們建立了一個共享的線程安全記憶體空間 Arc<Mutex<Vec<u8>>> 保存最新一幀的 JPEG 壓縮 Byte Array,以及一個 watch::channel 負責通知廣播。
接著,透過 tokio::task::spawn_blocking 將 OpenCV 的硬體讀取與圖像處理解耦出去:
// Shared thread-safe storage for the latest encoded frame.
let frame_state = Arc::new(Mutex::new(initial_jpeg.to_vec()));
let frame_state_clone = Arc::clone(& frame_state);
// Watch channel to notify web clients of new frames.
let (watch_tx, watch_rx) = watch::channel(0 u64 );
// 將硬體 VideoCapture 迴圈隔離在獨立的 blocking worker 執行緒
let camera_index = args.camera_index;
let filters = args.filter.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = camera::run_capture_loop(camera_index, filters, frame_state_clone, watch_tx) {
error! ("Capture loop exited with error: {:?}" , e);
}
});
💡 架構深挖:為什麼不直接用 Channel 傳送 JPG 影像?
許多人在設計視訊串流時,第一直覺是建立一個 mpsc::channel 或 broadcast::channel,將壓縮好的 Vec<u8> JPG 影像直接扔進 Channel 送給 Web 伺服器。但在高效能 IP Camera 的情境下,我們選擇了 Arc<Mutex<Vec<u8>>> (共享記憶體) + tokio::sync::watch (訊號廣播) 的組合,主要考量如下:
保證「永遠只看最新影格 (Latest-Frame-Only)」的即時性 :
IP 監控攝影機的核心要求是極低延遲與即時性 。使用者想看的是「此刻發生了什麼」,而不是 3 秒前積壓在佇列裡的歷史影格。
若使用傳統管道(Channel Queue),當某些 Web 客戶端網路較慢(例如用手機 3G 觀看)時,Channel 內部會積壓大量未消費的 JPG 影格,導致畫面延遲越來越大(Lag)且記憶體不斷暴增。
而 watch 頻道搭配共享記憶體具有天然的 最新狀態覆寫 (Single-Slot Overwrite) 語意:慢速客戶端被喚醒時,永遠只會讀取到當前最新的那張 JPG 影格,自動跳過中間沒來得及發送的影格,實現 零積壓、零延遲 !
避免多連線時的記憶體重複複製 (Zero Fan-Out Memory Inflation) :
若透過 broadcast channel 發送 Vec<u8> 影像,每當有 $N$ 個客戶端連線時,系統就必須將圖像 Payload 複製 $N$ 份或處理複雜的共享記憶體佇列。
採用 Arc<Mutex<Vec<u8>>> 後,無論是 0 個還是 100 個客戶端連線,背景 Worker 執行緒永遠只執行一次 JPEG 壓縮與單一記憶體覆寫 ,將記憶體開銷牢牢鎖定在最小的單影格大小。
無人觀看時的極致零開銷 (Zero-Cost Idle) :
當無人連線觀看視訊時,Worker 執行緒更新 Arc<Mutex> 並呼叫 watch_tx.send(frame_id) 僅會更新一個 $u64$ 的 Frame Counter 號碼牌,完全不會產生任何排隊訊息開銷或記憶體洩漏風險。
核心技術二:OpenCV 5 圖像處理管線與 EMA FPS 計算
在 src/camera.rs 的擷取迴圈中,我們不僅處理影像,還利用 Exponential Moving Average (EMA) 演算法計算平滑的即時 FPS,避免數據劇烈跳動:
$$\text{FPS}_{\text{smoothed}} = 0.95 \cdot \text{FPS}_{\text{smoothed}} + 0.05 \cdot \text{FPS}_{\text{instant}}$$
// 計算即時動態 FPS
frame_count += 1 ;
let now = Instant::now();
let delta = now.duration_since(last_fps_time).as_secs_f64();
last_fps_time = now;
if delta > 0.0 {
let instant_fps = 1.0 / delta;
// 使用 EMA 演算法平滑化 FPS 數值
fps = fps * 0.95 + instant_fps * 0.05 ;
}
HUD 掃描框與動態雷射光束
在 CV 濾波器管線中,我們能隨意疊加高斯模糊 (Blur)、Canny 邊緣檢測 (Canny)、灰階 (Grayscale)、色彩反轉 (Invert),或者啟動一個帶有紅光雷射掃描與綠色 HUD 角落標記的 Scanner 模式:
FilterConfig::Scanner { margin } => {
if let Ok(size) = current_frame.size() {
let m = * margin;
let box_w = std::cmp::max(1 , size.width - m * 2 );
let box_h = std::cmp::max(1 , size.height - m * 2 );
let roi_rect = Rect::new(m, m, box_w, box_h);
// 畫出 HUD 外框與綠色四角標記
let _ = imgproc::rectangle(& mut current_frame, roi_rect, Scalar::new(99.0 , 102.0 , 241.0 , 0.0 ), 2 , imgproc::LINE_AA , 0 );
// 計算雷射紅線上下掃描的位置
let scan_period = 100 ;
let scan_pos = (frame_count % scan_period) as i32 ;
let scan_y = m + (scan_pos * box_h / scan_period as i32 );
let _ = imgproc::line(
& mut current_frame,
Point::new(m + 2 , scan_y),
Point::new(m + box_w - 2 , scan_y),
Scalar::new(0.0 , 0.0 , 255.0 , 0.0 ), // 雷射紅光
2 ,
imgproc::LINE_AA ,
0 ,
);
}
}
在 Worker 執行緒預先完成 JPEG 壓縮
為了避免 10 個 Web 客戶端連線時,伺服器必須執行 10 次重複的 JPEG 壓縮運算,我們直接在 Capture 迴圈最後將 Mat 壓縮成 85% 品質的 JPEG Byte 陣列:
let mut jpeg_buf = Vector::< u8 > ::new();
let mut encode_params = Vector::< i32 > ::new();
encode_params.push(imgcodecs::IMWRITE_JPEG_QUALITY );
encode_params.push(85 ); // 85% 品質:兼顧畫質與網路頻寬
imgcodecs::imencode(".jpg" , & frame, & mut jpeg_buf, & encode_params)? ;
// 將 compressed bytes 寫入共享記憶體,並發送 watch 廣播
{
let mut lock = frame_state.lock().unwrap();
* lock = jpeg_buf.to_vec();
}
let _ = watch_tx.send(watch_tx.borrow().wrapping_add(1 ));
核心技術三:Axum 0.7 異步 MJPEG 串流與 stream::unfold
多媒體 MJPEG (Motion JPEG) 串流採用 HTTP 標準的 multipart/x-mixed-replace; boundary=frame 標頭。每個 Chunk 由 --frame 分界,隨後接上 Content-Type: image/jpeg 與 raw bytes。
在 src/handlers.rs 中,我們透過 futures_util::stream::unfold 搭配 watch_rx.changed().await 構造出極致高效的非同步串流 Response:
pub async fn stream_handler (State(state): State < AppState> ) -> impl IntoResponse {
let rx = state.watch_rx.clone();
let frame_state = state.frame_state.clone();
// 利用 stream::unfold 打造非同步影格串流
let stream = stream::unfold((true , rx, frame_state), move | (is_first, mut rx, frame_state)| async move {
if ! is_first {
// 沒有新影格時,Task 在此處非同步休眠,不消耗 CPU!
if rx.changed().await .is_err() {
return None; // Sender dropped
}
}
// 從共享記憶體讀取最新的 JPEG Bytes
let jpeg_bytes = {
let lock = frame_state.lock().unwrap();
lock.clone()
};
// 封裝 multipart/x-mixed-replace boundary 封包
let header = format! (
"--frame \r\n Content-Type: image/jpeg \r\n Content-Length: {} \r\n\r\n " ,
jpeg_bytes.len()
);
let mut body_bytes = Vec::new();
body_bytes.extend_from_slice(header.as_bytes());
body_bytes.extend_from_slice(& jpeg_bytes);
body_bytes.extend_from_slice(b " \r\n " );
Some((
Ok::< Bytes, std::io::Error> (Bytes::from(body_bytes)),
(false , rx, frame_state),
))
});
let body = Body::from_stream(stream);
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE ,
HeaderValue::from_static("multipart/x-mixed-replace; boundary=frame" ),
);
(headers, body)
}
💡 深入剖析 stream::unfold:如何用數行程式碼生成無限非同步串流?
在函數式程式設計(Functional Programming)與 Rust 非同步生態集中,unfold 是 fold 的對偶(Dual)操作:fold 是將一個集合「坍縮/歸約」成單一數值,而 unfold 則是從一個初始狀態種子(Initial State Seed)開始,依序「展開」產生一個無限或有限的非同步 Stream。
stream::unfold 的運算模型如下:
$$\text{unfold}\Big(S_0, f: S_k \to \text{Future}\langle\text{Option}(Item, S_{k+1})\rangle\Big) \implies \text{Stream}\langle Item\rangle$$
在 stream_handler 中,我們傳入初始狀態 Tuple (is_first: true, rx, frame_state):
第 1 次迭代 (is_first = true) :
跳過 rx.changed().await 等待,直接從共享記憶體讀取當前最新的 JPEG Byte Array,組裝出首張 multipart 封包。回傳 Some((Ok(Bytes), (false, rx, frame_state)))。這使得瀏覽器一開啟網頁就能瞬間載入第一張畫面(Zero Delay First Frame) !
後續迭代 (is_first = false) :
執行 rx.changed().await。此時 Tokio Task 會進入完全休眠狀態(0 CPU 消耗) 。當背景 Capture Worker 發送新影格訊號時,Task 被喚醒,讀取最新 JPG bytes,並再次產出 Some((Ok(Bytes), (false, rx, frame_state)))。
串流終止 (Sender dropped) :
若背景擷取執行緒關閉,rx.changed().await 回傳 Err(_),閉包回傳 None,stream::unfold 便會優雅地宣告 Stream 結束,通知 Axum 與 TCP Socket 關閉連線。
為什麼選擇 stream::unfold 而非手動實作 Stream Trait?
若要手動為自訂 Struct 實作 futures_util::Stream,我們必須編寫 poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Item>>,手動處理 Pin 指標、 unsafe 轉換、狀態機以及 Waker 的註冊與喚醒機制。
stream::unfold 將這一切繁瑣的底層 Async State Machine 封裝起來,讓我們能直接在 async move 區塊中使用標準的 .await 語法,極致優雅地將異步頻道通知轉譯為 Axum 相容的 axum::body::Body::from_stream!
當網頁瀏覽器打開 http://localhost:8080/stream 時,無需任何額外的 JavaScript 播放器,<img> 標籤就能原生、滑順地播放即時視訊串流!
韌性離線備援:模擬雷達儀 (Mock Radar Scope)
在 Headless VM 或 CI/CD 環境下測試時,往往沒有實體 Webcam。專案設計了自動備援機制:當 videoio::VideoCapture 打開失敗時,自動進入 is_simulated = true 模式,繪製動態雷達掃描儀:
// 繪製動態旋轉雷達綠光束與脈衝目標
let center = Point::new(size.width / 2 , size.height / 2 );
let angle = (frame_count as f64 * 0.05 ) % (2.0 * std::f64 ::consts::PI );
let radius = 120.0 ;
let radar_end = Point::new(
(center.x as f64 + radius * angle.cos()) as i32 ,
(center.y as f64 + radius * angle.sin()) as i32 ,
);
imgproc::line(& mut frame, center, radar_end, Scalar::new(0.0 , 255.0 , 0.0 , 0.0 ), 1 , imgproc::LINE_AA , 0 )? ;
imgproc::put_text(& mut frame, "⚠️ CAMERA OFFLINE - RUNNING IN SIMULATED MODE" , Point::new(20 , size.height - 20 ), .. .)? ;
這保證了伺服器在任何環境下都能正常啟動並提供穩定的串流服務。
跨平台編譯與運行指南
1. Windows (MSVC) 環境
專案內附 build.bat 腳本,自動設定 OPENCV_LINK_PATHS、OPENCV_INCLUDE_PATHS 與 runtime DLL PATH:
# 編譯並運行 Release 版本
.\build.bat run --release
2. Linux & 樹莓派 4 (Raspberry Pi 4 ARM64)
在 Debian / Raspberry Pi OS 上,只要安裝 clang 與 pkg-config 即可輕鬆編譯:
sudo apt update && sudo apt install -y build-essential clang libclang-dev pkg-config
cargo run --release
實務討論與技術體會 (Technical Trade-offs)
作為一個教學與實驗導向的專案,我們也必須坦實討論 MJPEG 串流的技術權衡:
MJPEG vs RTSP/H.264/H.265 :
優點 :MJPEG 不需要複雜的跨平台 H.264 硬體編碼器(如 NVENC 或 QuickSync),相容性極佳,所有瀏覽器用普通 <img> 標籤就能播放。
缺點 :因為每一影格都是完整的 JPEG 圖片,缺乏 Frame 之間的 Intra-frame 壓縮,頻寬佔用較大(640x360@30fps 約需 2~4 Mbps)。
記憶體拷貝優化空間 :
目前 shared state 採用 Mutex<Vec<u8>> 搭配 lock().clone(),在極高並發(如數百個客戶端)時可進一步改用 bytes::Bytes 實現零拷貝(Zero-Copy)廣播。
但作為 Rust 52 Projects 挑戰,這個專案完美展示了如何優雅地組合 Tokio 非同步生態系 、Axum 7 路由 與 OpenCV C++ 綁定 ,打造出兼具效能與韌性的視訊服務!
學到的 Rust 關鍵技術
技術主題
應用與實現
tokio::task::spawn_blocking
將同步硬體 I/O 與 OpenCV 密集計算隔離出 Tokio Event Loop
tokio::sync::watch
實現單一生產者、多消費者的無鎖影格更新通知廣播
futures_util::stream::unfold
將異步頻道通知轉譯為符合 HTTP Standard 的 Response Stream
Axum v0.7 Router
處理狀態注入 (AppState) 與 multipart/x-mixed-replace 標頭
OpenCV 5 imgproc & imgcodecs
繪製 HUD 雷射框、計算 EMA FPS 並在 Worker 執行緒完成 JPEG 壓縮
結語
ip-camera 專案展示了 Rust 在高效能視訊串流與電腦視覺領域的強大能力。從非同步解耦到跨平台(Windows / 樹莓派)支援,整個設計簡潔而堅固。
歡迎前往專案 Repo 查看程式碼並親自試跑!
參考資源
在 Rust 中用 OpenCV 5 DNN 與 MediaPipe ONNX 打造零延遲 AI 手勢作業系統控制器
想像一下:完全不需要摸滑鼠與鍵盤,只要在 Webcam 前伸出食指就能滑順地移動游標、握緊拳頭就能點擊與拖曳視窗、伸出兩根手指就能滾動網頁,甚至快速向左或向右揮手就能瞬間切換 Windows 虛擬桌面!
這就是 gesture-control 專案的起點。身為 Rust 52 Projects 挑戰的一部分,我希望嘗試用 Rust + OpenCV 5 DNN + MediaPipe ONNX 打造一個無鎖、低延遲且完全執行於背景的原生手勢作業系統控制器。
坦白說,這套系統絕對離商業級的硬體產品(例如 Vision Pro 或立體深度感測器)還有一段距離,單眼 Webcam 亦有其物理上的局限性;但作為一個學習 Rust 跨語言 FFI、AI 視覺推論與時序演算法的實驗專案,它的表現已經足夠令人驚喜且樂趣十足 。
這篇文章將全面介紹 gesture-control 的系統架構與實現細節,包含兩階段 AI 視覺推論管線 、自動左手翻轉推論 (Left-Hand Fallback) 、時序追蹤與遲滯投票機制 (Hysteresis Voting) 、360 度旋轉無關幾何算子 ,以及 Enigo 原生 OS 輸入模擬 !
專案核心特色
高精度 AI 3D 骨骼追蹤 :基於 MediaPipe Palm Detection (SSD) 與 21-Landmark 3D Hand Pose Estimator ONNX 模型。
自動左手翻轉推論 (Auto Left-Hand Fallback) :解析模型的 3 層輸出張量(含 Layer 2 右手性分數),利用雙向推論解決深度學習模型常見的「右手偏見(Right-hand Bias)」。
時序追蹤與遲滯投票 (Temporal Tracking & Hysteresis Voting) :採用貪婪歐式距離配對與 $[-30, +30]$ 票數滑動視窗,消除左右手標籤閃爍與同手重複框(Ghost Hands)。
360 度旋轉無關幾何算子 :基於手腕相對歐式距離比較,不受手掌旋轉角度影響。
眾數去顫濾波 (Mode-Based Debouncing) :統計歷史影格眾數,過濾過渡影格的誤觸。
無頭背景模式 (Headless Mode) :校正完畢後可關閉所有 GUI 視窗,達成零 GUI 開銷背景執行。
系統整體架構:雙執行緒與管線解耦
為了保證游標移動的絕對順暢,專案採用多執行緒解耦架構:視訊解碼與 AI 推論 運作於獨立的 Vision 執行緒,而 OS 輸入模擬 則運作於主執行緒,兩者透過 Rust 標準庫的無鎖 Channel (std::sync::mpsc) 通訊。
graph TD
A[Webcam 視訊擷取 640x480] -->|BGR 影格| B[OpenCV 5 DNN 視覺執行緒]
B -->|Palm Detection ONNX| C[192x192 掌心候選框 + 距離中心 NMS]
C -->|Hand Crop + WarpAffine 旋轉對齊| D[Handpose Estimation ONNX]
D -->|Layer 2 右手性雙向推論| E[Sub-millisecond 翻轉推論與反向映射]
E -->|回傳 21 個 3D 骨骼點| F[360 度旋轉幾何算子]
F -->|貪婪距離配對 + 遲滯投票| G[TrackedHand 時序追蹤器]
G -->|眾數滑動視窗 N=10| H[Mode Debouncing 低通濾波器]
H -->|GestureState Enum| I(std::sync::mpsc Channel)
I -->|GestureState| J[主執行緒 OS Controller Loop]
J -->|EMA 游標平滑化 + Win32 FFI| K[Native OS 滑鼠/鍵盤/虛擬桌面切換]
在入口點 src/main.rs 中,執行緒的分工與生命週期管理簡潔明瞭:
fn main () -> Result< ()> {
let cli = Cli::parse();
let show_preview = ! cli.no_preview;
// 1. 建立跨執行緒 Channel
let (tx, rx) = std::sync::mpsc::channel();
// 2. 啟動視覺處理執行緒 (Vision Loop Thread)
let vision_handle = thread::spawn(move || {
if let Err(e) = vision::run_vision_loop(cli.camera, tx, show_preview) {
eprintln! ("Error in vision thread: {:?} " , e);
}
});
// 3. 在主執行緒運行 OS 輸入控制迴圈 (Controller Loop)
controller::run_controller_loop(rx)? ;
// 4. 等待視覺執行緒安全結束
if let Err(e) = vision_handle.join() {
eprintln! ("Failed to join vision thread: {:?} " , e);
}
Ok(())
}
1. 掌心偵測與距離中心 NMS 抑制
手部追蹤的第一步是從 640×480 的視訊影格中定位手掌。MediaPipe 的 palm_detection_mediapipe_2023feb.onnx 模型接收 $192 \times 192$ 的輸入,在 2016 個預設 Anchor 上預測邊界框與 7 個手掌特徵點。
為了防止傳統 IoU NMS 在同隻手掌上殘留「一大一小」的重複候選框,我們在 src/palm.rs 中加入了一層基於歐式距離的掌心中心抑制 :
// 若兩掌心中心距離小於現有框尺寸的 75%,判定為同一隻手並進行抑制
let mut detections: Vec< PalmDetection> = Vec::new();
for det in raw_detections {
let mut duplicate = false ;
for existing in & detections {
let dx = existing.center.x - det.center.x;
let dy = existing.center.y - det.center.y;
let dist = (dx * dx + dy * dy).sqrt();
let min_size = existing.bbox.width.min(existing.bbox.height) as f32 ;
if dist < min_size * 0.75 {
duplicate = true ; // 抑制重複框
break ;
}
}
if ! duplicate {
detections.push(det);
}
}
2. 手掌旋轉對齊與 Crop 仿射變換 (warp_affine)
在將手部 Crop 丟入 handpose_estimation_mediapipe_2023feb.onnx (224×224) 之前,模型要求手掌必須「旋轉朝上」。我們根據掌心角度建立 2×3 仿射旋轉矩陣,進行平移與旋轉對齊:
// 手掌旋轉對齊:建立 2x3 Affine Rotation Matrix
let angle_rad = (palm.angle as f64 ) * std::f64 ::consts::PI / 180.0 ;
let alpha = angle_rad.cos();
let beta = angle_rad.sin();
let mut rotation_matrix = Mat::new_rows_cols_with_default(2 , 3 , CV_64F , Scalar::all(0.0 ))? ;
* rotation_matrix.at_2d_mut::< f64 > (0 , 0 )? = alpha;
* rotation_matrix.at_2d_mut::< f64 > (0 , 1 )? = beta;
* rotation_matrix.at_2d_mut::< f64 > (0 , 2 )? = (1.0 - alpha) * palm_center.x as f64 - beta * palm_center.y as f64 ;
* rotation_matrix.at_2d_mut::< f64 > (1 , 0 )? = - beta;
* rotation_matrix.at_2d_mut::< f64 > (1 , 1 )? = alpha;
* rotation_matrix.at_2d_mut::< f64 > (1 , 2 )? = beta * palm_center.x as f64 + (1.0 - alpha) * palm_center.y as f64 ;
// 對整張影像進行仿射旋轉與 Crop 截取
let mut rotated_image = Mat::default();
imgproc::warp_affine(& frame, & mut rotated_image, & rotation_matrix, Size::new(frame.cols(), frame.rows()), .. .)? ;
3. 手法突破:解決「右模型偏見」的 Left-Hand Auto-Fallback
MediaPipe 的 Handpose 模型會輸出 3 個層級(Output Layers):
Layer 0 :21 個 3D 關節點座標 $[1, 63]$
Layer 1 :手部整體置信度分數 (Confidence Score)
Layer 2 :右手性置信度分數 (Right-Handedness Score)
由於訓練資料集的偏差,模型對右手的辨識率極高,但對左手容易產生低分或扭曲。為了改善這個問題,我們實作了雙向推論與 Layer 2 右手性評估 :
let mut evaluate = | img: & Mat | -> Result< (f32 , f32 , Vector< Mat> )> {
let blob = float_mat.reshape_nd(1 , & [1 , 224 , 224 , 3 ])? ;
net.set_input(& blob, "" , 1.0 , Scalar::default())? ;
let mut out_layers = Vector::< Mat> ::new();
net.forward(& mut out_layers, & net.get_unconnected_out_layers_names()? )? ;
let score = * out_layers.get(1 )? .at_2d::< f32 > (0 , 0 )? ; // Layer 1: 置信度
let handedness = * out_layers.get(2 )? .at_2d::< f32 > (0 , 0 )? ; // Layer 2: 右手性得分
Ok((score, handedness, out_layers))
};
// 同時對原始 Crop 與水平翻轉 Crop (rgb_flipped) 進行推論
let (score_unflipped, handedness_unflipped, out_unflipped) = evaluate(& rgb)? ;
let (score_flipped, handedness_flipped, out_flipped) = evaluate(& rgb_flipped)? ;
// 模型永遠會給「看起來像右手」的影像更高的 Layer 2 分數
// 若翻轉後的影像分數更高,代表原始影像必定是「左手」!
let is_left_hand = handedness_flipped > handedness_unflipped;
// 若為左手,採用翻轉影像的推論結果,並將 X 座標鏡射還原 (224.0 - X)
透過這種雙向推論機制,系統對左手與右手的識別穩定度有了顯著提升!
核心技術二:時序追蹤與遲滯投票機制 (Hysteresis Voting)
單影格推論容易受到極端角度或運動模糊干擾,造成左右手標籤在相鄰影格間跳動。為此,我們設計了 TrackedHand 時序追蹤結構 :
#[derive(Clone)]
struct TrackedHand {
keypoints: Vec< Point> ,
score: f32 ,
center: Point ,
left_votes: i32 , // 遲滯投票計數器 [-30..+30]
is_left_hand: bool , // 最終鎖定的左右手標籤
missed_frames: usize , // 遮擋遺失影格補償計數
}
1. 貪婪歐式距離配對 (Greedy Assignment)
每一影格收到新的 raw 偵測結果時,計算所有歷史追蹤器與新偵測點的掌心距離,按距離從小到大(門檻 $<400\text{px}$)進行貪婪配對,更新中心座標與關節點。
2. 遲滯投票機制 (Hysteresis Voting)
新的偵測若判定為左手,left_votes 加 1;若為右手則減 1(限制在 $[-30, +30]$ 範圍內)。只有當票數累積達到 $+20$ 或 $-20$ 的絕對壓倒性門檻時,才允許切換 is_left_hand 狀態 :
if * is_left {
tracker.left_votes = (tracker.left_votes + 1 ).min(30 );
} else {
tracker.left_votes = (tracker.left_votes - 1 ).max(- 30 );
}
// 遲滯門檻:防止單影格誤判造成的標籤閃爍
if tracker.left_votes >= 20 {
tracker.is_left_hand = true ;
} else if tracker.left_votes <= - 20 {
tracker.is_left_hand = false ;
}
3. 遮擋遺失補償 (Missed Frame Interpolation)
當手指快速揮過或被短暫遮擋時,未配對到的追蹤器會保留最多 5 個影格(約 80ms),避免手勢在中途斷開:
for (t_idx, tracker) in hand_trackers.iter_mut().enumerate() {
if ! matched_tracker[t_idx] {
tracker.missed_frames += 1 ;
if tracker.missed_frames < 5 {
new_trackers.push(tracker.clone()); // 保持追蹤
}
}
}
核心技術三:360 度旋轉無關幾何算子與眾數去顫
1. 旋轉無關的手指伸展判定
傳統演算法常假設手指朝上(檢查 $Y_{\text{tip}} < Y_{\text{pip}}$),手掌橫放即失靈。我們改用基於手腕(Wrist, Landmark 0)的相對歐氏距離比較 :
$$\text{Extended} \iff \text{Dist}(\text{Wrist}, \text{Tip}) > \text{Dist}(\text{Wrist}, \text{PIP})$$
let wrist = kps[0 ];
let index_ext = dist(wrist, kps[8 ]) > dist(wrist, kps[6 ]); // 8: 食指尖, 6: 食指 PIP 關節
let middle_ext = dist(wrist, kps[12 ]) > dist(wrist, kps[10 ]); // 12: 中指尖, 10: 中指 PIP 關節
let ring_ext = dist(wrist, kps[16 ]) > dist(wrist, kps[14 ]); // 16: 無名指尖, 14: 無名指 PIP 關節
let pinky_ext = dist(wrist, kps[20 ]) > dist(wrist, kps[18 ]); // 20: 小指尖, 18: 小指 PIP 關節
let extended_count = (index_ext as usize ) + (middle_ext as usize ) + (ring_ext as usize ) + (pinky_ext as usize );
由於歐氏距離具有旋轉不變性,無論手掌如何旋轉,伸展判斷皆相當穩定。
2. 眾數滑動視窗去顫 (Mode-Based Debouncing)
利用 VecDeque<usize> 維持最近 $N$ 影格(預設 $N=10$)的手指數量歷史,計算統計眾數(Mode)作為最終結果,有效過濾姿態切換瞬間的脈衝雜訊。
手勢狀態機與 OS 輸入模擬
在 src/controller.rs 中,控制執行緒接收到 GestureState 後,利用 enigo 與 Win32 API 執行系統操作:
手勢型態
手指伸展數 / 特徵條件
動作說明
OS 模擬行為 (Enigo / Win32)
Hover
1 根手指(食指)
游標平滑移動
絕對座標定位至食指尖 ($X_{8}, Y_{8}$)
Click / Drag
0 根手指(握拳 / Pinch)
滑鼠點擊與拖曳
發送 Button::Left Press + 移動
Scroll
2 根手指(食指+中指)
網頁高解析度滾動
計算兩指中心垂直位移發送 scroll
Wave Left
4+ 根手指 + 快速向左揮
虛擬桌面左切
發送 Win + Ctrl + LeftArrow
Wave Right
4+ 根手指 + 快速向右揮
虛擬桌面右切
發送 Win + Ctrl + RightArrow
Idle
手部離開畫面
釋放按鍵防卡死
發送 Button::Left Release
看這段在 src/controller.rs 裡的核心事件迴圈:
pub fn run_controller_loop (rx: Receiver < GestureState> ) -> Result< ()> {
let mut enigo = Enigo::new(& Settings::default())? ;
let (screen_w, screen_h) = get_screen_size(); // Win32 GetSystemMetrics
let alpha = 0.20 f32 ; // EMA 平滑係數
while let Ok(state) = rx.recv() {
match state {
GestureState::Hover { x, y } => {
if is_pressed {
enigo.button(Button::Left, Direction::Release)? ;
is_pressed = false ;
}
// EMA 平滑化算式
let target_x = alpha * x + (1.0 - alpha) * last_x;
let target_y = alpha * y + (1.0 - alpha) * last_y;
let px = (target_x * (screen_w as f32 )) as i32 ;
let py = (target_y * (screen_h as f32 )) as i32 ;
enigo.move_mouse(px, py, Coordinate::Abs)? ;
last_x = target_x;
last_y = target_y;
}
GestureState::Click { x, y } => {
if ! is_pressed {
enigo.button(Button::Left, Direction::Press)? ;
is_pressed = true ;
}
// 拖曳游標平滑移動...
}
GestureState::Scroll { dy } => {
scroll_accumulator += dy;
let scroll_clicks = scroll_accumulator.trunc() as i32 ;
if scroll_clicks != 0 {
enigo.scroll(- scroll_clicks, Axis::Vertical)? ;
scroll_accumulator -= scroll_clicks as f32 ;
}
}
GestureState::WaveLeft => {
// 觸發 Windows 切換虛擬桌面快捷鍵
enigo.key(Key::Control, Direction::Press)? ;
enigo.key(Key::Meta, Direction::Press)? ;
enigo.key(Key::LeftArrow, Direction::Click)? ;
enigo.key(Key::Meta, Direction::Release)? ;
enigo.key(Key::Control, Direction::Release)? ;
}
// ...
}
}
Ok(())
}
而在 src/vision.rs 中,極速揮手(Wave Left / Right)則是透過時間差 $dt$ 計算平滑動態速度 $v_x$:
let dx = (palm_pt.x as f32 ) - last_palm_x;
last_palm_x = palm_pt.x as f32 ;
let velocity = dx / dt;
let alpha_vel = (dt * 10.0 ).clamp(0.0 , 1.0 );
velocity_x_smoothed = alpha_vel * velocity + (1.0 - alpha_vel) * velocity_x_smoothed;
if debounced_count >= 4 && elapsed_wave > 1.2 {
if velocity_x_smoothed < - 450.0 {
current_state = GestureState::WaveLeft;
} else if velocity_x_smoothed > 450.0 {
current_state = GestureState::WaveRight;
}
}
安裝、校正與 Headless 背景模式
專案提供 PowerShell 腳本自動下載 OpenCV Zoo 模型:
這會將 palm_detection_mediapipe_2023feb.onnx 與 handpose_estimation_mediapipe_2023feb.onnx 下載至 models/ 目錄。
2. 互動校正
執行 cargo run 可開啟 GUI 視窗,即時調整 Trackbar 參數:
Palm Conf % :掌心偵測門檻(預設 65%)。
Pose Conf % :3D 骨骼點置信度門檻(預設 75%)。
Debounce Frames :去顫視窗影格數(預設 10 幀)。
3. Headless 背景運行
參數校正完成後,加上 --no-preview 即可進行零 GUI 開銷的背景執行:
cargo run --release -- --no-preview
品質保證:自動化測試與 FAR 驗證
專案包含基本的 #[cfg(test)] 自動化測試套件,輔助驗證功能:
FAR (False Acceptance Rate) 假陽性測試 :
測試純黑影像 (test_far_blank_image)、靜態彩繪雜訊 (test_far_noise_image) 與幾何圖像 (test_far_geometric_shapes),確保系統在無手狀態下不致誤觸。
#[test]
fn test_far_blank_image () {
let mut net = dnn::read_net_def("models/palm_detection_mediapipe_2023feb.onnx" ).unwrap();
let frame = Mat::new_rows_cols_with_default(480 , 640 , CV_8UC3 , Scalar::all(0.0 )).unwrap();
let result = crate ::palm::detect_mediapipe_palm(& mut net, & frame, 0.65 ).unwrap();
assert! (result.is_empty(), "FAR Failure: Palm detected in solid black image!" );
}
實體影片時序斷言測試 :
讀取測試影片 test_assets/one-hand-flipping.mp4,驗證單手翻轉時系統不會重複生成 Ghost 追蹤器(assert!(max_trackers <= 1))。
執行測試命令:
cargo test -- --nocapture
running 7 tests
test vision::tests::test_load_mediapipe_onnx_models ... ok
test vision::tests::test_far_blank_image ... ok
test vision::tests::test_far_noise_image ... ok
test vision::tests::test_far_geometric_shapes ... ok
test vision::tests::test_video_one_hand_flipping ... ok
test result: ok. 7 passed; 0 failed
實務限制與學習體會 (Limitations & Learning Takeaways)
作為一個個人實驗與學習專案,客觀來說它仍有一些實務上的限制:
單眼 RGB Webcam 的物理極限 :缺乏紅外線與 3D 深度感測器,在極暗光線、強烈背光或手掌大幅度遮擋時,追蹤穩定度仍無法與 Apple Vision Pro 或 Leap Motion 等專用硬體相提攜。
手臂肌肉疲勞 (Gorilla Arm Effect) :長時間懸空懸空手勢操控會導致手臂疲勞,在日常辦公或精細的像素級點擊時,實體滑鼠依然是不可替代的生產力工具。
全 CPU 推論開銷 :雖然在現代電腦上能順暢執行,但若能進一步串接 TensorRT 或 DirectML 硬體加速,電力與 CPU 佔用表現會更好。
但作為 Rust 52 Projects 挑戰的一部分,這個專案已經達成了很好的學習與驗證目的 !它讓我完整體驗了如何在 Rust 中整合 OpenCV 5 FFI、處理複雜的神經網路張量輸出、用純 Rust 設計時序平滑演算法,以及透過 Enigo 進行系統級輸入注入。
學到的 Rust 關鍵技術
技術主題
應用與實現
std::sync::mpsc Channel
跨執行緒無鎖傳輸輕量 GestureState enum,確保 60 FPS 解耦
OpenCV 5 DNN 模組
使用 dnn::read_net_def 與 reshape_nd 進行 ONNX 權重解析與張量前處理
Win32 FFI
宣告原生 GetSystemMetrics 動態取得多螢幕解析度
時序遲滯狀態機
透過貪婪歐式配對與 $[-30, +30]$ 遲滯投票鎖定左右手狀態
旋轉無關幾何算子
採用 3D 關節點相對於手腕的歐氏距離比值進行 360 度伸展判斷
結語
gesture-control 探索了用 Rust 打造即時電腦視覺工具的可能性。儘管它不完美,但作為一個探索型 side project,它好玩、有挑戰性,且充滿了技術趣味。
歡迎前往 GitHub 專案庫查看程式碼並提出改進建議!
參考資源