Rust GUI开发:iced框架入门

Rust生态的GUI框架一直是个痛点,iced作为一个纯Rust、跨平台、受Elm架构启发的GUI框架,终于让Rust写桌面应用变得优雅起来。这篇文章从架构到实战,完整过一遍iced的核心概念。

iced是什么

iced是一个跨平台的GUI库,灵感来自Elm语言的架构模式(The Elm Architecture,简称TEA)。它的核心理念是:UI是状态的函数。你定义状态、定义消息、定义状态如何响应消息、定义状态如何渲染为UI——框架帮你处理剩下的一切。

相比其他Rust GUI方案(如egui、druid、gtk-rs),iced的优势在于:

  • 纯Rust实现,不依赖C/C++绑定
  • 类型安全的消息机制
  • 响应式布局
  • 支持async
  • 渲染后端可选(wgpu/tiny-skia)

Elm架构(TEA)

整个iced应用围绕四个概念运转:

  1. State:应用的完整状态,一个Rust struct
  2. Message:用户交互产生的事件,一个enum
  3. Update:收到Message后如何更新State,一个方法
  4. View:State如何渲染为UI,一个方法

数据流是单向的:View渲染UI → 用户操作产生Message → Update处理Message更新State → 重新View。这个循环保证了状态和UI的一致性。

基础组件

iced提供了一套基础组件:

文本与按钮:

use iced::widget::{text, button, column};

// 文本
text("Hello, iced!")
    .size(24)

// 按钮
button(text("Click me"))
    .on_press(Message::ButtonPressed)
    .padding(10)

输入框:

use iced::widget::text_input;

text_input("请输入...", &self.input_value)
    .on_input(Message::InputChanged)
    .on_submit(Message::InputSubmitted)
    .padding(10)

布局容器:

use iced::widget::{column, row, container};

// 垂直布局
column![
    text("第一行"),
    text("第二行"),
    text("第三行"),
]
.spacing(10)
.padding(20)

// 水平布局
row![
    button(text("左")).on_press(Message::Left),
    button(text("右")).on_press(Message::Right),
]
.spacing(10)

// 居中容器
container(some_widget)
    .width(Length::Fill)
    .height(Length::Fill)
    .center_x()
    .center_y()

其他常用组件:

  • checkbox:复选框
  • radio:单选按钮
  • slider:滑块
  • pick_list:下拉选择
  • scrollable:可滚动容器
  • toggler:开关

计数器示例

经典入门示例,一个带+/-按钮的计数器:

use iced::widget::{button, column, text};
use iced::{Alignment, Element, Sandbox, Settings};

pub fn main() -> iced::Result {
    Counter::run(Settings::default())
}

#[derive(Default)]
struct Counter {
    value: i64,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    Increment,
    Decrement,
}

impl Sandbox for Counter {
    type Message = Message;

    fn new() -> Self {
        Self::default()
    }

    fn title(&self) -> String {
        String::from("计数器")
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::Increment => self.value += 1,
            Message::Decrement => self.value -= 1,
        }
    }

    fn view(&self) -> Element<Message> {
        column![
            button(text("+")).on_press(Message::Increment),
            text(self.value).size(50),
            button(text("-")).on_press(Message::Decrement),
        ]
        .padding(20)
        .spacing(10)
        .align_items(Alignment::Center)
        .into()
    }
}

这里用的是Sandbox trait,适合不需要异步操作的简单应用。Sandbox要求实现四个方法:new(初始化)、title(窗口标题)、update(处理消息)、view(渲染UI)。

TODO应用示例

稍微复杂一点的例子,一个TODO列表:

use iced::widget::{button, checkbox, column, row, text, text_input, scrollable};
use iced::{Alignment, Element, Length, Sandbox, Settings};

pub fn main() -> iced::Result {
    TodoApp::run(Settings {
        window: iced::window::Settings {
            size: (400, 600),
            ..Default::default()
        },
        ..Default::default()
    })
}

#[derive(Debug, Clone)]
struct Task {
    description: String,
    completed: bool,
}

struct TodoApp {
    input_value: String,
    tasks: Vec<Task>,
}

#[derive(Debug, Clone)]
enum Message {
    InputChanged(String),
    AddTask,
    ToggleTask(usize),
    DeleteTask(usize),
}

impl Sandbox for TodoApp {
    type Message = Message;

    fn new() -> Self {
        Self {
            input_value: String::new(),
            tasks: Vec::new(),
        }
    }

