RustでGUIアプリ開発! icedで室温観測アプリ作ってみた


SWEの松本(@matsu7874)です。普段はWebアプリケーションやCLIツールを開発していますが、GUIを持ったデスクトップアプリ(以下、GUIアプリ)に対して漠然とした憧れがありました。夏休みの自由研究としてRustのicedというクレートを使ってGUIアプリを作ってみました。

他のGUIアプリ用のライブラリと比較したわけではありませんが、icedはデータの流れが分かりやすく、さまざまな既存ライブラリとの接続も整備されていて使いやすいと感じました。

この記事で作るアプリのイメージ

何を作るの?

室温観測アプリを作ります。機能は2つです。

機能1: 「暑いボタン」「寒いボタン」で不快感を感じたタイミングを記録できる機能です。自分がいつ・どのような環境で不快感を感じたか記録することで、快適な作業環境の構築に役立ちます。

機能2: これまでの室温推移をグラフで表示する機能です。時系列で表現されるため温度の推移が見やすいです。(今回はグラフ部分の技術検証に絞るため、Nature RemoのAPIから取得したデータをCSVファイルに書いた物を使います。こちらからダウンロードできます。)

どうやって作るの

GUIはicedというクレートを使います。

iced
icedはRustのUIライブラリです。クロスプラットフォーム対応です。仕事ではMacを使いつつ、Windowsでゲームもする私にはぴったりです。

依存crateを含め、先に全てCargo.tomlに記入しておきます。

[package]
name = "temp_humid_dash"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = "0.4.38"
csv = "1.3.0"
iced = { version = "0.12.1", features = ["canvas", "tokio"] }
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.121"
tokio = { version = "1.39.2", features = ["full"] }
plotters = "0.3"
plotters-iced = "0.10"
plotters-backend = "0.3.6"
anyhow = "1.0.86"

以降のコードは全てmain.rsです。

実装STEP1: ウィンドウを表示する

まずはGUIアプリの主役であるウィンドウを描画したいです。

必要なコンポーネントを宣言します。TextFontが画面に文字を表示するために必要な型で、Settingsがアプリの起動に必要な型、それ以外はApplicationの関連型に必要な型です。

use iced::{
    executor,
    widget::{Text, Theme},
    Application, Command, Element, Font, Settings, Subscription,
};

次に struct MyAppを定義し、trait Applicationを実装します。これがicedの肝でして、Applicationを実装するとそれぞれのメソッドの挙動に従ってGUIアプリとして動いてくれるのです。

struct MyApp {}
impl Application for MyApp {
    type Message = ();
    type Executor = executor::Default;
    type Flags = ();
    type Theme = Theme;

    fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) {
        (Self {}, Command::none())
    }

    fn title(&self) -> String {
        "Windowのサンプルアプリ".to_owned()
    }

    fn update(&mut self, _message: Self::Message) -> Command<Self::Message> {
        Command::none()
    }

    fn view(&self) -> Element<'_, Self::Message> {
        Text::new("Window内のタイトル").into()
    }

    fn subscription(&self) -> Subscription<Self::Message> {
        Subscription::none()
    }
}
  • fn titleはウィンドウのタイトルを定義しているメソッドです。この値を変化させるとウィンドウのタイトルが変わることが確認できます。
  • fn viewが画面を描画内容を定義しています。今回はWindow内のタイトルという文字列を表示しています。
  • fn update , fn subscription の解説はSTEP2で行います。

最後に定義したアプリケーションを実行するためのmain関数です。

fn main() {
    MyApp::run(Settings {
        antialiasing: true, // trueのほうが見やすい気がする
        default_font: Font::with_name("ヒラギノ角ゴシック"),
        ..Settings::default()
    })
    .unwrap();
}
  • trait Applicationにデフォルト実装されている fn runでアプリケーションを起動します。

ではcargo runでアプリケーションを起動しましょう。ウィンドウが立ち上がりましたね!

Tips: 日本語のフォントを指定する
GUIアプリ起動時に日本語のフォントを指定しないと、下記画像のように日本語の文字が■のような見た目で表示されてしまいます。MyApp::rundefault_fontを設定した Settings を渡すことで選択したフォントで表示されるようになっています。


実装STEP2: ボタンを表示する

次のステップとしてボタンを実装し、押した時刻のログが一定時間画面に表示され、ファイルにも書き込まれる機能を作ります。


イベント処理
ここでウィンドウとユーザーのインタラクション・および画面の更新処理が出てきました。icedがどのようにウィンドウを更新していくか説明します。

struct MyAppが関連型のMessageの形で各ウィジェットからの更新通知を受け取り、fn updateで更新処理を振り分けて内部状態を更新し、 fn viewで画面の描画内容を更新するという処理の流れになります。 Message を発するのは各ウィジェットと、定期的に実行されるように fn subscriptionで定義されている Subscriptionの2箇所です。

