> ## Content Index
> Fetch the complete content index at: https://www.hubwiz.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# 对 GPUI 的批评：它显得快，是因为 Zed 的界面太简单
- URL: https://www.hubwiz.com/blog/gpui-notify-is-zonejs-critique/
- Published: 2026-11-10T06:53:32.000Z
- Updated: 2026-11-10T10:34:05.000Z
- Description: GPUI 显得快，是因为 Zed 的界面本来就简单。改一个标签 8.4 毫秒，改成相同的值也一样——成本跟着树走，不跟着变化走。
- Author: admin
- Tags: Rust, GUI, GPUI, 框架设计, 性能

Zed 的界面框架 GPUI 最近名声很响：2026 年 11 月 4 日，Discord CTO 宣布桌面客户端要用它重写。与此同时，一批 LLM 客户端克隆出的大多选了 egui。Rust 原生 GUI 看起来要成气候了。

zortax 在 11 月 8 日泼了盆水，标题就叫《A Critique of GPUI》。他给出的数字很具体：**3,000 行实时列表上改一个标签，因为有 per-row 的 cached view，要 8.4 毫秒；把行写成普通元素，同样的改动要 92 毫秒**。

更扎人的是这句话：悬停、敲一个键、写入一个和旧值相同的值，耗时**都一样**——**成本跟着树走，不跟着变化走**。

而按他的说法，Zed 之所以显得快，是因为**Zed 在 UI 上本来就简单**：窗口外壳只有几个按钮和一个侧栏，真正贵的编辑器是自绘元素，绕开了 GPUI 的布局与 view 语义。

他把原因归到一行 API：`notify` 只告诉框架"这个实体的某个字段可能变了"，和 Angular 2023 年亲手拆掉的 zone.js 是同一类陈述，只是精度高一层。

> **披露（原文有，我们照译）**：作者把 GPUI 当前的 Linux 渲染器移植到了 wgpu，并搭建了下面这套 benchmark；他自己在写 zgui——一个基于 signal 的 Rust 框架，会出现在图表里。

## 一、所有 UI 框架最终都要回答同一个问题

Raph Levien 引用 Ron Minsky 那句话：**每个 UI 框架里都藏着某种增量计算框架**。UI 是状态的函数，而状态一次只变一点。一旦这个函数变贵，你就得找一条"从增量变化更快算出像素"的路。

所以每个框架都必须回答：**一个值变了，什么会跑？**

| 模型                              | 一次写入会跑什么           | 谁负责找出变化        |
| ------------------------------- | ------------------ | -------------- |
| 即时模式（egui）                      | 下一帧的全部 UI 代码       | 没人             |
| Elm（Iced）                       | 全部 view，然后做一次 diff | diff           |
| Views（GPUI）                     | 所有没有 cache 的 view  | **你**，靠 notify |
| Signal + 重跑（Freya、Compose）      | 读了这个值的组件           | 框架             |
| Signal + 只跑一次（Floem、Solid、zgui） | 读了这个值的闭包           | 框架             |

前三种为什么讨人喜欢，不难理解：没有要同步的状态、状态只有一处、`render` 就是普通 Rust、没有宏也没有额外编译器。作者要论证的是：**它们不随规模走**，而想修好底下的问题，就得把让它们"简单好懂"的那点东西拆掉。

## 二、GPUI：`notify` 就是 zone.js

GPUI 自称"即时模式与保留模式的混合体"：状态存在 entity 里，UI 写在 `render` 函数里，改状态必须触发重跑。

```rust
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` 和局部变量，没有奇怪的包装。改状态要显式通知：

```rust
self.threads[i].unread = false;
cx.notify();          // 只带 entity id

```

Rust 对 `&mut` 没有写屏障，所以 GPUI **看不见哪个字段变了，甚至看不见有没有变**。Angular 团队 2023 年写 zone.js 的话可以原样搬过来：它"只能通知我们某件事**可能**发生了"，而"这种做法没有随规模走"。

