featured.svg

背單字這種事,光靠死記實在是頗沒效率,所以我一直很喜歡間隔重複(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);
});

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 的手感大概會紮實不少喔。

參考資源