STEP2のコード

use chrono::{DateTime, Local};
use iced::{
    executor,
    time::every,
    widget::{button, Column, Container, Text, Theme},
    Application, Command, Element, Font, Settings, Subscription,
};
use std::{collections::VecDeque, fs::OpenOptions, io::Write, time::Duration};

const SAMPLE_EVERY: Duration = Duration::from_secs(1);
  • chrono::{DateTime, Local}, iced::time::every, iced:widget::{button, Column, Container, } , std::{collections::VecDeque, fs::OpenOptions, io::Write, time::Duration}を追加しました。
  • 定期的な更新処理を設定するための1秒という定数を設定しています。
struct MyApp {
    action_log: VecDeque<(DateTime<Local>, String)>,
}
impl MyApp {
    /// ログを記録する
    fn add_action_log(&mut self, action: &str) {
        let now = Local::now();
        let timestamp: String = now.format("%Y-%m-%d %H:%M:%S").to_string();
        self.action_log
            .push_back((now, format!("{}:{}", timestamp, action.to_string())));
        let mut file = OpenOptions::new()
            .append(true)
            .create(true)
            .open("actions.csv") // カレントディレクトリに作成される
            .expect("Failed to open file");
        writeln!(file, "{},{}", timestamp, action).expect("Failed to write to file");
    }
    /// 画面上の古いログを削除する
    fn clear_action_log(&mut self) {
        let now = Local::now();
        while let Some((timestamp, _)) = self.action_log.front() {
            if now.signed_duration_since(*timestamp).num_seconds() > 10 {
                self.action_log.pop_front();
            } else {
                break;
            }
        }
    }
}
  • ボタンが押されたログを記録する構造体を追加し、ログを記録する構造体への書き込み・削除処理を実装しました。

    • n add_action_log はファイルへの書き込みも行います。
    • fn clear_action_log は10秒以上前のログを構造体から削除します。
#[derive(Debug, Clone)]
enum Message {
    HotPressed,
    ColdPressed,
    Tick,
}

impl Application for MyApp {
    type Message = Message;
    type Executor = executor::Default;
    type Flags = ();
    type Theme = Theme;

    fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) {
        (
            Self {
                action_log: VecDeque::new(),
            },
            Command::none(),
        )
    }

    fn title(&self) -> String {
        "ボタンのサンプルアプリ".to_owned()
    }

    fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
        match message {
            Message::Tick => self.clear_action_log(),

            Message::HotPressed => {
                self.add_action_log("Hot");
            }
            Message::ColdPressed => {
                self.add_action_log("Cold");
            }
        }
        Command::none()
    }

    fn view(&self) -> Element<'_, Self::Message> {
        // 「暑い」「寒い」ボタンを押した時刻を表示する
        let log_contents = self
            .action_log
            .iter()
            .map(|(_timestamp, action)| format!("{}", action))
            .collect::<Vec<_>>();

        let content = Column::new()
            .push(Text::new("押せ!!!"))
            .push(button("暑い").on_press(Message::HotPressed))
            .push(button("寒い").on_press(Message::ColdPressed));
        let content = log_contents.iter().fold(content, |content, log_content| {
            content.push(Text::new(format!("{}", log_content)))
        });
        Container::new(content).into()
    }

    fn subscription(&self) -> Subscription<Self::Message> {
        every(SAMPLE_EVERY).map(|_| Message::Tick)
    }
}

// fn mainは変更なし
  • アプリケーション上で必要な更新指示をenum Messageとして定義しました。
  • fn subscriptionSAMPLE_EVERYごと(1秒ごと)にMessage::Tickが発行されるようにしました。
  • 「暑い」「寒い」ボタンを追加し、それぞれに対応したメッセージを発行するようにしました。
  • 「暑い」「寒い」ボタンを押した時刻が画面に表示されるようにしました。
  • 複数の要素を描画するためContainerColumnを追加しました。

cargo runを実行するとGUIアプリが立ち上がり、ボタンを押すとカレントディレクトリにactions.csvが作成されることを確認できます。

実装STEP3: グラフを表示する

いよいよグラフを実装しましょう。グラフはplottersというグラフ描画ライブラリをicedから使えるようにするplotters-icedというcrateを使います。ちなみにcrates.ioをicedで検索してみると多くのiced上で何かをできるようにするcrateがいくつも作られています。

main.rsを修正していきましょう。

use anyhow::Result;
use chrono::{DateTime, Local, Utc};
use iced::{
    executor,
    time::every,
    widget::{
        button,
        canvas::{Cache, Frame, Geometry},
        Column, Container, Text, Theme,
    },
    Alignment, Application, Command, Element, Font, Length, Settings, Size, Subscription,
};
use plotters::prelude::ChartBuilder;
use plotters_backend::DrawingBackend;
use plotters_iced::{Chart, ChartWidget, Renderer};
use std::{collections::VecDeque, fs::OpenOptions, io::Write, time::Duration};

const PLOT_MINUTES: usize = 5; // 5 minutes
const SAMPLE_EVERY: Duration = Duration::from_secs(1);
  • plotters関連で必要なものが増えています。

  • PLOT_MINUTES は描画する時間幅です。CSVからデータを読み込むため毎秒1レコード換算で、5分=300レコードを上限としていますが、実際にAPI経由でリアルタイムのデータを使う場合はもっと長い時間を設定します。

#[derive(Debug, Clone, Copy)]
struct Weather {
    temperature: f32,
    humidity: i32,
}

static mut COUNTER: usize = 0;
async fn fetch_api_data() -> Result<Weather> {
    // カレントディレクトリからdata.csvを読み込む
    // 1行目はヘッダーなのでスキップ
    // 2行目以降は日時と気温、湿度のデータがカンマ区切りで格納されている
    let mut rdr = csv::Reader::from_path("data.csv")?;
    let mut data = vec![];
    for result in rdr.records() {
        let record = result?;
        let temperature = record.get(1).unwrap().parse::<f32>()?;
        let humidity = record.get(2).unwrap().parse::<i32>()?;
        data.push(Weather {
            temperature,
            humidity,
        });
    }

    let w = unsafe {
        COUNTER += 1;
        data[COUNTER]
    };

    Ok(w)
}

今回は実際のAPIを使うのではなく、開発用にダウンロード済みのデータを使います。時間経過の中で読む値が変わっていくことを再現するためのコードを書いています。毎回CSVファイルを全部読み、何も確かめずに「global変数の値」列目をパースするというやばい処理をしていますが、すぐ使わなくなるコードなので問題ありません。(本番のコードとしては使わないでください。)

グラフ描画の部分はplotters-icedのexamplesを参考にします
グラフ描画部分はplotters-icedのexampleに複数のCpu使用率の推移グラフを描画するサンプル表示が載っています。ほとんどそれをベースに自分で定義したデータ型とデータ範囲に修正してそのまま使います。

struct ApiDataChart {
    data_points: VecDeque<(DateTime<Utc>, Weather)>,
    limit: Duration,
    cache: Cache,
}

impl Default for ApiDataChart {
    fn default() -> Self {
        Self {
            data_points: VecDeque::new(),
            limit: Duration::from_secs((PLOT_MINUTES * 60) as u64),
            cache: Cache::new(),
        }
    }
}

impl ApiDataChart {
    fn push_data(&mut self, time: DateTime<Utc>, value: Weather) {
        let cur_ms = time.timestamp_millis();
        self.data_points.push_front((time, value));
        loop {
            if let Some((time, _)) = self.data_points.back() {
                let diff = Duration::from_millis((cur_ms - time.timestamp_millis()) as u64);
                if diff > self.limit {
                    self.data_points.pop_back();
                    continue;
                }
            }
            break;
        }
        self.cache.clear();
    }

    fn view(&self) -> Element<Message> {
        Column::new()
            .width(Length::Fill)
            .height(Length::Shrink)
            .spacing(5)
            .align_items(Alignment::Center)
            .push(ChartWidget::new(self).height(Length::Fixed(300.0)))
            .into()
    }
}
  • struct ApiDataChartはグラフ用のデータを扱う構造体です。exampleのコードでは CpuUsageChart にあたる構造体です。

  • ApiDataChart::viewMyApp::viewから呼び出され、ChartWidget::newに渡された self から次のコードの fn build_chartが呼び出される関係です。

impl Chart<Message> for ApiDataChart {
    type State = ();

    #[inline]
    fn draw<R: Renderer, F: Fn(&mut Frame)>(
        &self,
        renderer: &R,
        bounds: Size,
        draw_fn: F,
    ) -> Geometry {
        renderer.draw_cache(&self.cache, bounds, draw_fn)
    }

