< 返回版块

chh-itt 发表于 2026-07-23 16:26

Tags:GUI,Signal,所有权,异步,框架

做一个 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 都没有原语级支持的:

  1. Drop——值在离开作用域时自动执行清理逻辑。不需要 try-finally,不需要 defer,不需要 GC。
  2. ownership——每个值有唯一的所有者,所有者的生命周期决定值的生命周期。引用不会悬垂,借用检查器在编译期就给你保证了。
  3. 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 移除时,LifecycleComponentDrop——所有订阅闭包自动退订。

不需要手动取消订阅。不需要 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)共享同一套 Painter API,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,或者只是想聊聊,都可以。

项目地址:github.com/chh-itt/burin

评论区

写评论

还没有评论

1 共 0 条评论, 1 页