featured.svg

前陣子心血來潮,用兩個不同的 Rust GUI 框架把同一個計算機專案各做了一遍,分別是 GPUIIced。同一個東西寫兩次,最有趣的地方就是能直接感受到兩種設計哲學的差別。這篇就來聊聊兩者的架構差異和實際的開發體驗囉。

框架背景

框架 開發者 特色
GPUI Zed Industries GPU 加速、為 Zed 編輯器打造
Iced 社群驅動 受 Elm 啟發、跨平台、純 Rust

兩者都還算年輕,版本都停在 0.x 階段,所以 API 隨時可能變動,這點先放在心上吧。

1. 架構設計

GPUI:物件導向 + Render Trait

GPUI 走的是類似 React 的元件模式,每個元件就是一個 struct,靠實作 Render trait 來描述 UI:

struct CalculatorApp {
    input: String,
    result: Option<String>,
    engine: Calculator,
}

impl Render for CalculatorApp {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .flex()
            .flex_col()
            .size_full()
            .bg(rgb(0x1e1e1e))
            .child(self.render_display())
            .child(self.render_keypad(cx))
    }
}

Iced:Elm 架構 (Model-View-Update)

Iced 則是函數式的 Elm 架構,乾脆把整個應用程式切成三塊:

// Model(狀態)
struct App {
    input: String,
    result: Option<String>,
    engine: Calculator,
}

// Message(事件)
#[derive(Debug, Clone)]
enum Message {
    ButtonPressed(String),
    Clear,
    Evaluate,
}

// Update(狀態轉換)
impl App {
    fn update(&mut self, message: Message) {
        match message {
            Message::Clear => self.input.clear(),
            Message::Evaluate => { /* ... */ }
            Message::ButtonPressed(s) => self.input.push_str(&s),
        }
    }
}

// View(UI 描述)
impl App {
    fn view(&self) -> Element<'_, Message> {
        column![
            self.view_display(),
            self.view_keypad(),
        ].into()
    }
}

關鍵差異就在這裡:GPUI 的事件處理是直接內嵌在 view 裡的閉包,Iced 則強迫你把所有事件都抽象成 Message enum,再交給獨立的 update 函式去處理。一個放任、一個嚴格,個性差很多吧。

2. 事件處理

GPUI:閉包直接修改狀態

div()
    .on_click(cx.listener(move |this, _event, _window, cx| {
        match label.as_str() {
            "C" => this.clear(),
            "=" => this.evaluate(),
            other => this.append(other),
        }
        cx.notify();  // 手動觸發重新渲染
    }))

Iced:Message + Pattern Matching

// View 中只發送 Message
button(text("C"))
    .on_press(Message::Clear)

button(text("="))
    .on_press(Message::Evaluate)

// Update 中統一處理
fn update(&mut self, message: Message) {
    match message {
        Message::Clear => self.input.clear(),
        Message::Evaluate => { /* ... */ }
    }
    // 自動重新渲染,不需手動觸發
}

Iced 的優點

  • 所有狀態變更都集中在 update 函式裡,要追蹤、要測試都很省心
  • Message enum 天生可序列化,想做 undo/redo 或時間旅行除錯都方便

GPUI 的優點

  • 閉包可以直接抓局部變數,程式碼寫起來更緊湊
  • 不必為每個動作都定義一個 Message variant,省事

3. 樣式系統

GPUI:Tailwind 風格的 Fluent API

div()
    .flex()
    .flex_col()
    .p_4()           // padding: 16px
    .gap_3()         // gap: 12px
    .bg(rgb(0x1e1e1e))
    .rounded_lg()
    .text_xl()
    .text_color(rgb(0xffffff))

如果你寫過 Tailwind CSS,這種 API 上手起來實在是無痛,幾乎不用查文件。

Iced:Closure-based Styling

container(content)
    .padding(16)
    .width(Length::Fill)
    .style(|_theme| container::Style {
        background: Some(color!(0x2d2d2d).into()),
        border: iced::Border {
            radius: 8.0.into(),
            ..Default::default()
        },
        ..Default::default()
    })

Iced 的樣式是透過 style 方法傳一個閉包進去,好處是可以依照 theme 跟 widget status 動態決定長相,彈性頗大。

