用 C++23 與 Cabin 開發 herdr 排序外掛:如 Rust Cargo 般的現代 C++ 開發體驗與開源生態複用

featured.svg

隨著 AI coding agent(如 Claude Code、Codex、Pi、Aider 等)的普及,現代軟體開發的工作模式正在經歷巨大轉變:我們不再只是單線程地在單一終端裡寫程式,而是傾向於同時將多個獨立子任務平行分派給多個 AI agent

在這個背景下,專為 AI agent 設計的現代終端多工器(terminal multiplexer)——herdr(被許多人稱為「AI agent 時代的 tmux」)應運而生。

1. 痛點與核心設計目標

1.1 為什麼 herdr 會迅速累積大量工作區(workspaces)?

在傳統開發中,我們頂多開 2 到 3 個 tmux 視窗;但在使用 herdr 時,使用者往往會同時開啟 10 到 30+ 個工作區(workspaces)。這源於 herdr 的幾個核心架構特色:

  1. 內建 Git worktree 原生隔離:herdr 提供了原生的 worktree 整合(如 herdr worktree create --branch <name>)。當你要讓 agent 處理一個 issue 或進行重構時,herdr 會在獨立的 Git worktree 中建立專屬 workspace。這讓多個 agent 能在各自獨立的目錄分支下並行編譯與測試,完全不會產生 Git 鎖定或分支衝突。
  2. 多 agent 高並行分派(agent-first orchestration):開發者習慣為不同任務建立獨立 workspace,例如同時跑 feat/issue-2feat/issue-10bugfix/authdocs/apirefactor/db
  3. 程式化與 agent-to-agent 自動化開區:herdr 提供本地 socket API 與 CLI。負責統籌的主 agent(lead agent)或自動化腳本能透過程式化指令(herdr workspace create / herdr worktree create)動態建立工作區、派發任務,並監聽 agent 的即時工作狀態。
  4. 常駐背景守護(session persistence):herdr 的背景 daemon 會持續託管所有終端 session。即使筆電休眠、SSH 連線中斷或關閉前端視窗,所有執行中的 agent 依然在背景持續運作,導致工作區列表隨著多日專案持續累積。
