RustでGUIアプリを作るためのクレートであるicedに触れてみたので、気付いたことをメモとして残します。
編集履歴
-
2024/6/17
- IMEについての記載を追加
- Dioxusの項目に関連記事へのリンクを追加
-
2024/5/27 以下を変更
- フォントの読み込みが古いバージョンのicedのものだったのを修正
- 非同期処理の説明にあるMessageの要素型を変更
-
2024/5/26: Slintをバージョン1未満として説明していたのを修正
バージョンについて
この記事は2024年5月時点での最新であるiced 0.12.1をもとに書いています。icedはまだ開発段階のプロジェクトであり、バージョン1までにAPIの破壊的な変更が行われる可能性があります。
RustのGUIまわりの現状
RustのGUIまわりの技術はいろいろあります。私が観測した範囲で、以下のものが気になりました。
iced以外のものについてここで簡単に触れます。詳しい説明は公式のドキュメントや他の人の記事があるので、ここでは簡単な説明と個人的な所感を述べるだけに留めます。
TauriとSlint以外はバージョン1未満であり、APIに破壊的な変更が行われる可能性がある点に注意してください。
Tauri
Electronのような技術です。フロント側をTypeScriptやJavaScriptで記述し、バックエンド側をRustで記述します。フロント側はReact, Vue, Svelte, Yewなどのフレームワークを利用できます。
所感:
現状最も成熟しているフレームワークだと感じました。現在のバージョンは2.0であり、バージョン1未満の他のライブラリに比べ、APIの安定性を重視する人にとって採用しやすそうです。フロント側のフレームワークに応じたアプリの雛型を作るツール (create-tauri-app) の存在や、アプリの自動アップデートの仕組みなど、サポートも手厚いようです。
フロントエンドとバックエンドが別プロセスで動くため、画像や動画など大きなデータをやりとりする場合は通信のオーバーヘッドが気になるかもしれません。例えばWebカメラの映像を定期的に取得して画面に表示するアプリでパフォーマンスを重視するようなケースだと、他の技術の方が良さそうに思いました。
Web系の技術に明るいかどうかで評価が分かれると思われます。実際、「Rustを使わずにほぼTypescriptでアプリを作れた」といった評価も見かけました。
HTMLやCSSを使うため、UIの豊富さやカスタマイズ性は他よりも強いと思います。見た目にこだわりたい場合は有力な選択肢です。
egui
Immediate mode (即時モード) 系のライブラリ。簡単かつシンプルな記述が特徴です。
所感:
Immediate modeはメリットもデメリットもある点に注意が必要です。簡単にいえば「シンプルで簡単だが、複雑なことはできない」といった性質を持ちます。C++でDear ImGuiに触れたことがある人にはイメージしやすいでしょう。
UIとモデルをバインドする仕組みが要らない、ボタン押下等の動作をコールバックではなくif文で記述するなど、複雑な知識を必要としない点がメリットです。一方、UIを描画するコードが毎フレーム呼ばれるためCPUの負荷が大きい、複雑なUIを組み立てるのが苦手といった点がデメリットになります。
ちなみに、immediate modeでないGUIはretained mode (保持モード) という呼ばれ方をします。どちらかといえばimmediate modeの方が強い特徴を持つためか、これと比較する文脈以外でretained modeという表現をあまり見ない気がします。
Dioxus
Reactにインスパイアされたライブラリ。仮想DOMを使います。
所感:
HTMLとCSSを使いますが、プロセス間通信を行わない、HTMLの記述はRustのコード内にてrsx!
マクロで行うなど、Tauriとはだいぶ異なります。時間があれば詳しく見てみたいところ。
公式リポジトリのREADMEで他のフレームワークとの比較を書いているので、それを見るのも参考になると思います。
2024年6月17日 追記:
その後Dioxusにも触れました。icedと比べた際の考えや感想を記事にしたので、興味があればどうぞ。
Slint
組み込み機器でも動くような軽量さがウリのフレームワークです。.slint
という独自のマークアップでUIを記述するもので、RustだけでなくC++やJavaScriptでも使用できます。
所感:
ライセンス面に注意が必要です。詳しくは公式のFAQを参照してください。組込み機器で使うには有償、デスクトップアプリならロイヤリティーフリーでもOKだが、その場合はアプリ内のメニューで"About Slint"の表示が必要である等の制約があります。
.slint
で記述されたUIがどう表示されるかをプレビューするツールがあり、UIを確認しながら開発できる点が良さそうだと思いました。
Xilem
Xilem architectureという独自のアーキテクチャーに基づいて作られた実験的なフレームワークです。ElmやSwiftUI, Flutter等に影響を受けているようです。
所感:
Xilem architectureについてはこちらを参照してください。宣言的な記述 + immediate mode並みの簡潔さを目指しているように思われます。
Xilemは2024年5月にver.0.1として初めてリリースされたものであり、他のフレームワーク以上に未成熟であるため、現時点で使うにはかなり注意が必要です。リリース前の時点でGitHubのスター数が2kを越えるなど、注目を浴びていそうですが、成熟するにはまだ時間がかかるし、途中で消える可能性もあります。興味があれば動向を追ってみるのも良さそうです。
余談ですが、Xilemの開発チームはかつてDruidというGUIライブラリを開発していました。現在は開発の中心がXilemに移り、Druidは開発を停止しています。紹介しておいてなんですが、使っているライブラリが開発停止する憂き目にあう可能性もあるので、今後も開発が継続的に行われそうかどうかの動向はしっかりチェックした方が良いかもしれません。
その他
GTKやFLTKなど、他の言語で作られたフレームワークのRustバインディングも存在します。ここでは触れませんが、実際にアプリを作る際はこれらも選択肢に入るでしょう。
本題
前置きが長くなりましたが、ここからが本題です。
icedについて
Elmアーキテクチャという関数型の手法を採用したフレームワークで、アプリの状態管理がシンプルになるのが特徴です。ざっくりいえば
- アプリの状態はModelとして一元管理する
- Viewは状態を持たない
- ViewはModelから導出される
- ある状態のModelが入力されると、それに対してViewは一意に決まる
- Modelの操作はMessageを介してのみ行われる
- UIからの操作もこの流れに従う。ボタンであれば、View側にはボタン押下時に発行するMessageを定義し、Modelの更新はMessageに対する処理として記述する。
といったものです。なお、ここではModelと書きましたが、icedの説明ではStateという表現を使うようです。これに倣い、以降はStateという表現を使います。
icedの公式はこれを以下のような図で説明しています。
チュートリアル
公式にある例を元に説明します。以下のようにIncrementとDecrementボタンがある数値カウンターアプリを作りたいとします。
この場合、アプリの状態としてカウンターの現在の値を、状態変更のためのメッセージとしてIncrementとDecrementをenumとして定義します。
struct AppState {
value: i32,
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
}
Messageに対する処理であるupdate()
と、AppStateを元にViewを生成するview()
を記述します。
use iced::widget::{column, button, text};
use iced::{Element, Application, Alignment};
use iced::command::Command;
impl Application for AppState {
type Executor = executor::Default;
type Message = Message;
type Flags = ();
type Theme = iced::Theme;
fn update(&mut self, message: Message) {
match message {
Message::Increment => { self.value += 1 }
Message::Decrement => { self.value -= 1 }
}
Command::none()
}
fn view(&self) -> Element<Message> {
column![
button("Increment").on_press(Message::Increment),
text(self.value).size(50),
button("Decrement").on_press(Message::Decrement)
]
.padding(20)
.align_items(Alignment::Center)
.into()
}
// NOTE:
// Applicationトレイトとして実装すべきメソッドは
// 他にもありますが、ここでは省略します。
}
main関数は以下のようにします。
fn main() -> Result<(), Error> {
AppState::run(iced::Settings::default())
}
上記のように、とてもシンプルに書くことができます。Messageに対する処理をパターンマッチで書けるのがRustらしくて良いですね。
メリットとしては、制御の流れが一方向である、Viewが状態を持たないためStateと乖離しない、Stateを更新するコードがView側のロジックに書かれることがないといった点でしょうか。
一方で、状態を持つコンポーネントを作るにはState側での管理が必要な点、アプリ内の動作を全てメッセージとして定義する必要がありコード量が増える点などがデメリットかもしれません。前者については、例えば以下のような日付ピッカーを作る場合、「画面に現在表示している月」や「選択中の日付」をView側に閉じ込めることができず、Stateに持たせる必要があります。
実際に実装する場合は、日付ピッカーの状態を表す構造体を定義し、それをStateに持たせる形になるかと思います。後述するように、このようなウィジェットは既に用意されているものがあるので、それらを使うのが良いでしょう。
Messageには値を持たせることができるので、以下のようなこともできます。
use iced::widget::{column, button, text, Space};
use iced::{Element, Application, Alignment};
use iced::command::Command;
#[derive(Debug)]
enum Message {
Increment(i32), // 増やす量を持たせる
Decrement,
}
impl Application for AppState {
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Increment(x) => {
// メッセージが持つ値に応じて現在値を増やす
self.value += x
}
Message::Decrement => {
self.value -= 1
}
}
Command::none()
}
fn view(&self) -> Element<Self::Message> {
column![
// +1 ボタンと +5 ボタンを横に並べる
row![
button("+1").on_press(Message::Increment(1)),
Space::new(10, 0),
button("+5").on_press(Message::Increment(5)),
],
text(self.value).size(50),
button("-1").on_press(Message::Decrement),
]
.padding(20)
.align_items(Alignment::Center)
.into()
}
}
表示:
この例では敢えて分けていますが、-1ボタンもMessage::Increment(-1)
で実装できますね。メッセージは動作 (ここでは「カウンターの現在値を更新する」) を元に定義し、それに値を持たせる形が良さそうです。
メモ
詳しい説明は公式のREADMEやサンプルに任せます。ここでは個人的に引っかかった点や気付いた点などのメモを記します。
参考になるもの
公式リポジトリにあるexamplesおよび非公式のチュートリアルが参考になりました。
exampleコードをビルドできない
「公式にあるサンプルを参考にコードを書いたがビルドできない」という問題を見かけます。これについては、GitHubにあるexampleとリリース版のクレートに互換がないのが原因と思われます。
exampleを参考にする際は
- 公式のリポジトリをクローンする
- 使いたいバージョンのtagをチェックアウトする
を行ってから見るのが良いでしょう。
互換がないのは、公式リポジトリのmaster
の内容は次のリリースに向けて機能追加や変更がされており、exampleもそれに準じて変更されているためです。2024年5月現在、cargoコマンドでは最新リリースであるiced 0.12.1がプロジェクトに追加されますが、これは現在のmaster
よりも古いため、公式のexampleを手元でビルドできないといったことが起こります。
追加ウィジェット
iced-awというクレートでは、iced用の追加のウィジェットを提供しています。自分が欲しいウィジェットが公式にない場合は、こちらから探すのが良いでしょう。日付ピッカーもあります。
ウィジェットを自作するのはやや大変そうに思います。自作アプリに必要なウィジェットが存在しない場合は、他のフレームワークを選ぶのも選択肢の一つかと思います。
Windowsで使う場合のメモ
コンソールの無効化
Windowsで使う場合は
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
を記述しておき、コンソールを出さないようにするのが良さそうです。デバッグ時は標準出力等を見たい場合があるので、リリース時のみ無効化するよう not(debug_assertions)
を付けておきます。
起動や終了が遅い場合
デフォルトのバックエンドであるwgpuがVulkanやDX12を使う場合に起動が遅くなることがあるようです。環境変数でWGPU_BACKEND="gl"
を設定すると改善する可能性があります。
WGPU_BACKEND
を使う際は、dotenvyを使ってファイルから設定できるようにしておくと良さそうです。
wgpuはグラフィックス系のクレートで、Vulkan, Metal, D3D12, OpenGL, WebGL2などのAPIをラップするもののようです。デフォルトでは実行環境に応じて自動的にバックエンドを選びますが、WGPU_BACKEND
環境変数でこれを制御することができます。
なお、icedはwgpuのほかにtiny-skiaというバックエンドを使うこともできるようです。こちらは試していないので分かりませんが、wgpuで問題が生じる場合は切り替えてみるのも良いかもしれません。
Sandboxについての注意
icedではアプリを作るのにApplicationとSandboxという二つのトレイトを用意しており、Applicationはicedのフル機能を使うためのもの、SandboxはApplicationよりも機能が少ないけどより簡単に使えるもの……という扱いでしたが、現在のmasterブランチではSandboxが削除されているので注意が必要です。iced 0.13でこれが反映される予定です。
代わりのAPIについては以下のPRで説明されています。
現時点ではSandboxを使わず、Applicationで実装しておくのが無難でしょう。
日本語を使う場合
2024/5/27追記:
投稿時点でのこの項は古いバージョンのiced用のものでした。iced 0.12用に記述を修正しました。
日本語を使う場合は日本語用のフォントを指定する必要があります。
フォントの指定は以下のように行います。
fn main() -> Result<(), Error> {
let meiryo_ui = iced::Font {
family: iced::font::Family::Name("Meiryo UI"),
..iced::Font::default()
};
AppState::run(
iced::Settings{
default_font: meiryo_ui,
..iced::Settings::default()
}
)
}
システムにインストールされているフォントであればこれだけでOKです。
フォントをファイルから読む場合は以下のようにします。
#[derive(Debug)]
enum Message {
// フォント読み込み時に発行されるメッセージを定義する
FontLoaded,
}
impl Application for AppState {
fn new(_: Self::Flags) -> (Self, Command<Message>) {
let load_font = iced::font::load(include_bytes!("フォントファイルのパス"))
.map(|_| Message::FontLoaded);
(Self::default(), load_font)
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
// Message::FontLoadedがマッチするようパターンを書く。
// このメッセージに対して行うべき処理は特に無いが、
// match式はenumのパターンを網羅する必要があるため、
// _ もしくは Message::FontLoaded でマッチさせる。
_ => Command::none()
}
}
}
文字がジャギーな場合はアンチエイリアスを有効にすると良いかもしれません。
画像を使う場合
画像の表示につかうImageウィジェットはimage
フィーチャーを付けないとインストールされません。画像を扱う際はcargo add
の際に--features image
を付けるか、もしくはcargo.toml
でfeatures = ["image"]
を指定します。
[dependencies]
iced = { features = ["image"] }
タイマーやキーボード入力
subscription()
を使います。これについては前述の非公式チュートリアルのEventの項が参考になります。
非同期処理
非同期処理がだいぶ簡単に書けそうなのが個人的に良いと感じました。簡単に紹介します。
非同期処理については、公式のPokedex exampleや非公式チュートリアルのLoading Images Asynchronouslyが参考になります。Pokédexはポケモン図鑑の海外訳のようで、この例ではPokéAPIを使ってランダムなポケモンの画像を取得し、それを画面します。公式のexampleでポケモンを見かけてちょっとほっこりしました。
以降、非同期処理のキモになる部分を記します。
非同期処理を使うには、非同期系のフィーチャーを有効にする必要があります。tokio
やasync-std
などがありますが、ここではtokio
を使うことにします。cargo.toml
にfeatures = ["tokio"]
を追加しておきます。
[dependencies]
iced = { features = ["tokio"] }
iced::Application
にはExecutor
という関連型が用意されています。executor::Default
を指定した場合、これはicedのフィーチャーに基づいて動作が決まるようです。非同期系のフィーチャーを有効にした場合は非同期用のExecutorが使われます。
impl Application for AppState {
type Executor = executor::Default;
}
Web上から画像を取得し、それを表示するアプリを作ることを考えます。まず、Stateを以下のように定義します。
use iced::widget::image::Handle as ImageHandle;
struct MyApp {
image: Option<ImageHandle>,
}
Handleだけだと画像であることが分かりにくいので、ImageHandleという名前を付けています。imageがOption型なのは、アプリ起動時点では画像はまだ読み込まれていないためです。
画像を取得するためのメッセージと、取得した画像をStateに反映するためのメッセージを定義します。
enum Message {
FetchImage,
ImageLoaded(Option<ImageHandle>),
}
FetchImageはボタンなどのUIから発行され、ImageLoadedは非同期処理の完了時に発行されます。ImageLoadedは画像取得の結果を保持するためResult<Vec<u8>, Error>
としておきます。
Webから画像を取得する非同期関数を用意します。
async fn fetch_image() -> Result<ImageHandle, Error> {
// 実装は省略.
// バイト列から iced::image::Handle を生成する場合は
// iced::image::Handle::from_memory() を使う
}
メッセージに対する処理を記述します。
use iced::Command;
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::FetchImage => {
Command::perform(
fetch_image(),
|image| Message::ImageLoaded(image.ok())
)
}
Message::ImageLoaded(image) => {
self.image = image;
Command::none()
}
}
}
Command::performは第一引数でFutureとして渡した非同期処理を実行し、その完了後に第二引数で指定したメッセージを発行します。非同期処理の結果はメッセージに渡されます。
気付いた方もいるもしれませんが、非同期処理で取得したデータを使ってself.imageを更新する際にArcやMutexを必要としません。なぜなら、メッセージを受け取るupdate()
は同期関数であり、Message::UpdateImage
に対する処理も同期関数の中で行われるためです。Stateの更新がupdate()
に集約されているがゆえの利点ですね。この点が個人的に良いなと思いました。
もちろん、AppStateが持つデータをasync関数であるfetch_image()
の中で参照するような場合はArc等が必要になります。
2024/5/27追記: 画像の型について
投稿当初はMessage::ImageLoaded
の型をResult<Vec<u8>, Error>
としていましたが、これをOption<ImageHandle>
にしました。
使うウィジェットによってはMessageにCloneトレイトを要求されることがあります。その際、Vec<u8>
だとclone時にヒープの確保が行われるため、おそらく内部的に参照カウントを使っているであろうiced::image::Handle
を使う方が良さそうだと判断しました。
IMEについて (2024年6月17日 追記)
現在のiced (ver.0.12.1) はIMEをサポートしていません。日本語の表示だけであれば可能ですが、日本語入力 (あるいは中国語や韓国語の入力) を使うアプリを作る場合は、iced以外のものを選んだ方が良いと思います。
おわりに
まだ触ってみたばかりですが、icedは良いぞという印象を持ちました。メッセージをmatch式で処理できる点など、Rustの特徴ともよく合っているように思います。
RustのGUIまわりの状況は今後も変化すると思います。他の技術も含め、今後も動向を追いたいところです。