最近用 Slint UI 框架實作了一個間隔重複記憶卡應用,可以在桌面和 Android 上執行。這個專案涵蓋了狀態管理、演算法實作、跨平台建置等實用的 Rust 程式設計概念。
專案概述
這個記憶卡應用的功能:
- SM-2 間隔重複演算法排程複習
- 四個畫面:卡牌組列表、學習、新增卡片、統計
- JSON 檔案持久化儲存
- 同時支援桌面與 Android 平台
- 內建範例卡牌組(Rust 基礎、世界首都)
1. Slint 的宣告式 UI 與頁面路由
Slint 使用自己的標記語言定義 UI,和 Rust 程式碼分離。頁面路由透過一個整數屬性控制,用條件渲染切換畫面:
export component MainWindow inherits Window {
// 0=卡牌組列表, 1=學習, 2=編輯, 3=統計
in-out property <int> current-page: 0;
// 條件渲染:只有符合條件的頁面會被建立
if current-page == 0: DeckListPage {
study-deck(idx) => { root.study-deck(idx); }
}
if current-page == 1: StudyPage {
revealed: root.card-revealed;
reveal-card() => { root.reveal-card(); }
rate-card(q) => { root.rate-card(q); }
}
}
Slint 的 .slint 檔案在編譯時由 slint-build 轉換為 Rust 程式碼,所以 UI 結構的型別檢查在編譯期就完成。.slint 中定義的 struct 和 callback 會自動產生對應的 Rust 型別和方法。
值得注意的是,Slint 的屬性名稱使用 kebab-case(例如 card-count),在 Rust 端會自動轉換為 snake_case(card_count)。
2. Rc<RefCell<T>>:單執行緒的內部可變性
Slint 的事件迴圈是單執行緒的,所以不需要 Arc<Mutex<T>>。改用 Rc<RefCell<T>> 來在多個閉包間共享可變狀態:
let state = Rc::new(RefCell::new(AppState {
decks,
current_session: None,
editor_deck_index: None,
data_path: path,
}));
// 每個 callback 閉包都 clone 一份 Rc
{
let state = Rc::clone(&state);
window.on_study_deck(move |deck_index| {
let mut st = state.borrow_mut(); // 執行時借用檢查
// 修改 st...
});
}
{
let state = Rc::clone(&state);
window.on_rate_card(move |rating_int| {
let mut st = state.borrow_mut();
// 修改 st...
});
}
關鍵觀念:
Rc:引用計數智慧指標,讓多個閉包共享同一份資料的所有權
RefCell:將借用檢查從編譯期移到執行期,允許在不可變引用的情況下修改內容
Rc::clone:只增加引用計數(O(1)),不會深複製資料
- 這個模式比
Arc<Mutex<T>> 更輕量——沒有原子操作、沒有鎖的開銷——但只能在單執行緒使用
3. Weak 引用避免循環參考
每個 callback 都需要存取 Slint 視窗來更新 UI,但直接持有視窗的強引用會造成循環參考。Slint 提供 as_weak() 來解決:
let window_weak = window.as_weak();
window.on_reveal_card(move || {
let window = window_weak.unwrap(); // 從 Weak 升級為強引用
window.set_card_revealed(true);
});
ComponentHandle 和 Model 是 Slint 的 trait,需要明確引入才能使用 as_weak()、run()、row_count() 等方法:
use slint::{ComponentHandle, Model, ModelRc, VecModel};
4. 借用衝突的實戰解法
rate_card callback 是整個專案最複雜的部分——需要同時讀取 session 資訊和修改卡片資料,但它們都在同一個 AppState 裡:
// 這樣寫會編譯失敗!
let session = st.current_session.as_mut(); // &mut st
let card = &st.decks[session.deck_index]; // &st — 衝突!
解法:先用 as_ref() 從 session 提取需要的純值到區域變數,釋放對 st 的借用後再存取 st.decks:
// 先提取 session 的純值(Copy 型別),立即釋放借用
let (deck_idx, card_idx, _position, due_len) = {
let session = match st.current_session.as_ref() {
Some(s) => s,
None => return,
};
(
session.deck_index,
session.due_cards[session.current_position],
session.current_position,
session.due_cards.len(),
)
}; // session 的借用在這裡結束
// 現在可以安全地借用 st.decks
let result = sm2::review(&st.decks[deck_idx].cards[card_idx], rating);
let card = &mut st.decks[deck_idx].cards[card_idx];
card.ease_factor = result.new_ease_factor;
// ...
// 再次借用 session 來更新進度
let session = st.current_session.as_mut().unwrap();
session.cards_reviewed += 1;
session.current_position += 1;
這個模式的核心概念:當同一個 struct 的不同欄位需要不同的借用模式時,用區域變數搭配作用域來交錯借用。Rust 的借用檢查器是以作用域為單位的,所以只要確保可變和不可變借用不重疊,就能通過編譯。
5. SM-2 間隔重複演算法
SM-2 是 SuperMemo 2 演算法的簡稱,核心概念很簡單:答對的卡片間隔越來越長,答錯就重置。實作為純函數,方便測試:
pub fn review(card: &Card, rating: ReviewRating) -> ReviewResult {
let q = rating.quality() as f64;
// 更新簡易度因子:EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
let new_ef = (card.ease_factor
+ (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02)))
.max(1.3); // 最低 1.3
let (new_interval, new_repetition) = if rating == ReviewRating::Again {
(1, 0) // 答錯:重置為 1 天
} else {
let new_rep = card.repetition + 1;
let interval = match new_rep {
1 => 1, // 第一次:1 天
2 => 6, // 第二次:6 天
_ => (card.interval as f64 * new_ef).ceil() as u32, // 之後:前次間隔 × EF
};
(interval, new_rep)
};
ReviewResult {
new_ease_factor: new_ef,
new_interval,
new_repetition,
next_review: Utc::now() + chrono::Duration::days(new_interval as i64),
}
}
幾個設計重點:
- 純函數:輸入卡片狀態和評分,輸出新的排程結果。不修改任何全域狀態
match 做模式比對:前兩次複習有固定間隔(1 天、6 天),第三次開始才用 EF 計算
max(1.3) 限制下限:避免 EF 降到太低讓間隔收斂到 0
判斷哪些卡片到期也很直觀:
pub fn due_cards(cards: &[Card]) -> Vec<usize> {
let now = Utc::now();
cards
.iter()
.enumerate()
.filter(|(_, card)| card.next_review <= now)
.map(|(i, _)| i)
.collect()
}
6. 跨平台建置:桌面與 Android
這個專案同時支援桌面和 Android。關鍵在於 Cargo 的條件編譯和 feature flag:
[lib]
crate-type = ["cdylib", "lib"] # cdylib 給 Android,lib 給桌面
[[bin]]
name = "flashcard" # 桌面執行檔名稱和 package 不同,避免 PDB 衝突
path = "src/main.rs"
[dependencies]
slint = "1.9"
[features]
android = ["slint/backend-android-activity-06"] # Android 後端用 feature 控制
入口點用 #[cfg] 區分:
// src/main.rs — 桌面入口
fn main() {
flashcard_app::run();
}
// src/lib.rs — Android 入口
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
slint::android::init(app).unwrap();
run();
}
踩過的坑:
- PDB 檔名衝突:在 Windows 上,
crate-type = ["cdylib", "lib"] 搭配同名的 [[bin]] 會產生 PDB 衝突。解法是讓 bin 名稱和 package 名稱不同(package = flashcard-app,bin = flashcard)
- Feature 統一問題:用
[target.'cfg(target_os = "android")'.dependencies] 指定 Android 依賴,Cargo 仍會統一 feature,導致桌面建置也拉入 NDK 依賴。解法是改用 [features] 區段,建置 Android 時明確傳入 --features android
#[unsafe(no_mangle)]:這個語法需要 nightly Rust,stable 上要用 #[no_mangle]
7. 共享結構避免循環匯入
Slint 的多檔案架構可能遇到循環匯入。例如 main.slint 匯入 deck_list.slint,而 deck_list.slint 又需要 main.slint 中定義的 DeckInfo struct。
解法:把共享的 struct 抽到獨立的 types.slint:
// ui/types.slint — 共享的資料結構
export struct DeckInfo {
name: string,
description: string,
card-count: int,
due-count: int,
}
// ui/deck_list.slint — 從 types.slint 匯入,不匯入 main.slint
import { DeckInfo } from "types.slint";
export component DeckListPage inherits Rectangle {
in property <[DeckInfo]> decks;
// ...
}
// ui/main.slint — 匯入 components 和 re-export types
import { DeckListPage } from "deck_list.slint";
export { DeckInfo, DeckStats } from "types.slint";
8. JSON 序列化與 include_str!
資料持久化用 serde + serde_json,模型上加 derive 就搞定:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Card {
pub id: Uuid,
pub front: String,
pub back: String,
pub ease_factor: f64,
pub interval: u32,
pub repetition: u32,
pub next_review: DateTime<Utc>,
// ...
}
範例資料用 include_str! 在編譯時嵌入二進位檔:
pub fn sample_decks() -> Vec<Deck> {
let data = include_str!("../data/sample_decks.json");
let samples: Vec<SampleDeck> = serde_json::from_str(data)
.expect("invalid sample data");
samples.into_iter().map(|sd| {
let mut deck = Deck::new(sd.name, sd.description);
deck.cards = sd.cards.into_iter()
.map(|c| Card::new(c.front, c.back))
.collect();
deck
}).collect()
}
include_str! 在編譯期讀取檔案內容,嵌入到二進位檔中。好處是不需要在執行時處理檔案路徑問題,特別適合 Android 環境。
總結
這個記憶卡專案涵蓋了多個重要的 Rust 概念:
| 概念 |
應用場景 |
Rc<RefCell<T>> |
單執行緒多閉包共享可變狀態 |
| Weak 引用 |
避免 UI 元件的循環參考 |
| 借用衝突解法 |
用作用域交錯不同借用模式 |
| SM-2 演算法 |
純函數實作間隔重複排程 |
| Feature flag |
條件編譯控制跨平台依賴 |
cfg 屬性 |
桌面 vs Android 入口點切換 |
| Slint 宣告式 UI |
編譯時型別安全的 UI 定義 |
include_str! |
編譯期嵌入靜態資源 |
| serde derive |
零樣板的 JSON 序列化 |
如果你想找一個結合 UI 框架、演算法和跨平台建置的 Rust 練習專案,間隔重複記憶卡是個很好的選擇。從 SM-2 演算法開始,逐步加入 UI、持久化、Android 支援,每一步都能學到不同面向的 Rust 技巧。
參考資源
最近用 iced 框架實作了一個吉他指板視覺化工具,可以顯示 C 大調音階、繪製弦和琴格,並在點擊音符時播放逼真的撥弦音效。這個專案雖然不大,但涵蓋了多個實用的 Rust 程式設計概念。
專案概述
這個吉他指板視覺化工具的功能:
- 顯示標準調弦(E A D G B E)的 22 格指板
- 以不同顏色標示 C 大調音階音符
- 使用 Canvas 繪製琴弦、琴格和位置標記
- 點擊音符播放 Karplus-Strong 合成的撥弦音效
- 半透明音符圓圈,讓底層指板隱約可見
1. The Elm Architecture(TEA)
iced 框架採用 The Elm Architecture,這是一種函數式 UI 架構。整個應用只需要三個核心元素:
struct App {
_output_stream: Option<OutputStream>,
stream_handle: Option<rodio::OutputStreamHandle>,
}
#[derive(Debug, Clone)]
enum Message {
NoteClicked(usize, usize), // (string_index, fret)
}
impl App {
fn update(&mut self, message: Message) {
match message {
Message::NoteClicked(string_idx, fret) => {
self.play_note(string_idx, fret);
}
}
}
fn view(&self) -> Element<'_, Message> {
// 建構 UI 樹
}
}
TEA 的三個核心:
- Model(
App struct):應用程式的狀態
- Message(
Message enum):所有可能的使用者互動事件
- Update + View:
update 根據 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,
}
}
}
演算法的核心概念:
- 延遲緩衝區長度決定音高:
delay_samples = sample_rate / frequency,例如 440Hz 的 A 音需要 44100 / 440 = 100 個取樣
- 白噪音初始化:隨機值模擬撥弦時弦的不規則振動
- 低通濾波回饋:每次取出一個值後,與下一個值取平均再放回,模擬弦的能量自然衰減
- 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_root、is_c_major、bg_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::Program 和 Source |
| Iterator |
Karplus-Strong 音訊取樣產生 |
| 閉包 + move |
按鈕樣式與事件處理 |
| 所有權 |
音訊資源的生命週期管理 |
| 常數陣列 |
音樂理論的領域知識編碼 |
| Stack 佈局 |
Canvas 與互動元件的疊加 |
| 物理建模 |
即時音訊合成 |
如果你想找一個結合 GUI、音訊和數學的 Rust 練習專案,吉他指板是個很好的選擇。從簡單的視覺化開始,逐步加入音訊合成、Canvas 繪圖、半透明效果,每一步都能學到新的 Rust 技巧。
參考資源
最近「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,動手實作一個計算機是很好的練習。從簡單的四則運算開始,逐步加入變數、函數、甚至自定義語法,你會學到很多編譯器和語言設計的知識。
參考資源