做一个 GUI 框架,最难的不是画像素,而是回答一个问题——
"状态变了,谁该更新?"
这是所有 GUI 框架的共同敌人。过去四十年,每一代框架都在用不同的方式跟它搏斗。
Win32 / Swing / Qt 选择命令式手动更新——状态变了,你负责找到每一个依赖它的 UI 元素并手动更新。没有 diff 开销,但心智负载随应用规模指数增长。调了一个变量,忘了更新对应按钮的 enabled 状态——这类 bug 每个人都被咬过。
Flutter / SwiftUI / Jetpack Compose 选择声明式树 diff——你声明 UI 应该长什么样,框架在运行时比较新旧树,算出最小更新。状态同步自动化了,但 diff 本身的开销不可预测,而且整个对账过程是个黑盒——你不知道哪个元素脏了,框架替你决定的。
Slint 选择编译时依赖分析——编译器在编译期消解 property binding 之间的依赖关系,生成直接更新的代码。运行时不追踪依赖,效率极高。代价是 UI 必须用 .slint 文件编写,不能纯 Rust 组合,而且运行时动态性受限。
Iced 选择 Elm 架构——Model → update(Msg) → view() 的闭环,类型安全的消息分发。但在 Rust 里,消息枚举必须集中定义,每个新 widget 都要往同一份枚举里加变体。小应用优雅,大应用单点耦合。
Xilem 选择视图树重建 + 手动 diff——每次状态变化重跑整个 app 逻辑,生成新视图树,然后每个 View 的 rebuild() 方法逐字段比较新旧值。这套机制本身没问题,但它是在 Rust 里重现 React/SwiftUI 的思路。
这些全都是正确答案。但它们都在回答同一个问题:"怎么把某个已有的 GUI 范式搬进 Rust?"
我换个问题试了试——
"如果不用 Rust 去适配某个已有的 GUI 范式,而是让 GUI 范式从 Rust 中长出来,会长什么样?"
三个月后,Burin 出现了。
三个东西:Drop、ownership、async
Rust 有三个东西,是 C++ / Dart / Swift / Kotlin 都没有原语级支持的:
Drop——值在离开作用域时自动执行清理逻辑。不需要try-finally,不需要defer,不需要 GC。- ownership——每个值有唯一的所有者,所有者的生命周期决定值的生命周期。引用不会悬垂,借用检查器在编译期就给你保证了。
async/.await——异步任务的自然暂停点。配合 executor,延迟回调自动批处理,天然避免回调重入。
这三个放在一起,暗示了一套 GUI 架构——
信号 → 脏标记 → 渲染:一条没有岔路的管线
Burin 的核心机制只做了一件事:
把 Signal 的订阅闭包和 Element 的脏标记直接焊在一起。
挂载时
当一个元素绑定到 Signal,订阅闭包在创建时就已经知道了三样东西:
- 目标
ElementId - 要设置的脏标记(
REPAINT) - 信号本身
signal.subscribe(|| register_dirty(element_id, REPAINT))
这不是"通过依赖图查找谁依赖了我"——闭包在创建时,目标 ElementId 是写死的。
Signal 写入时
Signal::set() 触发,遍历 subscriber 列表,调用每个闭包。每个闭包直接就位——register_dirty(element_id, REPAINT),一步到位。
不查图。不遍历。不拓扑排序。
整个过程从 Signal 写入到脏标记到位,是一条直线:
Signal::set()
→ register_dirty(O(1))
→ process_dirty_set(O(k) 祖先上行,在 containment boundary 停止)
→ Taffy 增量布局(4 路径:MEASURE / REPOSITION / 跳过)
→ SubtreeCache 检查(未变更子树直接回放)
→ 仅绘制脏子树
→ GPU(wgpu)或 CPU(tiny-skia)
每一层都知道什么变了、什么没变。没变的完全跳过。
元素销毁时
Element 持有 LifecycleComponent,里面存着所有订阅句柄。元素从 arena 移除时,LifecycleComponent 被 Drop——所有订阅闭包自动退订。
不需要手动取消订阅。不需要 GC。不需要图遍历。
Signal 只管推,Element 只管活,活够了就走。一切订阅关系在编译期已经被 Rust 的所有权模型静态化了。
那 Flutter 是怎么做的?
写完这套机制后,我不放心——是不是我想多了?我去读了 Flutter、Slint、Xilem 的源码(对,本文的每个描述都是源码验证过的)。
Flutter 的 BuildOwner._dirtyElements + PipelineOwner._nodesNeedingLayout 用的是一个完全相同的脏标记模型:O(1) 标记 + O(k) 处理 + relayout/repaint boundary 短路。
Slint 的 Property<T> 在运行时通过 thread-local CURRENT_BINDING 自动注册依赖,和 Burin 的 Signal 订阅底层机制几乎一致。
Xilem 的 View::rebuild() 做的是逐字段 diff,但它的下层 Masonry 有一个保留模式 widget 树,脏标记系统也是同样的 O(1) 标记 + O(k) 处理。
三者殊途同归。
区别不在于"谁更先进"——
- Flutter 靠 Google 工程团队多年迭代,百万行代码
- Slint 靠 DSL 编译器生成使用
Property<T>的代码 - Burin 用 Rust 的 Signal + ownership + Drop,15 万行
这不是设计上的巧思。是这三种语言的能力不同,导致了工程成本的数量级差距。
代码长什么样
纯 Rust,无 DSL,无宏 DSL,无代码生成。IDE 的补全、跳转定义、重构全部开箱即用:
use burin::prelude::*;
use auralis_signal::Signal;
fn counter() -> impl Widget {
Compositor::new(|_scope| {
let count = Signal::new(0i32);
let label = Signal::new("0".to_string());
VStack::new()
.gap(12.0)
.push(Text::new("Counter").font_size(24.0))
.push(Text::new("0").bind(label.clone()).font_size(48.0))
.push(Button::new("+1").on_click({
let c = count.clone();
let l = label.clone();
move || {
let n = c.read() + 1;
c.set(n);
l.set(n.to_string());
}
}))
})
}
fn main() {
App::new()
.window(WindowConfig::default(), counter())
.run()
.unwrap();
}
目前能做什么
- 60 个内置 Widget:布局(VStack / HStack / Grid / ScrollView / SplitPane...)、输入(Button / TextInput / Checkbox / Slider / ComboBox...)、显示(Text / Image / Table / Tree / Chart...)、覆盖层(Modal / Popover / Tooltip / ContextMenu...)
- 双渲染后端:wgpu(GPU)和 tiny-skia(CPU)共享同一套
PainterAPI,GPU 不可用时自动回退 - 手势竞技场:7 种 Recognizer(Tap / Drag / EagerDrag / LongPress / DoubleTap / Scroll / Custom),一个 PointerDown 不会同时触发点击和拖拽
- Material 3 主题:HCT 色彩引擎,单种子色 → 完整亮/暗调色板
- TestHarness:无窗口全帧测试,快照回归,录制回放
- DevTools:运行时检查运行中 GUI 的 Signal 状态、元素树、性能面板
不适合的场景
- 快速原型 → 用 Egui。即时模式的简洁无法被保留模式超越。
- 嵌入式 / MCU → Slint 的编译时优化 +
#![no_std]是正确答案。 - 已有 Dart/JS 团队的跨平台项目 → Flutter / React Native 生态不可替代。
还在早期
Burin 是我和 DeepSeek 共同开发的——三个月,15 万行 Rust,从 Signal 到 GPU 一条管线。能跑,但不代表它不会出错。
如果你是 Rust 开发者,觉得这个方向有意思,欢迎来试试。也欢迎指出哪里还不成熟——issue、PR,或者只是想聊聊,都可以。
评论区
写评论还没有评论