`notify` 比 zone 精确——它点名了一个 entity。但在这个 id 之下，GPUI 一无所知，于是它重渲染所有不落在有效 `.cached()` 边界后面的 view。实践中往往就是**重渲染每个 view**，因为应用作者很少用 `.cached()`，而且它对布局语义有限制。

于是有了开头那组数字：3,000 行、每行一个 cached view，改一个标签 **8.4 ms**，并且以**每 1,000 行约 3 ms** 的速度线性上涨；写成普通元素 **92 ms**；手写一个相等守卫，把"写入相同的值"从 7.9 ms 降到 **0.9 µs**——这正好说明每个调用点还剩多少活要人干。

> 这不等于每个 GPUI 应用都慢。但按作者的说法，Zed 本身在 UI 上是个"相对简单"的应用：窗口 chrome 就是几个按钮、一个侧栏、一个编辑器，而编辑器是自绘元素，**完全绕开了 GPUI 的布局与 view 语义**。按 Zed 自己的说法，一个窗口"有几十个节点、几千个元素"。

## 三、把 GPUI 改精确，GPUI 就没了

最直接的修法是"多保留一点"。Zed 有实验分支，Longbridge 的 gpui-fast 是保留每帧 view 子树的 fork。它们有效，但有天花板：**能复用的最小单位是 view**。

为了让一个标签更新而不重建侧栏，这个标签就得**升格成一个 view**——需要 entity、`Render` 实现、订阅、值的一份副本、手写守卫和一个持有者。而在 signal 框架里，它是两行：

```rust
let unread = Memo::new(move |_| threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count()));
Label::derived(move || format!("{} unread", unread.get()))

```

麻烦不止于此。`Render` 不带 props，所以父级要在自己的 render 里把值推进子 entity；`RenderOnce` 组件（按钮、行就是它）画在父 view 里，**根本不是边界**；列表行是闭包，想要"行级 view"就得自己维护一张按 key 索引的 entity map。

而且它只在"状态刚好和 view 对齐"时有效。作者重跑了 gpui-fast 自己的 60 面板 benchmark：每帧画一次，再移动状态——**状态归各面板自己持有时省 83%；换成共享模型，保留反而慢 11%**。窗口和编辑内容完全一样。

Zed 团队自己的测量更值得看：保留在"普通负载上慢 1–3%"，"真实 Zed 上的大部分收益"来自别的工作，而且"微基准的胜利没有预测真实 Zed 的结果"。gpui-fast 把 3,000 行的标签从 8.4 ms 降到 3.4 ms——但那一帧只渲染三个 view、布局一个元素，仍然要 3.4 ms，因为**这一帧还是在遍历整棵树**。

Web 上早走过这条路。Redux 风格指南要求"让更多组件订阅 store、在更细的粒度读数据"，MobX 要求"造很多小的 observer 组件"。两家都在让你**手工**给每个投影划边界，因为框架推不出来。

这是作者最难接受的地方：人选 GPUI，是因为 `render` 是状态的普通函数。而一个"每个投影都是独立 entity + 订阅 + 守卫"的 UI，已经不再是那个东西了——它是用更重的零件手工搭出来的 signal 图，**漏一条边是静默的**。Zed 自己的设计文档写着："漏掉的 notify 是应用 bug。"

## 四、Elm：小规模时好得过分

在最常见的展示规模上，Elm 架构简单得漂亮：一个状态、一个 `update`、一个 `view`，每次变化都有名字。大型应用确实有用它成功的，但作者要指出：**这种清晰属于小规模**。

还拿 `unread` 计数举例。它必须在每条消息后都正确，那就只有两条路：`view` 每次重算，或者 `update` 自己维护。维护的写法是这样：

```rust
match message {
    InboxMessage::MarkRead(i) => {
        if self.threads[i].unread { self.threads[i].unread = false; self.unread -= 1; }
    }
    InboxMessage::Delete(i) => {
        if self.threads.remove(i).unread { self.unread -= 1; }
    }
    InboxMessage::Arrived(thread) => {
        self.unread += thread.unread as usize;
        self.threads.push(thread);
    }
}

```

