背單字這種事,光靠死記實在是頗沒效率,所以我一直很喜歡間隔重複(spaced repetition)這套玩法。這次乾脆自己動手,用 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-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 不同面向的眉角。做完一輪,你對 Rust 的手感大概會紮實不少喔。
參考資源
- flashcard-app 原始碼 - 本文範例的完整程式碼
- Slint 官方網站 - Rust 跨平台 UI 框架
- SM-2 演算法 - Wozniak 的原始規格
- Slint Android 文件 - Android 平台支援