    fn build_chart<DB: DrawingBackend>(&self, _state: &Self::State, mut chart: ChartBuilder<DB>) {
        use plotters::prelude::*;

        const PLOT_LINE_COLOR: RGBColor = RGBColor(0, 175, 255);

        // 描画するデータ範囲を計算します
        let newest_time = self
            .data_points
            .front()
            .unwrap_or(&(
                DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
                Weather {
                    temperature: 20.0,
                    humidity: 0,
                },
            ))
            .0;
        let oldest_time = newest_time - chrono::Duration::minutes(PLOT_MINUTES as i64);

        // 2次元の座標平面を作ります
        // データ範囲を気温の範囲に限定しています
        let mut chart = chart
            .x_label_area_size(0)
            .y_label_area_size(28)
            .margin(20)
            .build_cartesian_2d(oldest_time..newest_time, 18..40)
            .expect("failed to build chart");

        // グリッドの見た目を整えます
        chart
            .configure_mesh()
            .bold_line_style(plotters::style::colors::BLUE.mix(0.1))
            .light_line_style(plotters::style::colors::BLUE.mix(0.05))
            .axis_style(ShapeStyle::from(plotters::style::colors::BLUE.mix(0.45)).stroke_width(1))
            .y_labels(10)
            .y_label_style(
                ("sans-serif", 15)
                    .into_font()
                    .color(&plotters::style::colors::BLUE.mix(0.65))
                    .transform(FontTransform::Rotate90),
            )
            .y_label_formatter(&|y| format!("{}℃", y))
            .draw()
            .expect("failed to draw chart mesh");

        // データを描画します
        chart
            .draw_series(
                AreaSeries::new(
                    self.data_points
                        .iter()
                        .map(|x| (x.0, x.1.temperature.floor() as i32)),
                    16,
                    PLOT_LINE_COLOR.mix(0.175),
                )
                .border_style(ShapeStyle::from(PLOT_LINE_COLOR).stroke_width(2)),
            )
            .expect("failed to draw chart data");
    }
}
  • 描画するデータ範囲を指定してグラフを描画します。

以上でグラフの描画コンポーネントが用意できたので、大元のGUIアプリとの接続部分を実装していきます。

#[derive(Debug, Clone)]
enum Message {
    DataFetched(Weather),
    HotPressed,
    ColdPressed,
    Tick,
    None,
}

struct MyApp {
    action_log: VecDeque<(DateTime<Local>, String)>,
    chart: ApiDataChart,
}
// impl MyApp は変更なし
  • データ取得APIの結果をGUIアプリに伝えるためのDataFetched(Weather)を定義しました。

  • グラフを描画するApiDataChartstruct MyAppに追加しました。

impl Application for MyApp {
    // 変更のあるメソッドのみ
    fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) {
        let mut chart = ApiDataChart::default();
        let initial_data = vec![];
        let now = Utc::now();
        for value in initial_data {
            chart.push_data(now, value);
        }
        (
            Self {
                action_log: VecDeque::new(),
                chart,
            },
            Command::none(),
        )
    }

    fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
        match message {
            Message::Tick => {
                self.clear_action_log();
                return Command::perform(fetch_api_data(), |w| match w {
                    Ok(w) => Message::DataFetched(w),
                    Err(e) => {
                        eprintln!("Error: {}", e);
                        Message::None
                    }
                });
            }
            Message::DataFetched(data) => {
                self.chart.push_data(Utc::now(), data);
            }
            Message::HotPressed => {
                self.add_action_log("Hot");
            }
            Message::ColdPressed => {
                self.add_action_log("Cold");
            }
            _ => {}
        }
        Command::none()
    }
    fn view(&self) -> Element<'_, Self::Message> {
        let log_contents = self
            .action_log
            .iter()
            .map(|(_timestamp, action)| format!("{}", action))
            .collect::<Vec<_>>();

        let content = Column::new()
            .push(Text::new("グラフを出せるよ"))
            .push(self.chart.view()) // 追加
            .push(button("暑い").on_press(Message::HotPressed))
            .push(button("寒い").on_press(Message::ColdPressed));
        let content = log_contents.iter().fold(content, |content, log_content| {
            content.push(Text::new(format!("{}", log_content)))
        });

        Container::new(content).into()
    }
}
  • fn newchartの初期化処理を追加
  • fn updateにデータ取得APIを呼び出して返り値に応じたmessageを発行する処理を追加
  • fn viewにグラフコンポーネントを追加

完成

cargo run --releaseで実行しましょう。こんな感じの画面が表示され、ボタンを押すとカレントディレクトリのactions.csvにログが表示されるはずです。

じわじわとグラフが流れてくる様子

あとはリアルタイムの室温・湿度データを取り込めば、自分が快適な温度・湿度を外れた瞬間を特定する事ができるようになるでしょう。

現在、estie社内での本格的なデータ分析にはSnowflakeやStreamlitを使っていますが、ローカルでのデータ入力やリアルタイムの処理が伴うデータの確認をしたい状況ではicedは便利に使えそうに感じました。普段はGUIアプリを開発する機会はまだありませんが、RustでのWebアプリ開発はある職場です。ご興味を持っていただいた方はぜひカジュアル面談をお申し込みください。

hrmos.co

© 2019- estie, inc.