featured.svg

最近用 Iced 實作了一個漫畫閱讀器——能打開 CBZ、CB7 格式的漫畫壓縮檔,支援鍵盤翻頁,還有背景預載機制讓翻頁幾乎感受不到延遲。

這篇文章聚焦在幾個有趣的設計決策:trait object 搭配 Arc 的多格式抽象、以「距離」驅逐的 LRU 快取、以及 smol::unblock 把同步 I/O 推進執行緒池的技巧。

專案概述

comic-viewer 支援的功能:

  • 開啟 CBZ(ZIP)、CB7(7-Zip)格式漫畫檔
  • CBR(RAR)格式顯示友善的錯誤訊息(需外部函式庫,目前為 stub)
  • 鍵盤翻頁(方向鍵、PageUp/PageDown)
  • 頁面快取(最多 7 頁),快取命中時翻頁瞬間完成
  • 背景預載:目前頁面前後各 2 頁
  • Tokyo Night Storm 主題的深色 UI

專案結構很清楚:

comic-viewer/
├── Cargo.toml
└── src/
    ├── main.rs          # Iced 應用程式主體(App、Message、PageCache)
    └── reader/
        ├── mod.rs       # ComicReader trait + open() 工廠函式
        ├── cbz.rs       # ZIP 格式實作
        ├── cb7.rs       # 7-Zip 格式實作
        └── cbr.rs       # RAR stub

1. Arc<dyn ComicReader>:多格式的 Trait Object 抽象

不同壓縮格式的解壓縮方式差異很大,但對應用程式來說只需要「給我第 N 頁的圖片」。ComicReader trait 完美地封裝這個差異:

pub trait ComicReader: Send + Sync + std::fmt::Debug {
    fn title(&self) -> &str;
    fn page_count(&self) -> usize;
    fn extract_page(&self, index: usize) -> Result<iced::widget::image::Handle, String>;
}

幾個設計細節值得注意:

  • Send + Sync:因為 App.comicOption<Arc<dyn ComicReader>>,而 Arc<T> 要求 T: Send + Sync 才能跨執行緒共享。背景預載任務會 clone 這個 Arc 並在 smol 的執行緒池跑,所以必須是 Sync
  • &self 而非 &mut selfextract_page 刻意設計成不可變借用,這樣就能從多個執行緒同時呼叫,不需要加鎖
  • std::fmt::Debug:讓整個 App struct 可以 derive Debug(Iced 有時會要求)

工廠函式用副檔名做格式分派:

pub fn open(path: &Path) -> Result<Box<dyn ComicReader>, String> {
    match path
        .extension()
        .and_then(|e| e.to_str())
        .map(str::to_lowercase)
        .as_deref()
    {
        Some("cbz") => cbz::CbzReader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
        Some("cbr") => cbr::CbrReader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
        Some("cb7") => cb7::Cb7Reader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
        ext => Err(format!("Unsupported format: {}", ext.unwrap_or("none"))),
    }
}

呼叫端接到 Box<dyn ComicReader> 後立刻包進 Arc::from(box),讓後續可以廉價 clone 給多個背景任務:

Task::perform(
    async move { smol::unblock(move || reader::open(&path).map(Arc::from)).await },
    Message::ComicLoaded,
)

Arc::from(box)Box<dyn ComicReader> 轉成 Arc<dyn ComicReader>——這個轉換是零成本的,只是改變了「誰擁有這塊記憶體的所有權計數器」。

2. CBZ 與 CB7 的不同解壓策略

ZIP 和 7-Zip 的壓縮原理不同,導致兩種 reader 的實作策略截然不同。

CBZ(ZIP):隨機存取,按需解壓

ZIP 格式的每個檔案是獨立壓縮的,可以不看前面的 entry 直接跳到任意 entry 開始解壓。CbzReader 利用這個特性,只在記憶體中保留整份 .cbz 的原始位元組,每次 extract_page 再用 Cursor 包起來重新開一個 ZipArchive

#[derive(Debug)]
pub struct CbzReader {
    title: String,
    pages: Vec<String>,      // 排序後的圖片檔名列表
    archive_bytes: Vec<u8>,  // 整份 .cbz 的原始位元組
}

impl ComicReader for CbzReader {
    fn extract_page(&self, index: usize) -> Result<image::Handle, String> {
        let filename = self.pages.get(index)
            .ok_or_else(|| format!("Page index {index} out of bounds"))?;
        let cursor = std::io::Cursor::new(&self.archive_bytes);
        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
        let mut file = archive.by_name(filename).map_err(|e| e.to_string())?;
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
        Ok(image::Handle::from_bytes(bytes))
    }
}

