一、先厘清一个关键概念:什么是"一切皆设计图"?
上一课我们说过,Xilem 的核心理念是:你在 app_logic 里写的代码生成的不是真实 UI 组件,而是轻量级的 View 对象(设计图),框架负责根据设计图去建造或更新真实的 Widget(实物)。
把这个理念再提炼一层,它其实包含两个紧密关联的承诺:
- 承诺一:UI 是纯函数。给定相同的状态,永远生成相同的设计图。app_logic 就是一个纯函数:State → View Tree。
- 承诺二:框架自动 diff。开发者不需要手动告诉框架"哪里变了",框架自己对比新旧设计图,自动计算最小变更集,增量更新 Widget 树。
这两个承诺合在一起,就是"一切皆设计图"的完整含义。它不是某个具体的 API 设计,而是一种架构哲学。
现在问题来了:Rust 生态里有 egui、Iced、Dioxus、Slint、GPUI、Tauri 这么多框架,为什么只有 Xilem(以及它的前身 Druid)走了这条路?其他框架是不知道这个理念好吗?还是有什么深层原因让它们"不得不"走别的路?
二、核心约束:Rust 的类型系统为什么让"设计图"很难实现
要回答这个问题,必须先理解一个根本性的技术障碍:Rust 的所有权系统和生命周期机制,天然地和"设计图模式"存在张力。
障碍一:闭包捕获与生命周期
设计图模式要求你在视图函数里写这样的代码:
fn app_logic(data: &mut AppState) -> impl WidgetView {
flex(Axis::Vertical, (
label(format!("计数:{}", data.count)),
text_button("+1", |data: &mut AppState| data.count += 1),
))
}
注意 text_button 的第二个参数------一个闭包。这个闭包需要被存储在 View 对象里,等用户点击时再执行。这意味着闭包必须拥有自己的环境(captured environment),而且这个环境的生命周期必须和 View 对象一样长。
在 Rust 中,闭包捕获的变量如果是引用,就会引入生命周期约束。如果 View 对象需要持有这个闭包,那 View 对象的生命周期就被闭包中捕获的引用约束了。这就产生了一个连锁反应:整棵 View 树都被生命周期参数污染了。
Xilem 的解决方案是:闭包不捕获外部引用,而是通过框架传入的 &mut AppState 参数来访问状态。这样闭包本身是 'static 的(不依赖任何外部引用),View 对象也不需要携带生命周期参数。这是一个精妙的设计,但代价是所有状态必须集中在一个 AppState 结构体中,不能随意捕获局部变量。
其他框架面对同样的问题时,选择了不同的绕行方案------有的放弃了设计图模式,有的用宏来掩盖复杂度,有的用运行时类型擦除来绕过编译期检查。
障碍二:类型擦除的代价
设计图模式需要框架能够处理"不同类型的设计图节点"。比如 flex 的子节点可能是 label,也可能是 text_button,它们的 Rust 类型完全不同。框架需要把它们放进同一个容器(比如 Vec)里统一管理。
在 Rust 中,把不同类型的东西放进同一个容器,只有两种方式:
- 枚举(enum):编译期确定所有可能的类型,零运行时开销,但扩展性差------每增加一种新组件就要修改枚举定义。
- Trait Object(Box):运行时动态分发,扩展性好,但引入了虚表查找(vtable lookup)的运行时开销,且无法内联优化。
Xilem 选择了一条更难但性能更好的路:用泛型和关联类型在编译期完成所有类型匹配,尽量避免 trait object。这就是为什么 Xilem 的类型签名极其复杂------impl WidgetView + use<> 这种写法背后,是大量泛型约束在编译期确保类型安全。
其他框架要么选择了 trait object(接受运行时开销),要么选择了枚举(限制扩展性),要么干脆放弃了设计图模式来彻底回避这个问题。
三、逐框架分析:每个框架为什么走了不同的路
1. egui:即时模式------根本没有"设计图"这个概念
egui 的哲学和 Xilem 完全相反。它不是"画设计图,让框架去施工",而是每一帧直接画到屏幕上。
// egui 的代码:没有 View 对象,没有设计图
ui.heading("我的应用");
if ui.button("点我").clicked() {
self.count += 1;
}
ui.label(format!("计数: {}", self.count));
这段代码在每一帧(通常 60fps)都会被完整执行一遍。ui.button("点我") 不是生成一个设计图节点,而是当场计算按钮的位置、大小,检测鼠标是否悬停/点击,然后立即发出绘制指令。
为什么 egui 不采用设计图模式?
因为即时模式的核心优势恰恰是跳过设计图这一层。egui 的作者 emilk 明确说过:保留模式(retained mode)的 GUI 本质上是一个缓存失效问题(cache invalidation problem)------你需要维护一棵 Widget 树,追踪哪些节点变了,做 diff,做增量更新。这整套机制本身就有复杂性开销。即时模式直接说:我不缓存,每帧全部重算,反而更简单。
这个选择在 Rust 中特别合理,因为:
- Rust 的编译期优化非常强,每帧重算 UI 逻辑的开销在编译器优化后很小
- 没有 GC 停顿,每帧的执行时间非常可预测
- 不需要维护 Widget 树,就没有生命周期问题、所有权问题
代价是什么?
- 每帧都要执行所有 UI 代码,即使界面没有任何变化。对于复杂 UI(上千个组件),CPU 开销不可忽视
- 没有持久的 Widget 对象,就无法做精细的动画、焦点管理、无障碍支持(这些都需要"记住"某个组件的状态)
- 布局能力受限------即时模式的顺序执行天然不适合做"先知道子元素尺寸,再决定父元素布局"这种双向布局
本质取舍:egui 用"每帧全量重算的 CPU 开销"换取了"零状态同步烦恼、零生命周期复杂度"。对于工具类、调试面板、游戏 UI 等场景,这个取舍非常划算。
2. Iced:Elm 架构------有设计图,但 diff 策略不同
Iced 是 Rust 生态中最接近 Xilem 理念的框架之一。它也采用声明式编程,也有状态→消息→更新→视图的单向数据流。但它和 Xilem 有一个关键区别:Iced 的视图函数返回的是 Widget 树的描述,但它的更新机制不是基于 View 树的 diff。
// Iced 的代码:Elm 架构
impl Counter {
fn view(&self) -> Column {
column![
button("+").on_press(Message::Increment),
text(self.value).size(50),
button("-").on_press(Message::Decrement),
]
}
}
Iced 的 view 函数每次状态变化时也会重新执行,生成新的 Widget 描述。但 Iced 的更新策略更简单粗暴:状态变了就整棵树重新布局、重新渲染(0.14 版本引入了响应式渲染优化,只重绘变化的部分,但核心思路仍然是"整棵树重建")。
为什么 Iced 不做 Xilem 那样的精细 diff?
因为 Iced 选择了更简单的架构来换取更低的维护成本。Xilem 的三层架构(View → Element → Widget)和精细 diff 引擎带来了更好的性能,但也带来了巨大的架构复杂度。Iced 的作者认为,对于大多数桌面应用,"整棵树重建"的性能已经足够好,不值得为精细 diff 付出架构复杂度的代价。
本质取舍:Iced 用"更粗粒度的更新策略"换取了"更简单的框架内部实现"。
3. Dioxus:类 React 的 Virtual DOM------设计图的另一种实现
Dioxus 其实也采用了设计图模式,只不过它的设计图叫 Virtual DOM(虚拟 DOM),和 React 的概念一脉相承。
// Dioxus 的代码:RSX 宏生成 Virtual DOM
fn app() -> Element {
let mut count = use_signal(|| 0);
rsx! {
h1 { "计数: {count}" }
button { onclick: move |_| count += 1, "+1" }
}
}
rsx! 宏生成的就是一棵"设计图"(Virtual DOM 节点树),Dioxus 的运行时对这棵树做 diff,然后增量更新真实的 DOM 或渲染器。
Dioxus 和 Xilem 的设计图有什么区别?
核心区别在于类型系统的使用方式:
- Xilem 的设计图类型是在编译期通过泛型完全确定的,diff 过程不需要运行时类型检查
- Dioxus 的设计图节点是运行时动态类型(类似 React 的 JSX),diff 过程需要运行时比较节点类型和属性
这是因为 Dioxus 要支持 Web 端(渲染到真实 DOM),而 DOM 天然是动态类型的------你不知道一个节点是
还是 ,要到运行时才能确定。Xilem 只做原生渲染,所有组件类型在编译期就完全确定,所以可以把 diff 逻辑推到编译期。
本质取舍:Dioxus 用"运行时 diff 的开销"换取了"跨 Web/桌面/移动的统一架构"。
4. Slint:DSL 分离------设计图写在另一种语言里
Slint 走了一条完全不同的路。它的设计图不是用 Rust 代码写的,而是用一种专属的 DSL(领域特定语言) 写在 .slint 文件里:
// Slint 的 DSL:设计图写在 .slint 文件中
export component MainWindow inherits Window {
callback clicked;
Text {
text: "Hello World";
color: blue;
}
}
编译时,Slint 的编译器把 .slint 文件编译成原生机器码,直接生成 Widget 树,没有运行时的设计图 diff 过程。
为什么 Slint 不用 Rust 代码写设计图?
两个原因:
- 目标用户不同:Slint 面向嵌入式和工业场景,设计师和前端开发者需要能直接编辑 UI 文件,不应该要求他们写 Rust 代码
- 编译期优化更彻底:DSL 在编译时就能完全确定 UI 结构,编译器可以做激进的优化(内联、死代码消除、布局预计算),生成的代码比运行时 diff 快得多
本质取舍:Slint 用"放弃 Rust 代码描述 UI 的灵活性"换取了"编译期完全优化、运行时零 diff 开销"。
5. GPUI:混合模式------设计图和实物之间的灰色地带
GPUI(Zed 编辑器的 UI 框架)走了一条介于即时模式和保留模式之间的路。它的 render 方法确实生成一个元素树(类似设计图),但这个元素树和最终渲染的 Widget 之间的界限比较模糊:
// GPUI 的代码:混合模式
impl Render for Counter {
fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement {
div()
.flex()
.child(format!("Count: {}", self.count))
.child(div().child("Increment").on_click(cx.listener(|this, _, cx| {
this.count += 1;
cx.notify();
})))
}
}
GPUI 的特点是把大量工作推到了 GPU 上------布局计算、文本排版、渲染都在 GPU 上完成,CPU 端只做最轻量的元素树构建。它不追求 Xilem 那样精细的 CPU 端 diff,而是靠 GPU 的并行能力来暴力解决性能问题。
本质取舍:GPUI 用"GPU 暴力渲染"换取了"CPU 端极简的更新逻辑"。
6. Tauri:WebView------设计图交给浏览器引擎
Tauri 的 UI 是 HTML/CSS/JS,运行在系统 WebView 里。它的设计图就是浏览器的 DOM 树,diff 和更新全部交给浏览器引擎(Blink/WebKit/Gecko)来做。Rust 端只负责后端逻辑,通过 IPC 和前端通信。
Tauri 根本不关心设计图的问题,因为它把整个问题甩给了浏览器------浏览器做了几十年的 DOM diff 和增量渲染,比任何 Rust 框架自己实现的都要成熟。
本质取舍:Tauri 用"依赖系统 WebView 的限制"换取了"不用自己实现 UI 渲染管线"。
四、汇总对比:一张表看清所有取舍
框架 有没有"设计图" 更新策略 为什么不走 Xilem 的路 核心取舍
Xilem ✅ 有,View 对象 编译期泛型 diff,精细增量更新 --- 架构复杂度换性能和类型安全
egui ❌ 没有,每帧直接画 每帧全量重算 即时模式天然不需要设计图 CPU 开销换零状态同步
Iced 有,但更粗粒度 整棵树重建(0.14 后部分优化) 精细 diff 的架构复杂度不值得 简单性换极致性能
Dioxus 有,Virtual DOM 运行时 diff 需要兼容 Web DOM 的动态类型 跨平台统一性换编译期优化
Slint 有,但用 DSL 写 编译期完全优化,无运行时 diff 面向嵌入式/设计师,不能用 Rust 写 UI 灵活性换编译期优化
GPUI 有,但很薄 GPU 暴力渲染 靠 GPU 并行解决性能,不需要精细 CPU diff GPU 开销换 CPU 端简洁
Tauri 有,DOM 树 浏览器引擎做 diff 把问题甩给浏览器 原生能力受限换开发效率
五、回到根本:Xilem 的设计图模式到底值不值?
看完上面的对比,你可能会问:既然其他框架都找到了绕开"设计图"问题的方法,Xilem 为什么还要死磕这条路?
答案是:Xilem 的目标和其他框架不同。
- egui 的目标是简单和快速原型,不需要精细的 diff
- Iced 的目标是结构清晰的中大型应用,粗粒度更新够用
- Dioxus 的目标是跨 Web/桌面/移动统一,必须兼容动态类型
- Slint 的目标是嵌入式和商业闭源,需要编译期优化
- GPUI 的目标是极致渲染性能,靠 GPU 暴力解决
Xilem 的目标是:在纯 Rust 原生渲染的前提下,实现编译期类型安全 + 精细增量更新 + 声明式开发体验。 这个组合是其他框架都没有同时做到的。
为了达到这个目标,Xilem 付出的代价是:
- 极其复杂的泛型类型签名(编译器友好,人类不友好)
- 所有状态必须集中在一个结构体中(不能随意捕获局部变量)
- 框架内部架构复杂(三层树 + diff 引擎)
- 至今仍处于 Alpha 阶段,API 频繁变动
这就是为什么其他 Rust 框架不走这条路------不是不知道这条路好,而是这条路的工程代价太大,和各自的目标不匹配。
六、一个思想实验:如果 Rust 有 GC
为了更深刻地理解这个问题,做一个思想实验:
如果 Rust 有垃圾回收(GC),Xilem 的设计图模式会简单多少?
- 闭包捕获引用不再有生命周期问题------GC 会保证引用有效
- View 对象可以随意持有对其他 View 的引用------不用担心悬垂指针
- 不需要 'static 约束,不需要 use<> 语法
- 类型签名会简单一个数量级
事实上,React(JavaScript,有 GC)、Flutter(Dart,有 GC)、SwiftUI(Swift,ARC 引用计数)之所以能优雅地实现设计图模式,很大程度上是因为它们的语言运行时帮忙管理了内存生命周期。
Rust 没有 GC,所以 Xilem 必须在编译期用类型系统手动解决所有生命周期问题。 这就是为什么 Xilem 的类型签名那么恐怖------它不是故意炫技,而是被 Rust 的所有权系统"逼"出来的。
七、练习题
练习1:架构判断
以下代码片段最可能来自哪个框架?为什么?
rust
// 片段 A
ui.heading("My App");
if ui.button("Click me").clicked() {
self.count += 1;
}
// 片段 B
fn view(&self) -> Column<Message> {
column![
button("+").on_press(Message::Increment),
text(self.value).size(50),
]
}
// 片段 C
fn app_logic(data: &mut Counter) -> impl WidgetView<Counter> + use<> {
flex(Axis::Vertical, (
label(format!("{}", data.num)),
text_button("increment", |data: &mut Counter| data.num += 1),
))
}
练习2:取舍分析
假设你要开发一个"嵌入式工业控制面板"应用,运行在 ARM Cortex-M 处理器上,内存只有 512KB。从 Xilem、egui、Slint 三个框架中选一个,你会选哪个?为什么?
练习3:思考题
Xilem 要求所有状态集中在一个 AppState 结构体中,闭包通过 |data: &mut AppState| 参数访问状态,而不是捕获外部变量。这个设计决定的根本原因是什么?如果 Rust 有 GC,这个限制还需要吗?
八、练习题答案
练习1答案:
- 片段 A 来自 egui。特征:即时模式,ui.button() 当场返回 Response,通过 .clicked() 判断是否被点击,没有 View 对象,没有消息类型。
- 片段 B 来自 Iced。特征:Elm 架构,view 方法接收 &self(不可变引用),返回 Column,按钮通过 .on_press(Message::Increment) 发送消息。
- 片段 C 来自 Xilem。特征:app_logic 接收 &mut AppState(可变引用),返回 impl WidgetView + use<>,闭包直接修改状态 |data: &mut Counter| data.num += 1。
练习2答案:
选 Slint。
原因:
- 512KB 内存的嵌入式环境,Slint 的最低内存占用仅需 300KB,是唯一能在这么小的内存中运行的选项
- Slint 的 DSL 在编译期完全优化,运行时零 diff 开销,CPU 占用极低,适合低功耗嵌入式处理器
- Slint 的 UI 编译为原生机器码,不需要运行时设计图、不需要 GC、不需要 Virtual DOM
- Xilem 的三层架构和 diff 引擎在内存受限环境下开销太大
- egui 每帧全量重算的 CPU 开销在低功耗处理器上不可接受
练习3答案:
根本原因是 Rust 的所有权和生命周期规则。
如果闭包捕获了外部局部变量的引用,那闭包就携带了生命周期参数。当闭包被存储在 View 对象中时,View 对象也被这个生命周期参数约束。整棵 View 树都被生命周期参数污染,类型签名变得极其复杂甚至无法表达。
通过要求所有状态集中在 AppState 中,闭包通过框架传入的 &mut AppState 参数访问状态,闭包本身不捕获任何外部引用,因此闭包是 'static 的,View 对象也不需要携带生命周期参数。
如果 Rust 有 GC,这个限制就不需要了。GC 会自动保证闭包捕获的引用在需要时始终有效,不会出现悬垂指针。闭包可以自由捕获任何外部变量,View 对象可以持有任意引用,类型签名也会简单得多。SwiftUI 和 React 之所以能自由捕获外部变量,正是因为 Swift 的 ARC 和 JavaScript 的 GC 帮它们管理了生命周期。
九、本课知识点总结
- "一切皆设计图"包含两个承诺:UI 是纯函数(State → View Tree),框架自动 diff 增量更新
- Rust 的所有权系统和生命周期机制天然地和设计图模式存在张力,主要体现在闭包捕获的生命周期问题和类型擦除的代价
- egui 走即时模式,根本没有设计图,用每帧全量重算换取零状态同步
- Iced 有设计图但 diff 更粗粒度,用简单性换极致性能
- Dioxus 有设计图(Virtual DOM)但用运行时 diff,因为要兼容 Web DOM 的动态类型
- Slint 用 DSL 写设计图,编译期完全优化,面向嵌入式场景
- GPUI 靠 GPU 暴力渲染,CPU 端不做精细 diff
- Tauri 把设计图问题甩给浏览器引擎
- Xilem 选择设计图模式是为了同时实现编译期类型安全 + 精细增量更新 + 声明式开发体验,代价是架构复杂度和 API 稳定性
- 如果 Rust 有 GC,设计图模式的实现会简单得多,Xilem 的类型签名也不会那么复杂
这一课从底层解释了 Xilem 的设计选择为什么在 Rust 生态中是"少数派"。理解了这些取舍,你对 Xilem 的 API 设计(比如为什么状态必须集中、为什么类型签名那么复杂)就不会觉得莫名其妙了。