`unread` 的定义现在**不在任何具体位置**：它散落在每一个碰过 thread 的分支里，每加一种消息都得记得改。编译器会检查 `match` 穷尽，**但不会检查这几个算式**。而 memo 只有一处定义，新的写入方忘不掉它。

位置状态同理：悬停态、打了一半的草稿放哪？单一数据源说放 model 里，向下发一个 message 类型，向上回一个结果。Iced 维护者自己也说这套架构里的组件"最终总是以痛苦收场"，同时给了最好的辩护：不这么做"会逼你认真想清楚数据模型"。

但 cosmic-files 有 **136 个顶层 message 变体**，一个 tab 还有 73 个，外加一个把命令返回给父级解释的 `Tab::update`。全都是显式的，**没有一样是简单的**。

性能上同理：Iced 每批消息之后重建整个 `view`，3,000 行改一个标签会构建 **39,023 个 widget、耗时 7.3 ms**；每行加 `Lazy` 只降到 **5.7 ms**，因为 `view` 还是要逐行去 hash key。官方 `lazy` 示例的 key 是一个 `version: u8`，由四个 `update` 分支手工递增——**那就是一个改了名字的脏标记**。

公平地说：cosmic-files 和 Halloy 根本没用 `lazy`，而在**虚拟化列表**上 Iced 是作者测过最快的框架之一（**0.19 ms**）。问题不在常数，在渐近复杂度。

## 五、即时模式：一句话

egui 的卖点全行业最好："你不必担心应用状态和 GUI 状态不同步。" 对工具类界面，这几乎无敌。

代价是：**一帧的成本等于 UI 的大小，与"变了什么"无关**。3,000 行下每次小编辑约 **32 ms**，关掉无障碍约 **19 ms**。egui 作者对这个取舍很坦率：即时模式"更好用，但更弱"。

应用一变大，最初的吸引力就没有了。布局悖论被作者称为"根本缺陷"：长列表要用 `show_rows`，而它要求**固定行高**。已知最大的 egui 应用 rerun，靠"检查上一帧的 egui 状态"来夹逼查询。任何昂贵的东西都要塞进一个按你的数据做 key 的缓存——**而一个必须和数据保持一致的缓存，正是即时模式想消灭的同步问题**，只是挪到了应用代码里，还晚一帧。

## 六、同一个引擎，不同接法

这套 harness 能直接证明这一点，因为有些框架可以用粗、细两种方式接同一个引擎：

- **Iced 的粗细比很小**，因为 `view` 函数仍然要跑。
- **Floem 的粗粒度案例值得一看**：每个 cell 都去读一个全局 revision signal——**那就是穿了 signal 外套的 `notify`**。它是图里最慢的结果。

所以：**signal 救不了粗糙的状态**。React 团队从另一侧得到同样结论：在"一个巨大输入"的 benchmark 上，signal 风格运行时令人失望，Joe Savona 的结论是数据模型"同样重要，也许更重要"。**状态得长成读取的形状**——这也是作者管它叫"状态模型问题"的原因。

## 七、为什么"只跑一次"

假设你接受上面全部论证：要框架跟踪读取，要状态匹配读取。那最简 API 长什么样？

```rust
pub fn sidebar(threads: RwSignal<Vec<Thread>>) -> impl IntoView {
    // 只在侧栏挂载时跑一次
    let unread = Memo::new(move |_| threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count()));
    Stack::vertical((
        Label::derived(move || format!("{} unread", unread.get())),
        dyn_stack(move || threads.get(), |t| t.id, thread_row),
    ))
}

```

函数体只跑一次；交给框架的闭包在**它读的值变了**时重跑。局部变量就是局部变量：没有 hook 规则、没有依赖数组、没有过期闭包、props 上没有 `PartialEq`，也没有东西去重建"行的身份"——因为行还在那。

对照 Freya 的"重跑组件"模型：它跑得很好，3,000 行下一次按键 **1.2 ms**。但看一个计数器就知道代价：

