开发者发文批评 GPUI 状态模型,称其为复杂桌面应用死路
来源标题:A Critique of GPUI
一位曾将 GPUI Linux 渲染器移植到 wgpu 的开发者发文批评,认为 GPUI 和 egui 的状态模型对复杂桌面应用是死路。文章以 Discord 宣布用 GPUI 重写桌面应用为背景,指出两者虽有适用场景,但不应成为原生桌面应用的默认选择。
以下正文由 AI 翻译自 Hacker News(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
2026年11月8日 20分钟阅读 ui rust gpui signals benchmarks 本页内容唯一真正重要的问题 GPUI:notify 就是 zone.js 让 GPUI 变精确会摧毁 GPUI Elm:在小规模下具有欺骗性的简单 关于即时模式的一点说明 同一个引擎,不同的接线方式 为什么只运行一次 Signals 并不会自动让你的应用变快 结论 方法 参考文献 最近,出现了一股使用原生 Rust GUI 框架构建桌面 GUI 应用的潮流。如今甚至有知名的 Electron 应用在进行官方重写:Discord 宣布其应用正在用 GPUI 重写。¹ 与此同时,许多新发布的 LLM 克隆产品似乎选择了 egui。在这篇文章中,我想解释为什么我不确定这些框架是构建复杂桌面应用的良好基础。
直截了当地说:我认为 GPUI 和 egui 的状态模型对于复杂的桌面应用都是死路一条。两者都是令人印象深刻的项目,也各有其适用之处。Zed 确实让人感觉快速且好用,我自己也在 bevy 游戏里用 egui 做调试界面。但我不认为这些框架应该成为原生桌面应用的默认选择。
利益披露:我把 GPUI 当前的 Linux 渲染器移植到了 wgpu²,我搭建了下面用到的基准测试工具,并且我在开发 zgui,一个基于 signal 的 Rust 框架,它出现在图表中。
Raph Levien 引用了 Ron Minsky 的话:“每个 UI 框架内部都隐藏着某种增量计算框架。”³ UI 是状态的函数,而状态一次只变化一点点。但如果这个函数变得昂贵,你可能就需要寻找方法,从增量的状态变化中推导出更快的屏幕像素更新方式。所以每个框架都必须回答一个问题:当一个值变化时,什么会运行?
我理解人们为什么喜欢前三种方式。不需要同步状态,状态只存在于一个地方,渲染函数就是普通的 Rust,没有宏,也没有单独的编译器。我要论证的是,这些方式无法扩展,而修复底层问题又会摧毁这些模型之所以简单、易于推理的原因。
GPUI 自称是“混合即时模式和保留模式”的框架。⁴ 它在 entity 中保留状态。UI 在 render 函数中定义,改变状态必须触发重跑。
impl Render for Sidebar {
fn render(
&mut self,
_: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let inbox = self.inbox.read(cx);
div()
.child(format!("{} unread", inbox.unread()))
.children(inbox.threads.iter().map(|t| t.title.clone()))
这就是普通的 Rust。你可以使用 if、for 和局部变量,没有任何奇怪的包装。状态变化需要显式通知:
self.threads[i].unread = false;
cx.notify();
notify 携带 entity id。⁵ 由于 Rust 对 &mut 没有写屏障,GPUI 无法看到哪个字段变了(甚至无法看到是否有任何变化)。Angular 团队在 2023 年这样评价 zone.js:它“只能在我们可能发生了某事时通知我们”,并且“这种方式扩展性不佳”。⁶ notify 比 zone 更精确,因为它指明了 entity,但归根结底它做出的是同一类声明。在那个 id 之下,GPUI 一无所知,所以它会重新渲染每一个不在有效 .cached() 边界之后的视图。而在实践中,GPUI 往往会重新渲染每一个视图,因为应用作者很少使用 .cached(),而且它会施加某些布局语义上的限制。
GPUI 有“快”的名声。虽然常数可能相对较低,但从渐近角度看,它还有很多潜力没有发挥出来。所以我跑了一些基准测试,专门测试在大 UI 中一个稀疏的状态变化到底要花多少成本。在有 3,000 行活跃行、每行一个 cached 视图的情况下,改变一个标签需要 8.4 ms。悬停、按键和写入一个与旧值相等的值,成本都差不多,因为成本跟随的是树,而不是变化。它每 1,000 行活跃行大约增长 3 ms。如果像上面的代码那样把行写成普通元素,同样的标签更新需要 92 ms。一个手写的相等性检查把相等写入从 7.9 ms 降到 0.9 µs(这说明每个调用点还有多少工作要自己去找)。
这并不意味着每个 GPUI 应用都慢,但它展示了状态模型的极限。尤其是在布局更昂贵的情况下,实践中你确实会撞到这堵墙。Zed 可能感觉很快,但事实是,Zed 在 UI 方面是一个相对简单的应用。应用的界面框架包含几个按钮,布局简单,一个侧边栏和一个文本编辑器,而文本编辑器是作为自定义元素实现的,完全绕过了 GPUI 的布局和视图语义。根据他们自己的说法,一个窗口“有几十个节点和几千个元素”。⁷
显而易见的修复方法是保留更多东西。Zed 为此有实验性分支,而 Longbridge 的 gpui-fast 是一个 fork,它在帧之间保留每个视图的子树。⁸ ⁹ 它在一定限度内有效:两者能复用的最小单位是一个视图。所以要在不重建侧边栏的情况下更新一个标签,这个标签必须是一个视图:
pub struct UnreadBadge { unread: usize, _watch: Subscription }
impl UnreadBadge {
pub fn new(inbox: Entity<Inbox>, cx: &mut Context<Self>) -> Self {
let unread = inbox.read(cx).unread();
let _watch = cx.observe(&inbox, |this, inbox, cx| {
let unread = inbox.read(cx).unread();
if unread != this.unread {
this.unread = unread;
cx.notify();
}
});
Self { unread, _watch }
}
}
impl Render for UnreadBadge {
fn render(
&mut self,
_: &mut Window,
_: &mut Context<Self>,
) -> impl IntoElement {
format!("{} unread", self.unread)
}
}
然后它的所有者必须创建它并保留句柄:
let badge = cx.new(|cx| UnreadBadge::new(inbox.clone(), cx));
这就是一个 entity、一个 Render 实现、一个订阅、一个存储的值副本、一个手写的守卫和一个所有者,全都只是为了表达一个标签依赖于一个计数。在 signal 框架中,这只需要:
let unread = Memo::new(move |_| {
threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count())
});
Label::derived(move || format!("{} unread", unread.get()))
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。