    fn title(&self) -> String {
        format!("TODO ({}/{})",
            self.tasks.iter().filter(|t| t.completed).count(),
            self.tasks.len()
        )
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::InputChanged(value) => {
                self.input_value = value;
            }
            Message::AddTask => {
                if !self.input_value.trim().is_empty() {
                    self.tasks.push(Task {
                        description: self.input_value.clone(),
                        completed: false,
                    });
                    self.input_value.clear();
                }
            }
            Message::ToggleTask(index) => {
                if let Some(task) = self.tasks.get_mut(index) {
                    task.completed = !task.completed;
                }
            }
            Message::DeleteTask(index) => {
                if index < self.tasks.len() {
                    self.tasks.remove(index);
                }
            }
        }
    }

    fn view(&self) -> Element<Message> {
        // 输入区域
        let input_row = row![
            text_input("添加新任务...", &self.input_value)
                .on_input(Message::InputChanged)
                .on_submit(Message::AddTask)
                .padding(10),
            button(text("添加"))
                .on_press(Message::AddTask)
                .padding(10),
        ]
        .spacing(10);

        // 任务列表
        let tasks: Element<_> = if self.tasks.is_empty() {
            text("暂无任务,添加一个吧").into()
        } else {
            let task_list = self.tasks.iter().enumerate().fold(
                column![].spacing(5),
                |col, (i, task)| {
                    let task_row = row![
                        checkbox(&task.description, task.completed, move |_| {
                            Message::ToggleTask(i)
                        }),
                        button(text("删除").size(12))
                            .on_press(Message::DeleteTask(i))
                            .padding(5),
                    ]
                    .spacing(10)
                    .align_items(Alignment::Center);

                    col.push(task_row)
                },
            );
            scrollable(task_list)
                .height(Length::Fill)
                .into()
        };

        column![input_row, tasks]
            .spacing(20)
            .padding(20)
            .width(Length::Fill)
            .into()
    }
}

这个例子展示了几个实用模式:

  • 复合消息InputChanged(String)携带数据,ToggleTask(usize)携带索引
  • 条件渲染:空列表和非空列表渲染不同UI
  • 动态列表:用fold动态构建组件列表
  • 窗口配置:通过Settings设置窗口大小

主题自定义

iced内置了Light和Dark两套主题,也支持完全自定义:

use iced::theme::{self, Theme};
use iced::Color;

// 使用内置主题
fn theme(&self) -> Theme {
    Theme::Dark
}

// 自定义按钮样式
use iced::widget::button;

fn primary_button_style(theme: &Theme, status: button::Status) -> button::Appearance {
    let palette = theme.palette();
    button::Appearance {
        background: Some(iced::Background::Color(Color::from_rgb(0.2, 0.6, 0.9))),
        text_color: Color::WHITE,
        border_radius: 8.0.into(),
        border_width: 0.0,
        border_color: Color::TRANSPARENT,
        ..Default::default()
    }
}

// 使用自定义样式
button(text("Primary"))
    .on_press(Message::Click)
    .style(primary_button_style)

也可以创建完全自定义的Theme,实现各组件的StyleSheet trait。不过对于大多数场景,在内置主题基础上微调个别组件样式就够了。

Sandbox vs Application

前面的例子都用了Sandbox,它适合简单场景。如果需要:

  • 异步操作(网络请求、文件IO)
  • 订阅事件(键盘快捷键、定时器)
  • 自定义初始化逻辑

就要用Application trait:

use iced::{Application, Command, Element, Settings, Theme};

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

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

    fn title(&self) -> String {
        String::from("My App")
    }

    fn update(&mut self, message: Message) -> Command<Message> {
        match message {
            Message::FetchData => {
                // 返回一个异步Command
                Command::perform(
                    async { fetch_api().await },
                    Message::DataLoaded,
                )
            }
            Message::DataLoaded(result) => {
                self.data = result;
                Command::none()
            }
        }
    }

    fn view(&self) -> Element<Message> {
        // ...
    }

    fn subscription(&self) -> iced::Subscription<Message> {
        // 可以订阅键盘事件、定时器等
        iced::Subscription::none()
    }
}

Applicationupdate返回Command而不是(),这让你可以触发异步任务。subscription方法让你可以监听外部事件源。

项目配置

Cargo.toml配置:

[package]
name = "my-iced-app"
version = "0.1.0"
edition = "2021"

[dependencies]
iced = { version = "0.12", features = ["canvas", "tokio"] }
tokio = { version = "1", features = ["full"] }

常用feature:

  • canvas:自定义绘制
  • image:图片显示
  • svg:SVG渲染
  • tokio:使用tokio作为异步运行时
  • debug:调试overlay

总结

iced的Elm架构让GUI开发变得非常结构化——状态、消息、更新、视图,四个部分职责清晰。写复杂UI时不容易乱,重构也方便。缺点是生态还不够成熟,文档偏少,复杂布局有时候要绕弯路。但作为纯Rust GUI框架,iced目前是我觉得最值得投入的选择。