跳转到内容

Rust GUI 应用:Tauri、egui、Slint 和 Iced

迟早有一天,你会想要一个真正带按钮的窗口,而不只是终端里的文字。用 Rust 能做到吗?能。Rust 写的 GUI 应用速度快、体积小、也很安全,现在已经有好几个不错的工具包。不过,和 C# 的 WPF、JavaScript 的 Electron 比起来,Rust 的 GUI 生态还比较年轻,所以在选择之前,先了解一下整体情况会很有帮助。

这一页会实事求是地介绍现状,给你一张帮你做选择的表格,讲清楚即时模式(immediate mode)和保留模式(retained mode)背后的思路,并为最流行的几个工具包各写一个小程序。

代码在哪里运行? GUI 需要一个真正的窗口,所以 egui、Tauri、Slint 和 Iced 的程序要在你自己的计算机上用 cargo run 运行。这一页的 egui 和 Iced 程序都用 cargo check 在当前版本上编译检查过。另外有两节用纯 Rust 模拟了 GUI,打印文字而不是画窗口,所以可以直接在浏览器里运行。找找“动手试一试”按钮就行。

社区网站 Are we GUI yet?(英文)用一句话做了总结:“The roots aren’t deep but the seeds are planted.”(根还扎得不深,但种子已经种下了。)这个评价到现在依然中肯:

  • 优点:速度快、应用体积小,还有 Rust 的安全性。egui、Iced 和 Slint 自己绘制所有内容,所以同一份代码能在 Windows、macOS 和 Linux 上运行。Tauri 让你复用 Web 开发的技能,而且因为它用的是系统自带的 webview,应用比 Electron 小得多。
  • 还在成长:没有像 C++ 的 Qt 或 C# 的 WPF 那样唯一的“标准”工具包。好几个流行的 crate 还停留在 0.x 版本,升级时可能会破坏你的代码。无障碍支持、从右到左的文字和原生外观,各个工具包的支持程度都不一样。
  • 好消息:你不用去找“完美”的工具包。写第一个应用时,下表里的任何一个都很好用。

想看更详细的对比,可以读 2025 survey of Rust GUI libraries(英文),它把许多工具包逐一试了一遍。

现在就能看到的真实应用:

  • Rerun Viewer(英文)是一个查看机器人和计算机视觉数据的工具,用 egui 构建。(Rerun 也在资助 egui 的开发。)
  • System76 为 Linux 打造的桌面环境 COSMIC 是用 Iced 构建的,IRC 聊天客户端 Halloy 也是。
  • 带 Web 风格界面的桌面工具:笔记应用、仪表盘和小型业务工具,常常很适合用 Tauri 来写。
  • 设备上的屏幕:Slint 还能在微控制器上运行,所以很适合机器和家电上的触摸屏。

版本号是 2026 年 10 月的最新版本。

crate 版本 怎么写界面 运行平台 适合的场景
Tauri 2.12(3.0 处于 alpha 阶段) 界面用 HTML、CSS 和 JavaScript;逻辑用 Rust Windows、macOS、Linux、Android、iOS 你会 Web 开发,或者想要丰富、精美的界面
egui + eframe 0.37 纯 Rust,即时模式 Windows、macOS、Linux、Web(Wasm) 工具、调试面板、编辑器,以及用纯 Rust 最快上手
Slint 1.18 自己的 .slint 标记语言,逻辑用 Rust 桌面、嵌入式、移动端、Web 精致的应用,或者嵌入式设备上的屏幕
Iced 0.14 纯 Rust,Elm 架构(状态、消息、update、view) Windows、macOS、Linux、Web 你喜欢干净、可预测的代码,所有修改都集中在一个地方
gtk-rs(gtk4) 0.11 GTK 4 的 Rust 绑定,保留模式 优先支持 Linux;Windows 和 macOS 需要更多配置 Linux 上 GNOME 桌面风格的原生应用
Dioxus 0.7 类似 React 的组件,用 rsx! 宏 Web、桌面(webview)、移动端 你会 React,想让 Web 和桌面共用一份代码

