开发者撰文批评GPUI与egui状态模型不适合复杂桌面应用
来源标题:A Critique of GPUI
一位开发者发文认为GPUI和egui的状态模型对复杂桌面应用是死路,尽管Zed用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 重写。1 与此同时,大量新发布的 LLM 克隆产品似乎都选择了 egui。在这篇文章里,我想解释为什么我不确定这些框架是构建复杂桌面应用的良好基础。
说白了:我认为 GPUI 和 egui 的状态模型对于复杂桌面应用来说都是死路。两者都是令人印象深刻的项目,也各有其用武之地。Zed 确实感觉很快、很好用,我自己在 bevy 游戏里也用 egui 做调试界面。但我不认为这些框架应该成为原生桌面应用的默认选择。
声明:我把 GPUI 当前的 Linux 渲染器移植到了 wgpu2,我构建了下面用到的基准测试工具,我也在开发 zgui——一个基于 signal 的 Rust 框架,它出现在图表中。
Raph Levien 引用 Ron Minsky 的话:“每个 UI 框架内部都藏着某种增量化的框架。”3 UI 是状态的函数,而状态是一次一点地变化的。但如果这个函数变得昂贵,你可能就要开始寻找方法,从增量的状态变化中推导出更快的方式来更新屏幕上的像素。所以每个框架都必须回答一个问题:当一个值发生变化时,什么会运行?
我理解人们为什么喜欢前三种(做法)。没有需要同步的状态,状态只存在一个地方,渲染函数就是普通的 Rust 代码,没有宏,也没有单独的编译器。我要论证的是,这些方法无法扩展,而修复底层问题又会破坏这些模型之所以简单、易于推理的特点。
GPUI 自称是“即时模式和保留模式的混合体”框架。4 它把状态保留在 entity 中。UI 在渲染函数中定义,改变状态必须触发重新运行。
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。5 由于 Rust 在 &mut 上没有写屏障,GPUI 无法看到哪个字段变了(甚至无法看到是否有任何变化)。Angular 团队在 2023 年这样评价 zone.js:它“只能在我们可能发生了某些事情时通知我们”,而且“这种方法扩展性不好”。6 notify 比 zone 更精确,因为它指明了某个 entity,但归根结底它做出的是同一类陈述。在那个 id 之下,GPUI 一无所知,所以它会重新渲染所有不在有效 .cached() 边界之后的视图。而在实践中,GPUI 常常会重新渲染每个视图,因为 .cached() 很少被应用作者使用,而且它对布局语义施加了某些限制。
GPUI 有“快”的名声。虽然常数可能相对较低,但从渐进复杂度来看,它浪费了很多。所以我跑了一些基准测试,专门测试大型 UI 中一次稀疏状态变更实际要花多少代价。在 3,000 行活跃数据、每行一个 cached 视图的情况下,改变一个 label 需要 8.4 ms。一次 hover、一次按键,以及写入一个与旧值相同的值,代价都差不多,因为代价跟随的是树,而不是变更本身。它大约每 1,000 行活跃数据增长 3 ms。如果把行写成普通元素,就像上面代码那样,同一个 label 需要 92 ms。一个手写的相等性守卫把相等写入从 7.9 ms 降到 0.9 µs(这说明每个调用点还有多少东西需要自己去找)。
这并不意味着每个 GPUI 应用都慢,但它展示了状态模型的极限。尤其是在布局更昂贵的情况下,实践中你确实会撞上这堵墙。Zed 可能感觉很快,但事实是,Zed 在 UI 方面是一个相对简单的应用。它的界面外壳只包含几个按钮、一个简单布局、一个侧边栏和一个文本编辑器,而文本编辑器是作为自定义元素实现的,完全绕过了 GPUI 的布局和视图语义。按他们自己的说法,一个窗口“有几十个节点和几千个元素”。7
显而易见的修复办法是保留更多东西。Zed 为此有实验性分支,Longbridge 的 gpui-fast 是一个 fork,它在帧之间保留每个视图的子树。8 9 它在一定限度内有效:两者能复用的最小单位都是视图。所以,要想在不重建侧边栏的情况下更新一个 label,这个 label 必须是一个视图:
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 实现、一个订阅、一个存储的值副本、一个手写守卫和一个所有者,全都只是为了表达这一个 label 依赖于一个计数。而在 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。