每次 extract_page 都重新建立 ZipArchive,看似浪費,但因為 ZipArchive::new 只是 parse ZIP 的 central directory(在檔案末尾,很快),解壓單頁的成本主要在 inflate,整體還是很快的。

CB7(7-Zip):區塊壓縮,開檔時全部解壓

7-Zip 使用 LZMA/LZMA2 區塊壓縮,整個壓縮包的資料是相互依賴的,沒辦法隨機存取單一 entry。Cb7Reader 乾脆在 open() 時一次解壓全部圖片,把位元組存進記憶體:

#[derive(Debug)]
pub struct Cb7Reader {
    title: String,
    pages: Vec<Vec<u8>>,  // 每頁的原始圖片位元組,開檔時全部解壓好
}

impl Cb7Reader {
    pub fn open(path: &Path) -> Result<Self, String> {
        let mut reader = SevenZReader::open(path, Password::empty())
            .map_err(|e| e.to_string())?;

        let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
        reader.for_each_entries(|entry, reader| {
            if entry.is_directory() || !super::is_image_file(entry.name()) {
                return Ok(true); // 跳過,繼續
            }
            let mut bytes = Vec::new();
            reader.read_to_end(&mut bytes).map_err(sevenz_rust::Error::from)?;
            entries.push((entry.name().to_owned(), bytes));
            Ok(true)
        }).map_err(|e| e.to_string())?;

        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
        // ...
    }
}

開檔比 CBZ 慢(要解壓全部內容),但 extract_page 就只是 .clone() 一個 Vec<u8>,非常快。

這是一個典型的「把開銷前移」設計:把慢的動作(解壓縮)移到啟動時,換取後續每次存取都接近零成本。

CBR(RAR):誠實的 Stub

RAR 格式需要 unrar native library,不是純 Rust 可以輕鬆搞定的。CbrReader 選擇一個直白但很務實的做法:

impl CbrReader {
    pub fn open(_path: &Path) -> Result<Self, String> {
        Err(
            "CBR (RAR) format is not yet supported. \
             Consider converting to CBZ using Calibre or 7-Zip."
                .to_string(),
        )
    }
}

open() 時就立刻回傳清楚的錯誤和建議,不讓使用者走到一半才踩雷。這樣的 stub 還有個好處:整個程式的型別系統是完整的,未來只需要填充 CbrReader::open 的實作就好,其餘不用改。

3. 以「距離」驅逐的 LRU 快取

傳統 LRU 快取驅逐最久未使用的項目,但對漫畫閱讀器來說「最久未使用」不是最好的策略——你更想保留目前頁面周圍的頁面,而不是剛才看完的頁面。

PageCache 改用「距離目前頁面最遠的項目優先驅逐」:

const CACHE_CAPACITY: usize = 7;

struct PageCache {
    entries: HashMap<usize, image::Handle>,
}

impl PageCache {
    fn insert(&mut self, index: usize, handle: image::Handle, current_page: usize) {
        if !self.entries.contains_key(&index) && self.entries.len() >= CACHE_CAPACITY {
            // 找距離 current_page 最遠的 key
            let evict = *self
                .entries
                .keys()
                .max_by_key(|&&k| k.abs_diff(current_page))
                .expect("cache is non-empty");
            self.entries.remove(&evict);
        }
        self.entries.insert(index, handle);
    }
}

usize::abs_diff 計算兩個 usize 的絕對差值,不需要轉成有號整數,在 Rust 1.60 加入標準函式庫。

有一個借用技巧值得注意:

// usize is Copy,所以可以在可變借用前先把 key 複製出來
let evict = *self
    .entries
    .keys()
    .max_by_key(|&&k| k.abs_diff(current_page))
    .expect("cache is non-empty");
self.entries.remove(&evict);  // 這裡才開始可變借用

如果寫成 let evict_ref = self.entries.keys().max_by_key(...) 然後直接傳給 remove,借用檢查器會拒絕——因為 keys() 返回的 iterator 持有對 self.entries 的不可變借用,而 remove 需要可變借用。提前 * 解引用並把 usize(Copy 型別)複製出來,借用就在那一行結束了。

為什麼 capacity 選 7?前後各 2 頁(4 頁)加上目前頁面(1 頁)= 5,多留 2 頁緩衝,讓快速翻頁時舊頁面還在快取裡。