关于许可证和成熟度,有几点要说清楚:

  • Slint 在 GPLv3 下免费;也可以按它的免版税许可证,免费用于闭源的桌面、移动和 Web 应用(需要注明你用了 Slint)。闭源的嵌入式产品需要购买付费许可证。发布之前请先读一下 slint.dev/pricing(英文)。
  • egui、Iced 和 Dioxus 还在 0.x 版本。每次发布都可能有小的破坏性变更,所以要确认教程和你用的版本一致。比如,旧的 egui 教程用的是 fn update(&mut self, ctx: &egui::Context, ...),而 eframe 0.37 用的是 fn ui(&mut self, ui: &mut egui::Ui, ...)。
  • Tauri、Dioxus 桌面版和 gtk-rs 依赖系统库。见下文的平台准备工作。

各个工具包在一个大问题上意见不一:由谁来记住这些控件?

在保留模式下(gtk-rs、Slint、Tauri 的网页,以及其他语言的大多数工具包),按钮只创建一次。工具包把它保存在一棵控件树里。你给它挂上一个回调函数,用户点击时,工具包就调用你的回调。以后想改按钮上的文字,你需要拿着这个按钮的句柄。

在即时模式下(egui),什么都不保存。你的界面函数每一帧都会重新运行一遍,只负责描述此刻要显示什么。画一个按钮、检查它有没有被点击,一行就搞定:if ui.button("+").clicked() { ... }。没有回调,也没有句柄。应用的状态放在你自己的结构体里,界面每次都根据它来画,所以屏幕上显示的内容永远不会和数据对不上。

代价是:工具包每一帧都要重新布局整个界面,所以界面特别大时会多占一些 CPU;有些布局(比如让一个大小还不确定的窗口居中)也更难实现。egui 只在发生事情时(比如鼠标移动)才画新的一帧,所以空闲的应用几乎不占 CPU。

下面用纯 Rust 写一个假装的、迷你的即时模式工具包。循环每走一步就是一帧,clicks 数组扮演鼠标的角色:

// 一个假装的 "Ui",记录这一帧画了什么。
struct Ui {
clicked: Option<&'static str>, // 这一帧鼠标点击的按钮
drawn: Vec<String>,
}
impl Ui {
fn label(&mut self, text: &str) {
self.drawn.push(text.to_string());
}
// 画出按钮,并且马上告诉你它有没有被点击。
fn button(&mut self, text: &'static str) -> bool {
self.drawn.push(format!("[{text}]"));
self.clicked == Some(text)
}
}
// 你的界面代码:每一帧都会重新调用。
fn draw(count: &mut i32, ui: &mut Ui) {
ui.label(&format!("计数:{count}"));
if ui.button("+") {
*count += 1;
}
if ui.button("重置") {
*count = 0;
}
}
fn main() {
let mut count = 0; // 应用状态放在界面代码之外
let clicks = [None, Some("+"), Some("+"), None, Some("重置"), None];
for (frame, click) in clicks.into_iter().enumerate() {
let mut ui = Ui { clicked: click, drawn: Vec::new() };
draw(&mut count, &mut ui);
println!("第 {frame} 帧 → {}", ui.drawn.join(" "));
}
}

输出:

第 0 帧 → 计数:0 [+] [重置]
第 1 帧 → 计数:0 [+] [重置]
第 2 帧 → 计数:1 [+] [重置]
第 3 帧 → 计数:2 [+] [重置]
第 4 帧 → 计数:2 [+] [重置]
第 5 帧 → 计数:0 [+] [重置]
▶ 动手试一试

看第 1 帧:你点了“+”,标签却还显示 0。因为标签是在按钮发现被点击之前画出来的,新值要到下一帧才显示。真正的 egui 会替你处理好这件事:任何点击或按键都会让它立刻画下一帧,所以你永远不会看到旧的数字。