按鈕樣式比較

GPUI

div()
    .bg(rgb(0x3c3c3c))
    .hover(|style| style.bg(rgb(0x4a4a4a)))
    .rounded_md()

Iced

button(content)
    .style(move |_theme, status| {
        let bg = match status {
            button::Status::Hovered => color!(0x5a5a5a),
            _ => color!(0x3c3c3c),
        };
        button::Style {
            background: Some(bg.into()),
            border: iced::Border { radius: 6.0.into(), ..Default::default() },
            ..Default::default()
        }
    })

4. 主題支援

GPUI

GPUI 沒有內建主題系統,顏色常數得自己一個個定義:

const BG_DARK: u32 = 0x1e1e1e;
const BG_BUTTON: u32 = 0x3c3c3c;
const TEXT_PRIMARY: u32 = 0xffffff;

Iced

Iced 則內建了一堆主題,一行就能切換,這點真的頗貼心:

iced::application("Calculator", App::update, App::view)
    .theme(|_| Theme::TokyoNightStorm)  // 或 Theme::Dark, Theme::Light 等
    .run()

5. 程式進入點

GPUI

fn main() -> Result<()> {
    let app = Application::new();

    app.run(move |cx| {
        let bounds = Bounds::centered(None, size(px(340.), px(480.)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |_window, cx| cx.new(|_cx| CalculatorApp::new()),
        ).expect("Failed to open window");
    });

    Ok(())
}

Iced

fn main() -> iced::Result {
    iced::application("Iced Calculator", App::update, App::view)
        .theme(|_| Theme::TokyoNightStorm)
        .window_size((340.0, 480.0))
        .run()
}

Iced 的 builder pattern 明顯更簡潔,GPUI 則是把底層控制權交還給你,要自己開視窗、設 bounds。想要省事還是想要掌控,就看你怎麼取捨啦。

6. 跨平台支援

平台 GPUI Iced
macOS 完整支援 (Metal) 完整支援
Windows 實驗性 完整支援
Linux 支援 (Vulkan) 完整支援
Web 不支援 支援 (WebGPU/WebGL)

跨平台這一塊,Iced 成熟度明顯領先,尤其是 GPUI 目前連 Web 都還沒打算支援,差距就拉開了。

7. 程式碼行數比較

同樣的計算機功能:

檔案 GPUI Iced
main.rs ~250 行 ~260 行
總計 差不多 差不多

行數其實差不多,所以別指望換個框架就能少寫一堆 code,真正的差別是在結構而不是篇幅。

8. 編譯時間與依賴

# GPUI
cargo build  # ~2 分鐘,743 個 crates

# Iced
cargo build  # ~1.5 分鐘,415 個 crates

Iced 拉進來的依賴比較少,編譯速度也跟著快一點點。差距不算大,但天天 cargo build 累積下來,還是有感。

選擇建議

選擇 GPUI 如果你

  • 正在為 Zed 編輯器開發插件
  • 偏好物件導向的元件設計
  • 需要極致的渲染效能
  • 主要目標平台是 macOS

選擇 Iced 如果你

  • 需要跨平台支援(特別是 Web)
  • 喜歡 Elm 架構的狀態管理
  • 想要內建主題支援
  • 偏好穩定的 API(雖然都還在 0.x)

總結

特性 GPUI Iced
架構 物件導向 + Render Elm (MVU)
樣式 Tailwind-like Closure-based
主題 手動定義 內建多種
事件 閉包 + cx.notify() Message enum
跨平台 有限 優秀
成熟度 較新 較成熟

寫完這兩個版本,我的感想是:兩個框架都很漂亮地展示了 Rust 做 GUI 的可能性,只是方向不太一樣。GPUI 走的是高效能、GPU 加速那條路,骨子裡是為 Zed 編輯器量身打造;Iced 則把函數式跟跨平台的理念貫徹到底。要我說的話,如果只是想趕快做個跨平台小工具,我會先伸手拿 Iced;但如果你的舞台就在 macOS、又想榨出效能,GPUI 也很值得一試。當然這只是我寫一個計算機的心得,別把它當成什麼鐵則,最後還是得看你的專案需求跟個人偏好來決定喔。

參考資源