Simply Patrick

WASM 裡的 Rust Async 沒有 Runtime

在做 wgpu Game of Life 時發現一件事:WASM 環境下的 Rust async 不需要 Tokio 或 async-std——瀏覽器的 event loop 就是 runtime

在 native Rust,你需要一個 executor 來 poll futures。但在 WASM,wasm-bindgen-futures 把 Rust 的 Future 轉換成 JavaScript 的 Promise,交給瀏覽器的 event loop 來驅動:

#[wasm_bindgen]
pub async fn start(canvas_id: &str) {
    // 每個 .await 都是把控制權交還給瀏覽器的 event loop
    let adapter = instance.request_adapter(&options).await;
    let (device, queue) = adapter.request_device(&desc, None).await;
}

JavaScript 端看到的就是一個回傳 Promise 的函式:

await wasm.start("canvas");

每次 .await 時,Rust future 暫停執行,控制權回到瀏覽器。當底層的 JS 操作(例如 WebGPU 的 requestAdapter)完成時,瀏覽器透過 microtask 觸發 Rust 的 waker,從暫停處繼續執行。

Native WASM
Runtime Tokio / async-std 瀏覽器 event loop
Executor Rust 端的 thread pool JS microtask queue
Spawn tokio::spawn(多執行緒) spawn_local(單執行緒)

所以 Cargo.toml 只需要 wasm-bindgen-futures,不需要任何 Rust async runtime。


用 Rust + WebGPU 在瀏覽器跑 Game of Life

featured.svg

這個專案把 Conway’s Game of Life 搬到 GPU 上面跑——用 Rust 的 wgpu 寫 WebGPU compute shader,編譯成 WASM 在瀏覽器裡執行。128x128 的網格、上萬個細胞的模擬,全部在 GPU 上平行計算。

專案原始碼:wgpu-game-of-life

先玩再說

操作方式:Play/Pause 開始模擬,點擊畫布可以畫細胞,Speed 調整速度。需要 WebGPU 支援的瀏覽器(Chrome 113+、Edge 113+、Firefox 141+)。

顏色代表細胞年齡:綠色是新生的,黃色是年輕的,橘色是成熟的,白色是古老的 still life 結構。

為什麼做這個?

在完成 WASM Markdown 編輯器之後,我想更深入探索 WASM 的可能性。Markdown 編輯器純粹是 CPU 計算,但現代瀏覽器已經支援 WebGPU——可以直接存取 GPU 的算力。

Game of Life 是個完美的入門專案:

  1. 天然適合平行計算:每個細胞的下一代只取決於鄰居,可以完全並行
  2. 需要 compute shader + render pipeline:同時學兩種 GPU 程式設計模式
  3. 視覺回饋即時:寫完馬上看到結果
  4. 規模剛好:不會太大,但足以理解 GPU 程式設計的核心概念

技術架構

整體流程

[ S t o r a g e B u f f e r A ] r e a d [ C [ [ o R C m e a p n n u d v t e a e r s S P O h i u a p t d e p e l u r i t ] n ] e ] w r i t r e e a d [ S t o r a g e B u f f e r B ]

核心是 ping-pong 雙緩衝:兩個 storage buffer 交替讀寫。每一步模擬時,compute shader 從一個 buffer 讀取當前狀態,計算下一代寫入另一個 buffer,然後交換。Render pipeline 負責把結果畫到畫面上。

專案結構

wgpu-game-of-life/
├── Cargo.toml
├── src/
│   ├── lib.rs           # WASM 進入點,匯出 API
│   ├── gpu.rs           # wgpu 初始化、pipeline 建立、模擬邏輯
│   ├── compute.wgsl     # Compute shader(Game of Life 規則)
│   └── render.wgsl      # Vertex + Fragment shader(網格視覺化)
├── index.html
└── www/
    ├── index.js         # 控制邏輯、動畫迴圈、滑鼠互動
    └── styles.css

Rust 依賴項

[dependencies]
wgpu = "24"                        # WebGPU API
wasm-bindgen = "0.2"               # JS 互操作
wasm-bindgen-futures = "0.4"       # async 支援(wgpu 初始化是 async 的)
web-sys = { version = "0.3", features = ["Document", "Window", "Element", "HtmlCanvasElement", "console"] }
console_error_panic_hook = "0.1"
js-sys = "0.3"
bytemuck = { version = "1", features = ["derive"] }

跟 Markdown 編輯器比,多了 wgpu(核心)、wasm-bindgen-futures(因為 GPU 初始化是非同步的)和 bytemuck(安全地把 Rust 資料轉成 GPU buffer 需要的位元組)。

Compute Shader:Game of Life 規則

這是整個專案最核心的部分——用 WGSL 寫的 compute shader:

@group(0) @binding(0) var<uniform> grid: vec2u;
@group(0) @binding(1) var<storage, read> cells_in: array<u32>;
@group(0) @binding(2) var<storage, read_write> cells_out: array<u32>;

@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3u) {
    if (id.x >= grid.x || id.y >= grid.y) { return; }

    // 數 8 個鄰居(環形邊界)
    var neighbors: u32 = 0u;
    for (var dy: i32 = -1; dy <= 1; dy++) {
        for (var dx: i32 = -1; dx <= 1; dx++) {
            if (dx == 0 && dy == 0) { continue; }
            let nx = u32((i32(id.x) + dx + i32(grid.x)) % i32(grid.x));
            let ny = u32((i32(id.y) + dy + i32(grid.y)) % i32(grid.y));
            neighbors += select(0u, 1u, cells_in[cell_index(nx, ny)] > 0u);
        }
    }

    let idx = cell_index(id.x, id.y);
    let age = cells_in[idx];

    // Conway's rules + 年齡追蹤
    if (neighbors == 3u && age == 0u) {
        cells_out[idx] = 1u;                    // 誕生
    } else if (age > 0u && (neighbors == 2u || neighbors == 3u)) {
        cells_out[idx] = min(age + 1u, 255u);   // 存活,年齡 +1
    } else {
        cells_out[idx] = 0u;                    // 死亡
    }
}

幾個重點:

  • @workgroup_size(8, 8):每個工作群組處理 8x8 = 64 個細胞,GPU 會自動分配到各個核心
  • cells_in 是唯讀,cells_out 是可寫:避免讀寫衝突,這就是為什麼需要兩個 buffer
  • 環形邊界(toroidal wrapping):左邊超出會接到右邊,上面超出接到下面
  • 年齡追蹤:不只是 0/1,而是記錄細胞存活了幾代(上限 255)

128x128 的網格需要 dispatch ceil(128/8) × ceil(128/8) = 16 × 16 = 256 個工作群組,每個群組 64 個執行緒,總共 16,384 個 GPU 執行緒平行計算。

Render Shader:年齡上色

Fragment shader 根據年齡把細胞染成不同顏色:

fn age_color(age: u32) -> vec4f {
    if (age == 0u) {
        return vec4f(0.06, 0.06, 0.12, 1.0);  // 死亡:深色背景
    }
    let t = clamp(f32(age - 1u) / 50.0, 0.0, 1.0);

    // 顏色漸層:亮綠 → 黃綠 → 橘 → 暖白
    let c0 = vec3f(0.15, 0.90, 0.30);  // 新生
    let c1 = vec3f(0.80, 0.90, 0.15);  // 年輕
    let c2 = vec3f(0.95, 0.60, 0.10);  // 成熟
    let c3 = vec3f(1.00, 0.85, 0.70);  // 古老

    // 三段線性插值
    if (t < 0.33) { return mix(c0, c1, t / 0.33); }
    else if (t < 0.66) { return mix(c1, c2, (t - 0.33) / 0.33); }
    else { return mix(c2, c3, (t - 0.66) / 0.34); }
}

渲染方式是畫一個全螢幕四邊形(6 個頂點、2 個三角形),fragment shader 根據 UV 座標查詢對應的細胞年齡。這比為每個細胞生成幾何體(instanced rendering)更簡單,而且效能足夠。

Rust 端:wgpu 初始化

wgpu 在 WASM 環境下的初始化跟 native 基本一樣,只是 surface 從 canvas 建立:

// 從 HTML canvas 建立 surface
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
    backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
    ..Default::default()
});

let surface = instance
    .create_surface(wgpu::SurfaceTarget::Canvas(canvas))
    .expect("failed to create surface");

// 請求 adapter 和 device(非同步)
let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
    compatible_surface: Some(&surface),
    ..Default::default()
}).await.expect("no adapter");

let (device, queue) = adapter.request_device(
    &wgpu::DeviceDescriptor {
        required_limits: wgpu::Limits::downlevel_webgl2_defaults()
            .using_resolution(adapter.limits()),
        ..Default::default()
    },
    None,
).await.expect("no device");

Backends::BROWSER_WEBGPU | Backends::GL 讓它在支援 WebGPU 的瀏覽器用 WebGPU,不支援的用 WebGL2 作為 fallback。

Ping-Pong 雙緩衝

最有趣的設計是 bind group 的建立——為了實現 ping-pong,我們建兩組 bind group:

let compute_bind_groups = [
    // Step 0: 讀 A,寫 B
    create_bind_group(&cell_buffers[0], &cell_buffers[1]),
    // Step 1: 讀 B,寫 A
    create_bind_group(&cell_buffers[1], &cell_buffers[0]),
];

每一步模擬只要切換 step_index,就自動交換讀寫方向。

WASM API 設計

Rust 端透過 thread_local! 儲存全域狀態,匯出簡單的函式給 JavaScript:

為什麼需要 thread_local!?因為 #[wasm_bindgen] 匯出的必須是自由函式,JavaScript 呼叫 step()render() 時不會帶著物件——所以 Simulation 必須存在模組層級的 static 裡。但一般的 static 要求內容必須實作 Sync,而 RefCell 不是 SyncSimulation 裡面持有的 wgpu 資源(DeviceQueueSurface 等)也不是 Send/Sync 的。thread_local! 讓每個執行緒擁有自己的副本,繞過了 Sync 的限制——在 WASM 環境下本來就只有一個執行緒,所以它實際上就是一個不需要 Sync 的全域可變變數。

thread_local! {
    static SIMULATION: RefCell<Option<Simulation>> = RefCell::new(None);
}

#[wasm_bindgen]
pub async fn start(canvas_id: &str, grid_width: u32, grid_height: u32) {
    console_error_panic_hook::set_once();
    let sim = Simulation::new(canvas_id, grid_width, grid_height).await;
    SIMULATION.with(|s| *s.borrow_mut() = Some(sim));
}

#[wasm_bindgen]
pub fn step() {
    with_sim(|sim| sim.step());
}

#[wasm_bindgen]
pub fn toggle_cell(x: u32, y: u32) {
    with_sim(|sim| sim.toggle_cell(x, y));
}

start()async 的,因為 wgpu 初始化(request_adapterrequest_device)都是非同步操作。其他函式都是同步的。

CPU 端的細胞鏡像

一個實作上的巧妙之處:我們在 CPU 端維護一份細胞狀態的副本。

為什麼?因為當使用者點擊畫布要切換某個細胞時,從 GPU 讀回資料(readback)是很昂貴的操作。所以我們在 CPU 端維護一份鏡像,toggle 時修改 CPU 資料再上傳到 GPU:

pub fn toggle_cell(&mut self, x: u32, y: u32) {
    let idx = (y * self.grid_width + x) as usize;
    self.cells[idx] = if self.cells[idx] > 0 { 0 } else { 1 };
    self.queue.write_buffer(&self.cell_buffers[buf_idx], 0,
        bytemuck::cast_slice(&self.cells));
    self.render();
}

每次 step() 時也同步執行 CPU 端的模擬,確保鏡像保持一致。

建置與執行

cd wgpu-game-of-life
wasm-pack build --target web
python -m http.server 8080
# 開啟 http://localhost:8080

建置輸出:

  • WASM 檔案:117 KB
  • JS 膠水程式碼:57 KB
  • 總計:~174 KB

比 Markdown 編輯器的 235 KB 還小,主要因為 wgpu 的 WASM backend 大部分邏輯在瀏覽器原生的 WebGPU API 裡。

學到的東西

WebGPU / wgpu 特定

  1. Compute shader 比想像中簡單:WGSL 語法接近 Rust,workgroup/dispatch 的概念很直覺
  2. Bind group 是關鍵抽象:它定義了 shader 能存取哪些資源,切換 bind group 就能改變資料流向
  3. wgpu 的跨平台設計很優秀:同一份 Rust 程式碼,換個 backend 就能在 native 和 WASM 上跑
  4. GPU readback 很貴:不要隨便從 GPU 讀資料回來,用 CPU 鏡像是常見的解法

跟 Markdown 編輯器的比較

Markdown 編輯器 Game of Life
GPU 使用 Compute + Render
非同步初始化 是(wgpu 需要 async)
主要瓶頸 CPU 解析 GPU shader 編譯
互動模式 文字輸入 滑鼠繪圖 + 動畫迴圈
套件大小 235 KB 174 KB

結語

這是我第一次寫 GPU shader 程式——用 Rust 配 wgpu 的體驗非常好。wgpu 把 WebGPU 的複雜性封裝得很乾淨,而 WGSL shader 語言的設計也很現代。

如果你也想學 WebGPU,Game of Life 真的是個很好的起點:概念簡單、視覺效果漂亮、而且剛好涵蓋 compute pipeline 和 render pipeline 兩個核心概念。

參考資源


深入理解 cargo-apk:從 Rust 到 Android APK 的完整流程

featured.svg

當我們用 Rust 開發 Android 應用時,cargo-apk 是一個自動化整個建置流程的工具——從 Rust 原始碼編譯、產生 AndroidManifest.xml、整合 Gradle、到最後簽章產出 APK。這篇文章深入探討 cargo-apk 的內部運作機制,以及現代替代方案。