eframe 是 egui 官方的“应用框架”:它负责打开窗口,并在窗口里运行 egui。创建一个项目,把它加进来:

Terminal window
cargo new counter
cd counter
[dependencies]
eframe = "0.37"

把 src/main.rs 替换成下面的代码:

use eframe::egui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 160.0]),
..Default::default()
};
eframe::run_native(
"Counter",
options,
Box::new(|_cc| Ok(Box::<CounterApp>::default())),
)
}
#[derive(Default)]
struct CounterApp {
count: i32,
}
impl eframe::App for CounterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading(format!("Count: {}", self.count));
ui.horizontal(|ui| {
if ui.button("-").clicked() {
self.count -= 1;
}
if ui.button("+").clicked() {
self.count += 1;
}
if ui.button("Reset").clicked() {
self.count = 0;
}
});
});
}
}

用 cargo run 运行。第一次编译要下载并编译几百个 crate,所以请等上几分钟,之后再编译就快了。屏幕上会打开一个小窗口,显示“Count: 0”和三个按钮。

这些代码是怎么配合的:

  • CounterApp 就是你的状态。#[derive(Default)] 让 count 从 0 开始。
  • run_native 负责打开窗口。闭包只调用一次,在一个 Box 里创建你的应用(见智能指针)。
  • eframe 每一帧都会调用 ui。CentralPanel 填满整个窗口,ui.horizontal 把三个按钮排成一行。
  • 每个 if ui.button(..).clicked() 都是上一节讲的即时模式写法:画按钮、检查它、修改状态。

这也是为什么状态必须放在 self 里,而不能放在 ui 里的局部变量中:在那里写 let mut count = 0;,每一帧都会重新从 0 开始。

一个 Tauri 应用分成两部分。前端是一个普通的网页(HTML、CSS 和 JavaScript,或者 React、Svelte 这样的框架),运行在系统的 webview 里。后端是你的 Rust 代码。前端调用的 Rust 函数叫作命令(command)。

最简单的起步方式是用官方的项目生成器。它会问你项目名称和前端模板(“vanilla”模板不带任何框架,第一次用很合适):

Terminal window
cargo install create-tauri-app --locked
cargo create-tauri-app

如果你已经在用 Node.js,也可以用 npm create tauri-app@latest。然后进入新建的文件夹启动应用:npm 模板用 npm install 和 npm run tauri dev;纯 Rust 模板用 cargo install tauri-cli --version "^2.0.0" --locked 和 cargo tauri dev。生成器会根据你的选择打印出正确的命令。

生成的 src-tauri/src/lib.rs 里已经有你的第一个命令:

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

前端(vanilla 模板里的 src/main.js,这里做了精简)是这样调用它的:

const { invoke } = window.__TAURI__.core;
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });

这里发生了什么:

  • #[tauri::command] 是一个属性宏(见过程宏)。它会生成胶水代码,把 JavaScript 的调用转换成普通的 Rust 调用。
  • 参数以 JSON 的形式传过来,所以每个参数的类型都必须实现 serde 的 Deserialize,返回类型必须实现 Serialize(见用 Serde 处理 JSON)。在 JavaScript 里,参数名要写成 camelCase:Rust 参数 invoke_message 要写成 { invokeMessage: "..." }。
  • generate_handler![greet] 负责注册命令。新加了命令却忘了写进这里,JavaScript 的调用就会失败。
  • 想报告错误就返回 Result:Ok 会让 JavaScript 的 promise 成功完成(resolve),Err 会让它失败(reject),这样你就能用 .catch() 处理。例如 fn load(path: String) -> Result<String, String>。
  • main.rs 只负责调用 run()。代码放在 lib.rs 里,是为了让同一个应用也能编译成 Android 和 iOS 版本。