```rust
#[derive(PartialEq)]
struct Counter;

impl Component for Counter {
    fn render(&self) -> impl IntoElement {
        let mut count = use_state(|| 0);   // 因为这一行会再跑一次，所以需要它
        // ...
    }
}

```

`use_state` 的存在，是因为那一行会再跑。你现在同时拥有两个模型：读取跟踪决定哪个作用域重跑，然后**作用域内部**又是重渲染语义（hook、prop 相等、diff）。想更细就得拆更小组件——这正是 GPUI 的问题，只是价格低得多。

Dan Abramov 的反驳很有说服力：只跑一次把渲染逻辑塞进闭包，**顶层控制流就用不了了**，代码会围着值而不是围着逻辑组织，"React 的美在于把东西变成 computed 是优化，不是必须"。

作者认这一点，但也认另一半：那种重组和 `useMemo` 优化时的重组是同一件事，差别只是**什么时候付**——一开始，还是以后在 profiler 下面。Ryan Carniato 的回应是他站的那边："在 Solid 里，你永远按'数据是派生的'来写，所以不用为了优化去重构。"

而且失败模式偏向只跑一次：**忘了挖的洞，会在第一次更新时就在你写它的地方变旧**；而**忘了 notify，取决于别的地方碰巧重绘了什么**。

## 八、signal 不会自动让你的应用变快

细粒度响应式本身不保证快。作者逐个点名（同一台机器、同一套 harness）：

- **Floem** 的响应式完全兑现承诺：改一个标签跑 5 个 effect、**0.01 ms**，然后花约 **14 ms** 在布局上，因为 Taffy 不知道有一个叶子动了（Floem 还有个已知 bug：这次编辑从不重绘，所以只能当地板看）。
- **Vizia** 跑 4 个绑定，然后遍历 **120,091 个 entity**，**46 ms**。
- **Slint** 写入相同值 **4 µs**，改一个标签 **172 ms**，那一趟走了约 30,000 个 item。
- **Freya** 产生 5 次变更，然后访问 **57,050 个布局节点** 重算 14 个。
- 这次编辑上，**GPUI 打败了其中大多数**。

这正是论证的关键区分：这些线性成本**在引擎里**，应用代码并不要求它们，所以可以在同一套 API 后面修掉。zgui 展示了能修到哪：3,000 行标签 **0.69 ms**，每 1,000 行约 **0.26 ms**——比 GPUI 平约 **12 倍**（这是作者自己的框架、在这套 harness 上调过，数字打个折）。

而 Iced 和 egui 的线性成本**写在契约里**：API 本身就说"你的代码会跑"。引擎修不掉，应用必须改。GPUI 大概在两者之间——这也是 gpui-fast 能改善一部分的原因。

代价还有三处：

- **创建**：3,000 行下做过滤，Iced **56 ms**、egui **67**、GPUI **123**、zgui **371**、Floem **835**、Freya **982**（虚拟化列表：Iced 1.0 ms、zgui 7.2 ms）。"建依赖图不是免费的"。
- **内存**：保留得越多，大场景的常驻内存越高。
- **Effects**：Facebook 放弃双向绑定，就是因为"级联更新"变得难以预测；现代依赖图无毛刺，但随手写的 effect 照样能造出反馈环。

Rust 这一侧也有摩擦：signal 依赖 arena 句柄和 `move` 闭包，Levien 认为这种模式"和 Rust 适配得不好"。作者认为够用（GPUI 的 entity 也是 arena 句柄），但也承认不是白吃的午餐。

## 九、原文结论

GPUI 让"一整帧"相对便宜，这让它对很多场景**够用**——对一个昂贵表面被手工接管的编辑器，当然够。但对大型应用它留了一道选择题：**要么保留大家喜欢的 API、按 view 付费；要么用 entity 手工搭一张 signal 图换精确度。** Elm 与即时模式有同样的问题：各自在小规模最清晰，而一旦精确度变成必需品，各自丢掉当初被选中的理由——按作者的经验，**这比人们预期来得更快**。