flowchart TD D["🖥️ herdr daemon (常駐背景服務)
託管多個平行 session 與 Git worktree"] W["📦 20+ 平行工作區 (workspaces & worktrees)
● working · ▲ blocked · ✓ done · ○ idle"] P["⚠️ 工作區組織與切換痛點
❌ 傳統字典序:issue-10 排在 issue-2 前面
❌ 狀態混雜:等待輸入 (blocked) 難以察覺
❌ 分支散落:不同 Git 倉庫缺乏層級整理"] D ==>|背景持續運行| W W ==>|缺乏排序機制| P classDef daemonNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff; classDef wsNode fill:#1e293b,stroke:#fb923c,stroke-width:2px,color:#fff7ed; classDef probNode fill:#450a0a,stroke:#f87171,stroke-width:2px,color:#fef2f2; class D daemonNode; class W wsNode; class P probNode; linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none; linkStyle 1 stroke:#f43f5e,stroke-width:3px,fill:none;

1.2 排序與組織痛點

當工作區膨脹到數十個時,側邊欄的瀏覽與切換體驗會急劇下降:

  • 傳統字典序混亂:常規字母排序會將 feat/issue-10 排在 feat/issue-2 前面。
  • agent 狀態難以一目了然:正在處理(working)或等待人類輸入(blocked)的關鍵 agent 散落在列表中,與已經完成(done)或閒置(idle)的工作區混在一起。
  • 跨專案 worktree 分散:不同專案的 Git worktrees 缺乏依倉庫名稱或工作路徑的層級整理。

為了解決這個痛點,我用現代 C++23 開發了一個高效、穩健的 herdr 排序與工作區管理外掛:herdr-sort-workspaces

在開發這個專案的過程中,有兩個體驗讓我非常驚艷:

  1. Cabin 建置系統帶來如 Rust Cargo 般的現代開發體驗:告別寫了幾十年依舊繁瑣且容易踩坑的 CMakeLists.txt,僅靠一份簡潔的聲明式 cabin.toml,就能自動解析 ports 套件依賴、管理子模組,並以 cabin buildcabin test 一鍵編譯與測試。
  2. 現代 C++23 與高品質開源生態的完美結合:結合 std::expectedstd::rangesstd::lexicographical_compare_three_waystd::format,並複用社群頂級函式庫(CLI11nlohmann_jsonFTXUICatch2 v3),在保證極致效能與零額外抽象開銷的同時,寫出具備高型別安全、高可讀性與完整測試覆蓋的現代系統程式。

1.3 五大核心需求與系統架構

在打造這款外掛時,我設定了幾個核心需求:

  1. 自然字母數字排序(natural alphanumeric sort):數字區塊需視為整數比較,確保 workspace-2 排在 workspace-10 之前;同時支援不區分大小寫的初級比較與嚴格大小寫的平手裁決,且必須能處理任意長度的大數值而不發生整數溢位。
  2. 多維度排序策略
    • natural:自然數字排序(預設)。
    • label / alpha:純字典字母排序。
    • status:依照 agent 工作狀態優先權排序(working > blocked > done > idle > unknown)。
    • repo:依據 Git 倉庫根目錄分組,並整理旗下各 worktree 分支。
    • path:依據工作目錄(CWD)層級排序。
    • panes / tabs:依據終端分割窗格或分頁數量排序(快速找出最活躍的工作區)。
    • reverse:反轉現有工作區順序。
  3. 最小移動次數計算(minimal move reordering):herdr 的 RPC 提供 workspace.move(id, insert_index) 介面。我們不能暴力重置所有工作區,而必須透過置換模擬演算法計算出最少的移動步驟,減少畫面閃爍與 IPC 負擔。
  4. 宣告式互動 TUI(live preview terminal UI):利用 FTXUI 打造即時互動終端介面,支援快捷鍵 [1-8] 切換策略、[r] 反轉、[f] 置頂當前焦點工作區,並以 ANSI 顏色徽章和位置偏移指示器(如 +2, -1, 0)提供即時重排預覽。
  5. Unix domain socket IPC 與容錯機制:優先透過 /tmp/herdr.sock~/.config/herdr/herdr.sock 與 herdr 背景守護程序(daemon)通訊,若 socket 不可用則無縫退回呼叫 herdr CLI。

以下是整個系統的通訊與模組架構圖:

flowchart TD subgraph UI ["1. 使用者介面與觸發 (UI & CLI)"] direction LR Pal["herdr command palette
(快捷鍵 / 外掛選單)"] -->|啟動| CLI["CLI 命令列 (CLI11)
sort / list / hook / bench"] TUI["FTXUI 互動終端介面
(即時預覽 / [1-8] 快捷鍵)"] BenchTool["bench-natural-sort
(專屬效能評測二進位檔)"] end subgraph Core ["2. 核心排序與評測模組 (C++23 Sorter & Bench Engine)"] direction LR Sorter["⚙️ Sorter 策略排程器
(std::ranges::stable_sort / partition)"] NatSort["🔤 Natural Sort 比較器
(自然字母數字分塊比對)"] Model["📦 資料模型映射
(nlohmann_json 序列化)"] BenchCore["⚡ 效能基準評測引擎
(do_not_optimize / 9 種資料分佈)"] Sorter --- NatSort Sorter --- Model BenchCore --- NatSort BenchCore --- Sorter end subgraph PlannerSub ["3. 置換規劃模組 (Reorder Planner)"] Planner["📐 最小移動規劃器
(前綴不變量置換狀態機演算法)"] end subgraph Transport ["4. 通訊傳輸層 (herdrClient)"] direction LR Sock["⚡ Unix domain socket
(JSON-RPC / 5 秒逾時保護)"] CliFallback["🐚 herdr CLI 子程序
(popen 管道容錯回退)"] Sock -.->|連線失敗時回退| CliFallback end subgraph Daemon ["5. herdr 執行時環境 (daemon)"] HerdrCore["🖥️ herdr core daemon
(workspace.list / workspace.move / notification.show)"] end CLI ==>|傳入排序或壓測請求| Sorter TUI ==>|即時預覽與套用| Sorter BenchTool ==>|執行全場景基準評測| BenchCore Sorter ==>|輸出目標排序清單| Planner Planner ==>|生成最小移動指令序列| Sock Sock ==>|socket 通訊| HerdrCore CliFallback ==>|CLI 指令管道| HerdrCore classDef uiNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#ecfdf5; classDef coreNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff; classDef planNode fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#e0e7ff; classDef transNode fill:#431407,stroke:#fb923c,stroke-width:2px,color:#fff7ed; classDef daemonNode fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#f8fafc; class Pal,CLI,TUI,BenchTool uiNode; class Sorter,NatSort,Model,BenchCore coreNode; class Planner planNode; class Sock,CliFallback transNode; class HerdrCore daemonNode; style UI fill:#022c22,stroke:#10b981,stroke-width:2px,color:#6ee7b7 style Core fill:#082f49,stroke:#0284c7,stroke-width:2px,color:#7dd3fc style PlannerSub fill:#1e1b4b,stroke:#6366f1,stroke-width:2px,color:#a5b4fc style Transport fill:#271007,stroke:#ea580c,stroke-width:2px,color:#fdba74 style Daemon fill:#0f172a,stroke:#64748b,stroke-width:2px,color:#cbd5e1 linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none; linkStyle 0 stroke:#6ee7b7,stroke-width:2px,fill:none; linkStyle 1,2,3,4 stroke:#38bdf8,stroke-width:1.5px,stroke-dasharray:3 3,fill:none; linkStyle 5 stroke:#fb923c,stroke-width:2px,stroke-dasharray:4 4,fill:none; linkStyle 6,7,8 stroke:#34d399,stroke-width:2.5px,fill:none; linkStyle 9 stroke:#38bdf8,stroke-width:2.5px,fill:none; linkStyle 10 stroke:#818cf8,stroke-width:2.5px,fill:none; linkStyle 11 stroke:#f97316,stroke-width:2.5px,fill:none; linkStyle 12 stroke:#fb923c,stroke-width:2px,fill:none;

2. 亮點:Cabin 建置系統 — C++ 如 Rust Cargo 般的現代體驗

長期以來,C++ 專案的建置系統與依賴管理一直是開發者心中的痛。CMake 雖然功能強大,但其語法晦澀、歷史包袱沉重,要引入外部相依套件通常得在 FetchContentfind_package、vcpkg、Conan 或手動編譯之間痛苦掙扎,動輒數十行的樣板程式碼更讓人心力交瘁。

在這個專案中,我全面採用了新一代 C++ 套件管理與建置系統:Cabincabinpkg)。

2.1 簡潔優雅的 cabin.toml

Cabin 借鑑了 Rust Cargo 的設計哲學,使用單一 TOML 檔宣告專案資訊、編譯標準、相依套件與建置目標。看看 herdr-sort-workspaces 的完整 cabin.toml

[package]
name = "herdr-sort-workspaces"
version = "0.1.0"
cxx-standard = "c++23"

[dependencies]
nlohmann_json = { port = true, version = "^3.12.0" }
ftxui = { path = "third_party/ftxui" }
CLI11 = { port = true, version = "^2.6.2" }

[target.herdr-sort-workspaces]
type = "executable"
sources = [
    "src/main.cc",
    "src/model.cc",
    "src/natural_sort.cc",
    "src/sorter.cc",
    "src/client.cc",
    "src/tui.cc",
    "src/benchmark.cc"
]
include-dirs = ["include", "third_party/ftxui/include"]
deps = ["nlohmann_json", "ftxui", "CLI11"]

[target.test-sorter]
type = "test"
sources = [
    "tests/test_sorter.cc",
    "src/model.cc",
    "src/natural_sort.cc",
    "src/sorter.cc",
    "src/benchmark.cc"
]
include-dirs = ["include"]
deps = ["nlohmann_json", "catch2"]

[target.bench-natural-sort]
type = "executable"
sources = [
    "benchmarks/bench_main.cc",
    "src/benchmark.cc",
    "src/model.cc",
    "src/natural_sort.cc",
    "src/sorter.cc"
]
include-dirs = ["include"]
deps = ["nlohmann_json", "CLI11"]

[dev-dependencies]
catch2 = { port = true, version = "^3.15.1" }

2.2 Cabin 的關鍵優勢

  1. 宣告式 ports 生態(port = true: 如 nlohmann_jsonCLI11catch2,只需標註 port = true 與版本範圍語意(如 ^3.12.0),Cabin 會自動從官方 ports 倉庫解析、下載、快取並編譯對應版本,完全不需手動配置 CMake 或下載標頭檔。
  2. 多目標清晰隔離: 將主執行檔(executable)、測試套件(test)與專屬壓測目標(bench-natural-sort)分開宣告,dev-dependencies(如 Catch2)僅會在編譯測試目標時被拉取與鏈結,避免污染最終的發布二進位檔案。
  3. 無縫整合本地子模組: 對於需要深度客製化或特定分支的函式庫(例如終端圖形庫 ftxui),可以直接使用 path = "third_party/ftxui" 引入,Cabin 會自動處理包含目錄與原始碼建置。
  4. 標準化的命令列工作流
    • cabin build:一鍵以 Debug 模式編譯所有目標(背後以 Ninja 高速並行編譯)。
    • cabin build --release:產生極致最佳化的發布二進位檔案。
    • cabin test:自動編譯並執行 Catch2 測試套件,直接在終端輸出美觀的測試進度與摘要。
    • cabin run --release --bin bench-natural-sort:直接以 release 模式執行高效能基準測試。

相較於傳統 CMake 專案動輒上百行的 CMakeLists.txt,Cabin 讓 C++ 開發者終於擁有了與現代 Rust、Go 相同的極簡心智模型。

3. 現代 C++23 特性深度實踐

C++23 帶來了許多革命性的標準庫與語言特性,讓系統級程式碼在維持零執行期開銷的同時,表達力與安全性得到質的飛躍。

3.1 std::expected 與 monadic 錯誤處理

在 IPC 網路通訊、JSON 解析與排序選項驗證中,傳統的錯誤處理通常有兩種極端:

  • C 風格錯誤碼 / 輸出參數:型別不安全,呼叫端容易忽略錯誤檢查,且函式簽名冗長。
  • C++ 例外(exceptions):隱式控制流、跨執行緒與 IPC 不易捕獲,且可能帶來額外的二進位體積與堆疊展開(stack unwinding)開銷。

C++23 引入的 std::expected<T, E> 提供了一種值語意(value-based)的 monadic 錯誤處理機制。當運算成功時回傳 T,失敗時回傳包裝在 std::unexpected 中的錯誤資訊 E

include/herdr/sorter.hppsrc/client.cc 中,所有可能出錯的操作均採用 std::expected 簽名:

// include/herdr/sorter.hpp
[[nodiscard]] std::expected<SortStrategy, std::string> parse_sort_strategy(
    std::string_view sv
) noexcept;

[[nodiscard]] std::expected<std::vector<Workspace>, std::string> sort_workspaces(
    std::span<const Workspace> current,
    const SortOptions& options
);

在實作時,解析錯誤時回傳 std::unexpected,呼叫端則以乾淨的值判斷進行處理:

// src/sorter.cc
std::expected<SortStrategy, std::string> parse_sort_strategy(std::string_view sv) noexcept {
    if (sv == "natural" || sv == "nat" || sv == "numeric") return SortStrategy::NaturalAsc;
    if (sv == "status"  || sv == "agent" || sv == "active")  return SortStrategy::Status;
    if (sv == "repo"    || sv == "git"   || sv == "worktree") return SortStrategy::Repo;
    if (sv == "panes"   || sv == "pane-count")              return SortStrategy::PanesDesc;
    // ... (其他策略如 label, path, tabs, reverse 等省略)
    
    // 返回攜帶清晰錯誤字串的 unexpected
    return std::unexpected(std::format("Unknown sort strategy: '{}'", sv));
}

在呼叫端(如 main.cc)中,我們能以簡潔、型別安全的方式解包或傳播錯誤:

auto strat_res = herdr::parse_sort_strategy(strategy_str);
if (!strat_res.has_value()) {
    std::cerr << std::format("\033[31mError:\033[0m {}\n", strat_res.error());
    return 1;
}
SortStrategy strategy = *strat_res;

3.2 宣告式 std::ranges 演算法管線

C++20/23 的 std::ranges 演算法擺脫了傳統 std::sort(vec.begin(), vec.end()) 的繁瑣迭代器語法,直接接受容器或 view,並支援安全的原地排列與管線操作。

src/sorter.ccsort_workspaces 核心流程中,我們運用了 std::ranges::stable_sortstd::ranges::reversestd::ranges::stable_partition

std::expected<std::vector<Workspace>, std::string> sort_workspaces(
    std::span<const Workspace> current,
    const SortOptions& options
) {
    std::vector<Workspace> result(current.begin(), current.end());
    if (result.empty()) return result;

    // 1. 根據策略執行穩定排序(保留同鍵值時的原有順序)
    switch (options.strategy) {
        case SortStrategy::NaturalAsc:
            std::ranges::stable_sort(result, compare_natural_asc);
            break;
        case SortStrategy::NaturalDesc:
            std::ranges::stable_sort(result, [](const Workspace& a, const Workspace& b) {
                return compare_natural_asc(b, a);
            });
            break;
        case SortStrategy::Status:
            std::ranges::stable_sort(result, compare_status);
            break;
        case SortStrategy::Repo:
            std::ranges::stable_sort(result, compare_repo);
            break;
        case SortStrategy::PanesDesc:
            std::ranges::stable_sort(result, compare_panes_desc);
            break;
        case SortStrategy::Reverse:
            std::ranges::reverse(result);
            break;
    }

    // 2. 全域反轉旗標處理
    if (options.reverse && options.strategy != SortStrategy::Reverse) {
        std::ranges::reverse(result);
    }

    // 3. 置頂焦點工作區或特定前綴工作區(使用穩定分割演算法)
    if (options.pinned_first || !options.pin_prefix.empty()) {
        std::ranges::stable_partition(result, [&](const Workspace& w) {
            if (options.pinned_first && w.focused) return true;
            if (!options.pin_prefix.empty() && w.label.starts_with(options.pin_prefix)) return true;
            return false;
        });
    }

    return result;
}

std::ranges::stable_partition 確保被置頂的工作區移到前面時,其他工作區彼此之間的相對排序嚴格保持不變,展現了現代標準庫演算法的高度表達力。

3.3 三向比較運算子與 std::lexicographical_compare_three_way

在純字典字母排序中,我們希望達成:

  1. 主要比較:不區分大小寫(case-insensitive),例如 'a''A' 視為相同。
  2. 平手仲裁(tie-breaker):若字母相同,以 ASCII 大小寫順序(大寫優先於小寫)作為平手仲裁,避免不同大小寫字串被判定為完全相等而產生未定義的隨機順序。

在 C++23 中,我們可以直接使用 <compare> 標頭檔中的 std::lexicographical_compare_three_way 與太空船運算子(<=>):

bool compare_label_asc(const Workspace& a, const Workspace& b) {
    std::string_view sa = a.display_title_sv();
    std::string_view sb = b.display_title_sv();

    auto cmp = std::lexicographical_compare_three_way(
        sa.begin(), sa.end(),
        sb.begin(), sb.end(),
        [](char c1, char c2) {
            char l1 = to_lower_char(c1);
            char l2 = to_lower_char(c2);
            if (l1 != l2) return l1 <=> l2; // 主要不區分大小寫比較
            return c1 <=> c2;               // 平手時以原始大小寫仲裁
        }
    );

    if (cmp != 0) return cmp < 0;
    return a.workspace_id < b.workspace_id; // 最終以 workspace_id 字典序作為唯一平手仲裁
}

這段程式碼將原本需要寫十幾行雙迴圈、大小寫轉換與指標推進的繁瑣邏輯,濃縮成兼具極致編譯器最佳化與數學嚴謹性的三向比較表達式。

3.4 std::format 型別安全字串格式化

告別易引發記憶體安全問題的 snprintf 與繁複且低效的 std::ostringstream,C++23 的 std::format 在編譯期檢查格式化字串型別,並提供極致的字串組合效率。

例如在終端列表輸出與 RPC 請求 ID 生成中:

// 格式化請求序號與通知內容
const std::string req_id = std::format("sort_req_{}", ++request_seq_);
std::string msg = std::format("Sorted {} workspace{} by {}.",
                              count, (count == 1 ? "" : "s"), strategy_name);

// 終端對齊輸出表格
std::cout << std::format("  {:2}. \033[36m{:<4}\033[0m {} {:<10} {:>2}p/{:>1}t {:<32} \033[90m{}\033[0m\n",
    i + 1, w.workspace_id, focus_mark, w.status_badge_ansi(),
    w.pane_count, w.tab_count, w.display_title(), w.cwd
);

3.5 零拷貝檢視:std::string_viewstd::span 的生命週期管理

在整個外掛的排序管線中,工作區標籤(label)、目錄路徑(CWD)與工作區 ID 需要頻繁進行字串比對與切片。為了徹底消除短命 std::string 的堆積記憶體配置(heap allocation):

  1. std::string_view:在字串自然比對器 natural_compare(std::string_view lhs, std::string_view rhs) 中,直接操作字串指標與長度,不產生任何記憶體複製。
  2. std::span<const Workspace>:在接收工作區列表時,函式接收唯讀的非擁有式切片(span),無論底層是 std::vector 還是靜態陣列,皆可零成本傳遞。

同時,在 Workspace 模型中,我們精確區分了檢視方法與產生字串的方法:

struct Workspace {
    std::string workspace_id;
    std::string label;
    // ...
    
    // 零拷貝檢視(生命週期依附於 Workspace 實例)
    std::string_view display_title_sv() const noexcept {
        return !label.empty() ? std::string_view(label) : std::string_view(workspace_id);
    }

    // 需產生 ANSI 格式化字串時才進行拷貝
    [[nodiscard]] std::string status_badge_ansi() const;
    [[nodiscard]] std::string display_title() const;
};

4. 開源生態的高品質複用

現代 C++ 開發絕非閉門造車。透過複用經過社群嚴格考驗的開源函式庫,我們能以極少的程式碼實現強大的工業級功能。

函式庫 角色與責任 為專案帶來的價值
nlohmann_json JSON 序列化與 RPC 通訊 透過 ADL to_json / from_json 實現 WorkspaceWorktreeInfo 的自動雙向序列化,處理 herdr socket 回傳的複雜樹狀快照。
CLI11 命令列解析與子命令架構 支援 sortlistinteractivehook 四大子命令,提供豐富的參數校驗(如 CLI::IsMember 檢查合法排序策略)與內建色彩 help 格式化。
FTXUI 終端互動式 UI 元件 採用 Functional Reactive 模式構建全螢幕 TUI,包含 radio menu、checkbox、table、即時按鍵監聽器與 ANSI 彩色渲染。
Catch2 v3 單元測試與微基準評測 提供強大的 TEST_CASESECTION 階層測試與 BENCHMARK / BENCHMARK_ADVANCED 微基準評測,涵蓋 185+ 斷言 與隔離統計計時,驗證正確性與極致效能。

5. 核心演算法與架構解析

5.1 自然字母數字排序演算法(natural sort algorithm)

常規字串比對是逐字元比較 ASCII 碼,導致 "item10""item2" 小(因為 '1' < '2')。

src/natural_sort.cc 中,我實作了一套支援任意長度大數值前導零平手仲裁的高效分塊演算法:

flowchart TD Start["natural_compare(lhs, rhs)"] --> Loop{"雙指標 i, j 尚未到底?"} subgraph Branch ["每輪字元分塊比對"] direction TB IsDigit{"lhs[i] 與 rhs[j]
皆為數字?"} NumBranch["🔢 數字區塊比較
1. 計算並跳過前導零
2. 量測有效數字長度 (長者大)
3. 等長則逐位比較數值
4. 完全相同則記錄前導零 bias"] CharBranch["🔤 非數字字元比較
1. 轉小寫比對 (相異定勝負)
2. 相同則記錄大小寫 bias"] IsDigit -- 是 --> NumBranch IsDigit -- 否 --> CharBranch end Diff{"分塊是否相異?"} RetDiff["🛑 回傳分塊勝負結果 (-1 / +1)"] Next["推進指標 i, j"] subgraph EndCheck ["尾端長度與平手仲裁 (Loop 結束)"] direction TB LenCheck{"兩字串長度不同?"} RetLen["長度較長者為大 (±1)"] RetBias["回傳累積之 Bias (前導零 / 大小寫)
若完全相同則回傳 0"] LenCheck -- 是 --> RetLen LenCheck -- 否 --> RetBias end Loop -- 是 --> IsDigit NumBranch --> Diff CharBranch --> Diff Diff -- 相異 --> RetDiff Diff -- 相同 --> Next Next --> Loop Loop -- 否 (比對完畢) --> EndCheck classDef startNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff; classDef decisionNode fill:#431407,stroke:#fb923c,stroke-width:2px,color:#fff7ed; classDef procNode fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc; classDef retNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#ecfdf5; classDef retNegNode fill:#450a0a,stroke:#f87171,stroke-width:2px,color:#fef2f2; class Start startNode; class Loop,IsDigit,Diff,LenCheck decisionNode; class NumBranch,CharBranch,Next procNode; class RetLen,RetBias retNode; class RetDiff retNegNode; style Branch fill:#0f172a,stroke:#38bdf8,stroke-width:1.5px,color:#7dd3fc style EndCheck fill:#111827,stroke:#34d399,stroke-width:1.5px,color:#6ee7b7 linkStyle default stroke:#38bdf8,stroke-width:2.5px,fill:none;

演算法核心程式碼精華

// src/natural_sort.cc
int natural_compare(std::string_view lhs, std::string_view rhs) noexcept {
    size_t i = 0, j = 0;
    const size_t len1 = lhs.size(), len2 = rhs.size();
    int bias = 0;

    while (i < len1 && j < len2) {
        char c1 = lhs[i], c2 = rhs[j];

        if (is_digit(c1) && is_digit(c2)) {
            // 1. 統計並略過前導零
            size_t z1 = 0, z2 = 0;
            while (i < len1 && lhs[i] == '0') { ++z1; ++i; }
            while (j < len2 && rhs[j] == '0') { ++z2; ++j; }

            // 2. 找出有效數字區塊長度
            size_t start1 = i;
            while (i < len1 && is_digit(lhs[i])) ++i;
            size_t num_len1 = i - start1;

            size_t start2 = j;
            while (j < len2 && is_digit(rhs[j])) ++j;
            size_t num_len2 = j - start2;

            // 長度不同者,長度大者數值必大(支援任意超長數值,不發生整數溢位!)
            if (num_len1 < num_len2) return -1;
            if (num_len1 > num_len2) return 1;

            // 3. 長度相同時,逐位元比對 ASCII 數值
            for (size_t k = 0; k < num_len1; ++k) {
                if (lhs[start1 + k] < rhs[start2 + k]) return -1;
                if (lhs[start1 + k] > rhs[start2 + k]) return 1;
            }

            // 4. 數值完全相等但前導零數量不同時,記錄平手仲裁 bias(較少前導零者優先:7 < 007)
            if (bias == 0 && z1 != z2) {
                bias = (z1 > z2) ? 1 : -1;
            }
        } else {
            char l1 = to_lower(c1), l2 = to_lower(c2);
            if (l1 != l2) return (l1 < l2) ? -1 : 1;
            if (bias == 0 && c1 != c2) {
                bias = (c1 < c2) ? -1 : 1; // 大小寫平手仲裁
            }
            ++i; ++j;
        }
    }

    if (i < len1) return 1;
    if (j < len2) return -1;
    return bias;
}

這個演算法不僅完美處理了 "item2" < "item10""v1.2" < "v1.10",連多達 30 位的巨型數字字串(例如 Git SHA、長 issue ID)也能在微秒內完成精確比對。

5.2 最小移動置換演算法(minimal move permutation planner)

當我們計算出目標工作區順序後,必須將現有順序轉換為一組最小的 workspace.move(id, insert_index) 操作序列。

因為每呼叫一次 workspace.move(id, idx),目標工作區會被抽出並插入到指定位置,進而改變後續工作區的索引偏移。

src/sorter.cc 中,我實作了一套前綴不變量置換狀態機(prefix-invariant permutation simulation):

// src/sorter.cc
std::vector<ReorderMove> calculate_reorder_moves(
    std::span<const Workspace> current,
    std::span<const Workspace> target
) {
    std::vector<ReorderMove> moves;
    std::vector<std::string> state;
    state.reserve(current.size());

    for (const auto& w : current) {
        state.push_back(w.workspace_id);
    }

    // 依序滿足 target[0], target[1], ..., target[N-1] 的位置
    for (size_t i = 0; i < target.size(); ++i) {
        const std::string& target_id = target[i].workspace_id;
        auto it = std::ranges::find(state, target_id);
        if (it != state.end()) {
            size_t cur_idx = static_cast<size_t>(std::distance(state.begin(), it));
            if (cur_idx != i) {
                // 將元素從當前位置移動到索引 i
                std::string item = std::move(*it);
                state.erase(it);
                state.insert(state.begin() + static_cast<std::ptrdiff_t>(i), item);
                moves.push_back(ReorderMove{
                    .workspace_id = target_id,
                    .insert_index = static_cast<uint32_t>(i),
                    .label = target[i].label
                });
            }
        }
    }

    return moves;
}

置換演算法工作原理演示

假設目前工作區順序為 [w1, w2, w3, w4, w5],排序後目標順序為 [w5, w3, w1, w4, w2]

  1. $i=0$:目標是 w5,當前 w5 位於索引 4。執行 move(w5, 0),狀態變為 [w5, w1, w2, w3, w4]
  2. $i=1$:目標是 w3,當前 w3 位於索引 3。執行 move(w3, 1),狀態變為 [w5, w3, w1, w2, w4]
  3. $i=2$:目標是 w1,當前 w1 已經位於索引 2,無需移動。
  4. $i=3$:目標是 w4,當前 w4 位於索引 4。執行 move(w4, 3),狀態變為 [w5, w3, w1, w4, w2]
  5. $i=4$:目標是 w2,當前 w2 已經位於索引 4,無需移動。

透過這個演算法,原本看似混亂的 5 元素完全亂序,僅用 3 次移動 即達成目標,大幅減少了 IPC 通訊與 UI 重繪次數。

5.3 Unix domain socket IPC 通訊與 RAII 守護

在 Linux 與 macOS 環境下,與 herdr 守護程序通訊最快的方式是 Unix domain socket。

為了防止 socket 檔案描述符(file descriptor)洩漏,我們設計了 RAII 資源管理器 SocketGuard,並透過 poll 機制設定 5 秒逾時,確保不會因 daemon 無回應而導致 CLI 永久卡死:

// src/client.cc
struct SocketGuard {
    int fd{-1};
    ~SocketGuard() {
        if (fd >= 0) ::close(fd);
    }
};

std::expected<nlohmann::json, std::string> HerdrClient::send_socket_request(
    std::string_view method,
    const nlohmann::json& params
) {
    int fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
    if (fd < 0) return std::unexpected(std::format("Socket creation error: {}", errno));
    SocketGuard guard{fd}; // 函式退出時保證自動 close(fd)

    // ... 連線與發送 JSON-RPC 請求 ...

    struct pollfd pfd{ .fd = fd, .events = POLLIN, .revents = 0 };
    int pr = ::poll(&pfd, 1, 5000); // 5 秒逾時保護
    if (pr <= 0) {
        return std::unexpected(pr == 0 ? "Timeout waiting for herdr socket" : "Poll error");
    }

    // 接收並解析 JSON 回應
    // ...
}

若 socket 連線失敗(例如 herdr 守護程序未以 socket 模式啟動),HerdrClient 會自動無縫退回呼叫 herdr workspace listherdr notification show 等 CLI 管道,保證外掛在任何環境下皆能 100% 正常運作。

6. FTXUI 互動式終端介面與 herdr 外掛整合

除了命令列指令,專案還透過 FTXUI 提供了即時互動預覽介面(herdr-sort-workspaces interactive)。

6.1 FTXUI 介面特色

┌─ Sorting Strategies ──────────────────┐┌─ Live Reorder Preview ──────────────────────────────┐
│  ● 1. Natural Alphanumeric (A-Z)      ││ 1. w3 ★ ● working  2p/3t  alpha-2   /a/2         +2 │
│  ○ 2. Pure Alphabetical (A-Z)         ││ 2. w2   ▲ blocked  4p/2t  alpha-10  /a/10         0 │
│  ○ 3. Agent Status (Active first)     ││ 3. w4   ✓ done     5p/1t  beta      /b           -1 │
│  ○ 4. Git Repo & Worktrees            ││ 4. w1   ○ idle     1p/1t  zebra     /z           -3 │
│  [ ] Reverse order (Z-A)              │└─────────────────────────────────────────────────────┘
│  [X] Pin focused workspace to top     │
│       [ Apply & Reorder ] [ Cancel ]  │
└───────────────────────────────────────┘
 [1-8] Quick Strategy • [r] Reverse • [f] Pin • [Enter] Apply • [q] Exit
  1. 左側策略選單:支援即時選取 8 種排序策略,並提供反轉與焦點置頂 checkbox。
  2. 右側即時預覽表格:每次變更選項,表格會即時計算重排後的結果,並在最右側以綠色 +2 或紅色 -1 標註每個工作區相對於原本位置的位置位移量(delta)
  3. 直覺快捷鍵:按下數字鍵 [1-8] 即可瞬間切換排序策略;按下 [r] 快速切換升降序;按下 [f] 切換焦點置頂;按下 [Enter] 立即套用並向 herdr 派發重排指令。

6.2 宣告式外掛清單:herdr-plugin.toml

為了讓 herdr 的 command palette 能夠直接喚起排序功能,專案提供了標準的 herdr-plugin.toml 清單:

id = "herdr-sort-workspaces"
name = "herdr Workspace Sorter"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Sort herdr workspaces alphabetically, by status, path, active state, or custom criteria in modern C++23."
platforms = ["linux", "macos"]

[[build]]
command = ["cabin", "build", "--release"]

[[actions]]
id = "sort-natural"
title = "Sort Workspaces: Natural (A-Z)"
contexts = ["workspace", "global"]
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces", "sort", "--by", "natural", "--notify"]

[[actions]]
id = "sort-status"
title = "Sort Workspaces: Agent Status (Active first)"
contexts = ["workspace", "global"]
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces", "sort", "--by", "status", "--notify"]

[[actions]]
id = "interactive"
title = "Sort Workspaces: Interactive Sorter"
contexts = ["workspace", "global"]
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces", "interactive"]

[[panes]]
id = "picker"
title = "Sort Workspaces"
placement = "overlay"
command = ["./build/release/packages/herdr-sort-workspaces/herdr-sort-workspaces", "interactive"]

透過 herdr plugin link . 指令,herdr 會自動將這些 action 註冊到快捷鍵與命令面板中;排序完成後,還會呼叫 herdr 的桌面原生 toast 通知使用者。

7. 效能基準測試與 CI 自動化(performance benchmarking & CI integration)

當開發者或背景排程 agent 在 herdr 中託管數十甚至上百個工作區時,每次建立 worktree、切換焦點或狀態變更,排序演算法都會在即時路徑(hot path)上被呼叫。

7.1 為什麼需要嚴格的基準測試?

自然字母數字排序(natural sort)與標準字串字典序比較不同:它需要動態辨識連續數字分塊去除前導零比較有效數字長度處理平手仲裁。 相較於 C++ 標準函式庫純逐字元比較的 std::less<std::string_view>,自然排序邏輯較為複雜。為了確保演算法在任何極端情境下皆維持零動態記憶體配置(zero heap allocation)與極致輸送量,我們設計了全方位的雙軌基準測試體系

  1. Catch2 v3 微基準測試(microbenchmark):整合於單元測試套件中,提供隔離的奈秒級微基準量測。
  2. 自研 C++23 高輸送量評測引擎(include/herdr/benchmark.hpp & src/benchmark.cc:零外部相依、支援 9 種真實開發分佈、提供基線倍率對比(vs std::less),並原生支援 ANSI 彩色終端表格、Markdown、JSON 與 CSV 多種輸出格式。
flowchart TD subgraph Engine ["⚡ C++23 高輸送量評測引擎 (include/herdr/benchmark.hpp)"] direction TB DataGen["🎲 9 種真實資料分佈
(Shuffled, Branches, SemVer...)"] Warmup["🔥 暖身運行
(Warmup Runs)"] Opt["🛡️ do_not_optimize
(防止編譯器死碼消除)"] Timer["⏱️ steady_clock 奈秒級統計量測 (Mean / Median / P95 / P99)"] DataGen --> Timer Warmup --> Timer Opt --> Timer end subgraph Scope ["🎯 評測範疇 (3 大層級)"] direction LR Micro["1️⃣ Micro (Pairwise)
13 組極限字串兩兩比對"] Sort["2️⃣ Dataset Sort
N=10 ~ 20,000 陣列排序"] E2E["3️⃣ Sorter E2E
策略排序 + 最小移動置換"] Micro --> Sort --> E2E end subgraph Output ["📊 多元輸出與 CI 整合"] direction LR CLI_Out["🖥️ ANSI 終端表格
(bench CLI 子命令)"] GHA["📈 GitHub Actions
($GITHUB_STEP_SUMMARY)"] Art["📁 Artifacts 保存
(JSON & CSV 報告)"] end Engine ==>|驅動基準評測| Scope Scope ==>|匯出評測報告| Output classDef engNode fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#f0f9ff; classDef scpNode fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#e0e7ff; classDef outNode fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#ecfdf5; class Warmup,Timer,Opt,DataGen engNode; class Micro,Sort,E2E scpNode; class CLI_Out,GHA,Art outNode; style Engine fill:#082f49,stroke:#0284c7,stroke-width:2px,color:#7dd3fc style Scope fill:#1e1b4b,stroke:#6366f1,stroke-width:2px,color:#a5b4fc style Output fill:#022c22,stroke:#10b981,stroke-width:2px,color:#6ee7b7

7.2 雙軌評測設計:Catch2 BENCHMARK_ADVANCED vs 自研引擎

1. Catch2 BENCHMARK_ADVANCED 與 Chronometer 隔離測量

在微基準測試中,最常見的陷阱是把「資料準備與容器複製」的時間算入演算法耗時。Catch2 v3 的 Catch::Benchmark::Chronometer 允許我們在計時器啟動前預先配置與複製資料:

// tests/test_sorter.cc
BENCHMARK_ADVANCED("Sort 100 Shuffled Workspace Labels")(Catch::Benchmark::Chronometer meter) {
    // 預先在計時範圍外準備好 meter.runs() 份資料副本
    std::vector<std::vector<std::string>> storage(meter.runs());
    for (auto& s : storage) s = shuffled_100;
    
    // meter.measure 僅量測真正的演算法核心區塊
    meter.measure([&](int i) {
        std::ranges::stable_sort(storage[i], herdr::natural_less);
        return storage[i].size();
    });
};

2. 自研 C++23 評測引擎與 do_not_optimize

自研引擎利用內嵌組合語言指令,防止 Clang/GCC 在 -O3 發布最佳化時將純函式呼叫視為死碼(dead code elimination)而最佳化抹除:

// include/herdr/benchmark.hpp
template <typename T>
inline void do_not_optimize(const T& val) noexcept {
#if defined(__GNUC__) || defined(__clang__)
    asm volatile("" : : "g"(val) : "memory");
#else
    const volatile void* volatile p = static_cast<const void*>(&val);
    (void)p;
#endif
}

7.3 涵蓋 9 大真實開發情境的資料集分佈

為了真實反映 herdr 在不同工作流程下的負載,測試引擎內建了 9 種資料分佈產生器(DataDistribution):

  • GitBranches:模擬真實 Git 分支與 worktree 命名(如 feat/issue-10, feat/issue-2, bugfix/auth-v2)。
  • SemVerTags:語意化版本標籤(如 v1.2.3, v1.10.0, v2.0.0-rc1)。
  • HierarchicalPaths:多層級 POSIX 工作目錄路徑(如 /var/log/audit/2026/08/node-1)。
  • LeadingZeros:帶有補零編號的日誌或產出檔(如 job_00042.log)。
  • Shuffled / AlreadySorted / ReverseSorted / NearlySorted90 / PureNumeric:涵蓋最佳情況(0 次反序)、最差情況($N(N-1)/2$ 次反序)、90% 局部有序以及純大數值比對。

7.4 實測數據與效能分析

以下為在 x86_64 Linux 環境(-O3 --release)測得的代表性基準數據:

1. 兩兩字串比對(micro pairwise)

測試場景 資料長度 平均耗時 (Mean) 輸送量 (Throughput) vs 字典序 (std::less)
Numeric Suffix (workspace-2 vs workspace-10) 1 pair 46.51 ns 21.50 Mops/s 25.99x
Large Integer (run-999999999999999 vs 1000000000000000) 1 pair 28.02 ns 35.69 Mops/s 15.70x
SemVer Multi-Segment (v1.10.4-rc2 vs v1.2.18-rc1) 1 pair 13.21 ns 75.73 Mops/s 7.55x
Pure Numeric (123456789 vs 123456790) 1 pair 11.70 ns 85.49 Mops/s 6.69x
Leading Zeros Bias (item007 vs item7) 1 pair 21.27 ns 47.00 Mops/s 12.17x

💡 核心洞察:單次自然比對僅需 11 ~ 46 奈秒(ns),每秒可處理 2,000 萬至 8,500 萬次 比較!

2. 資料集排序(dataset sort)與端到端排程(sorter E2E)

評測場景 資料規模 $N$ 排序平均耗時 換算輸送量 vs 字典序開銷比
Workspaces (Shuffled) 10 1.41 µs 7.07 M items/s 5.07x
Workspaces (Shuffled) 50 13.04 µs 3.83 M items/s 6.34x
Git Branches / Worktrees 50 7.17 µs 6.97 M items/s 7.97x
Semantic Versions (SemVer) 50 5.91 µs 8.47 M items/s 3.09x
sort_workspaces (NaturalAsc) 50 19.35 µs 2.58 M items/s 1.01x
calculate_reorder_moves 50 12.17 µs 4.11 M items/s

在一般的 herdr 使用情境(10 ~ 50 個工作區)下:

  • 完整的自然排序僅耗時 13 ~ 19 微秒(µs)(相當於每秒能完成 5 萬次以上完整工作區重排)。
  • 最小置換路徑計算僅需 12 微秒(µs)
  • 相較於 Unix domain socket 的 IPC 通訊延遲(約 100 ~ 500 µs)或終端渲染重繪時間(數毫秒),排序演算法本身的 CPU 開銷完全可以忽略不計(亞毫秒級別)。

7.5 獨立 CLI 工具與 GitHub Actions CI 自動化整合

專案將基準測試包裝為兩個靈活的入口:

  1. 專屬評測二進位檔cabin run --release --bin bench-natural-sort -- --category all(支援 --markdown--json--csv)。
  2. 主程式子命令herdr-sort-workspaces bench --sizes 10,50,200

在 GitHub Actions CI(.github/workflows/ci.yml)中,每次 PR 與 push 都會自動在 GCC 14 與 Clang 18 release 模式下執行評測管線:

# .github/workflows/ci.yml
- name: Run Natural Sort performance benchmarks
  run: |
    echo "## Natural Sort Benchmark Report (${{ matrix.compiler }})" >> $GITHUB_STEP_SUMMARY
    ./build/release/packages/herdr-sort-workspaces/bench-natural-sort --category all --sizes 10,50,200,1000 --markdown >> $GITHUB_STEP_SUMMARY
    ./build/release/packages/herdr-sort-workspaces/bench-natural-sort --category all --sizes 10,50,200,1000 --json > benchmark-results-${{ matrix.compiler }}.json
    ./build/release/packages/herdr-sort-workspaces/bench-natural-sort --category all --sizes 10,50,200,1000 --csv > benchmark-results-${{ matrix.compiler }}.csv

- name: Run Catch2 benchmark microbenchmarks
  run: |
    ./build/release/packages/herdr-sort-workspaces/test-sorter "[!benchmark]"

- name: Upload benchmark reports
  uses: actions/upload-artifact@v4
  with:
    name: benchmark-results-${{ matrix.compiler }}
    path: |
      benchmark-results-${{ matrix.compiler }}.json
      benchmark-results-${{ matrix.compiler }}.csv

CI 的自動化成果:

  • $GITHUB_STEP_SUMMARY 即時儀表板:自動將 Markdown 格式的完整評測表格附加在每輪 GitHub Actions 摘要頁面上,開發者無需點進 log 即可一目了然。
  • 歷史數據歸檔(artifacts):自動將 JSON 與 CSV 格式的評測數據打包上傳,方便追蹤跨編譯器(GCC 14 vs Clang 18)與版本演進時的效能趨勢。

8. 完整測試與跨編譯器 CI (GCC 14 + Clang 18)

系統工具的穩定性至關重要。專案透過 Catch2 v3 建立了詳盡的單元測試套件(位於 tests/test_sorter.cc),包含:

  1. 自然排序測試:驗證遞移律(Transitivity: $a < b \land b < c \implies a < c$)、前導零仲裁、符號與分支路徑、以及超過 30 位的超長數值比對。
  2. 策略排序測試:驗證 8 種策略的升降序行為、多窗格與多分頁排序、Git worktree 歸屬排序。
  3. 邊界情況測試:空工作區列表、單一工作區、標籤為空時的 ID 回退機制、重複標籤時的穩定性。
  4. 置換模擬測試:驗證 10 元素反轉與隨機洗牌置換模擬,確保計算出的 calculate_reorder_moves 在逐步套用後,狀態嚴格等於預期目標。

在 GitHub Actions CI(.github/workflows/ci.yml)中,我們在 Ubuntu 24.04 上建立了雙編譯器矩陣:

# .github/workflows/ci.yml
strategy:
  matrix:
    compiler: [gcc, clang]
    include:
      - compiler: gcc
        cc: gcc-14
        cxx: g++-14
      - compiler: clang
        cc: clang-18
        cxx: /usr/local/bin/clang++-libcxx # 使用 clang++-18 -stdlib=libc++ 封裝腳本

透過快取 Cabin 二進位檔與 ports 快取目錄(~/.cache/cabin),整個 CI 矩陣在 GCC 14 與 Clang 18(搭配 LLVM libc++)下,從下載依賴、編譯到執行全部 185+ 測試斷言僅需不到 30 秒。

9. 結語與反思

透過開發 herdr-sort-workspaces,我對現代 C++ 的開發體驗有了全新的體認:

  1. C++ 不再等於「繁瑣的 CMake」:Cabin 證明了 C++ 也能擁有如同 Rust Cargo 般愉悅的相依套件管理與建置體驗。聲明式 TOML 讓專案設定清晰明瞭,新手與老手都能在幾秒鐘內輕鬆上手。
  2. C++23 讓系統程式更加安全且優雅std::expected 終結了錯誤碼與例外之爭;std::rangesstd::lexicographical_compare_three_way 讓演算法更加精練;std::string_viewstd::span 則在維持極致效能的同時避免了記憶體浪費。
  3. 強大且成熟的開源生態:從 nlohmann_json 的優雅序列化,到 CLI11 的健全命令列解析,再到 FTXUI 的終端互動體驗,現代 C++ 社群的基礎設施已非常健全。

如果你也在使用 herdr 管理你的日常開發與 AI agent 工作區,歡迎試用並將專案 clone 下來體驗:


MiniCompose:手刻最小化 Jetpack Compose 渲染引擎、雙進程畫面隔離技術與 1000 節點微秒級動態基準測試

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 中打開並運行於實機上!


在 Rust 中整合 Gemma-4 多模態模型、WebRTC APM 與 openWakeWord 實驗本機語音助理 (Rust 52 Projects #52)

featured.svg

在桌面上運行一個簡易的語音助理,一直是我很想嘗試的學習專案。市面上的商業語音助理(如 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.0f32; 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.0f32;
    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-4MtmdContext(多模態上下文)將音訊檔案 (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 = 0i32;
    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 的動畫加速

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 的底層渲染架構與動畫加速!

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 完全不需要重寫,所有的位移與旋轉矩陣運算都直接交由 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 等,將在後續文章中陸續為大家拆解!)


在 Rust 中用 OpenCV 5 與 Axum 7 打造跨平台高效能 IP Camera 視訊串流伺服器

featured.svg

架設一台網路監控攝影機(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(0u64);

// 將硬體 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::channelbroadcast::channel,將壓縮好的 Vec<u8> JPG 影像直接扔進 Channel 送給 Web 伺服器。但在高效能 IP Camera 的情境下,我們選擇了 Arc<Mutex<Vec<u8>>> (共享記憶體) + tokio::sync::watch (訊號廣播) 的組合,主要考量如下:

  1. 保證「永遠只看最新影格 (Latest-Frame-Only)」的即時性: IP 監控攝影機的核心要求是極低延遲與即時性。使用者想看的是「此刻發生了什麼」,而不是 3 秒前積壓在佇列裡的歷史影格。 若使用傳統管道(Channel Queue),當某些 Web 客戶端網路較慢(例如用手機 3G 觀看)時,Channel 內部會積壓大量未消費的 JPG 影格,導致畫面延遲越來越大(Lag)且記憶體不斷暴增。 而 watch 頻道搭配共享記憶體具有天然的 最新狀態覆寫 (Single-Slot Overwrite) 語意:慢速客戶端被喚醒時,永遠只會讀取到當前最新的那張 JPG 影格,自動跳過中間沒來得及發送的影格,實現零積壓、零延遲

  2. 避免多連線時的記憶體重複複製 (Zero Fan-Out Memory Inflation): 若透過 broadcast channel 發送 Vec<u8> 影像,每當有 $N$ 個客戶端連線時,系統就必須將圖像 Payload 複製 $N$ 份或處理複雜的共享記憶體佇列。 採用 Arc<Mutex<Vec<u8>>> 後,無論是 0 個還是 100 個客戶端連線,背景 Worker 執行緒永遠只執行一次 JPEG 壓縮與單一記憶體覆寫,將記憶體開銷牢牢鎖定在最小的單影格大小。

  3. 無人觀看時的極致零開銷 (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\nContent-Type: image/jpeg\r\nContent-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 非同步生態集中,unfoldfold 的對偶(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. 第 1 次迭代 (is_first = true): 跳過 rx.changed().await 等待,直接從共享記憶體讀取當前最新的 JPEG Byte Array,組裝出首張 multipart 封包。回傳 Some((Ok(Bytes), (false, rx, frame_state)))。這使得瀏覽器一開啟網頁就能瞬間載入第一張畫面(Zero Delay First Frame)
  2. 後續迭代 (is_first = false): 執行 rx.changed().await。此時 Tokio Task 會進入完全休眠狀態(0 CPU 消耗)。當背景 Capture Worker 發送新影格訊號時,Task 被喚醒,讀取最新 JPG bytes,並再次產出 Some((Ok(Bytes), (false, rx, frame_state)))
  3. 串流終止 (Sender dropped): 若背景擷取執行緒關閉,rx.changed().await 回傳 Err(_),閉包回傳 Nonestream::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_PATHSOPENCV_INCLUDE_PATHS 與 runtime DLL PATH

# 編譯並運行 Release 版本
.\build.bat run --release

2. Linux & 樹莓派 4 (Raspberry Pi 4 ARM64)

在 Debian / Raspberry Pi OS 上,只要安裝 clangpkg-config 即可輕鬆編譯:

sudo apt update && sudo apt install -y build-essential clang libclang-dev pkg-config
cargo run --release

實務討論與技術體會 (Technical Trade-offs)

作為一個教學與實驗導向的專案,我們也必須坦實討論 MJPEG 串流的技術權衡:

  1. MJPEG vs RTSP/H.264/H.265
    • 優點:MJPEG 不需要複雜的跨平台 H.264 硬體編碼器(如 NVENC 或 QuickSync),相容性極佳,所有瀏覽器用普通 <img> 標籤就能播放。
    • 缺點:因為每一影格都是完整的 JPEG 圖片,缺乏 Frame 之間的 Intra-frame 壓縮,頻寬佔用較大(640x360@30fps 約需 2~4 Mbps)。
  2. 記憶體拷貝優化空間: 目前 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 查看程式碼並親自試跑!


參考資源