返回资讯中心
外部精选
软件工程
#社区热议

开发者发文批评 GPUI 状态模型,称其为复杂桌面应用死路

来源标题:A Critique of GPUI

一位曾将 GPUI Linux 渲染器移植到 wgpu 的开发者发文批评,认为 GPUI 和 egui 的状态模型对复杂桌面应用是死路。文章以 Discord 宣布用 GPUI 重写桌面应用为背景,指出两者虽有适用场景,但不应成为原生桌面应用的默认选择。

Hacker NewsByteAtATime2026/11/9 · 今天30 分钟阅读

以下正文由 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →