最近「Vibe Coding」這個詞在開發者社群中掀起熱議——與其逐行敲鍵盤,不如用自然語言描述你想要的功能,讓 AI 幫你實現。聽起來很美好,但真的能用來開發生產級別的應用嗎?
我決定用 Claude Code 和 Claude 4.5 Opus 來驗證這個想法,從零開始打造一款完整的 iOS 應用程式:AlphaGPS ——一個透過藍牙低功耗(BLE)將 iPhone GPS 資料同步到 Sony 相機的工具。
值得一提的是:我從來沒有寫過任何 Swift 程式 。雖然我有 Objective-C 的 iOS 開發經驗,但 Swift 和現代 iOS 開發框架對我來說是全新的領域——SwiftUI、Combine 框架、async/await 語法、Live Activity,這些我都是第一次接觸。
這不是一個簡單的 Hello World 專案。它涉及 BLE 硬體協議、iOS 背景執行限制、複雜的狀態管理,以及 Live Activity 整合。最終成果?約 4,100 行 Swift 程式碼、30 個檔案、90 個 commits,以及一個真正能用的產品。
為什麼選擇這個專案?
作為攝影愛好者,我一直希望照片能自動記錄拍攝地點。雖然 Sony 有官方的 Creators App,但它的背景同步體驗並不理想——經常需要手動重新連線,而且會中斷其他藍牙連接。
我想要的是:打開相機就自動連線、App 在背景時持續同步、完全不需要人工干預。
這個需求恰好涵蓋了許多有趣的技術挑戰,非常適合測試 AI 輔助開發的極限。
Vibe Coding 的實際體驗
第一天(1/11):從協議規格到基礎架構
第一個 commit 是 Initial commit with Sony GPS protocol spec——我先把逆向工程得到的 Sony 藍牙 GPS 協議文件放進去,然後告訴 Claude:「根據這份協議規格,幫我設計一個 iOS App。」
接下來的兩個 commits 分別是:
feat(phase1): implement iOS app foundation for Sony camera GPS sync
feat(phase2): implement BLE foundation and GPS encoding
Claude 建議使用 Multi-Manager Singleton 模式搭配 Combine 響應式框架:
「你的 App 需要協調多個系統:藍牙掃描、GPS 定位、相機連線狀態。
建議使用獨立的 Manager 單例,各自管理 @Published 狀態,
透過 Combine 讓 UI 自動響應變化。」
這不是 Claude 隨意生成的建議——它是基於 iOS 開發的最佳實踐,考慮到 CoreBluetooth 的 Delegate 模式和 SwiftUI 的響應式特性。
第二到第三天(1/12-1/13):攻克背景執行
iOS 對背景執行有嚴格限制。大多數 App 在進入背景後很快就會被系統終止。但我需要的是:即使 App 被殺掉,當相機重新開機時也能自動重連。
這部分的開發歷程可以從 commit 歷史看出挑戰有多大:
feat(phase4): implement background lifecycle and persistence
Implement iOS background BLE state preservation and cleanup code
Start scanning in background when cameras exist but none connected
Start scanning when last connected camera disconnects in background
Abort connecting cameras when app enters background
Stop scanning when camera connects in background
這就是 iOS Core Bluetooth State Preservation 登場的時候。我向 Claude 解釋了需求:
「我需要相機關機後再開機時自動重連,
即使使用者已經完全關閉 App。」
Claude 不只給了程式碼,還解釋了整個機制:
用 CBCentralManagerOptionRestoreIdentifierKey 初始化藍牙管理器
iOS 會在背景記住連線意圖
當藍牙事件發生時,iOS 會喚醒 App 並呼叫 willRestoreState
App 需要在這個回調中重建狀態並恢復連線
最困難的是處理邊界情況:藍牙 UUID 可能改變、相機可能同時連線多台、恢復可能在位置權限授權之前發生。Claude 幫我逐一考慮這些情況,最終實現了真正的「零操作」體驗。
Sony 的 BLE 協議
Sony 沒有公開他們的藍牙 GPS 同步協議。幸運的是,我在網路上找到了其他開發者的逆向工程成果(特別是 camera-gps-link 專案),他們已經分析出協議的細節。我把整理後的協議規格放在 docs/sony-gps-protocol-spec.md。
協議的關鍵:寫入 GPS 資料需要一個三步驟的「解鎖」序列:
寫入解鎖指令到 DD30 characteristic
寫入鎖定指令到 DD31 characteristic
寫入 GPS 封包到 DD11 characteristic(91 或 95 bytes)
跳過任何一步,相機會靜默忽略資料——沒有錯誤回報。
當我把這個發現告訴 Claude 時,它立刻理解了非同步寫入的挑戰:
// Claude 建議的解決方案
private var unlockWriteComplete = false
private var lockWriteComplete = false
// 只有在兩個階段都完成後才發送 GPS
func checkAndWriteLocation () {
guard unlockWriteComplete, lockWriteComplete else { return }
// 發送 GPS 封包...
}
這種狀態追蹤模式解決了 BLE 寫入回調可能亂序或遺失的問題。
印象深刻的地方
1. 理解上下文的能力
1/13 那天,我花了不少時間實作 Live Activity。Commit 歷史反映了這段旅程:
Implement Live Activity for camera connection status
Fix iOS availability checks for Live Activity API
Fix Widget Extension bundle configuration
Fix ActivityViewContext build error by inlining Live Activity view
後來在 1/16,我發現「這個 Live Activity 在斷線時沒有正確結束」。Claude 不只是搜尋關鍵字,它追蹤了整個程式碼流程,發現問題出在狀態轉換的時序:
didConnect → 狀態設為 .connected → 啟動 Live Activity
↓
handleConnection → 狀態改為 .connecting(進行服務發現)
↓
didDisconnect → wasConnected 檢查回傳 false → Live Activity 沒有結束!
修復結果就是 fix(ble): always end Live Activity on disconnect 這個 commit。有這種程度的理解,除錯快了很多。
2. 主動考慮邊界情況
在實作電池優化時,Claude 主動提出:
「你需要考慮這些情況:
- App 在背景但沒有相機連線 → 應該暫停 GPS
- 相機在背景連線 → 立即恢復 GPS
- 使用者關閉背景同步設定 → 停止所有更新」
這最終成為 feat(location): stop GPS updates when no cameras connected in background 這個 commit。這些不是我明確要求的,但對產品品質至關重要。
3. 架構一致性
隨著專案成長到 30 個 Swift 檔案,Claude 始終記得我們建立的模式:Manager 單例、原子性狀態更新、Combine 訂閱管理。它不會突然建議一個風格完全不同的實作。
看看這些 refactor commits,Claude 幫我持續改進架構而不破壞一致性:
Refactor to single camera list and state-based architecture
Consolidate Camera discovering and connecting states
Refactor autoConnect to separate field from connection state
Consolidate connected and syncing states into single connected state
refactor: simplify connecting state by removing retryCount
不完美的地方
坦白說,Vibe Coding 不是萬能的。從 90 個 commits 中可以看到很多 fix commits:
Bug fixes 佔了很大比例 :
fix(ble): clear peripheral when aborting connection
fix(ble): cancel connection timeout on disconnect
fix(ble): fix critical disconnect handling issues
fix(ble): clear stale peripheral on background disconnect
這些都是在實機測試時發現的問題。Claude 可以寫出結構良好的程式碼,但 BLE 和 iOS 背景執行的邊界情況太多,只有實際測試才能發現。
需要明確指示 :Claude 有時會過度工程化。我必須明確說「保持簡單,不要加額外功能」。
測試依然困難 :BLE 和 Live Activity 無法在模擬器中測試。我仍然需要在實體裝置上手動測試每個功能。
領域知識必要 :雖然 Claude 幫我寫程式碼,但理解 Sony 協議、iOS 背景限制、BLE 特性——這些需要我自己研究。AI 是超強的副駕駛,但你還是需要知道要去哪裡。
成果總覽
指標
數值
程式碼行數
~4,100 行
Swift 檔案數量
30
Git commits
90
開發時間
6 天(兼職,1/11 - 1/16)
手動寫的程式碼
< 5%
功能清單:
自動掃描和連接 Sony 相機
背景 GPS 同步(App 在背景時持續運作)
Live Activity 即時顯示同步狀態
相機斷線自動重連
電池優化的智慧掃描
支援 iOS 15.0+,完整功能需 iOS 16.1+
我學到的事
Vibe Coding 是真的 :對於有經驗的開發者,AI 可以將生產力提升 5-10 倍。關鍵是你要能驗證和引導它的輸出。
對話品質決定結果 :模糊的指示得到模糊的程式碼。具體描述需求、約束和邊界情況,Claude 就能給出專業級的解決方案。
Claude 4.5 Opus 的差異 :相比之前的模型,Opus 4.5 在理解複雜系統、保持上下文一致性、主動考慮邊界情況方面有明顯提升。
這不是取代學習 :AI 讓你更快實現想法,但你還是需要理解底層技術。否則你無法判斷 AI 的建議是否正確。
寫在最後
AlphaGPS 現在是我日常使用的工具。每次出門拍照,相機開機就自動連接、照片自動標記位置、全程不需要碰手機。
回頭看這個專案,最有意思的不是程式碼本身,而是這種新的開發方式:我專注於「想要什麼」和「為什麼」,Claude 幫我處理「怎麼做」的細節。
這就是 Vibe Coding 的精髓——不是讓 AI 取代你,而是讓你把精力放在真正重要的事情上。
如果你也想嘗試,我的建議是:找一個你真正想解決的問題,然後跟 Claude 聊聊。你可能會驚訝於它能帶你走多遠。
這篇文章本身也是用 Claude 協助撰寫的——用 AI 寫一篇關於 AI 開發的文章,似乎也是剛好而已。
最近用兩個不同的 Rust GUI 框架實作了同一個計算機專案,分別是 GPUI 和 Iced 。這篇文章比較兩者的架構差異和開發體驗。
框架背景
框架
開發者
特色
GPUI
Zed Industries
GPU 加速、為 Zed 編輯器打造
Iced
社群驅動
受 Elm 啟發、跨平台、純 Rust
兩者都是相對新的框架,目前都還在 0.x 版本階段。
1. 架構設計
GPUI:物件導向 + Render Trait
GPUI 採用類似 React 的元件模式,每個元件是一個 struct,透過實作 Render trait 來定義 UI:
struct CalculatorApp {
input: String,
result: Option< String> ,
engine: Calculator ,
}
impl Render for CalculatorApp {
fn render (& mut self, _window: & mut Window, cx: & mut Context< Self> ) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.bg(rgb(0x1e1e1e ))
.child(self.render_display())
.child(self.render_keypad(cx))
}
}
Iced:Elm 架構 (Model-View-Update)
Iced 採用函數式的 Elm 架構,將應用程式分為三個部分:
// Model(狀態)
struct App {
input: String,
result: Option< String> ,
engine: Calculator ,
}
// Message(事件)
#[derive(Debug, Clone)]
enum Message {
ButtonPressed(String),
Clear,
Evaluate,
}
// Update(狀態轉換)
impl App {
fn update (& mut self, message: Message ) {
match message {
Message::Clear => self.input.clear(),
Message::Evaluate => { /* ... */ }
Message::ButtonPressed(s) => self.input.push_str(& s),
}
}
}
// View(UI 描述)
impl App {
fn view (& self) -> Element < '_, Message> {
column! [
self.view_display(),
self.view_keypad(),
].into()
}
}
關鍵差異 :GPUI 的事件處理是內嵌在 view 中的閉包,而 Iced 強制將所有事件抽象為 Message enum,再透過獨立的 update 函數處理。
2. 事件處理
GPUI:閉包直接修改狀態
div()
.on_click(cx.listener(move | this, _event, _window, cx| {
match label.as_str() {
"C" => this.clear(),
"=" => this.evaluate(),
other => this.append(other),
}
cx.notify(); // 手動觸發重新渲染
}))
Iced:Message + Pattern Matching
// View 中只發送 Message
button(text("C" ))
.on_press(Message::Clear)
button(text("=" ))
.on_press(Message::Evaluate)
// Update 中統一處理
fn update (& mut self, message: Message ) {
match message {
Message::Clear => self.input.clear(),
Message::Evaluate => { /* ... */ }
}
// 自動重新渲染,不需手動觸發
}
Iced 的優點 :
所有狀態變更集中在 update 函數,易於追蹤和測試
Message enum 是可序列化的,方便實作 undo/redo 或時間旅行除錯
GPUI 的優點 :
閉包可以直接存取局部變數,程式碼更緊湊
不需要為每個動作定義 Message variant
3. 樣式系統
GPUI:Tailwind 風格的 Fluent API
div()
.flex()
.flex_col()
.p_4() // padding: 16px
.gap_3() // gap: 12px
.bg(rgb(0x1e1e1e ))
.rounded_lg()
.text_xl()
.text_color(rgb(0xffffff ))
這種 API 對熟悉 Tailwind CSS 的開發者非常直覺。
Iced:Closure-based Styling
container(content)
.padding(16 )
.width(Length::Fill)
.style(| _theme| container::Style {
background: Some(color! (0x2d2d2d ).into()),
border: iced ::Border {
radius: 8.0. into(),
.. Default::default()
},
.. Default::default()
})
Iced 的樣式透過 style 方法傳入閉包,可以根據 theme 和 widget status 動態決定樣式。
按鈕樣式比較
GPUI :
div()
.bg(rgb(0x3c3c3c ))
.hover(| style| style.bg(rgb(0x4a4a4a )))
.rounded_md()
Iced :
button(content)
.style(move | _theme, status| {
let bg = match status {
button::Status::Hovered => color! (0x5a5a5a ),
_ => color! (0x3c3c3c ),
};
button::Style {
background: Some(bg.into()),
border: iced ::Border { radius: 6.0. into(), .. Default::default() },
.. Default::default()
}
})
4. 主題支援
GPUI
GPUI 沒有內建主題系統,需要自己定義顏色常數:
const BG_DARK : u32 = 0x1e1e1e ;
const BG_BUTTON : u32 = 0x3c3c3c ;
const TEXT_PRIMARY : u32 = 0xffffff ;
Iced
Iced 內建多種主題,只需一行切換:
iced::application("Calculator" , App::update, App::view)
.theme(| _| Theme::TokyoNightStorm) // 或 Theme::Dark, Theme::Light 等
.run()
5. 程式進入點
GPUI
fn main () -> Result< ()> {
let app = Application::new();
app.run(move | cx| {
let bounds = Bounds::centered(None, size(px(340. ), px(480. )), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
.. Default::default()
},
| _window, cx| cx.new(| _cx| CalculatorApp::new()),
).expect("Failed to open window" );
});
Ok(())
}
Iced
fn main () -> iced ::Result {
iced::application("Iced Calculator" , App::update, App::view)
.theme(| _| Theme::TokyoNightStorm)
.window_size((340.0 , 480.0 ))
.run()
}
Iced 的 builder pattern 更簡潔,GPUI 則提供更多底層控制。
6. 跨平台支援
平台
GPUI
Iced
macOS
完整支援 (Metal)
完整支援
Windows
實驗性
完整支援
Linux
支援 (Vulkan)
完整支援
Web
不支援
支援 (WebGPU/WebGL)
Iced 在跨平台方面明顯更成熟。
7. 程式碼行數比較
同樣的計算機功能:
檔案
GPUI
Iced
main.rs
~250 行
~260 行
總計
差不多
差不多
兩者的程式碼量相當,但結構不同。
8. 編譯時間與依賴
# GPUI
cargo build # ~2 分鐘,743 個 crates
# Iced
cargo build # ~1.5 分鐘,415 個 crates
Iced 的依賴較少,編譯速度稍快。
選擇建議
選擇 GPUI 如果你 :
正在為 Zed 編輯器開發插件
偏好物件導向的元件設計
需要極致的渲染效能
主要目標平台是 macOS
選擇 Iced 如果你 :
需要跨平台支援(特別是 Web)
喜歡 Elm 架構的狀態管理
想要內建主題支援
偏好穩定的 API(雖然都還在 0.x)
總結
特性
GPUI
Iced
架構
物件導向 + Render
Elm (MVU)
樣式
Tailwind-like
Closure-based
主題
手動定義
內建多種
事件
閉包 + cx.notify()
Message enum
跨平台
有限
優秀
成熟度
較新
較成熟
兩個框架都展示了 Rust GUI 開發的可能性。GPUI 代表了高效能、GPU 加速的方向,而 Iced 則體現了函數式、跨平台的設計理念。選擇哪個取決於你的專案需求和個人偏好。
參考資源
最近用 GPUI 框架實作了一個圖形化計算機,過程中學到不少 Rust 的實用技巧。這篇文章整理了從這個專案中可以學到的 Rust 程式設計概念。
專案概述
這個計算機支援基本四則運算、指數運算 (^)、取餘數 (%)、變數賦值,以及內建常數 pi 和 e。UI 使用 Zed 編輯器團隊開發的 GPUI 框架,這是一個 GPU 加速的 Rust UI 框架。
1. 使用 Enum 定義 Token
Rust 的 enum 非常適合用來定義詞法分析器 (lexer) 的 token 類型:
#[derive(Debug, Clone, PartialEq)]
pub enum Token {
Name(String), // 變數名稱
Number(f64 ), // 數字
Plus, // +
Minus, // -
Mul, // *
Div, // /
Mod, // %
Pow, // ^
Print, // ;
Assign, // =
LP , // (
RP , // )
}
這種設計的好處:
類型安全 :編譯器確保你處理所有可能的 token 類型
資料攜帶 :Name(String) 和 Number(f64) 可以攜帶額外資料
模式匹配 :配合 match 表達式,程式碼清晰易讀
2. 實作 Iterator Trait
將 lexer 實作為 Iterator,讓程式碼更符合 Rust 慣例:
pub struct TokenStream {
input: Vec< char > ,
offset: usize ,
}
impl Iterator for TokenStream {
type Item = Token;
fn next (& mut self) -> Option< Self::Item> {
loop {
if self.offset >= self.input.len() {
return None;
}
let ch = self.input[self.offset];
self.offset += 1 ;
match ch {
'+' => return Some(Token::Plus),
'-' => return Some(Token::Minus),
'*' => return Some(Token::Mul),
// ... 其他 token
x if x.is_whitespace() => continue ,
_ => return None,
}
}
}
}
實作 Iterator trait 的好處:
可以使用 .collect() 收集所有 token
可以搭配其他 iterator 方法如 .map()、.filter() 等
延遲求值 (lazy evaluation),不會一次解析完整個輸入
3. 遞迴下降解析器
計算機的核心是一個遞迴下降解析器 (recursive descent parser),這是編譯器課程中的經典技術:
impl Calculator {
// 加減法 (最低優先級)
fn expr (& mut self, token: Option< Token> ) -> Result< f64 , String> {
let mut left = self.term(token)? ;
loop {
match self.current_token {
Some(Token::Plus) => left += self.term(None)? ,
Some(Token::Minus) => left -= self.term(None)? ,
_ => return Ok(left),
}
}
}
// 乘除法、取餘數 (中等優先級)
fn term (& mut self, token: Option< Token> ) -> Result< f64 , String> {
let mut left = self.power(token)? ;
loop {
match self.current_token {
Some(Token::Mul) => left *= self.power(None)? ,
Some(Token::Div) => left /= self.power(None)? ,
Some(Token::Mod) => left %= self.power(None)? ,
_ => return Ok(left),
}
}
}
// 指數運算 (高優先級,右結合)
fn power (& mut self, token: Option< Token> ) -> Result< f64 , String> {
let base = self.prim(token)? ;
if let Some(Token::Pow) = self.current_token {
let exp = self.power(None)? ; // 遞迴實現右結合
Ok(base.powf(exp))
} else {
Ok(base)
}
}
// 基本元素 (最高優先級)
fn prim (& mut self, token: Option< Token> ) -> Result< f64 , String> {
// 處理數字、變數、括號、一元負號
}
}
這個設計的關鍵:
運算子優先級 :透過函數呼叫順序自然實現(expr → term → power → prim)
結合性 :左結合用迴圈,右結合用遞迴
錯誤處理 :使用 Result<f64, String> 和 ? 運算子傳遞錯誤
4. GPUI 的 Render Trait
GPUI 使用 trait 來定義 UI 元件的渲染邏輯:
impl Render for CalculatorApp {
fn render (& mut self, _window: & mut Window, cx: & mut Context< Self> ) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.bg(rgb(0x1e1e1e ))
.p_4()
.gap_3()
.child(self.render_display())
.child(self.render_keypad(cx))
}
}
GPUI 的特色:
宣告式 UI :類似 SwiftUI 或 Flutter 的風格
Tailwind 風格的樣式 :.flex()、.p_4()、.gap_3() 等方法
GPU 加速 :比 Electron 少 60-80% 記憶體使用量
5. 事件處理與閉包
按鈕點擊事件使用閉包 (closure) 和 cx.listener() 來處理:
div()
.id(SharedString::from(format! ("btn_ {} " , label)))
.on_click(cx.listener(move | this, _event, _window, cx| {
match label_owned.as_str() {
"C" => this.clear(),
"⌫" => this.backspace(),
"=" => this.evaluate(),
"±" => this.toggle_sign(),
"π" => this.append("pi" ),
other => this.append(other),
}
cx.notify(); // 通知 UI 更新
}))
這裡展示了 Rust 閉包的幾個重點:
move 關鍵字將變數所有權移入閉包
閉包可以捕獲外部變數(label_owned)
cx.notify() 觸發 UI 重新渲染
6. 模組化設計
專案採用清晰的模組結構:
src/
├── main.rs # 進入點 + UI 元件
└── calculator/
├── mod.rs # 模組宣告
├── token.rs # Token 定義
├── lexer.rs # 詞法分析器
└── parser.rs # 語法分析器 + 測試
這種結構的好處:
關注點分離 :UI 和計算邏輯分開
可測試性 :parser 可以獨立測試
可重用性 :calculator 模組可以用於其他專案
7. 單元測試
Rust 內建強大的測試框架,測試直接寫在同一個檔案:
#[cfg(test)]
mod tests {
use super ::* ;
#[test]
fn test_exponentiation () {
let mut calc = Calculator::new();
assert_eq! (calc.evaluate("2^3" ).unwrap(), 8.0 );
assert_eq! (calc.evaluate("2^3^2" ).unwrap(), 512.0 ); // 右結合
assert_eq! (calc.evaluate("2 + 3^2" ).unwrap(), 11.0 ); // 優先級
}
#[test]
fn test_modulo () {
let mut calc = Calculator::new();
assert_eq! (calc.evaluate("10 % 3" ).unwrap(), 1.0 );
assert_eq! (calc.evaluate("17 % 5" ).unwrap(), 2.0 );
}
}
Rust 測試的特點:
#[cfg(test)] 確保測試碼不會編譯進正式版本
#[test] 標記測試函數
執行 cargo test 即可運行所有測試
8. 錯誤處理
使用 Result 類型和 ? 運算子進行錯誤處理:
pub fn evaluate (& mut self, input: & str ) -> Result< f64 , String> {
self.tokens = TokenStream::new(input).collect();
if self.tokens.is_empty() {
return Err("empty expression" .to_owned());
}
let first_token = self.next_token();
self.expr(first_token)
}
// 使用 ? 運算子傳遞錯誤
fn expr (& mut self, token: Option< Token> ) -> Result< f64 , String> {
let mut left = self.term(token)? ; // 如果 term 失敗,錯誤會被傳遞
// ...
}
這種模式的好處:
明確標示可能失敗的操作
錯誤類型清楚(這裡用 String,正式專案建議用自定義錯誤類型)
? 運算子讓錯誤處理程式碼簡潔
總結
這個計算機專案雖然簡單,但涵蓋了多個重要的 Rust 概念:
概念
應用場景
Enum + Pattern Matching
Token 定義與解析
Iterator Trait
詞法分析器
Recursive Descent
語法分析器
Trait (Render)
UI 元件定義
Closure
事件處理
Module System
程式碼組織
Unit Testing
驗證解析邏輯
Result + ?
錯誤處理
如果你想深入學習 Rust,動手實作一個計算機是很好的練習。從簡單的四則運算開始,逐步加入變數、函數、甚至自定義語法,你會學到很多編譯器和語言設計的知識。
參考資源
用了 Preonic 、Planck 好幾年後,我最近買了 Geonix rev.2 這把 40% 正交鍵盤 (鍵帽使用 NuPhy Shine-through White for Air60 V2) 方便隨身攜帶用。
這篇文章記錄我目前穩定使用的鍵盤配置,包含四層設計的思路與實際鍵位安排。
為什麼是 40% 正交鍵盤
從標準鍵盤轉到正交排列,最直接的好處是手指移動更直覺。傳統 row-stagger 鍵盤的錯位設計是打字機時代的遺留,對現代打字沒有實質幫助,反而讓左手手指需要斜向移動。正交排列讓每根手指都在垂直的軌道上移動,肌肉記憶更容易建立。
40% 鍵盤(60 鍵)看起來很極端,但透過分層設計,所有按鍵都能在手指不離開 home row 太遠的情況下觸及。少了數字行和功能鍵行,雙手的移動範圍大幅縮小,長時間打字反而更輕鬆。
配置總覽
我的配置採用四層設計(完整配置檔可在此下載:geonix_rev_2.layout.json ):
Layer 0 :基礎層,QWERTY 搭配 Home Row Mods
Layer 1 :符號層,透過左手拇指啟動
Layer 2 :數字與功能鍵層,透過右手拇指啟動
Layer 3 :系統層,RGB 控制與其他功能
Layer 0:基礎層
┌
│
├
│
├
│
├
│
└
─
─
─
-
─
─
─
T
─
E
─
/
─
L
─
─
a
─
s
─
S
─
3
─
─
b
─
c
─
f
─
─
─
─
─
t
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
A
─
─
─
─
─
/
─
─
C
─
─
Q
─
C
─
Z
─
t
─
─
─
t
─
─
l
─
─
─
l
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
S
─
─
─
─
─
/
─
─
A
─
─
W
─
A
─
X
─
l
─
─
─
l
─
─
t
─
─
─
t
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
D
─
─
─
─
─
/
─
─
G
─
─
E
─
G
─
C
─
u
─
─
─
u
─
─
i
─
─
─
i
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
F
─
─
─
─
─
/
─
─
L
─
─
R
─
S
─
V
─
1
─
─
─
f
─
─
─
─
─
t
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
T
─
G
─
B
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
S
─
─
Y
─
H
─
N
─
p
─
─
─
─
─
c
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
J
─
─
─
─
─
/
─
─
L
─
─
U
─
S
─
M
─
2
─
─
─
f
─
─
─
─
─
t
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
K
─
─
─
─
─
/
─
─
─
─
I
─
G
─
,
─
←
─
─
─
u
─
─
─
─
─
i
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
L
─
─
─
─
─
/
─
─
─
─
O
─
A
─
─
↓
─
─
─
l
─
─
─
─
─
t
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
;
─
─
─
─
─
/
─
─
─
─
P
─
C
─
─
↑
─
─
─
t
─
─
─
─
─
l
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
'
─
─
─
B
─
E
─
/
─
─
─
s
─
n
─
S
─
→
─
─
p
─
t
─
f
─
─
─
─
─
t
─
─
┐
│
┤
│
┤
│
┤
│
┘
Home Row Mods
基礎層最核心的設計是 Home Row Mods:把 Ctrl、Alt、Gui(Command/Win)、Shift 四個修飾鍵放在 home row 的位置。按住時是修飾鍵,輕點則輸出原本的字母。
左手:
A = Ctrl(按住)/ a(輕點)
S = Alt(按住)/ s(輕點)
D = Gui(按住)/ d(輕點)
F = Shift(按住)/ f(輕點)
右手採用對稱配置,但有個細節:我使用了 MOD_LSFT | MOD_RSFT 這樣的雙邊修飾鍵設定。這讓同手組合鍵(例如右手按 J + K 來輸出 Shift+Gui)更容易觸發,不會因為 timing 問題誤判成連續輕點。
其他設計選擇
方向鍵保留在基礎層 :許多 40% 配置會把方向鍵放到其他層,但我發現導航時常常需要快速切換,放在右下角的位置讓右手小指和無名指自然就能觸及。
- 和 ' 是 Shift-Tap :這兩個符號使用頻率高,放在角落作為 Shift-Tap 鍵,既保留了符號本身,又多了兩個 Shift 鍵可用。
Esc 在 Caps Lock 位置 :經典的 Vim 使用者配置。
Layer 1:符號層
透過左手拇指按住 MO(1) 進入:
┌
│
├
│
├
│
├
│
└
─
─
─
S
─
─
─
─
E
─
h
─
─
─
~
─
s
─
i
─
─
─
─
c
─
f
─
─
─
─
─
t
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
!
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
@
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
#
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
$
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
%
─
─
[
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
─
─
]
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
&
─
─
=
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
H
─
─
─
─
─
o
─
─
─
─
─
m
─
─
─
─
─
e
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
P
─
─
─
─
─
g
─
─
─
{
─
─
D
─
─
─
─
─
n
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
P
─
─
─
─
─
g
─
─
─
}
─
─
U
─
─
─
─
─
p
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
S
─
─
─
D
─
─
h
─
E
─
─
e
─
─
i
─
n
─
─
l
─
─
f
─
d
─
─
─
─
t
─
─
┐
│
┤
│
┤
│
┤
│
┘
符號層的設計邏輯:
頂排是 Shifted 數字 :!@#$%^&*() 維持與標準鍵盤相同的位置對應,降低學習成本。
括號集中在右手 :(), [], {} 三組括號放在右手區域,寫程式時非常順手。
-> 巨集 :寫程式常用的箭頭符號,一鍵輸出。
導航鍵 :方向鍵位置變成 Home, PgDn, PgUp, End,用於快速跳轉。
Layer 2:數字與功能鍵層
透過右手拇指按住 MO(2) 進入:
┌
│
├
│
├
│
├
│
└
─
─
─
S
─
─
─
─
E
─
h
─
─
─
`
─
s
─
i
─
─
─
─
c
─
f
─
─
─
─
─
t
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
F
─
F
─
─
─
1
─
1
─
7
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
F
─
F
─
─
─
2
─
2
─
8
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
F
─
F
─
─
─
3
─
3
─
9
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
F
─
F
─
─
─
4
─
4
─
1
─
─
─
─
─
0
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
F
─
F
─
─
─
5
─
5
─
1
─
─
─
─
─
1
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
F
─
F
─
─
─
6
─
6
─
1
─
─
─
─
─
2
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
7
─
↓
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
H
─
M
─
─
─
─
o
─
u
─
─
8
─
↑
─
m
─
t
─
─
─
─
e
─
e
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
V
─
─
─
─
E
─
o
─
─
9
─
[
─
n
─
l
─
─
─
─
d
─
-
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
V
─
─
─
─
─
o
─
─
0
─
]
─
─
l
─
─
─
─
─
+
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
P
─
P
─
P
─
─
D
─
g
─
g
─
l
─
─
e
─
U
─
D
─
a
─
─
l
─
p
─
n
─
y
─
─
─
─
─
─
┐
│
┤
│
┤
│
┤
│
┘
這一層的設計重點:
數字在頂排 :維持標準鍵盤的數字位置,直覺好記。
F1-F12 在 home row 和下排 :左手區域,與數字層分開。
媒體控制在右下角 :靜音、音量、播放暫停,用右手小指區域操作。
額外的方向鍵和導航 :↓, ↑ 在 J, K 位置(Vim 風格),搭配 Home, End, PgUp, PgDn。
Layer 3:系統層
透過左下角的 MO(3) 進入:
┌
│
├
│
├
│
├
│
└
─
─
─
─
─
─
C
─
─
─
─
─
4
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
C
─
─
─
─
─
1
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
C
─
─
─
─
─
2
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
C
─
─
─
─
─
3
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
C
─
─
─
─
─
0
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
R
─
─
─
─
─
G
─
─
─
─
─
B
─
─
─
─
─
─
─
─
─
─
M
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
─
─
─
C
─
─
─
─
─
9
─
─
─
─
─
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
R
─
S
─
─
─
─
G
─
P
─
─
─
─
B
─
D
─
─
─
─
+
─
-
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
R
─
─
─
─
C
─
G
─
─
─
─
5
─
B
─
─
─
─
─
-
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
S
─
─
─
─
─
P
─
─
─
─
─
D
─
─
─
─
─
+
─
─
─
─
─
─
┬
│
┼
│
┼
│
┼
│
┴
─
─
─
─
─
─
C
─
─
─
─
─
8
─
─
─
─
─
─
─
─
─
─
─
─
─
─
┐
│
┤
│
┤
│
┤
│
┘
系統層主要用於 RGB 控制:
RGB M:切換 RGB 模式
RGB+ / RGB-:調整亮度
SPD+ / SPD-:調整動畫速度
C0 - C9:自定義功能(可能是 RGB 預設或其他 custom keycode)
使用心得
這套配置已經用了好幾年,從 Preonic 到 Planck 再到 Geonix rev.2,核心邏輯基本沒有大改,只是隨著不同鍵盤的物理配置做微調。
幾個心得:
Home Row Mods 需要調整 timing :預設的 tapping term 通常太短,容易誤觸發。我調整到比較長的數值後才穩定下來。
分層設計要符合使用情境 :左手拇指給符號層(寫程式時左手常按 modifier),右手拇指給數字層(數字輸入時右手主導),這樣的分配比較符合我的使用習慣。
不要一次改太多 :剛開始調整配置時很容易想要一次到位,但肌肉記憶需要時間建立。每次只改一兩個地方,適應後再繼續調整。
40% 的限制反而是優點 :強迫自己思考每個按鍵的使用頻率,最後得到的配置比全尺寸鍵盤更有效率。
如果你也在考慮嘗試小型鍵盤或正交配置,希望這篇文章能提供一些參考。
Rust 的 FFI(Foreign Function Interface)讓我們可以呼叫 C 函式庫,但正確實作並不容易。手寫綁定容易出錯,維護成本高;bindgen 自動產生綁定但仍需要 unsafe;safe wrapper 提供安全的 API 但設計繁瑣。
本文以綁定 FFmpeg 的 libavformat 為例,完整介紹:
三種 FFI 方式 的優缺點比較
手寫綁定的常見陷阱 :結構體大小、欄位順序、對齊、生命週期…
Bindgen 的工作原理 :如何用 libclang 解析 C 標頭檔
Safe Wrapper 設計原則 :RAII、類型狀態、錯誤處理、thiserror
AI Coding Agent 如何加速開發 :讓最佳實踐不再繁瑣
如果你正在考慮綁定一個 C 函式庫,或是想了解為什麼「先用 unsafe 頂著」是個壞主意,這篇文章應該能幫到你。
三種 FFI 方式
1. 手寫 FFI(Manual)
最傳統的方式:手動撰寫 extern "C" 宣告。
#[link(name = "avformat" )]
extern "C" {
pub fn avformat_open_input (
ps: * mut * mut AVFormatContext,
url: * const c_char,
fmt: * const c_void,
options: * mut * mut c_void,
) -> c_int ;
}
優點:
缺點:
容易出錯:必須精確對應 C 的結構體佈局
維護負擔:函式庫更新時需手動同步
ABI 細節容易遺漏
2. Bindgen 自動生成
使用 bindgen 在編譯時從 C 標頭檔自動產生綁定:
// build.rs
let bindings = bindgen::Builder::default()
.header_contents("wrapper.h" , r #"
#include <libavformat/avformat.h>
"# )
.allowlist_function("avformat_open_input" )
.generate()
.expect("Failed to generate bindings" );
優點:
準確:直接從 C 標頭檔產生
完整:包含所有型別、函式、常數
可維護:標頭檔更新時自動同步
缺點:
編譯時需要 libclang
產生的程式碼冗長
可能包含不需要的內容
3. 安全封裝(Safe Wrapper)
在 bindgen 綁定之上,建立符合 Rust 慣例的 API:
pub struct FormatContext {
ptr: * mut bindgen::AVFormatContext,
}
impl FormatContext {
pub fn open < P: AsRef< Path>> (path: P ) -> Result< Self> {
// 安全的 Rust API,內部處理所有 unsafe
}
}
impl Drop for FormatContext {
fn drop (& mut self) {
unsafe { bindgen::avformat_close_input(& mut self.ptr); }
}
}
優點:
使用者不需要寫 unsafe
RAII 自動管理資源
編譯器協助防止錯誤
缺點:
需要額外的抽象層
可能有輕微的效能開銷
設計 API 需要深思熟慮
手寫 FFI 的常見陷阱
手寫 FFI 看似簡單,但有許多隱藏的坑。以下是實際可能發生的錯誤:
1. 結構體大小不匹配
// 你的定義(40 bytes)
#[repr(C)]
pub struct AVPacket {
pub pts: i64 ,
pub dts: i64 ,
pub data: * mut u8 ,
pub size: i32 ,
}
// 實際 C 結構體(104 bytes)
// 還有很多你沒定義的欄位
後果: 當 FFmpeg 寫入結構體時,會覆寫超出你定義範圍的記憶體 → 程式崩潰或資料損壞。
2. 欄位順序錯誤
// 錯誤的順序
pub struct AVRational {
pub den: c_int , // 你把分母放前面
pub num: c_int ,
}
// C 標頭檔的定義
struct AVRational {
int num; // 分子在前!
int den;
};
後果: 你的 num 讀到分母,den 讀到分子 → 計算錯誤、除以零。
3. 對齊問題
#[repr(C)]
pub struct Misaligned {
pub flag: u8 ,
pub value: u64 , // 期望 8-byte 對齊
}
後果: 在某些平台上,C 會在 flag 後加 7 bytes 的 padding。如果 Rust 沒有,value 欄位就會讀到垃圾值。
4. 列舉值不匹配
// 你的猜測
pub enum AVMediaType {
Video = 0 ,
Audio = 1 ,
}
// 實際 FFmpeg(不同版本)
// FFmpeg 4.x: Video = 0, Audio = 1
// FFmpeg 6.x: Unknown = -1, Video = 0, Audio = 1
後果: 檢查 if type == Video 可能失敗,因為數值已經偏移。
5. 指標生命週期
// C 函式內部儲存了這個指標
let path = CString::new("/path/to/file" ).unwrap();
avformat_open_input(& mut ctx, path.as_ptr(), .. .);
drop(path); // CString 被釋放!
// ctx 現在持有一個指向已釋放記憶體的懸空指標
後果: Use-after-free → 崩潰或安全漏洞。
6. 呼叫慣例錯誤
extern "C" fn callback (x: i32 ) -> i32 // 假設是 cdecl
// 但 FFmpeg 在 Windows 上可能期望 stdcall
後果: 堆疊損壞、錯誤的返回值。
7. 版本相依的欄位
// 在 FFmpeg 5.0 上正常運作
pub struct AVCodecParameters {
pub codec_type: AVMediaType ,
pub codec_id: u32 ,
pub bit_rate: i64 ,
}
// FFmpeg 6.0 在 bit_rate 之前新增了一個欄位
後果: bit_rate 現在從錯誤的偏移量讀取 → 得到無意義的值。
這就是為什麼 bindgen 是更安全的選擇 。
Bindgen 如何避免這些陷阱
Bindgen 的核心原理是:在編譯時讀取實際的 C 標頭檔,使用 libclang 解析 AST,然後產生對應的 Rust 程式碼 。
工作流程
flowchart TD
A["C 標頭檔 (avformat.h)"] --> B["libclang 解析"]
B --> C["AST(抽象語法樹)"]
C --> D["bindgen 轉換"]
D --> E["Rust 綁定 (bindings.rs)"]
為什麼能避免手寫的陷阱
陷阱
Bindgen 如何解決
結構體大小
從 AST 讀取精確的 sizeof,產生正確大小的 Rust struct
欄位順序
按照 C 標頭檔中的宣告順序產生欄位
對齊問題
讀取每個欄位的 alignof,自動加入正確的 #[repr(C)] 和 padding
列舉值
直接從標頭檔讀取每個列舉的數值
版本相依
每次編譯時重新產生,自動適應安裝的函式庫版本
實際產生的程式碼
當 bindgen 處理 AVRational 時:
// bindgen 產生的程式碼(自動)
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct AVRational {
pub num: ::std::os::raw::c_int, // 順序正確
pub den: ::std::os::raw::c_int,
}
對於複雜的結構體如 AVFormatContext:
// bindgen 產生數百行,包含所有欄位
#[repr(C)]
pub struct AVFormatContext {
pub av_class: * const AVClass,
pub iformat: * const AVInputFormat,
pub oformat: * const AVOutputFormat,
pub priv_data: * mut ::std::os::raw::c_void,
pub pb: * mut AVIOContext,
pub ctx_flags: ::std::os::raw::c_int,
pub nb_streams: ::std::os::raw::c_uint,
pub streams: * mut * mut AVStream,
// ... 還有幾十個欄位 ...
}
Bindgen 的限制
Bindgen 不是萬能的,它無法解決 :
1. 跨版本相容性
編譯時:FFmpeg 6.0 安裝 → bindings.rs 對應 FFmpeg 6.0
執行時:FFmpeg 7.0 安裝 → ABI 不相容 → 未定義行為
解決方案: 重新編譯,或使用版本檢查。
2. 指標生命週期
Bindgen 只產生型別定義,不會幫你管理生命週期:
// bindgen 產生的函式簽名
pub fn avformat_open_input (
ps: * mut * mut AVFormatContext,
url: * const ::std::os::raw::c_char, // 誰負責這個指標的生命週期?
// ...
) -> ::std::os::raw::c_int;
解決方案: 在 safe wrapper 層處理。
3. 語意正確性
Bindgen 確保 ABI 正確,但不保證你正確使用 API:
// 這段程式碼 ABI 正確,但語意錯誤
let ctx = avformat_open_input(.. .);
// 忘記呼叫 avformat_find_stream_info()
let streams = (* ctx).nb_streams; // 可能是 0 或垃圾值
解決方案: 閱讀文件,或使用 safe wrapper 強制正確的呼叫順序。
結論:三層防護
理想的 FFI 架構是三層:
block-beta
columns 1
block:layer1
A["Safe Wrapper(語意正確性)← 人類設計"]
end
block:layer2
B["Bindgen 綁定(ABI 正確性)← 工具產生"]
end
block:layer3
C["C 函式庫(實際實作)← 外部依賴"]
end
Bindgen 確保 Rust 和 C 之間的 ABI 匹配
Safe Wrapper 確保 API 被正確使用
人類 負責設計 wrapper 的 API 和處理邊界情況
手寫 FFI 適合學習底層原理,但在生產環境中,bindgen + safe wrapper 的組合才是正確的選擇。
Safe Wrapper 的設計原則
Safe wrapper 是 FFI 的最後一道防線,設計得好可以讓使用者完全不需要接觸 unsafe。以下是幾個關鍵原則:
1. RAII:資源獲取即初始化
原則: 用 Rust 的所有權系統管理 C 資源的生命週期。
pub struct FormatContext {
ptr: * mut AVFormatContext, // C 資源
}
impl FormatContext {
pub fn open (path: & str ) -> Result< Self> {
let mut ptr = std::ptr::null_mut();
let ret = unsafe { avformat_open_input(& mut ptr, .. .) };
if ret < 0 {
return Err(AvError::from_code(ret));
}
Ok(FormatContext { ptr }) // 獲取資源
}
}
impl Drop for FormatContext {
fn drop (& mut self) {
unsafe { avformat_close_input(& mut self.ptr); } // 自動釋放
}
}
效果: 使用者不可能忘記釋放資源,編譯器保證。
2. 類型狀態模式:編譯時強制正確順序
原則: 用不同的類型表示不同的狀態,讓編譯器阻止錯誤的呼叫順序。
// 未初始化的 context
pub struct UninitializedContext { ptr: * mut AVFormatContext }
// 已讀取 stream info 的 context
pub struct ReadyContext { ptr: * mut AVFormatContext }
impl UninitializedContext {
pub fn find_stream_info (self) -> Result< ReadyContext> {
let ret = unsafe { avformat_find_stream_info(self.ptr, .. .) };
if ret < 0 {
return Err(.. .);
}
Ok(ReadyContext { ptr: self .ptr })
}
}
impl ReadyContext {
// 只有 ReadyContext 才能讀取 packets
pub fn read_packet (& mut self) -> Result< Packet> { .. . }
}
效果: 不可能在 find_stream_info() 之前呼叫 read_packet(),編譯器會報錯。
3. 借用而非擁有:避免不必要的複製
原則: 對於 C 結構體內的資料,返回借用而非複製。
impl Packet {
// 返回借用,避免複製整個 packet 資料
pub fn data (& self) -> Option<& [u8 ]> {
unsafe {
let ptr = (* self.ptr).data;
let size = (* self.ptr).size;
if ptr.is_null() || size <= 0 {
None
} else {
Some(std::slice::from_raw_parts(ptr, size as usize ))
}
}
}
}
效果: 零複製存取,效能與直接使用 C API 相同。
4. 錯誤類型化:用 Rust 的 Result 取代錯誤碼
原則: 把 C 的錯誤碼轉換成有意義的 Rust 錯誤類型。
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error( "End of file" )]
Eof,
#[error( "Failed to open input: {0}" )]
OpenInput(String),
#[error( "FFmpeg error ({code}): {message}" )]
Ffmpeg { code: i32 , message: String },
}
impl AvError {
pub fn from_code (code: i32 ) -> Self {
if code == AVERROR_EOF {
return AvError::Eof;
}
let message = get_error_string(code);
AvError::Ffmpeg { code, message }
}
}
效果: 使用者可以用 match 處理特定錯誤,IDE 有自動完成。
為什麼用 thiserror?
thiserror 是定義錯誤類型的最佳選擇,原因如下:
1. 自動實作 std::error::Error
// 手寫需要這些
impl std::fmt::Display for AvError { .. . }
impl std::error::Error for AvError { .. . }
// thiserror 一行搞定
#[derive(thiserror::Error)]
2. 錯誤訊息內嵌在類型定義中
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error( "Failed to open '{path}': {reason}" )]
OpenInput { path: String, reason: String },
#[error( "Codec not found: {0}" )]
CodecNotFound(String),
}
程式碼和文件在同一處,不會不同步。
3. 支援錯誤鏈(Error Source)
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error( "IO error" )]
Io(#[from] std::io::Error), // 自動實作 From<std::io::Error>
#[error( "Invalid path: {0}" )]
InvalidPath(#[source] std::ffi::NulError), // 保留原始錯誤
}
使用者可以用 .source() 追溯錯誤來源。
4. 與 anyhow 完美搭配
// 函式庫用 thiserror 定義具體錯誤
#[derive(thiserror::Error)]
pub enum AvError { .. . }
// 應用程式用 anyhow 統一處理
fn main () -> anyhow ::Result< ()> {
let ctx = FormatContext::open("video.mp4" )? ; // AvError 自動轉換
Ok(())
}
5. 零執行時開銷
thiserror 是純粹的 proc-macro,所有程式碼在編譯時產生,執行時沒有任何額外成本。
比較:不同錯誤處理方式
方式
優點
缺點
返回 i32 錯誤碼
與 C API 一致
無類型安全、難以理解
手寫 Error trait
完全控制
樣板程式碼多
thiserror
簡潔、類型安全
需要依賴(但很輕量)
anyhow::Error
最簡單
丟失具體類型,不適合函式庫
結論: 函式庫應該用 thiserror 定義具體錯誤類型,讓使用者可以精確處理每種錯誤情況。
5. 隱藏指標:不暴露原始指標給使用者
原則: 所有原始指標都應該是 struct 的私有欄位。
pub struct FormatContext {
ptr: * mut AVFormatContext, // 私有!
}
impl FormatContext {
// 提供安全的存取方法
pub fn nb_streams (& self) -> usize {
unsafe { (* self.ptr).nb_streams as usize }
}
// 如果真的需要原始指標(進階使用),標記為 unsafe
pub unsafe fn as_ptr (& self) -> * mut AVFormatContext {
self.ptr
}
}
效果: 一般使用者完全不需要寫 unsafe。
6. 迭代器模式:讓集合存取符合 Rust 慣例
原則: 用迭代器取代 C 風格的索引存取。
impl FormatContext {
pub fn streams (& self) -> impl Iterator< Item = StreamInfo> + '_ {
(0 .. self.nb_streams()).map(move | i| self.stream_info(i).unwrap())
}
}
// 使用方式
for stream in ctx.streams() {
println! ("Stream {} : {:?} " , stream.index, stream.media_type);
}
效果: 符合 Rust 慣例,可以用 filter、map 等方法鏈。
7. 文件化不變量:清楚說明 Safety 條件
原則: 即使是 safe 函式,也要說明前提條件和可能的 panic。
impl FormatContext {
/// Read the next packet from the container.
///
/// # Returns
/// - `Ok(true)` if a packet was read
/// - `Ok(false)` if end of file reached
/// - `Err(...)` if an error occurred
///
/// # Panics
/// Never panics. All errors are returned as `Err`.
pub fn read_packet (& mut self, packet: & mut Packet) -> Result< bool > {
// ...
}
}
設計檢查清單
設計 safe wrapper 時,問自己這些問題:
問題
如果答案是「否」
使用者需要寫 unsafe 嗎?
提供更高層的 API
使用者可能忘記釋放資源嗎?
實作 Drop
使用者可能呼叫順序錯誤嗎?
使用類型狀態模式
使用者可能傳入無效參數嗎?
在建構時驗證
錯誤訊息有意義嗎?
定義專屬的錯誤類型
API 符合 Rust 慣例嗎?
參考標準庫的設計
好的 safe wrapper 讓使用者感覺像在用純 Rust 函式庫,完全不知道底下是 C。
AI Coding Agent 與 FFI 開發
Bindgen + Safe Wrapper 的組合是最佳實踐,但過去有個問題:太繁瑣了 。
設定 build.rs、處理 pkg-config、設計 wrapper API、實作 Drop、寫文件、寫測試… 這些工作加起來可能比實際的業務邏輯還多。這也是為什麼很多人選擇「先用 unsafe 頂著,以後再說」。
AI coding agent 改變了這個權衡。
AI 擅長的 FFI 任務
1. Build Script 設定
告訴 AI:「用 bindgen 綁定 libavformat,只需要這幾個函式…」
AI 會產生完整的 build.rs:
// AI 產生,包含 pkg-config 偵測、bindgen 設定、錯誤處理
fn main () {
let lib = pkg_config::probe_library("libavformat" )
.expect("libavformat not found" );
let bindings = bindgen::Builder::default()
.header_contents("wrapper.h" , r #"#include <libavformat/avformat.h>"# )
.allowlist_function("avformat_open_input" )
.allowlist_function("avformat_find_stream_info" )
// ... 完整設定
.generate()
.expect("Failed to generate bindings" );
// 輸出到正確位置
bindings.write_to_file(out_path.join("bindings.rs" )).unwrap();
}
手寫這個需要查文件、試錯、處理邊界情況。AI 幾秒鐘搞定。
2. Safe Wrapper 骨架
告訴 AI:「為 AVFormatContext 建立 safe wrapper,實作 RAII」
AI 會產生符合所有設計原則的程式碼:
pub struct FormatContext {
ptr: * mut AVFormatContext,
}
impl FormatContext {
pub fn open < P: AsRef< Path>> (path: P ) -> Result< Self> { .. . }
pub fn nb_streams (& self) -> usize { .. . }
pub fn streams (& self) -> impl Iterator< Item = StreamInfo> + '_ { .. . }
}
impl Drop for FormatContext {
fn drop (& mut self) { .. . }
}
包含:
正確的生命週期標註
Result 錯誤處理
迭代器 API
Drop 實作
3. 錯誤類型定義
告訴 AI:「用 thiserror 定義錯誤類型,涵蓋 FFmpeg 的錯誤碼」
#[derive(Debug, thiserror::Error)]
pub enum AvError {
#[error( "End of file" )]
Eof,
#[error( "Failed to open input: {0}" )]
OpenInput(String),
#[error( "FFmpeg error ({code}): {message}" )]
Ffmpeg { code: i32 , message: String },
}
4. 文件和測試
AI 自動產生:
/// # Safety 段落
參數和返回值說明
基本的單元測試
使用範例
這些在手動開發時往往被省略,但對使用者很重要。
人類的角色
AI 不是萬能的。人類仍然負責:
任務
為什麼需要人類
決定要綁定哪些函式
需要理解業務需求
審查 API 設計
確保符合 Rust 慣例和使用者期望
驗證 ABI 正確性
編譯成功不代表執行時正確
處理邊界情況
AI 可能遺漏特殊情況
效能調校
需要實際測量和分析
實際工作流程
flowchart TD
A["1. 人類:「綁定 libavformat 的 open/read/close」"] --> B["2. AI:產生 Cargo.toml、build.rs、bindgen 設定"]
B --> C["3. 人類:cargo build,確認編譯成功"]
C --> D["4. AI:產生 safe wrapper(FormatContext, Packet...)"]
D --> E["5. 人類:審查 API,要求修改(「加上迭代器」)"]
E --> F["6. AI:修改 + 補充文件和測試"]
F --> G["7. 人類:用真實檔案測試,確認功能正確"]
整個過程可能只需要 30 分鐘,而不是以前的一整天。
為什麼 FFI 特別適合 AI 輔助
模式明確 :bindgen 設定、RAII wrapper、錯誤處理都有標準模式
正確性可驗證 :能編譯、能執行、測試通過
重複性高 :每個函式的 wrapper 結構類似
文件密集 :需要大量的 Safety 說明和使用範例
AI 處理這些機械性工作,人類專注於設計決策和驗證。
結論
FFI 開發的最佳實踐是 bindgen + safe wrapper :
Bindgen 確保 ABI 正確性(結構體大小、欄位順序、對齊)
Safe Wrapper 確保語意正確性(資源管理、呼叫順序、錯誤處理)
過去這個組合太繁瑣,很多人選擇捷徑。現在有了 AI coding agent,完整實作的成本大幅降低。
不要再「先用 unsafe 頂著」了。讓 AI 幫你產生正確的 bindgen + safe wrapper,你只需要審查和測試。
這就是 AI 時代的 FFI 開發:人類做設計決策,AI 做繁瑣實作,最終得到安全、符合慣例的 Rust 綁定。
專案原始碼:rust-52-projects/libavformat-ffi