持有 image::Handle 的好處是:Iced 會把解碼後的圖片上傳到 GPU 紋理,而 Handle 是對那份紋理的引用(Arc 語義)。只要 Handle 活著,GPU 上的資料就不會被回收——快取命中時翻頁是真的零解碼。

4. Iced 的 MVU 架構與 Task 系統

Iced 採用 Elm 架構(Model-View-Update,MVU)。整個應用程式的運作流程是:

graph LR A[用戶操作 / 事件] -->|產生| B[Message] B -->|傳入| C[update 函式] C -->|修改| D[App 狀態] C -->|回傳| E[Task] E -->|完成後產生| B D -->|傳入| F[view 函式] F -->|產生| G[Element UI 樹] G -->|渲染到螢幕| A

Message enum 是所有事件的完整清單:

#[derive(Debug, Clone)]
enum Message {
    OpenFile,
    FileSelected(Option<PathBuf>),
    ComicLoaded(Result<Arc<dyn reader::ComicReader>, String>),
    NextPage,
    PrevPage,
    PagePreloaded(usize, Option<image::Handle>),
}

Task<Message> 是 Iced 的非同步工作單元。update 函式回傳 Task,Iced 的 runtime 會執行它,完成後把結果包成 Message 再餵回 update。這讓狀態轉換邏輯永遠是同步、純粹的,而非同步副作用完全由 Task 處理。

開檔的流程就是兩個 Task 串接:

// 第一步:呼叫原生對話框
Message::OpenFile => {
    Task::perform(
        async {
            rfd::AsyncFileDialog::new()
                .add_filter("Comic Book Archive", &["cbz", "cbr", "cb7"])
                .pick_file()
                .await
                .map(|f| f.path().to_owned())
        },
        Message::FileSelected,  // 完成後產生 FileSelected(Option<PathBuf>)
    )
}
// 第二步:在執行緒池解壓縮並建立 Reader
Message::FileSelected(Some(path)) => Task::perform(
    async move { smol::unblock(move || reader::open(&path).map(Arc::from)).await },
    Message::ComicLoaded,  // 完成後產生 ComicLoaded(Result<Arc<...>, String>)
),

5. smol::unblock:把同步 I/O 推進執行緒池

Iced 的 async runtime 是基於 futures-executor(smol 生態系),不是 Tokio——從 Cargo.lock 可以確認整個依賴樹裡根本沒有 tokio。但不管底層是哪套 executor,async 執行緒都不適合跑耗時的同步運算(如解壓縮)。如果直接在 async task 裡呼叫 zip::ZipArchive::new(),會阻塞整個執行緒,造成 UI 卡頓。

smol::unblock 是解法:把一個同步的閉包包起來,丟進 smol 的阻塞執行緒池(blocking thread pool)跑,並回傳一個 Future

// 背景預載的單頁提取
let comic = Arc::clone(comic);
Task::perform(
    async move {
        let handle = smol::unblock(move || comic.extract_page(idx).ok()).await;
        (idx, handle)
    },
    |(idx, handle)| Message::PagePreloaded(idx, handle),
)

smol::unblock 的原理很直觀:它維護一個獨立的執行緒池,專門用來跑阻塞工作。move || 閉包在那個執行緒上同步執行,.await 等它完成,結果就像一個普通的 Future

這裡 Arc::clone(comic) 非常關鍵——每個預載任務需要獨立持有對 ComicReader 的引用,才能在自己的執行緒上呼叫 extract_page,而且這份 clone 是 O(1) 的引用計數遞增,不是深複製。

6. 預載窗口與 Task::batch

preload_adjacent 計算出需要預載的頁面集合,然後用 Task::batch 同時啟動所有任務:

fn preload_adjacent(&self, around: usize) -> Task<Message> {
    let Some(comic) = &self.comic else { return Task::none(); };
    let page_count = comic.page_count();

    let start = around.saturating_sub(PRELOAD_LOOKAHEAD);  // 避免 usize 下溢
    let end = (around + PRELOAD_LOOKAHEAD).min(page_count - 1);

    let candidates: Vec<usize> = (start..=end)
        .filter(|&i| i != around && !self.page_cache.contains(i))
        .collect();

    if candidates.is_empty() {
        return Task::none();
    }

    let tasks: Vec<Task<Message>> = candidates
        .into_iter()
        .map(|idx| {
            let comic = Arc::clone(comic);
            Task::perform(
                async move {
                    let handle = smol::unblock(move || comic.extract_page(idx).ok()).await;
                    (idx, handle)
                },
                |(idx, handle)| Message::PagePreloaded(idx, handle),
            )
        })
        .collect();

    Task::batch(tasks)  // 並行啟動所有預載任務
}

