最近用 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 技巧。

參考資源