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应用围绕四个概念运转:
- State:应用的完整状态,一个Rust struct
- Message:用户交互产生的事件,一个enum
- Update:收到Message后如何更新State,一个方法
- 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()
}
}
Application的update返回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目前是我觉得最值得投入的选择。