Simply Patrick

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

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

專案概述

這個記憶卡應用的功能:

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

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

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

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

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

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

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

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

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

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

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

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

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

關鍵觀念:

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

3. Weak 引用避免循環參考

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

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

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

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

4. 借用衝突的實戰解法

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

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

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

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

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

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

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

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

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

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

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

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

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

幾個設計重點:

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

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

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

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

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

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

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

[dependencies]
slint = "1.9"

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

入口點用 #[cfg] 區分:

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

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

踩過的坑:

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

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

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

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

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

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

8. JSON 序列化與 include_str!

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

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

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

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

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

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

總結

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

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

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

參考資源


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

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

guitar-fretboard.png

專案概述

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

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

1. The Elm Architecture(TEA)

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

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

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

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

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

TEA 的三個核心:

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

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

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

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

Canvas 繪圖:實作 canvas::Program

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

struct FretboardCanvas;

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

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

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

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

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

        vec![frame.into_geometry()]
    }
}

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

音訊來源:實作 Iterator + Source

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

impl Iterator for KarplusStrong {
    type Item = f32;

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

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

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

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

        Some(current)
    }
}

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

關鍵觀念:

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

3. Karplus-Strong 撥弦合成

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

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

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

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

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

演算法的核心概念:

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

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

4. Stack:UI 圖層疊加

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

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

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

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

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

5. 閉包與按鈕樣式

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

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

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

這裡的重點:

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

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

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

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

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

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

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

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

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

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

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

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

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

幾個關鍵細節:

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

總結

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

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

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

參考資源


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,動手實作一個計算機是很好的練習。從簡單的四則運算開始,逐步加入變數、函數、甚至自定義語法,你會學到很多編譯器和語言設計的知識。

參考資源