Slint 用一种小巧的声明式语言来描述界面,有点像 HTML 和 CSS 的结合,逻辑交给 Rust 代码处理。下面是官方的 hello world。在 [dependencies] 下加上 slint = "1.18",然后把这段代码放进 src/main.rs:

slint::slint!{
export component HelloWorld {
Text {
text: "hello world";
color: green;
}
}
}
fn main() {
HelloWorld::new().unwrap().run().unwrap();
}

slint! 宏在编译时编译这段标记,并为你生成一个 Rust 类型 HelloWorld。在真正的应用里,标记会放在单独的 .slint 文件里。下面这个计数器来自官方的 Slint Rust 模板(英文)。文件 ui/app-window.slint:

import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in-out property <int> counter: 42;
callback request-increase-value();
VerticalBox {
Text {
text: "Counter: \{root.counter}";
}
Button {
text: "Increase value";
clicked => {
root.request-increase-value();
}
}
}
}

Rust 这一侧的 src/main.rs:

slint::include_modules!();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ui = AppWindow::new()?;
let ui_handle = ui.as_weak();
ui.on_request_increase_value(move || {
let ui = ui_handle.unwrap();
ui.set_counter(ui.get_counter() + 1);
});
ui.run()?;
Ok(())
}

Slint 是保留模式:counter 属性保存在界面里,按钮会调用一个回调。Slint 根据标记生成 get_counter、set_counter 和 on_request_increase_value(短横线会变成下划线)。闭包持有的是一个弱句柄(as_weak()),这样窗口和它自己的回调就不会互相拉着对方、永远无法释放(见 Weak 与 Cow)。使用 .slint 文件时,还需要在 [build-dependencies] 下加上 slint-build = "1.18",并写一个只有一行的 build.rs:slint_build::compile("ui/app-window.slint").expect("Slint build failed");。

Iced 遵循 Elm 架构,名字来自 Elm 语言。每个应用都有四个部分:

  1. 状态:一个结构体,装着应用需要记住的所有东西。
  2. 消息:一个枚举,列出所有可能发生的事情(“+ 按钮被按下了”)。
  3. update:接收状态和一条消息,然后修改状态。这是唯一会发生修改的地方。
  4. view:根据状态构建控件。控件自己不改任何东西,只负责发送消息。

下面是官方的 Iced 计数器(在 [dependencies] 里加上 iced = "0.14"):

use iced::widget::{button, column, text, Column};
use iced::Center;
fn main() -> iced::Result {
iced::run(Counter::update, Counter::view)
}
#[derive(Default)]
struct Counter {
value: i64,
}
#[derive(Debug, Clone, Copy)]
enum Message {
Increment,
Decrement,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value -= 1,
}
}
fn view(&self) -> Column<'_, Message> {
column![
button("Increment").on_press(Message::Increment),
text(self.value).size(50),
button("Decrement").on_press(Message::Decrement),
]
.padding(20)
.align_x(Center)
}
}

on_press(Message::Increment) 并不会执行任何代码,它只是说明“被点击时,发送这条消息”。然后 Iced 用这条消息调用 update,再重新调用 view 来显示新的状态。因为所有修改都要经过同一个 match,应用能做的所有事情一眼就能看清(也很容易测试)。这是枚举和 match 的一个很好的应用场景。

练习这个思路并不需要 Iced。下面这个待办事项模型有同样的四个部分。view 返回文字而不是控件,一串消息代替了用户的打字和点击:

// 1. 状态:应用需要记住的所有东西。
#[derive(Default)]
struct App {
draft: String,
todos: Vec<Todo>,
}
struct Todo {
title: String,
done: bool,
}
// 2. 消息:所有可能发生的事情。
#[derive(Debug)]
enum Message {
DraftChanged(String),
AddPressed,
Toggle(usize),
ClearDone,
}
impl App {
// 3. update:唯一会修改状态的地方。
fn update(&mut self, message: Message) {
match message {
Message::DraftChanged(text) => self.draft = text,
Message::AddPressed => {
let title = self.draft.trim();
if !title.is_empty() {
self.todos.push(Todo { title: title.to_string(), done: false });
}
self.draft.clear();
}
Message::Toggle(index) => {
if let Some(todo) = self.todos.get_mut(index) {
todo.done = !todo.done;
}
}
Message::ClearDone => self.todos.retain(|todo| !todo.done),
}
}
// 4. view:把状态变成用户看到的内容(这里是文字)。
fn view(&self) -> String {
let mut screen = format!("新任务:[{}]\n", self.draft);
for (index, todo) in self.todos.iter().enumerate() {
let mark = if todo.done { "x" } else { " " };
screen.push_str(&format!("{index}. [{mark}] {}\n", todo.title));
}
screen
}
}
fn main() {
let mut app = App::default();
// 假装这些来自按键和按钮点击。
let messages = vec![
Message::DraftChanged("买牛奶".to_string()),
Message::AddPressed,
Message::DraftChanged("给银行打电话".to_string()),
Message::AddPressed,
Message::DraftChanged("给植物浇水".to_string()),
Message::Toggle(0),
];
for message in messages {
println!("> {message:?}");
app.update(message);
}
print!("{}", app.view());
app.update(Message::ClearDone);
println!("> ClearDone 之后:");
print!("{}", app.view());
}

输出:

> DraftChanged("买牛奶")
> AddPressed
> DraftChanged("给银行打电话")
> AddPressed
> DraftChanged("给植物浇水")
> Toggle(0)
新任务:[给植物浇水]
0. [x] 买牛奶
1. [ ] 给银行打电话
> ClearDone 之后:
新任务:[给植物浇水]
0. [ ] 给银行打电话
▶ 动手试一试

注意,view 从不修改任何东西,update 也从不打印任何东西。这种分工就是全部的诀窍。要把它变成真正的 Iced 应用,你只需要重写 view,让它返回控件(一个发送 DraftChanged 的 text_input,一个发送 AddPressed 的 button)。状态、消息和 update 完全不用改。

GUI crate 要和操作系统打交道,所以光有 Rust 还不够。先按照 Rust 工具与环境配置把基础环境装好,然后:

操作系统 egui/eframe、Iced、Slint Tauri 还需要
Windows 除了 MSVC C++ 工具之外,不需要别的 WebView2。Windows 10(1803 及以后的版本)和 Windows 11 已经自带
macOS Xcode 命令行工具(xcode-select --install) 桌面应用不需要别的;iOS 需要完整的 Xcode
Linux(Debian/Ubuntu) eframe 的 README 列出了 libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev webkit2gtk:libwebkit2gtk-4.1-dev,再加上 Tauri 前置要求页面里列出的几个包

如果 Linux 上编译失败,pkg-config 报告缺少某个库,就安装那个库的 -dev 包,然后重新编译。gtk-rs 需要 GTK 4 的开发文件(Ubuntu 上是 libgtk-4-dev)。

  • 阻塞界面线程。 在 egui 的 ui 函数或 Iced 的 update 里下载文件或做长时间的计算,整个窗口都会卡住,直到它完成。把慢的工作放到另一个线程或异步任务里(见并发和异步(async/await)),再把结果作为消息发回界面。
  • 把 egui 的状态放在局部变量里。 ui 每一帧都会重新运行,所以里面的 let mut 每次都从头开始。把状态放进你的应用结构体。
  • 忘了注册 Tauri 命令。 每个命令都必须出现在 tauri::generate_handler![...] 里。另外,在 lib.rs 里不要把命令标记为 pub:Tauri 文档解释过,这会导致 E0255 “defined multiple times”(重复定义)错误。
  • 照着旧教程写。 对 0.x 的 crate 来说,去年的示例可能已经编译不过了。对比一下教程里的版本和你的 Cargo.toml,再去看看 crate 自己的 examples 文件夹里对应你所用版本的示例。