saturating_sub 是個小細節:aroundusize,如果 around < PRELOAD_LOOKAHEAD 直接相減會 panic(或在 debug 模式 panic,release 模式則 wrap around)。saturating_sub 讓結果最低為 0,不需要額外的 if around >= PRELOAD_LOOKAHEAD 判斷。

Task::batch 把多個 Task 打包成一個,Iced 會並行執行它們。這意味著如果需要預載頁面 4 和頁面 6,兩個解壓縮任務會同時在執行緒池跑,而不是串行。

7. on_press_maybe:條件式按鈕啟用

Iced 提供了一個很方便的 API 來處理「條件式可按」的按鈕:

let can_prev = self.current_page > 0;
let can_next = self
    .comic
    .as_ref()
    .is_some_and(|c| self.current_page + 1 < c.page_count());

let prev_btn = button(text("◄ Previous").size(14))
    .on_press_maybe((has_comic && can_prev).then_some(Message::PrevPage));

let next_btn = button(text("Next ►").size(14))
    .on_press_maybe((has_comic && can_next).then_some(Message::NextPage));

on_press_maybe 接受 Option<Message>Some(msg) 代表按鈕可按,None 代表禁用。搭配 bool::then_sometrue 回傳 Some(value)false 回傳 None),可以非常簡潔地表達「有符合條件才能按」。

禁用狀態的視覺樣式也透過 nav_button_style 函式裡的 button::Status::Disabled 分支處理:

fn nav_button_style(_theme: &Theme, status: button::Status) -> button::Style {
    let bg = match status {
        button::Status::Hovered | button::Status::Pressed => iced::color!(0x565f89),
        button::Status::Disabled => iced::color!(0x292e42),  // 暗色,表示禁用
        _ => iced::color!(0x414868),
    };
    let text_color = match status {
        button::Status::Disabled => iced::color!(0x3d4168),  // 文字也變暗
        _ => iced::color!(0xc0caf5),
    };
    // ...
}

8. Subscription:鍵盤事件的全域監聽

Subscription 是 Iced 中持續監聽某個事件來源的機制,不同於 Task(一次性任務),Subscription 會在應用程式整個生命週期都保持活躍。

鍵盤翻頁用 iced::keyboard::on_key_press 建立訂閱:

fn subscription(&self) -> Subscription<Message> {
    iced::keyboard::on_key_press(|key, _modifiers| match key.as_ref() {
        iced::keyboard::Key::Named(Named::ArrowRight)
        | iced::keyboard::Key::Named(Named::PageDown) => Some(Message::NextPage),
        iced::keyboard::Key::Named(Named::ArrowLeft)
        | iced::keyboard::Key::Named(Named::PageUp) => Some(Message::PrevPage),
        _ => None,
    })
}

閉包接受按鍵和修飾鍵,回傳 Option<Message>Some(msg) 代表這個按鍵要觸發對應的 message,None 代表忽略。這讓翻頁鍵(方向鍵 + PageUp/PageDown)不需要點擊按鈕就能使用,閱讀體驗更自然。

key.as_ref() 是必要的——iced::keyboard::Key 包含 owned 的字元資料,.as_ref() 轉成借用版本才能做 pattern matching 而不移動所有權。

總結

概念 在專案中的應用
Arc<dyn Trait> ComicReader 跨執行緒共享,零成本 clone 給預載任務
Send + Sync bound 確保 trait object 可安全跨執行緒使用
&self on trait method 允許多執行緒並發呼叫 extract_page,不需鎖
按需解壓 vs 全量預載 ZIP 隨機存取 vs 7-Zip 區塊壓縮,各自最佳策略
距離驅逐快取 比傳統 LRU 更適合線性翻頁場景
abs_diff + Copy 借用技巧 規避借用檢查器限制的慣用寫法
Task + Task::batch Iced 的非同步副作用模型,並行預載
smol::unblock 把同步阻塞 I/O 推進獨立執行緒池
on_press_maybe 條件式按鈕啟用,搭配 then_some 很簡潔
Subscription 全域鍵盤事件監聽,生命週期同整個應用程式

漫畫閱讀器是一個很好的 Rust GUI 練習專案:需要處理多種檔案格式(trait object 的最佳用武之地)、需要快取和預載(資料結構設計)、需要在不卡 UI 的前提下做 I/O(非同步設計)。如果你正在學 Iced 的 MVU 架構,這個專案的規模剛好——不會太小而沒有學習點,也不會大到難以消化。

參考資源