跟踪读取是行业大多数最终落脚的地方。如果要去那儿，那就从那儿开始，并把组件做成**只跑一次的函数**。

## 十、我们的整理：什么时候够用，什么时候别赌

以上是译文，下面是我们加的判断。

| 你的场景                            | 够用的选择                           | 别赌的选择                             |
| ------------------------------- | ------------------------------- | --------------------------------- |
| 工具面板、调试界面、内部小工具（一屏几十到几百个元素）     | **egui**：同步问题直接消失，改一行就是一行       | 别为它写长列表，show\_rows 固定行高会先咬人       |
| 中等应用，列表 < 1,000 行，逻辑分支多、想要显式状态机 | **Iced / Elm**：状态可见，测试好写        | 别指望它的性能随规模走：7.3 ms 是 3,000 行，不是常数 |
| 编辑器、终端这类"昂贵表面自己画"的产品            | **GPUI**：只要你的重活不在 GPUI 的布局里     | 别把 GPUI 用在"整屏都是 GPUI 元素"的后台管理界面上  |
| 大表格、大列表、实时刷新的看板                 | **signal + 只跑一次**（zgui/Solid 系） | 别用它图省事：创建依赖图有成本，粗粒度用 signal 反而最慢  |

三条可以拿去问框架（或者问自己）的判据：

1. **你的列表会不会超过 1,000 行？** 会，就先按每 1,000 行 3 ms 去估算 GPUI 这类"按树付费"的框架。
2. **你改的那个值，和"贵"的那一层在不在同一个粒度上？** 改一个标签触发一次全布局，问题就不在状态模型而在引擎——那种情况换框架也救不了。
3. **你愿意手写多少个"边界"？** 每个 `cached()` / 每个行级 view，都是一次手工增量。超过十几处，说明你在用重零件搭 signal 图。

**边界**：这里的数字全部来自作者的 harness，同一台机器（i9-13900K / RTX 3080 Ti / Vulkan / Linux），只算 apply 与 settle 的中位数，**不含 Windows、合成器与输入**；框架适配器未经各框架维护者审查；作者是 zgui 作者，且在这套 harness 上调过它。所以**结论的方向可以信，绝对数字别跨平台套用**。

对我们做工业界面的人还有一条额外的：PLC/上位机界面里"贵"的往往是通信与刷新频率，不是控件树。3000 行的表格在工业场景不常见，但**每秒刷新 20 次的点位表**很常见——这种场景下，先解决数据层的节流，比挑框架重要一个数量级。

---

**来源**

- zortax，《A Critique of GPUI》，2026-11-08：[https://blog.zortax.de/posts/a-critique-of-gpui](https://blog.zortax.de/posts/a-critique-of-gpui?ref=hubwiz.com)（原文 20 分钟阅读，含完整 benchmark 矩阵与方法说明；本文按段翻译，删去部分代码细节，保留全部关键数字）
- 原文引用的关键出处：Angular《Sub-RFC 1: Signals for Angular Reactivity》(2023-04-03)、Redux Style Guide、Raph Levien《Xilem: an architecture for UI in Rust》与《The case for signals》(Levien 对 Rust 适配性的质疑即出于此)、Zed `gpui-retained-plan` 分支文档《retained\_extraction\_plan.md》(2026-10-30)、Longbridge `gpui-fast`、Vizia《Migration Guide: Lens-Based State to Signals》(2026-03)、Dan Abramov 与 Ryan Carniato 关于"只跑一次"的往复。
- 数字口径与版本：GPUI `ddc9eda925`、gpui-fast `86a8228229`、zgui `082337db`；2026 年 9–11 月三轮取最好的一次；标记 † 的格子未通过正确性检查（Floem 的标签与按键编辑、Slint 的字号）。

**相关阅读**：[Blender 的 UI 抽成库后：跑同一份 gpui 代码，首帧快 5 倍](https://www.hubwiz.com/blog/bui-blender-ui-library-vs-gpui/)——同一份 gpui 源码，换引擎后首帧 40 ms 对 231 ms，可以看清"引擎常数"与"状态模型渐近"是两件事。