2. コアアーキテクチャと状態管理
RustにおけるGUI状態管理の難しさ
Rust でコマンドラインツールや Web サーバーを作るときは、データの受け渡しが比較的シンプルです。 入力を受け取り、処理をして、結果を返せば処理が終わるため、データの「所有権(Ownership)」がどこにあるかを一直線に追跡できます。
しかし、GUI アプリケーションでは状況が一変します。 たとえば、「画面上部のヘッダー」「左側のサイドバー」「中央の編集画面」という 3 つの部品が、同じ「ユーザー設定」や「タスクリスト」のデータを同時に表示し、しかもどの部品からでもデータを書き換えられるようにしたい場面は日常茶飯事です。
[ヘッダー] ───┐
[サイドバー] ─┼──> 同じ「タスクリスト」を読み書きしたい!
[メイン画面] ─┘
これを素の Rust でそのまま書こうとすると、コンパイラの借用チェッカー(Borrow Checker)に阻まれます。
Rc<RefCell<T>> や Arc<Mutex<T>> を大量に組み合わせる方法もありますが、コードが .borrow_mut() だらけになって読みづらくなるうえ、実行時に二重借用でパニック(クラッシュ)したり、循環参照でメモリリークを起こしたりする危険がつきまといます。
GPUI は、この Rust 特有の課題を 「アプリケーション全体による状態の集中所有」 という鮮やかなアーキテクチャで解決しています。
Appによる状態の集中所有モデル
GPUI における最大のルールは、「画面の部品やモデルのデータ本体は、すべて App(アプリケーションコンテキスト)が 1 カ所でまとめて所有する」 というものです。
開発者が作った構造体のインスタンス(実体)は、App の内部にある巨大な保管庫(Entity マップ)に登録されます。
そして、他の構造体やイベント処理には、データ本体ではなく 「保管庫の何番に入っているかを示す軽量な引換券(ハンドル)」 だけを渡します。
graph TD
subgraph App["App(アプリケーション全体の保管庫)"]
Store["Entity Storage<br/>・ID 1: Counter { count: 0 }<br/>・ID 2: UserProfile { name: 'Alice' }<br/>・ID 3: MainView { ... }"]
end
Handle1["Entity<Counter><br/>(ID 1 の引換券)"] -.->|cx を通じてアクセス| Store
Handle2["Entity<UserProfile><br/>(ID 2 の引換券)"] -.->|cx を通じてアクセス| Store
この「引換券」は単なる ID 番号のラッパーなので、何回 .clone() してコピーしてもコストはほぼゼロです。
引換券自体にはライフタイム('a)の制約がないため、どの構造体にも自由に持たせることができます。
そして、「引換券を使って中身のデータを読んだり書き換えたりするときだけ、必ず cx(コンテキスト)という窓口を通す」というルールにすることで、Rust の安全性を保ったまま自由なデータ共有を実現しています。
EntityとContextの仕組み
GPUI のコードを読むと、必ず Entity<T> と Context<T>(変数名は慣習的に cx)が登場します。
この 2 つの役割を整理しましょう。
▸ Entity<T>(状態のハンドル)
App の保管庫に登録された T 型のデータを指すスマートポインタ(引換券)です。
以前のバージョンの GPUI では用途によって Model<T> と View<T> に分かれていましたが、現在の GPUI ではすべて Entity<T> に統一されています。
Entity<T> は参照カウント方式で管理されており、どこからも参照されなくなると自動的にメモリから解放されます。
▸ WeakEntity<T>(弱参照ハンドル)
参照カウントを増やさない引換券です。
親子関係でお互いに Entity を持ち合うと循環参照になってメモリが解放されなくなるため、子から親を指すときや、非同期処理(cx.spawn)の中に持ち込むときは entity.downgrade() で取得した WeakEntity<T> を使います。
▸ App と Context<T>(操作の窓口)
App: アプリケーション全体を表す最上位のコンテキストです。Context<T>: 特定のEntity<T>に紐づいたコンテキストです。Appのすべての機能に加えて、「自分自身の再描画を通知する(cx.notify())」「自分自身のイベントを発行する(cx.emit())」といった専用メソッドを持っています。
▸ データの作成・読み取り・更新の3ステップ
Entity<T> を扱う基本操作は、「作成(new)」「読み取り(read)」「更新(update)」の 3 つだけです。
#![allow(unused)]
fn main() {
use gpui::*;
// 管理したいデータの構造体
pub struct Counter {
pub count: i32,
}
fn example(cx: &mut App) {
// 1. 作成(cx.new): 保管庫に Counter を登録し、引換券 Entity<Counter> を受け取る
let counter: Entity<Counter> = cx.new(|_cx| Counter { count: 0 });
// 2. 読み取り(counter.read): 引換券と cx を使って中身を安全に参照する
let current = counter.read(cx).count;
println!("現在のカウント: {}", current);
// 3. 更新(counter.update): クロージャの中で &mut Counter と &mut Context<Counter> を受け取って書き換える
counter.update(cx, |state: &mut Counter, cx: &mut Context<Counter>| {
state.count += 10;
// 状態が変わったことをGPUIに知らせる
cx.notify();
});
}
}
update メソッドを使うときだけ一時的に可変参照(&mut Counter)が取り出され、クロージャを抜けるとすぐに返却されます。
そのため、アプリのどこからでも競合を起こさずに状態を更新できます。
RenderトレイトとViewの役割
GPUI において、単なるデータのかたまり(モデル)と、画面に表示される部品(ビュー)の違いはたった 1 つです。
それは 「Render トレイトを実装しているかどうか」 です。
ある構造体 MyView に Render トレイトを実装すると、そのハンドルである Entity<MyView> はそのまま画面の要素(IntoElement)として親の画面に配置できるようになります。
#![allow(unused)]
fn main() {
use gpui::*;
pub struct CounterView {
count: i32,
}
impl Render for CounterView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_2()
.p_4()
.child(format!("カウント値: {}", self.count))
.child(
div()
.id("add-btn")
.px_3()
.py_1()
.bg(rgb(0x2563eb))
.text_color(rgb(0xffffff))
.child("+1 増やす")
.on_click(cx.listener(|this: &mut Self, _event, _window, cx| {
this.count += 1;
cx.notify(); // 画面の再描画をリクエスト
})),
)
}
}
}
render メソッドの引数には以下の 3 つが渡されます。
&mut self: 自分自身の構造体への可変参照です。フィールドの値を読み取って画面に反映します。window: &mut Window: 描画対象のウィンドウへの参照です。ウィンドウのサイズを取得したり、フォーカスを制御したりするときに使います。cx: &mut Context<Self>: このビュー専用のコンテキストです。子ビューの状態を読み取ったり、イベントリスナーを登録したりするときに使います。
また、親のビューが子のビュー(Entity<ChildView>)をフィールドとして持っている場合、親の render メソッドの中で .child(self.child_view.clone()) と書くだけで、子の画面を好きな場所に埋め込むことができます。
状態変更の通知とリアクティブ更新
複数のビューやデータモデルを組み合わせてアプリを作る場合、「データモデル側が更新されたら、それを表示しているビューも自動的に再描画してほしい」という場面が出てきます。 GPUI には、そのための仕組みとして Observe(監視) と Subscribe(イベント購読) の 2 つが用意されています。
sequenceDiagram
participant Model as データ (Entity<Cart>)
participant GPUI as GPUI ランタイム
participant View as 画面 (Entity<HeaderView>)
Note over View,Model: 初期化時に cx.observe(&cart, ...) を登録
Model->>GPUI: 1. cx.notify() または cx.emit(Event)
GPUI->>View: 2. 登録されたコールバックを実行
View->>GPUI: 3. 自分自身の cx.notify() を呼ぶ
GPUI->>View: 4. 次のフレームで render() を再実行
▸ 1. cx.observe(状態変化の監視)
監視対象の Entity の中で cx.notify() が呼ばれたときに、自動的にコールバック関数を実行する仕組みです。
「対象がとにかく何か更新されたら、自分の画面も描き直したい」という場合は cx.observe を使います。
#![allow(unused)]
fn main() {
// 親ビューの初期化時(cx.new の中など)で設定する
let cart = cx.new(|_| ShoppingCart { items: vec![] });
cx.observe(&cart, |this: &mut HeaderView, _cart_entity, cx| {
// カートの中身が変わって cart 側で cx.notify() が呼ばれたら、ここが実行される
cx.notify(); // HeaderView 自身も再描画を予約する
})
.detach();
}
▸ 2. EventEmitter と cx.subscribe(型付きイベントの購読)
単なる「何かが変わった」だけでなく、「具体的にどんな出来事が起きたか(たとえば『アイテムが削除された』『保存が完了した』など)」というデータを添えて通知したい場合は、EventEmitter トレイトと cx.subscribe を使います。
#![allow(unused)]
fn main() {
use gpui::*;
// 1. 発行したいイベントの型を定義する
pub enum CartEvent {
ItemAdded(String),
Cleared,
}
pub struct ShoppingCart {
pub items: Vec<String>,
}
// 2. ShoppingCart が CartEvent を発行できることを宣言する
impl EventEmitter<CartEvent> for ShoppingCart {}
impl ShoppingCart {
pub fn add_item(&mut self, name: String, cx: &mut Context<Self>) {
self.items.push(name.clone());
// イベントを発行する
cx.emit(CartEvent::ItemAdded(name));
cx.notify();
}
}
}
購読する側のビューでは、cx.subscribe を使ってこの CartEvent を受け取ります。
#![allow(unused)]
fn main() {
// 別のビューの初期化時
cx.subscribe(&cart, |this: &mut MainView, _cart, event: &CartEvent, cx| {
match event {
CartEvent::ItemAdded(name) => {
this.last_message = format!("「{}」が追加されました", name);
}
CartEvent::Cleared => {
this.last_message = "カートが空になりました".to_string();
}
}
cx.notify();
})
.detach();
}
なお、cx.observe や cx.subscribe は戻り値として Subscription(購読チケット)を返します。
購読元のビューが生きている限りずっと監視を続けたい場合は末尾で .detach() を呼び、途中で監視を止めたい場合やビューと一緒に確実に破棄したい場合は、ビューの構造体のフィールド(Vec<Subscription> など)に保存しておきます。
▸ グローバル状態(Global)
アプリ全体でたった 1 つしか存在しない設定情報やテーマ、共有サービスなどは、Global トレイトを実装することで、cx.set_global(MyConfig { ... }) で登録し、どこからでも cx.global::<MyConfig>() で手軽に読み取ることができます。
第 5 章で学ぶ GPUI-Component のテーマ機能(cx.theme())も、この Global の仕組みを使って実装されています。
これで、GPUI の心臓部である「状態管理と再描画のサイクル」が分かりました。 次章では、画面の見た目を自在にデザインするための「レイアウトとスタイリング」の手法を詳しく見ていきます。