Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6. 状態永続化と非同期・マルチスレッド連携

アプリ状態の自動保存と復元(Persistence)

メモ帳や設定ツールを作ったとき、ウィンドウを閉じたら入力した内容がすべて消えてしまうのでは不便です。eframe には、Rust の定番データ変換ライブラリである serde と連携して、アプリ構造体のデータを自動でファイル(Web の場合はブラウザの LocalStorage)に保存・復元する persistence 機能が組み込まれています。

▸ Cargo.toml の設定

eframe の persistence フィーチャーと、serde の derive フィーチャーを有効にします。

[dependencies]
eframe = { version = "0.29", features = ["persistence"] }
serde = { version = "1.0", features = ["derive"] }

▸ 構造体への Serialize / Deserialize の付与と save / new の実装

保存したいアプリ構造体に #[derive(serde::Deserialize, serde::Serialize)] を付けます。そして起動時の new メソッドで前回のデータを読み込み、eframe::App の save メソッドで現在のデータを書き出します。

#![allow(unused)]
fn main() {
use eframe::egui;

#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // 新しいフィールドを追加したときに古い保存データでもエラーにしないおまじない
struct PersistentApp {
    memo: String,
    counter: i32,

    // 保存したくない一時的なフィールドには #[serde(skip)] を付ける
    #[serde(skip)]
    session_clicks: u32,
}

impl Default for PersistentApp {
    fn default() -> Self {
        Self {
            memo: String::new(),
            counter: 0,
            session_clicks: 0,
        }
    }
}

impl PersistentApp {
    fn new(cc: &eframe::CreationContext<'_>) -> Self {
        // 前回保存されたデータがあれば復元し、なければ Default::default() を返す
        if let Some(storage) = cc.storage {
            return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
        }
        Default::default()
    }
}

impl eframe::App for PersistentApp {
    // 定期的、およびアプリ終了時に自動で呼ばれる保存メソッド
    fn save(&mut self, storage: &mut dyn eframe::Storage) {
        eframe::set_value(storage, eframe::APP_KEY, self);
    }

    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Auto-Save Memo");
            ui.text_edit_multiline(&mut self.memo);

            if ui.button("Count Up").clicked() {
                self.counter += 1;
                self.session_clicks += 1;
            }
            ui.label(format!("Saved Counter: {}", self.counter));
            ui.label(format!("Session Clicks (not saved): {}", self.session_clicks));
        });
    }
}
}

#[serde(skip)] を付けたフィールド(上の例では session_clicksや、後述するスレッド通信用のチャネルなど)は保存対象から除外され、起動時には Default の初期値が入ります。

再描画タイミングの制御と省電力化

第 1 章で「Immediate Mode は毎フレーム update を呼ぶ」と説明しましたが、実は egui はマウスもキーボードも動いていない静止状態では、update の呼び出しをピタッと止めて待機します。これにより、ノートパソコンのバッテリーや CPU を無駄に消費しないよう賢く省電力化されています。

しかし、時計アプリのように「マウスを動かしていなくても 1 秒ごとに画面を書き換えたい」場合や、「別スレッドからデータが届いたら画面を更新したい」場合があります。そのときは ctx のメソッドを使って明示的に再描画をリクエストします。

メソッド動作と使いどころ
ctx.request_repaint()次のフレームをただちに再描画します。連続的なアニメーションを行うときや、別スレッドで処理が完了した瞬間に呼びます。
ctx.request_repaint_after(Duration)指定した時間が経過した後に再描画します。時計の表示更新(1 秒ごと)や定期的なステータス確認に使います。
#![allow(unused)]
fn main() {
use std::time::Duration;

// 例:マウス操作がなくても 1秒(1000ミリ秒)後に必ず update を呼び出してもらう
ctx.request_repaint_after(Duration::from_secs(1));
}

なぜ重い処理をupdate内で直接実行してはいけないのか

もしボタンが押されたときに、インターネットから大きなファイルをダウンロードしたり、数万枚の画像を変換したりする処理を update の中で直接実行してしまうとどうなるでしょうか。

#![allow(unused)]
fn main() {
// 悪い例:update メソッドの中で時間のかかる処理を直接呼んでいる
if ui.button("重い処理を実行").clicked() {
    // ここで5秒間止まると、その5秒間はウィンドウが完全にフリーズ(応答なし)になる!
    std::thread::sleep(std::time::Duration::from_secs(5));
}
}

update メソッドが終わらない限り、egui は次の画面を描くことも、マウスの移動やウィンドウの閉じるボタンに反応することもできません。そのため、0.1 秒以上かかるような処理は必ず「バックグラウンドスレッド(裏方の別スレッド)」に任せて、update メソッド自体は一瞬で終わらせるのが GUI プログラミングの基本原則です。

