Simply Patrick

Vibe Coding 實戰:打造 AlphaGPS

最近「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 不只給了程式碼,還解釋了整個機制:

  1. CBCentralManagerOptionRestoreIdentifierKey 初始化藍牙管理器
  2. iOS 會在背景記住連線意圖
  3. 當藍牙事件發生時,iOS 會喚醒 App 並呼叫 willRestoreState
  4. App 需要在這個回調中重建狀態並恢復連線

最困難的是處理邊界情況:藍牙 UUID 可能改變、相機可能同時連線多台、恢復可能在位置權限授權之前發生。Claude 幫我逐一考慮這些情況,最終實現了真正的「零操作」體驗。

Sony 的 BLE 協議

Sony 沒有公開他們的藍牙 GPS 同步協議。幸運的是,我在網路上找到了其他開發者的逆向工程成果(特別是 camera-gps-link 專案),他們已經分析出協議的細節。我把整理後的協議規格放在 docs/sony-gps-protocol-spec.md

協議的關鍵:寫入 GPS 資料需要一個三步驟的「解鎖」序列:

  1. 寫入解鎖指令到 DD30 characteristic
  2. 寫入鎖定指令到 DD31 characteristic
  3. 寫入 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+

我學到的事

  1. Vibe Coding 是真的:對於有經驗的開發者,AI 可以將生產力提升 5-10 倍。關鍵是你要能驗證和引導它的輸出。

  2. 對話品質決定結果:模糊的指示得到模糊的程式碼。具體描述需求、約束和邊界情況,Claude 就能給出專業級的解決方案。

  3. Claude 4.5 Opus 的差異:相比之前的模型,Opus 4.5 在理解複雜系統、保持上下文一致性、主動考慮邊界情況方面有明顯提升。

  4. 這不是取代學習:AI 讓你更快實現想法,但你還是需要理解底層技術。否則你無法判斷 AI 的建議是否正確。


寫在最後

AlphaGPS 現在是我日常使用的工具。每次出門拍照,相機開機就自動連接、照片自動標記位置、全程不需要碰手機。

回頭看這個專案,最有意思的不是程式碼本身,而是這種新的開發方式:我專注於「想要什麼」和「為什麼」,Claude 幫我處理「怎麼做」的細節。

這就是 Vibe Coding 的精髓——不是讓 AI 取代你,而是讓你把精力放在真正重要的事情上。

如果你也想嘗試,我的建議是:找一個你真正想解決的問題,然後跟 Claude 聊聊。你可能會驚訝於它能帶你走多遠。


這篇文章本身也是用 Claude 協助撰寫的——用 AI 寫一篇關於 AI 開發的文章,似乎也是剛好而已。


Rust GUI 框架比較:GPUI vs Iced

最近用兩個不同的 Rust GUI 框架實作了同一個計算機專案,分別是 GPUIIced。這篇文章比較兩者的架構差異和開發體驗。

框架背景

框架 開發者 特色
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 程式設計

最近用 GPUI 框架實作了一個圖形化計算機,過程中學到不少 Rust 的實用技巧。這篇文章整理了從這個專案中可以學到的 Rust 程式設計概念。

專案概述

這個計算機支援基本四則運算、指數運算 (^)、取餘數 (%)、變數賦值,以及內建常數 pie。UI 使用 Zed 編輯器團隊開發的 GPUI 框架,這是一個 GPU 加速的 Rust UI 框架。

gpui-calculator.png

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> {
        // 處理數字、變數、括號、一元負號
    }
}

這個設計的關鍵:

  • 運算子優先級:透過函數呼叫順序自然實現(exprtermpowerprim
  • 結合性:左結合用迴圈,右結合用遞迴
  • 錯誤處理:使用 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,動手實作一個計算機是很好的練習。從簡單的四則運算開始,逐步加入變數、函數、甚至自定義語法,你會學到很多編譯器和語言設計的知識。

參考資源


我的 Geonix rev.2 鍵盤配置

用了 PreonicPlanck 好幾年後,我最近買了 Geonix rev.2 這把 40% 正交鍵盤 (鍵帽使用 NuPhy Shine-through White for Air60 V2) 方便隨身攜帶用。

geonix-rev2.jpg

這篇文章記錄我目前穩定使用的鍵盤配置,包含四層設計的思路與實際鍵位安排。

為什麼是 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,核心邏輯基本沒有大改,只是隨著不同鍵盤的物理配置做微調。

幾個心得:

  1. Home Row Mods 需要調整 timing:預設的 tapping term 通常太短,容易誤觸發。我調整到比較長的數值後才穩定下來。

  2. 分層設計要符合使用情境:左手拇指給符號層(寫程式時左手常按 modifier),右手拇指給數字層(數字輸入時右手主導),這樣的分配比較符合我的使用習慣。

  3. 不要一次改太多:剛開始調整配置時很容易想要一次到位,但肌肉記憶需要時間建立。每次只改一兩個地方,適應後再繼續調整。

  4. 40% 的限制反而是優點:強迫自己思考每個按鍵的使用頻率,最後得到的配置比全尺寸鍵盤更有效率。

如果你也在考慮嘗試小型鍵盤或正交配置,希望這篇文章能提供一些參考。


Rust FFI 完整指南:從手寫綁定到 AI 輔助的 Safe Wrapper

Rust 的 FFI(Foreign Function Interface)讓我們可以呼叫 C 函式庫,但正確實作並不容易。手寫綁定容易出錯,維護成本高;bindgen 自動產生綁定但仍需要 unsafe;safe wrapper 提供安全的 API 但設計繁瑣。

本文以綁定 FFmpeg 的 libavformat 為例,完整介紹:

  1. 三種 FFI 方式的優缺點比較
  2. 手寫綁定的常見陷阱:結構體大小、欄位順序、對齊、生命週期…
  3. Bindgen 的工作原理:如何用 libclang 解析 C 標頭檔
  4. Safe Wrapper 設計原則:RAII、類型狀態、錯誤處理、thiserror
  5. 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 慣例,可以用 filtermap 等方法鏈。

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 輔助

  1. 模式明確:bindgen 設定、RAII wrapper、錯誤處理都有標準模式
  2. 正確性可驗證:能編譯、能執行、測試通過
  3. 重複性高:每個函式的 wrapper 結構類似
  4. 文件密集:需要大量的 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