看了 wasm-pack build --target web 產生的 pkg/ 目錄,搞懂了 JS 膠水程式碼的角色。
wasm-pack 輸出四個檔案:
檔案
用途
.wasm
編譯後的 WASM 二進位
.js
JS 膠水程式碼(runtime 必須 )
.d.ts
TypeScript 型別宣告(純靜態,runtime 不用 )
_bg.wasm.d.ts
原始 WASM exports 的型別宣告
.d.ts 不參與執行——它們只給 TypeScript 編譯器做型別檢查用。整個流程不涉及 TypeScript 編譯。
膠水程式碼做兩件事:
1. 載入 WASM :init() 用 WebAssembly.instantiateStreaming 串流編譯 .wasm 檔,邊下載邊編譯。
2. 雙向橋接 WASM 和瀏覽器 API :
JS → WASM :處理型別轉換。WASM 只懂數字,所以字串要先編碼成 UTF-8 寫進 WASM 線性記憶體,再傳指標和長度:
export function start (canvas_id , grid_width , grid_height ) {
const ptr0 = passStringToWasm0 (canvas_id , wasm .__wbindgen_malloc , ...);
const len0 = WASM_VECTOR_LEN ;
return wasm .start (ptr0 , len0 , grid_width , grid_height );
}
WASM → JS :web_sys 的每個呼叫都對應一個 JS import 函式。以 WebGPU 的 device.createBuffer() 為例,Rust 端呼叫 web_sys → WASM 呼叫 import → JS 膠水程式碼呼叫真正的瀏覽器 API:
__wbg_createBuffer_fb1752eab5cb2a7f : function (arg0 , arg1 ) {
const ret = arg0 .createBuffer (arg1 );
return ret ;
}
在 wgpu Game of Life 專案裡,膠水程式碼包含大約 200 個這樣的橋接函式,全部由 wasm-bindgen 在建置時根據 #[wasm_bindgen] 和 web_sys 的使用自動生成。
在做 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。
這個專案把 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 是個完美的入門專案:
天然適合平行計算 :每個細胞的下一代只取決於鄰居,可以完全並行
需要 compute shader + render pipeline :同時學兩種 GPU 程式設計模式
視覺回饋即時 :寫完馬上看到結果
規模剛好 :不會太大,但足以理解 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 不是 Sync。Simulation 裡面持有的 wgpu 資源(Device、Queue、Surface 等)也不是 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_adapter、request_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 特定
Compute shader 比想像中簡單 :WGSL 語法接近 Rust,workgroup/dispatch 的概念很直覺
Bind group 是關鍵抽象 :它定義了 shader 能存取哪些資源,切換 bind group 就能改變資料流向
wgpu 的跨平台設計很優秀 :同一份 Rust 程式碼,換個 backend 就能在 native 和 WASM 上跑
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 兩個核心概念。
參考資源
當我們用 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 進行最終組裝:
複製 .so 檔案 到 jniLibs/<arch>/
編譯 Java/Kotlin 程式碼 (若有)為 bytecode
產生 DEX :將 bytecode 轉換為 Dalvik Executable
打包 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 路徑解析
優先順序:
ANDROID_NDK_ROOT 環境變數
ANDROID_NDK_HOME 環境變數
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 build、x run、x doctor
裝置管理 :x devices 列出所有連接的裝置
持續開發 :活躍維護,積極修復問題
App Store 發佈 :內建發佈到 Google Play 和 App Store 的支援
安裝與使用 :
# 安裝
cargo install xbuild
# 檢查環境
x doctor
# 建置並部署到裝置
x devices # 列出裝置
x build --device adb:<device-id> # 建置並安裝
三階段建置流程 :
Fetch :下載預編譯的 Rust 標準函式庫
Build :透過 Cargo 編譯 Rust 程式碼
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
關鍵觀念回顧 :
APK 是 ZIP :理解目錄結構有助於除錯打包問題
cdylib 必要性 :Android 只能載入動態函式庫
多架構建置 :一個 APK 包含所有架構的 .so,安裝時系統自動選擇
Manifest 自動化 :Cargo.toml 配置轉換為 Android 設定
NDK 整合 :正確設定環境變數是成功建置的關鍵
透過理解這些底層機制,即使遇到建置問題也能快速定位原因並解決。
參考資源
最近用 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 );
});
ComponentHandle 和 Model 是 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 技巧。
參考資源