チャネル(mpsc)を使った非同期タスクと進捗表示

Rust の標準ライブラリにあるスレッド(std::thread)と、スレッド間で安全にメッセージを送受信するパイプであるチャネル(std::sync::mpsc)を組み合わせると、画面を一切フリーズさせずに重い処理を進めながら、進捗バー(ProgressBar)を滑らかに更新できます。

sequenceDiagram
    participant UI as メインスレッド (update)
    participant Ch as mpscチャネル (tx / rx)
    participant Worker as バックグラウンドスレッド

    UI->>Worker: 1. ボタン押下時に thread::spawn で起動 (tx と Context を渡す)
    loop 処理が完了するまで
        Worker->>Ch: 2. tx.send(進捗メッセージ) を送信
        Worker->>UI: 3. ctx.request_repaint() で画面更新を通知
        UI->>Ch: 4. rx.try_recv() で待たずにメッセージを受信・画面反映
    end

ここで最も重要なポイントは、データを受け取る update 側では、メッセージが届くまで立ち止まって待つ rx.recv() ではなく、「届いていれば受け取り、届いていなければ待たずにすぐ次へ進む rx.try_recv()」を使うことです。

実際に動作する完全なサンプルコードを見てみましょう。

use eframe::egui;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;

fn main() -> eframe::Result<()> {
    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([420.0, 220.0]),
        ..Default::default()
    };
    eframe::run_native(
        "Background Task Demo",
        options,
        Box::new(|_cc| Ok(Box::<AsyncDemoApp>::default())),
    )
}

// バックグラウンドスレッドからメインスレッドへ送るメッセージの種類
enum WorkerMessage {
    Progress(f32), // 0.0 〜 1.0 の進捗率
    Completed(String), // 完了時の結果テキスト
}

#[derive(Default)]
struct AsyncDemoApp {
    is_running: bool,
    progress: f32,
    result_text: String,
    // スレッドからメッセージを受け取る受信機
    receiver: Option<Receiver<WorkerMessage>>,
}

impl eframe::App for AsyncDemoApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // 1. バックグラウンドスレッドから届いているメッセージをすべて確認する(try_recv はブロックしない)
        if let Some(rx) = &self.receiver {
            while let Ok(msg) = rx.try_recv() {
                match msg {
                    WorkerMessage::Progress(p) => {
                        self.progress = p;
                    }
                    WorkerMessage::Completed(text) => {
                        self.progress = 1.0;
                        self.is_running = false;
                        self.result_text = text;
                    }
                }
            }
        }

        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Background Worker Example");
            ui.add_space(8.0);

            // 実行中はボタンを押せないように無効化する
            ui.horizontal(|ui| {
                if ui
                    .add_enabled(!self.is_running, egui::Button::new("Start Heavy Task"))
                    .clicked()
                {
                    self.is_running = true;
                    self.progress = 0.0;
                    self.result_text.clear();

                    // 送信用(tx)と受信用(rx)のチャネルペアを作成する
                    let (tx, rx) = mpsc::channel();
                    self.receiver = Some(rx);

                    // egui::Context は Clone 可能で、別スレッドへ安全に渡せる
                    let ctx_clone = ctx.clone();

                    // バックグラウンドスレッドを起動する
                    thread::spawn(move || {
                        for step in 1..=20 {
                            thread::sleep(Duration::from_millis(150));
                            let _ = tx.send(WorkerMessage::Progress(step as f32 / 20.0));
                            // 進捗を送ったら、画面をすぐ描き直すよう egui に知らせる
                            ctx_clone.request_repaint();
                        }
                        let _ = tx.send(WorkerMessage::Completed(
                            "すべてのデータ処理が正常に完了しました!".to_owned(),
                        ));
                        ctx_clone.request_repaint();
                    });
                }

                // 実行中はくるくる回るスピナーを表示する
                if self.is_running {
                    ui.spinner();
                    ui.label("Processing...");
                }
            });

            ui.add_space(12.0);

            // プログレスバー(進捗率バー)を表示する
            ui.add(
                egui::ProgressBar::new(self.progress)
                    .show_percentage()
                    .animate(self.is_running),
            );

            if !self.result_text.is_empty() {
                ui.add_space(8.0);
                ui.label(&self.result_text);
            }
        });
    }
}

egui::Context は内部でスレッド安全な参照カウント(Arc)を持っているため、ctx.clone() で簡単に複製して別スレッドの中へ持ち込むことができます。別スレッド側でデータを送った直後に ctx_clone.request_repaint() を呼ぶことで、ユーザーがマウスを動かしていなくても即座にプログレスバーが更新されます。

最終章となる次章では、これまで学んだ知識を総動員して実用的な「タスク管理(ToDo)アプリ」を構築し、さらに Web ブラウザ向けにビルドする手順を解説します。