写一个 Elm 风格的计数器,带 Increment、Decrement 和 Reset 三种消息。即使用户一直按“-”,值也绝不能低于 0。把一串消息依次交给 update,每处理一条就打印一次 view()。

提示:用 u32 和 saturating_sub(1),它会停在 0,而不会溢出(见整数溢出)。

查看答案
#[derive(Default)]
struct Counter {
value: u32,
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value = self.value.saturating_sub(1),
Message::Reset => self.value = 0,
}
}
fn view(&self) -> String {
format!("计数:{}", self.value)
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Decrement,
Message::Decrement,
Message::Increment,
Message::Reset,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

输出:

Increment -> 计数:1
Increment -> 计数:2
Decrement -> 计数:1
Decrement -> 计数:0
Decrement -> 计数:0
Increment -> 计数:1
Reset -> 计数:0
▶ 动手试一试

“永远不低于 0”这条规则只存在于一个地方:update。我们在调用 update 之前就把消息名格式化好了,因为 update 按值接收消息,会把它移动走。消息名用 {name:<9} 补齐到 9 个字符来对齐,而它们是英文的枚举变体名,所以对齐不受中文影响。

给你的计数器加一个 Undo 消息。每次修改都记住旧值,Undo 回退一步。没有历史记录时,Undo 什么也不做。在 view 里显示还剩几步可以撤销。

提示:在状态里放一个 history: Vec<u32>。每次修改之前先把旧值 push 进去,收到 Undo 时再 pop 出来。

查看答案
#[derive(Default)]
struct Counter {
value: u32,
history: Vec<u32>, // 旧值,最新的在最后
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
Undo,
}
impl Counter {
fn update(&mut self, message: Message) {
let new_value = match message {
Message::Increment => self.value + 1,
Message::Decrement => self.value.saturating_sub(1),
Message::Reset => 0,
Message::Undo => {
if let Some(previous) = self.history.pop() {
self.value = previous;
}
return;
}
};
self.history.push(self.value); // 先记住旧值
self.value = new_value;
}
fn view(&self) -> String {
format!("计数:{}(可撤销 {} 步)", self.value, self.history.len())
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Increment,
Message::Reset,
Message::Undo,
Message::Undo,
Message::Undo,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

输出:

Increment -> 计数:1(可撤销 1 步)
Increment -> 计数:2(可撤销 2 步)
Decrement -> 计数:1(可撤销 3 步)
Increment -> 计数:2(可撤销 4 步)
Reset -> 计数:0(可撤销 5 步)
Undo -> 计数:2(可撤销 4 步)
Undo -> 计数:1(可撤销 3 步)
Undo -> 计数:2(可撤销 2 步)
▶ 动手试一试

撤销功能之所以这么好加,是因为每次修改本来就都经过 update。在真正的 Iced 应用里,你只需要再加一个发送 Message::Undo 的按钮。

  • Rust GUI 现在已经很好用,但还没有唯一的标准工具包,好几个流行的 crate 仍然是 0.x 版本。
  • Tauri 把 Web 前端和 Rust 后端组合在一起;egui 是纯 Rust 的即时模式;Slint 用自己的标记语言,还能在嵌入式设备上运行;Iced 采用 Elm 架构。
  • 保留模式维护一棵控件树,并调用你的回调。即时模式每一帧都根据你的状态重画界面,没有回调。
  • Elm 架构把应用分成状态、消息、update 和 view,修改过程清楚好跟踪,也容易测试。
  • Tauri 在 Windows 上需要 WebView2,在 Linux 上需要 webkit2gtk。基础环境见 Rust 工具与环境配置。

小测验

1. 在 egui 里,为什么计数器的值必须放在应用结构体里,而不能放在 ui 里的局部变量中?

2. 在 Tauri 应用里,#[tauri::command] 有什么作用?

3. 在 Iced 使用的 Elm 架构里,应用状态在哪里改变?

4. 在 Linux 上,Tauri 需要而 egui 不需要的是什么?

得分: 0 / 4