最近用 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 技巧。
參考資源
- flashcard-app 原始碼 - 本文範例的完整程式碼
- Slint 官方網站 - Rust 跨平台 UI 框架
- SM-2 演算法 - Wozniak 的原始規格
- Slint Android 文件 - Android 平台支援