什麼是 cargo-apk

cargo-apk 是 rust-mobile 團隊開發的命令列工具,負責:

  • 將 Rust cdylib 編譯為多個架構的 .so 檔案(ARM64、ARMv7、x86 等)
  • 從 Cargo.toml 自動產生 AndroidManifest.xml
  • 呼叫 aapt/aapt2 處理 Android 資源
  • 透過 Gradle 組裝 APK(DEX 編譯 + ZIP 打包)
  • 用 keystore 簽章 APK

重要提醒:cargo-apk 在 2024-2025 年已標記為 deprecated,官方推薦改用 xbuild,後者支援跨平台(Android、iOS、Web、桌面)且持續維護。本文仍詳細介紹 cargo-apk 的運作原理,因為理解這些底層機制有助於除錯和選擇合適的建置工具。

1. APK 檔案結構剖析

APK 本質上是一個 ZIP 壓縮檔,包含應用程式執行所需的所有元件。理解 APK 結構是掌握建置流程的第一步。

標準 APK 目錄結構

app.apk (ZIP archive)
├── AndroidManifest.xml         # 應用程式中繼資料(二進位 XML)
├── classes.dex                 # Dalvik Executable(Android Runtime 執行檔)
├── classes2.dex                # 額外的 DEX 檔案(若超過大小限制)
├── resources.arsc              # 編譯後的資源表(二進位格式)
├── META-INF/                   # 簽章和資訊清單
│   ├── MANIFEST.MF             # 套件資訊清單
│   ├── CERT.SF                 # 簽章檔案
│   └── CERT.RSA                # 憑證和簽章資料
├── assets/                     # 開發者自訂的未編譯資源
│   └── (custom files)
├── res/                        # 編譯後的資源(不在 resources.arsc 中)
│   ├── drawable/
│   ├── layout/
│   └── values/
└── lib/                        # 平台特定的原生函式庫
    ├── arm64-v8a/              # ARM 64-bit(現代裝置主流)
    │   └── libflashcard_app.so
    ├── armeabi-v7a/            # ARM 32-bit(舊裝置支援)
    │   └── libflashcard_app.so
    ├── x86/                    # Intel 32-bit(模擬器/平板)
    │   └── libflashcard_app.so
    └── x86_64/                 # Intel 64-bit(模擬器)
        └── libflashcard_app.so

Rust 為什麼編譯成 .so 檔案?

Android 要求所有原生程式碼編譯為動態連結函式庫(shared object, .so),放在 lib/<ABI>/ 目錄下。Rust 專案必須將 crate-type 設為 ["cdylib"]

[lib]
crate-type = ["cdylib"]

cdylib 產生 C-compatible 的動態函式庫,Android Runtime 可以在執行時載入並呼叫其中的符號(透過 System.loadLibrary())。

2. 建置流程五階段

cargo-apk 的建置流程可以拆解為五個連續的階段。理解每個階段的輸入輸出,有助於除錯建置問題。

Phase 1: Rust 編譯

cargo-apk 針對每個目標架構呼叫 rustc

# 對於 ARM64
rustc --target aarch64-linux-android \
      --crate-type cdylib \
      -C linker=aarch64-linux-android-clang \
      ...

# 對於 ARMv7
rustc --target armv7-linux-androideabi \
      --crate-type cdylib \
      -C linker=armv7a-linux-androideabi-clang \
      ...

關鍵環節:

  • NDK 工具鏈選擇:每個架構使用對應的 clang linker(由 ANDROID_NDK_ROOT 提供)
  • Sysroot 連結:連結到 NDK 的平台 headers 和 libraries
  • RUSTFLAGS 設定:加入 -L 指定 NDK 函式庫路徑

輸出:

target/aarch64-linux-android/release/libmyapp.so
target/armv7-linux-androideabi/release/libmyapp.so
target/x86_64-linux-android/release/libmyapp.so

Phase 2: AndroidManifest.xml 產生

cargo-apk 從 Cargo.toml 的 [package.metadata.android] 讀取設定,自動產生 manifest:

[package.metadata.android]
package = "com.example.flashcard_app"
min_sdk_version = 21
target_sdk_version = 33

# 權限宣告
permissions = [
    "android.permission.INTERNET",
    "android.permission.WRITE_EXTERNAL_STORAGE"
]

# 建置目標架構
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi"]

# Activity 主題
activity_theme = "@android:style/Theme.NoTitleBar.Fullscreen"

產生的 AndroidManifest.xml 範例:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.flashcard_app">

    <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33"/>
    <uses-permission android:name="android.permission.INTERNET"/>

    <application>
        <activity android:name="android.app.NativeActivity"
                  android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
            <!-- 指定要載入的原生函式庫名稱 -->
            <meta-data android:name="android.app.lib_name"
                       android:value="flashcard_app"/>

            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

關鍵觀念:

  • NativeActivity:Android 提供的類別,自動處理原生程式碼的生命週期
  • android.app.lib_name:指定要載入的 .so 檔案名稱(不含 lib 前綴和 .so 後綴)
  • Rust 程式碼必須實作 android_main 函式作為進入點

Phase 3: 資源處理(aapt/aapt2)

Android Asset Packaging Tool 負責編譯資源檔:

aapt2 compile --dir res/ -o compiled_resources/
aapt2 link --manifest AndroidManifest.xml \
           -o base.apk \
           compiled_resources/*.flat

輸出:

  • resources.arsc:二進位資源表,包含所有字串、顏色、尺寸定義
  • 編譯後的 XML 資源檔案

Phase 4: Gradle APK 組裝

cargo-apk 呼叫 Gradle 進行最終組裝:

  1. 複製 .so 檔案jniLibs/<arch>/
  2. 編譯 Java/Kotlin 程式碼(若有)為 bytecode
  3. 產生 DEX:將 bytecode 轉換為 Dalvik Executable
  4. 打包 ZIP:將所有元件組合成 APK

Gradle 的 build.gradle 片段:

android {
    compileSdkVersion 33

    sourceSets {
        main {
            jniLibs.srcDirs = ['jniLibs']  // 原生函式庫來源
        }
    }
}

Phase 5: APK 簽章

最後一步是用 keystore 簽章 APK:

Debug 簽章(預設):

apksigner sign --ks ~/.android/debug.keystore \
               --ks-pass pass:android \
               --ks-key-alias androiddebugkey \
               app-debug.apk

Release 簽章(自訂 keystore):

[package.metadata.android]
release_keystore_path = "/path/to/release.jks"
release_keystore_password = "env:KEYSTORE_PASSWORD"
release_keystore_key_alias = "my-key"

簽章過程會在 APK 的 META-INF/ 目錄加入三個檔案:

  • MANIFEST.MF:列出 APK 中所有檔案及其 SHA-256 雜湊值
  • CERT.SF:MANIFEST.MF 的簽章
  • CERT.RSA:憑證和 RSA 簽章資料

Android OS 在安裝時會驗證這些簽章,確保 APK 沒有被竄改。

3. Cargo.toml 設定詳解

完整的 [package.metadata.android] 設定範例:

[package.metadata.android]
# 應用程式識別碼
package = "com.example.flashcard_app"
apk_name = "flashcard-app"

# SDK 版本
min_sdk_version = 21      # Android 5.0 Lollipop
target_sdk_version = 33   # Android 13
max_sdk_version = 34

# 建置目標架構
build_targets = [
    "aarch64-linux-android",      # ARM64(主要)
    "armv7-linux-androideabi"     # ARMv7(相容性)
]

# 權限宣告
permissions = [
    "android.permission.INTERNET",
    "android.permission.ACCESS_FINE_LOCATION"
]

# 硬體功能需求
features = ["android.hardware.location"]

# Activity 設定
activity_theme = "@android:style/Theme.NoTitleBar.Fullscreen"
main_activity_attributes = {
    "android:screenOrientation" = "portrait"
}

# Intent Filters(深度連結)
[[package.metadata.android.intent_filters]]
actions = ["android.intent.action.VIEW"]
categories = ["android.intent.category.DEFAULT", "android.intent.category.BROWSABLE"]
data = [{ scheme = "https", host = "example.com", path_prefix = "/app" }]

# 除錯設定
strip_symbols = false     # 保留符號以供除錯
debug = true

# Release 簽章
release_keystore_path = "/path/to/release.jks"
release_keystore_password = "env:KEYSTORE_PASSWORD"
release_keystore_key_alias = "my-key"
release_keystore_key_password = "env:KEY_PASSWORD"

4. NDK 整合機制

cargo-apk 如何找到並使用 Android NDK:

NDK 路徑解析

優先順序:

  1. ANDROID_NDK_ROOT 環境變數
  2. ANDROID_NDK_HOME 環境變數
  3. local.properties 檔案中的 ndk.dir

工具鏈選擇(按架構)

架構 Rust Target NDK Linker
ARM 64-bit aarch64-linux-android aarch64-linux-android-clang
ARM 32-bit armv7-linux-androideabi armv7a-linux-androideabi-clang
Intel 64-bit x86_64-linux-android x86_64-linux-android-clang
Intel 32-bit i686-linux-android i686-linux-android-clang

Sysroot 和 API Level

NDK 為每個 API level 提供不同的 sysroot(系統標頭檔和函式庫):

$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/
├── sysroot/
│   └── usr/
│       ├── include/         # C/C++ 標頭檔
│       └── lib/
│           ├── aarch64-linux-android/21/  # API 21 的 ARM64 函式庫
│           ├── aarch64-linux-android/28/  # API 28 的 ARM64 函式庫
│           └── ...

Rust 編譯時會連結到對應 API level 的 sysroot,確保產生的 .so 與目標 Android 版本相容。

5. 與手動 NDK 整合的比較

面向 cargo-apk 手動 NDK 整合
設定複雜度 cargo install cargo-apk 下載 NDK、設定路徑、撰寫建置腳本
Manifest 產生 自動從 Cargo.toml 手動編寫 XML
多架構建置 自動迴圈處理 每個架構手動呼叫
APK 組裝 Gradle 自動整合 手動呼叫 aapt + gradle
簽章 自動 keystore 處理 手動呼叫 apksigner
控制粒度 高層自動化 完全控制每個步驟
學習曲線 低(單一指令) 高(理解整個 Android 建置鏈)

cargo-apk 的核心價值是自動化——一行指令完成從 Rust 到 APK 的所有步驟。但代價是較少的客製化彈性,並且依賴 Gradle(可能增加建置時間)。

6. 目前狀態與替代方案

cargo-apk 的現況

cargo-apk 在 2024 年標記為 deprecated,原因:

  • 缺乏跨平台支援(僅限 Android)
  • 維護資源有限
  • 更現代的工具(xbuild)提供更好的開發體驗

xbuild:現代化的替代方案

xbuild 是 rust-mobile 團隊的新工具,支援多平台:

優勢

  • 跨平台支援:Android、iOS、Windows、Linux、Web
  • 統一命令介面x buildx runx doctor
  • 裝置管理x devices 列出所有連接的裝置
  • 持續開發:活躍維護,積極修復問題
  • App Store 發佈:內建發佈到 Google Play 和 App Store 的支援

安裝與使用

# 安裝
cargo install xbuild

# 檢查環境
x doctor

# 建置並部署到裝置
x devices                        # 列出裝置
x build --device adb:<device-id> # 建置並安裝

三階段建置流程

  1. Fetch:下載預編譯的 Rust 標準函式庫
  2. Build:透過 Cargo 編譯 Rust 程式碼
  3. Package:產生平台特定套件(APK、iOS bundle 等)

cargo-ndk:函式庫專用工具

cargo-ndk 適合只需要編譯 Rust 函式庫(不需要完整 APK)的專案:

cargo install cargo-ndk

cargo ndk -t arm64-v8a -t armeabi-v7a \
          -o ./jniLibs \
          build --release

輸出為標準的 jniLibs/ 目錄結構,可直接整合到現有的 Android Studio 專案。

工具比較表

工具 用途 平台支援 開發狀態 適用場景
cargo-apk 完整 APK 建置 Android only Deprecated (2024) 舊專案維護
xbuild 跨平台應用建置 Android/iOS/Web/Desktop 活躍開發 新專案首選
cargo-ndk NDK 函式庫編譯 Android (library) 活躍開發 整合到現有 Android 專案

7. 踩過的坑與解決方案

實際使用 cargo-apk 時常見的問題:

問題 1:NDK 找不到

Error: Failed to read source.properties: Os { code: 2, kind: NotFound }

原因:cargo-apk 找不到 NDK 安裝路徑。

解法

# 設定環境變數(Windows PowerShell)
[Environment]::SetEnvironmentVariable("ANDROID_NDK_ROOT", "C:\Users\p47ts\scoop\apps\android-clt\current\ndk\29.0.14206865", "User")
[Environment]::SetEnvironmentVariable("ANDROID_HOME", "C:\Users\p47ts\scoop\apps\android-clt\current", "User")

# 或在當前 shell(bash)
export ANDROID_NDK_ROOT="/path/to/ndk/29.0.14206865"
export ANDROID_HOME="/path/to/android-sdk"

問題 2:錯誤的架構

APK 安裝後閃退,logcat 顯示:

E/linker: library "libmyapp.so" not found

原因:裝置的 ABI 與 build_targets 不符(例如裝置是 ARM64,但只編譯了 ARMv7)。

解法

確認裝置 ABI:

adb shell getprop ro.product.cpu.abi
# 輸出:arm64-v8a

修改 Cargo.toml 加入對應架構:

[package.metadata.android]
build_targets = ["aarch64-linux-android"]  # 對應 arm64-v8a

問題 3:APK 檔案過大

Release APK 超過 100MB,包含大量除錯符號。

解法

啟用符號剝離:

[package.metadata.android]
strip_symbols = true  # 移除除錯符號

或在 Cargo.toml 設定 release profile:

[profile.release]
strip = true       # 剝離符號
opt-level = "z"    # 最小化檔案大小
lto = true         # Link-Time Optimization

問題 4:Feature flag 統一導致桌面建置失敗

使用 target-conditional 依賴時,Cargo 仍會統一 feature:

# ❌ 錯誤:桌面建置也會拉入 Android 依賴
[target.'cfg(target_os = "android")'.dependencies]
slint = { version = "1.9", features = ["backend-android-activity-06"] }

解法

改用 feature flag:

[features]
android = ["slint/backend-android-activity-06"]

[dependencies]
slint = "1.9"

建置時明確啟用:

cargo apk build --features android --target aarch64-linux-android --lib

問題 5:Windows 上的 PDB 檔名衝突

warning: output filename collision.
The bin target `flashcard-app` has the same output filename as the lib target `flashcard_app`.
Colliding filename is: flashcard_app.pdb

原因crate-type = ["cdylib", "lib"] 搭配同名的 [[bin]] 在 Windows 產生相同的 PDB 除錯檔案。

解法

讓 bin 名稱與 package 名稱不同:

[package]
name = "flashcard-app"

[[bin]]
name = "flashcard"  # 不同於 package name
path = "src/main.rs"

8. 完整建置範例

從零開始建置 Android APK 的完整流程:

# 1. 建立專案
cargo new --lib my-android-app
cd my-android-app

# 2. 設定 Cargo.toml
cat >> Cargo.toml << 'EOF'
[lib]
crate-type = ["cdylib"]

[package.metadata.android]
package = "com.example.myapp"
min_sdk_version = 21
target_sdk_version = 33
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi"]
EOF

# 3. 撰寫 Rust 入口點(src/lib.rs)
cat > src/lib.rs << 'EOF'
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
    // 應用程式邏輯
}
EOF

# 4. 設定環境變數(依實際路徑調整)
export ANDROID_NDK_ROOT="/path/to/ndk/29.0.14206865"
export ANDROID_HOME="/path/to/android-sdk"

# 5. 安裝 Rust Android target
rustup target add aarch64-linux-android armv7-linux-androideabi

# 6. 安裝 cargo-apk
cargo install cargo-apk

# 7. 建置 APK
cargo apk build --release

# 內部流程:
#   [Phase 1] rustc --target aarch64-linux-android ...
#             → target/aarch64-linux-android/release/libmyapp.so
#   [Phase 2] Generate AndroidManifest.xml from Cargo.toml
#   [Phase 3] aapt2 compile resources
#   [Phase 4] Gradle: package DEX + .so → APK
#   [Phase 5] apksigner sign with release.jks
#
# 輸出:target/release/apk/my-android-app.apk

# 8. 安裝到裝置
cargo apk run --release

建置完成後,APK 位置:

target/
└── release/
    └── apk/
        └── my-android-app.apk  # 已簽章的 APK

檢查 APK 內容:

unzip -l target/release/apk/my-android-app.apk
# 輸出:
# lib/arm64-v8a/libmyapp.so
# lib/armeabi-v7a/libmyapp.so
# AndroidManifest.xml
# classes.dex
# META-INF/MANIFEST.MF
# ...

總結

從 Rust 程式碼到可安裝的 Android APK,cargo-apk 自動化了複雜的建置鏈:

階段 輸入 輸出 關鍵技術
1. Rust 編譯 src/*.rs + Cargo.toml libmyapp.so (多架構) rustc + NDK toolchain
2. Manifest 產生 [package.metadata.android] AndroidManifest.xml NativeActivity 設定
3. 資源處理 res/, assets/ resources.arsc aapt/aapt2
4. APK 組裝 .so + DEX + resources unsigned APK (ZIP) Gradle
5. 簽章 APK + keystore signed APK apksigner

何時使用哪個工具?

  • 新專案:優先選擇 xbuild(跨平台、活躍開發、統一工具鏈)
  • 整合現有 Android 專案:使用 cargo-ndk(只編譯 .so,由 Android Studio 處理其餘)
  • 舊專案維護:可繼續使用 cargo-apk,但建議遷移到 xbuild

關鍵觀念回顧

  1. APK 是 ZIP:理解目錄結構有助於除錯打包問題
  2. cdylib 必要性:Android 只能載入動態函式庫
  3. 多架構建置:一個 APK 包含所有架構的 .so,安裝時系統自動選擇
  4. Manifest 自動化:Cargo.toml 配置轉換為 Android 設定
  5. NDK 整合:正確設定環境變數是成功建置的關鍵

透過理解這些底層機制,即使遇到建置問題也能快速定位原因並解決。

參考資源


從間隔重複記憶卡學習 Rust 程式設計

featured.svg

最近用 Slint UI 框架實作了一個間隔重複記憶卡應用,可以在桌面和 Android 上執行。這個專案涵蓋了狀態管理、演算法實作、跨平台建置等實用的 Rust 程式設計概念。

專案概述

這個記憶卡應用的功能:

  • SM-2 間隔重複演算法排程複習
  • 四個畫面:卡牌組列表、學習、新增卡片、統計
  • JSON 檔案持久化儲存
  • 同時支援桌面與 Android 平台
  • 內建範例卡牌組(Rust 基礎、世界首都)

1. Slint 的宣告式 UI 與頁面路由

Slint 使用自己的標記語言定義 UI,和 Rust 程式碼分離。頁面路由透過一個整數屬性控制,用條件渲染切換畫面:

export component MainWindow inherits Window {
    // 0=卡牌組列表, 1=學習, 2=編輯, 3=統計
    in-out property <int> current-page: 0;

    // 條件渲染:只有符合條件的頁面會被建立
    if current-page == 0: DeckListPage {
        study-deck(idx) => { root.study-deck(idx); }
    }

    if current-page == 1: StudyPage {
        revealed: root.card-revealed;
        reveal-card() => { root.reveal-card(); }
        rate-card(q) => { root.rate-card(q); }
    }
}

Slint 的 .slint 檔案在編譯時由 slint-build 轉換為 Rust 程式碼,所以 UI 結構的型別檢查在編譯期就完成。.slint 中定義的 struct 和 callback 會自動產生對應的 Rust 型別和方法。

值得注意的是,Slint 的屬性名稱使用 kebab-case(例如 card-count),在 Rust 端會自動轉換為 snake_case(card_count)。

2. Rc<RefCell<T>>:單執行緒的內部可變性

Slint 的事件迴圈是單執行緒的,所以不需要 Arc<Mutex<T>>。改用 Rc<RefCell<T>> 來在多個閉包間共享可變狀態:

let state = Rc::new(RefCell::new(AppState {
    decks,
    current_session: None,
    editor_deck_index: None,
    data_path: path,
}));

// 每個 callback 閉包都 clone 一份 Rc
{
    let state = Rc::clone(&state);
    window.on_study_deck(move |deck_index| {
        let mut st = state.borrow_mut(); // 執行時借用檢查
        // 修改 st...
    });
}

{
    let state = Rc::clone(&state);
    window.on_rate_card(move |rating_int| {
        let mut st = state.borrow_mut();
        // 修改 st...
    });
}

關鍵觀念:

  • Rc:引用計數智慧指標,讓多個閉包共享同一份資料的所有權
  • RefCell:將借用檢查從編譯期移到執行期,允許在不可變引用的情況下修改內容
  • Rc::clone:只增加引用計數(O(1)),不會深複製資料
  • 這個模式比 Arc<Mutex<T>> 更輕量——沒有原子操作、沒有鎖的開銷——但只能在單執行緒使用(多執行緒會無法編譯)

3. Weak 引用避免循環參考

每個 callback 都需要存取 Slint 視窗來更新 UI,但直接持有視窗的強引用會造成循環參考。Slint 提供 as_weak() 來解決:

let window_weak = window.as_weak();
window.on_reveal_card(move || {
    let window = window_weak.unwrap(); // 從 Weak 升級為強引用
    window.set_card_revealed(true);
});

ComponentHandleModel 是 Slint 的 trait,需要明確引入才能使用 as_weak()run()row_count() 等方法:

use slint::{ComponentHandle, Model, ModelRc, VecModel};

4. 借用衝突的實戰解法

rate_card callback 是整個專案最複雜的部分——需要同時讀取 session 資訊和修改卡片資料,但它們都在同一個 AppState 裡:

// 這樣寫會編譯失敗!
let session = st.current_session.as_mut(); // &mut st
let card = &st.decks[session.deck_index];  // &st — 衝突!

解法:先用 as_ref() 從 session 提取需要的純值到區域變數,釋放對 st 的借用後再存取 st.decks

// 先提取 session 的純值(Copy 型別),立即釋放借用
let (deck_idx, card_idx, _position, due_len) = {
    let session = match st.current_session.as_ref() {
        Some(s) => s,
        None => return,
    };
    (
        session.deck_index,
        session.due_cards[session.current_position],
        session.current_position,
        session.due_cards.len(),
    )
}; // session 的借用在這裡結束

// 現在可以安全地借用 st.decks
let result = sm2::review(&st.decks[deck_idx].cards[card_idx], rating);
let card = &mut st.decks[deck_idx].cards[card_idx];
card.ease_factor = result.new_ease_factor;
// ...

// 再次借用 session 來更新進度
let session = st.current_session.as_mut().unwrap();
session.cards_reviewed += 1;
session.current_position += 1;

這個模式的核心概念:當同一個 struct 的不同欄位需要不同的借用模式時,用區域變數搭配作用域來交錯借用。Rust 的借用檢查器是以作用域為單位的,所以只要確保可變和不可變借用不重疊,就能通過編譯。

5. SM-2 間隔重複演算法

SM-2 是 SuperMemo 2 演算法的簡稱,核心概念很簡單:答對的卡片間隔越來越長,答錯就重置。實作為純函數,方便測試:

pub fn review(card: &Card, rating: ReviewRating) -> ReviewResult {
    let q = rating.quality() as f64;

    // 更新簡易度因子:EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
    let new_ef = (card.ease_factor
        + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02)))
        .max(1.3); // 最低 1.3

    let (new_interval, new_repetition) = if rating == ReviewRating::Again {
        (1, 0) // 答錯:重置為 1 天
    } else {
        let new_rep = card.repetition + 1;
        let interval = match new_rep {
            1 => 1,                                          // 第一次:1 天
            2 => 6,                                          // 第二次:6 天
            _ => (card.interval as f64 * new_ef).ceil() as u32, // 之後:前次間隔 × EF
        };
        (interval, new_rep)
    };

    ReviewResult {
        new_ease_factor: new_ef,
        new_interval,
        new_repetition,
        next_review: Utc::now() + chrono::Duration::days(new_interval as i64),
    }
}

幾個設計重點:

  • 純函數:輸入卡片狀態和評分,輸出新的排程結果。不修改任何全域狀態
  • match 做模式比對:前兩次複習有固定間隔(1 天、6 天),第三次開始才用 EF 計算
  • max(1.3) 限制下限:避免 EF 降到太低讓間隔收斂到 0

判斷哪些卡片到期也很直觀:

pub fn due_cards(cards: &[Card]) -> Vec<usize> {
    let now = Utc::now();
    cards
        .iter()
        .enumerate()
        .filter(|(_, card)| card.next_review <= now)
        .map(|(i, _)| i)
        .collect()
}

6. 跨平台建置:桌面與 Android

這個專案同時支援桌面和 Android。關鍵在於 Cargo 的條件編譯和 feature flag:

[lib]
crate-type = ["cdylib", "lib"]  # cdylib 給 Android,lib 給桌面

[[bin]]
name = "flashcard"              # 桌面執行檔名稱和 package 不同,避免 PDB 衝突
path = "src/main.rs"

[dependencies]
slint = "1.9"

[features]
android = ["slint/backend-android-activity-06"]  # Android 後端用 feature 控制

入口點用 #[cfg] 區分:

// src/main.rs — 桌面入口
fn main() {
    flashcard_app::run();
}

// src/lib.rs — Android 入口
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
    slint::android::init(app).unwrap();
    run();
}

踩過的坑:

  • PDB 檔名衝突:在 Windows 上,crate-type = ["cdylib", "lib"] 搭配同名的 [[bin]] 會產生 PDB(程式偵錯資訊)衝突。解法是讓 bin 名稱和 package 名稱不同(package = flashcard-app,bin = flashcard
  • Feature 統一問題:用 [target.'cfg(target_os = "android")'.dependencies] 指定 Android 依賴,Cargo 仍會統一 feature,導致桌面建置也拉入 NDK 依賴。解法是改用 [features] 區段,建置 Android 時明確傳入 --features android
  • #[unsafe(no_mangle)]:這個語法需要 nightly Rust,stable 上要用 #[no_mangle]

7. 共享結構避免循環匯入

Slint 的多檔案架構可能遇到循環匯入。例如 main.slint 匯入 deck_list.slint,而 deck_list.slint 又需要 main.slint 中定義的 DeckInfo struct。

解法:把共享的 struct 抽到獨立的 types.slint

// ui/types.slint — 共享的資料結構
export struct DeckInfo {
    name: string,
    description: string,
    card-count: int,
    due-count: int,
}
// ui/deck_list.slint — 從 types.slint 匯入,不匯入 main.slint
import { DeckInfo } from "types.slint";

export component DeckListPage inherits Rectangle {
    in property <[DeckInfo]> decks;
    // ...
}
// ui/main.slint — 匯入 components 和 re-export types
import { DeckListPage } from "deck_list.slint";
export { DeckInfo, DeckStats } from "types.slint";

8. JSON 序列化與 include_str!

資料持久化用 serde + serde_json,模型上加 derive 就搞定:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Card {
    pub id: Uuid,
    pub front: String,
    pub back: String,
    pub ease_factor: f64,
    pub interval: u32,
    pub repetition: u32,
    pub next_review: DateTime<Utc>,
    // ...
}

範例資料用 include_str! 在編譯時嵌入二進位檔:

pub fn sample_decks() -> Vec<Deck> {
    let data = include_str!("../data/sample_decks.json");
    let samples: Vec<SampleDeck> = serde_json::from_str(data)
        .expect("invalid sample data");

    samples.into_iter().map(|sd| {
        let mut deck = Deck::new(sd.name, sd.description);
        deck.cards = sd.cards.into_iter()
            .map(|c| Card::new(c.front, c.back))
            .collect();
        deck
    }).collect()
}

include_str! 在編譯期讀取檔案內容,嵌入到二進位檔中。好處是不需要在執行時處理檔案路徑問題,特別適合 Android 環境。

總結

這個記憶卡專案涵蓋了多個重要的 Rust 概念:

概念 應用場景
Rc<RefCell<T>> 單執行緒多閉包共享可變狀態
Weak 引用 避免 UI 元件的循環參考
借用衝突解法 用作用域交錯不同借用模式
SM-2 演算法 純函數實作間隔重複排程
Feature flag 條件編譯控制跨平台依賴
cfg 屬性 桌面 vs Android 入口點切換
Slint 宣告式 UI 編譯時型別安全的 UI 定義
include_str! 編譯期嵌入靜態資源
serde derive 零樣板的 JSON 序列化

如果你想找一個結合 UI 框架、演算法和跨平台建置的 Rust 練習專案,間隔重複記憶卡是個很好的選擇。從 SM-2 演算法開始,逐步加入 UI、持久化、Android 支援,每一步都能學到不同面向的 Rust 技巧。

參考資源


從吉他指板視覺化學習 Rust 程式設計

featured.svg

最近用 iced 框架實作了一個吉他指板視覺化工具,可以顯示 C 大調音階、繪製弦和琴格,並在點擊音符時播放逼真的撥弦音效。這個專案雖然不大,但涵蓋了多個實用的 Rust 程式設計概念。

專案概述

這個吉他指板視覺化工具的功能:

  • 顯示標準調弦(E A D G B E)的 22 格指板
  • 以不同顏色標示 C 大調音階音符
  • 使用 Canvas 繪製琴弦、琴格和位置標記
  • 點擊音符播放 Karplus-Strong 合成的撥弦音效
  • 半透明音符圓圈,讓底層指板隱約可見

1. The Elm Architecture(TEA)

iced 框架採用 The Elm Architecture,這是一種函數式 UI 架構。整個應用只需要三個核心元素:

struct App {
    _output_stream: Option<OutputStream>,
    stream_handle: Option<rodio::OutputStreamHandle>,
}

#[derive(Debug, Clone)]
enum Message {
    NoteClicked(usize, usize), // (string_index, fret)
}

impl App {
    fn update(&mut self, message: Message) {
        match message {
            Message::NoteClicked(string_idx, fret) => {
                self.play_note(string_idx, fret);
            }
        }
    }

    fn view(&self) -> Element<'_, Message> {
        // 建構 UI 樹
    }
}

TEA 的三個核心:

  • ModelApp struct):應用程式的狀態
  • MessageMessage enum):所有可能的使用者互動事件
  • Update + Viewupdate 根據 Message 更新狀態,view 根據狀態產生 UI

這種架構的好處是單向資料流——狀態變更只透過 Message 觸發,UI 是狀態的純函數,讓程式邏輯容易理解和除錯。

2. 實作 Trait 來整合外部框架

這個專案大量使用了 Rust 的 trait 系統來與兩個外部框架(iced 和 rodio)整合。

Canvas 繪圖:實作 canvas::Program

iced 的 Canvas widget 要求實作 canvas::Program trait 來自定義繪圖邏輯:

struct FretboardCanvas;

impl canvas::Program<Message> for FretboardCanvas {
    type State = ();

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: iced::mouse::Cursor,
    ) -> Vec<canvas::Geometry<Renderer>> {
        let mut frame = canvas::Frame::new(renderer, bounds.size());

        // 繪製指板背景(木頭色)
        frame.fill_rectangle(
            Point::new(fretboard_x, fretboard_y),
            Size::new(fretboard_width, fretboard_height),
            canvas::Fill::from(iced::Color::from_rgb8(0x3d, 0x2b, 0x1f)),
        );

        // 繪製琴格(銀色垂直線)
        for fret in 1..NUM_FRETS {
            let x = fretboard_x + (fret as f32 + 1.0) * FRET_WIDTH - 2.0;
            frame.fill_rectangle(
                Point::new(x, fretboard_y),
                Size::new(3.0, fretboard_height),
                canvas::Fill::from(iced::Color::from_rgb8(0xc0, 0xc0, 0xc0)),
            );
        }

        // 繪製弦(粗細和顏色依弦種不同)
        let string_thicknesses = [3.0, 2.5, 2.0, 1.5, 1.2, 1.0];

        vec![frame.into_geometry()]
    }
}

這裡展示了 trait 的強大之處——只要實作 draw 方法,就能在 iced 的 Canvas 上繪製任意圖形。FretboardCanvas 是一個 zero-sized type(ZST),不佔任何記憶體,純粹作為 trait 實作的載體。

音訊來源:實作 Iterator + Source

rodio 的音訊播放需要實作兩個 trait:

impl Iterator for KarplusStrong {
    type Item = f32;

    fn next(&mut self) -> Option<f32> {
        if self.samples_remaining == 0 {
            return None;
        }
        self.samples_remaining -= 1;

        let current = self.buffer[self.index];

        // 低通濾波器:與下一個取樣值取平均
        let next_idx = (self.index + 1) % self.buffer.len();
        let filtered = (current + self.buffer[next_idx]) * 0.5 * self.decay;

        self.buffer[self.index] = filtered;
        self.index = (self.index + 1) % self.buffer.len();

        Some(current)
    }
}

impl Source for KarplusStrong {
    fn current_frame_len(&self) -> Option<usize> { None }
    fn channels(&self) -> u16 { 1 }
    fn sample_rate(&self) -> u32 { self.sample_rate }
    fn total_duration(&self) -> Option<Duration> { None }
}

關鍵觀念:

  • Iterator 提供逐一產生取樣值的能力
  • Source 描述音訊格式(取樣率、聲道數等)
  • 兩者結合後,rodio 就能播放自訂的音訊來源

3. Karplus-Strong 撥弦合成

這是本專案最有趣的部分——用物理建模合成法模擬吉他撥弦聲。

struct KarplusStrong {
    buffer: Vec<f32>,         // 環形延遲緩衝區
    index: usize,             // 目前在緩衝區中的位置
    sample_rate: u32,
    samples_remaining: usize, // 持續時間控制
    decay: f32,               // 衰減因子
}

impl KarplusStrong {
    fn new(frequency: f32, duration_ms: u64) -> Self {
        let sample_rate = 44100u32;
        let delay_samples = (sample_rate as f32 / frequency).round() as usize;
        let total_samples = (sample_rate as u64 * duration_ms / 1000) as usize;

        // 用白噪音填充緩衝區(模擬撥弦的初始能量)
        let mut rng = rand::thread_rng();
        let buffer: Vec<f32> = (0..delay_samples)
            .map(|_| rng.gen::<f32>() * 2.0 - 1.0)
            .collect();

        Self {
            buffer,
            index: 0,
            sample_rate,
            samples_remaining: total_samples,
            decay: 0.999,
        }
    }
}

演算法的核心概念:

  1. 延遲緩衝區長度決定音高delay_samples = sample_rate / frequency,例如 440Hz 的 A 音需要 44100 / 440 = 100 個取樣
  2. 白噪音初始化:隨機值模擬撥弦時弦的不規則振動
  3. 低通濾波回饋:每次取出一個值後,與下一個值取平均再放回,模擬弦的能量自然衰減
  4. decay 參數:控制聲音持續的長度,0.999 接近電吉他的效果

這個演算法展示了 Rust 在數值運算上的優勢——沒有 GC 暫停,每個取樣都能在確定的時間內計算完成,非常適合即時音訊處理。

4. Stack:UI 圖層疊加

指板的視覺效果需要將 Canvas(繪製弦和琴格)與按鈕(互動音符)疊加在一起。iced 的 stack! macro 正好解決這個問題:

fn view_fretboard(&self) -> Element<'_, Message> {
    let fretboard_canvas: Canvas<FretboardCanvas, Message, Theme, Renderer> =
        canvas(FretboardCanvas)
            .width(canvas_width as u16)
            .height(canvas_height as u16);

    // Canvas 在底層,按鈕在上層
    stack![fretboard_canvas, buttons_layer]
        .width(Length::Fill)
        .height(canvas_height as u16)
        .into()
}

stack! 的行為類似 CSS 的 position: absolute——後面的元素疊加在前面的元素上方。搭配半透明的按鈕背景,可以讓底層的指板圖案隱約可見:

let (bg_color, text_color) = if is_root {
    (iced::Color::from_rgba8(0xff, 0x9e, 0x64, 0.85), color!(0x1a1b26))
} else if is_c_major {
    (iced::Color::from_rgba8(0x7d, 0xcf, 0xff, 0.60), color!(0x1a1b26))
} else {
    (iced::Color::from_rgba8(0x41, 0x48, 0x68, 0.70), color!(0xa9b1d6))
};

5. 閉包與按鈕樣式

每個音符按鈕都有自定義樣式,根據音符類型(根音、音階內、其他)和互動狀態(一般、hover)顯示不同顏色:

let style = move |_theme: &Theme, status: button::Status| {
    let bg = match status {
        button::Status::Hovered | button::Status::Pressed => {
            if is_root {
                iced::Color::from_rgba8(0xff, 0xb3, 0x80, 0.95)
            } else if is_c_major {
                iced::Color::from_rgba8(0x9d, 0xd6, 0xff, 0.80)
            } else {
                iced::Color::from_rgba8(0x56, 0x5f, 0x89, 0.85)
            }
        }
        _ => bg_color,
    };

    button::Style {
        background: Some(bg.into()),
        text_color,
        border: iced::Border {
            radius: (circle_size / 2.0).into(), // 圓形按鈕
            ..Default::default()
        },
        ..button::Style::default()
    }
};

這裡的重點:

  • move 閉包:將 is_rootis_c_majorbg_color 等變數的所有權移入閉包
  • border.radius 做圓形:設定 radius 為直徑的一半,矩形按鈕變成圓形
  • 多層條件判斷:根音 > 音階音 > 其他音,優先級清晰

6. 常數與領域知識的編碼

將音樂理論編碼為 Rust 常數,讓程式碼自文件化(self-documenting):

const CHROMATIC_NOTES: [&str; 12] =
    ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const C_MAJOR_SCALE: [&str; 7] = ["C", "D", "E", "F", "G", "A", "B"];

// 標準調弦的 MIDI 音符編號
const OPEN_STRING_MIDI: [u8; 6] = [40, 45, 50, 55, 59, 64];

fn midi_to_frequency(midi_note: u8) -> f32 {
    440.0 * 2.0_f32.powf((midi_note as f32 - 69.0) / 12.0)
}

fn get_note_name(string_idx: usize, fret: usize) -> String {
    let midi_note = OPEN_STRING_MIDI[string_idx] + fret as u8;
    let note_idx = (midi_note % 12) as usize;
    CHROMATIC_NOTES[note_idx].to_string()
}

這段程式碼展示了幾個 Rust 的特色:

  • 固定大小陣列 [&str; 12]:編譯時確定大小,存取時有邊界檢查
  • MIDI 音高公式440 * 2^((n - 69) / 12) 是標準的十二平均律公式
  • 取餘數做循環midi_note % 12 將任何 MIDI 編號映射回 0-11 的音名索引

7. 所有權與音訊資源管理

音訊播放涉及作業系統資源,Rust 的所有權系統自然地處理了生命週期:

struct App {
    _output_stream: Option<OutputStream>,      // 必須持有,否則音訊會停止
    stream_handle: Option<rodio::OutputStreamHandle>,  // 用來建立 Sink
}

impl Default for App {
    fn default() -> Self {
        let (stream, handle) = OutputStream::try_default().ok().unzip();
        Self {
            _output_stream: stream,
            stream_handle: handle,
        }
    }
}

幾個關鍵細節:

  • _output_stream 的底線前綴:表示這個欄位不會被直接使用,但必須保持存活,因為它代表與音訊驅動程式的連接。一旦被 drop,所有音訊都會停止
  • Option 包裝:音訊初始化可能失敗(例如沒有音訊裝置),用 Option 優雅處理
  • ok().unzip():將 Result<(A, B)> 轉換為 (Option<A>, Option<B>),一行解決錯誤處理

總結

這個吉他指板專案涵蓋了多個重要的 Rust 概念:

概念 應用場景
TEA 架構 iced 應用的 Model-Message-Update-View
Trait 實作 canvas::ProgramSource
Iterator Karplus-Strong 音訊取樣產生
閉包 + move 按鈕樣式與事件處理
所有權 音訊資源的生命週期管理
常數陣列 音樂理論的領域知識編碼
Stack 佈局 Canvas 與互動元件的疊加
物理建模 即時音訊合成

如果你想找一個結合 GUI、音訊和數學的 Rust 練習專案,吉他指板是個很好的選擇。從簡單的視覺化開始,逐步加入音訊合成、Canvas 繪圖、半透明效果,每一步都能學到新的 Rust 技巧。

guitar-fretboard.png

參考資源