第 4 章:GPUI 的 Entity 世界——状态、事件与异步任务
GPUI 最重要的抽象不是
div(),而是“所有可变应用状态都在 App 所有的实体表中,由带能力的 Context 读写”。渲染只是这套运行时的一种消费者。
1. 为什么不是 Arc<Mutex<State>>
桌面应用同时面对三类压力:
- OS/UI 回调要求在前台串行修改状态;
- LSP、文件系统、模型流需要大量异步 I/O;
- 视图关闭后,旧任务和回调不能继续修改已经消失的状态。
到处使用 Arc<Mutex<T>> 会把锁、线程和生命周期问题扩散到业务代码。GPUI 选择另一种模型:
App
├─ EntityMap: EntityId → T
├─ Global: TypeId → value
├─ observers / subscribers
├─ windows / focus / keymap
├─ foreground executor
└─ background executor
Entity<T> = 有类型的稳定句柄
WeakEntity<T> = 不延长生命周期的句柄
Context<T> = 修改 T 时临时获得的能力2. Entity<T>:身份和数据分开
实体句柄可以 clone,但真正的 T 由 App 管理。典型操作是:
let counter = cx.new(|_| Counter { value: 0 });
counter.update(cx, |counter, cx| {
counter.value += 1;
cx.notify();
});
let value = counter.read(cx).value;AppContext trait 把创建、插入、读写实体统一起来。源码: crates/gpui/src/gpui.rs:164-239。
它带来的约束
- 修改必须拿到
&mut App或派生 Context; - 同一实体不会被两个前台回调同时可变借用;
- 每次 update 的边界清晰,结束后可以统一处理 effect;
- EntityId 可用于观察、订阅、渲染缓存和窗口归属。
WeakEntity<T> 为什么常见
异步任务、订阅回调和 registry 如果持有强句柄,很容易让面板关闭后仍不释放。弱句柄在回调 触发时尝试 upgrade():存在则更新,不存在则自然停止。
3. Context<T> 是能力对象
Context<'a, T> 内部持有 &'a mut App 和当前实体的弱句柄,并 Deref 到 App。 源码:crates/gpui/src/app/context.rs:19-59。
它额外提供与“当前 T”绑定的能力:
| 方法 | 含义 |
|---|---|
entity() / weak_entity() | 获取当前实体句柄 |
observe(other, f) | 对方 notify 时回调 |
subscribe(other, f) | 对方 emit 某个事件时回调 |
notify() | 标记当前实体发生一般变化 |
emit(event) | 发出有类型的领域事件 |
spawn(...) | 启动持有当前弱实体的前台 future |
listener(...) | 把普通 UI callback 包成当前实体更新 |
defer(...) | 当前 effect cycle 结束后再执行 |
4. notify 与 emit 不是一回事
notify():状态变了,请重新观察
Observer 只知道“这个实体变了”,需要重新读取状态。渲染实体常用它请求刷新。
源码:crates/gpui/src/app/context.rs:228-231。
emit(E):发生了某件明确的事
Event subscriber 收到有类型 payload,例如 BufferEvent::Edited、Workspace::Event::ItemAdded。 它适合边缘触发、携带上下文的领域变化。
选择规则
- 状态的最新值才重要:observe + notify;
- 每次发生都不能丢:subscribe + emit;
- 跨进程:不要用 GPUI event,转换成 proto/RPC message。
5. Subscription 本身就是生命周期
observe / subscribe 返回 Subscription。通常把它保存在实体字段 _subscriptions 中。 Subscription drop 后自动断开回调。
这形成一个很实用的所有权句子:
只要这个实体还拥有 subscription,它就关心对方的事件;实体释放,关系自动解除。
相比手动维护 listener id,这更适合 Pane、Panel 和临时菜单频繁创建销毁的桌面 UI。
6. 前台任务与后台任务
cx.spawn
运行能访问 AsyncApp 的本地 future。闭包通常得到当前实体的 WeakEntity<T>:
let task = cx.spawn(async move |this, cx| {
let result = do_io().await?;
this.update(cx, |state, cx| {
state.result = Some(result);
cx.notify();
})
});Future 可以跨 await,但每次真正进入实体更新仍须通过 AsyncApp。
cx.background_spawn
运行 Send + 'static 工作,不能直接触碰前台实体。适合解析、文件 I/O、网络和 CPU 工作。
Task 必须持有或 detach
GPUI 明确要求返回的 Task<R> 被持有或 detach()。常见模式:
- 字段保存
_load_task:新任务替换旧句柄,形成取消/失效语义; detach_and_log_err(cx):后台副作用与实体寿命无关,但错误必须记录;- await task:当前流程依赖结果。
7. Effect cycle:为什么需要 defer
实体更新期间,某些对象正从 EntityMap 临时借出。若回调立即再次修改同一关系,可能形成重入。 defer 把工作排到当前 effect cycle 末尾,等借用归还后执行。
这不是通用“下一帧”:
defer解决当前状态传播周期中的重入;window.on_next_frame才是下一帧渲染时机;- timer 解决真实时间延迟。
8. Global:应用级服务定位
实现 Global 的类型可以按 TypeId 存入 App。典型对象有 SettingsStore、ThemeRegistry、 AppState、Fs、LanguageModelRegistry。
Global 适合:
- 全应用唯一 registry;
- 无自然父实体的基础设施;
- 大量组件都需读取的配置。
它不适合把所有对象都变成 service locator。Project、Workspace、Buffer 仍通过明确 Entity 字段 传递,因为它们存在多个实例并有结构化所有权。
9. Window 把视觉能力加入 Context
没有 Window 的 App Context 可以管理非视觉实体;涉及焦点、布局、输入和绘制时,还要传 &mut Window。VisualContext trait 抽象了“有当前窗口”的上下文。
这解释了常见回调签名:
fn action(&mut self, _: &SomeAction, window: &mut Window, cx: &mut Context<Self>)self:领域状态;window:焦点、命中测试、绘制帧;cx:实体、事件、任务、global。
10. Action 是可序列化的意图层
GPUI Action 把按键与函数调用解耦:keymap 把按键映射到 action 名称和可选参数,焦点路径上的 handler 决定谁处理它。菜单、command palette、测试也能触发同一 Action。
因此“用户按了 Cmd+S”不是业务 API;“触发 workspace::Save”才是。
11. 一次异步加载的完整状态机
Action handler
→ 更新 state.loading = true;cx.notify()
→ cx.spawn async task
→ 后台 I/O
→ WeakEntity.update
├─ 实体仍存在:写结果、emit、notify
└─ 实体已释放:update 返回错误,结果被丢弃
→ Task 字段被新请求替换时,旧结果不再拥有提交路径这套模式贯穿 file finder、LSP completion、extension install、Agent turn 和 remote connect。
12. 设计取舍
得到的
- UI 状态基本免锁;
- 生命周期与 callback/subscription/task 所有权一致;
- 测试可以使用 TestAppContext 驱动同一套逻辑;
- 观察、事件和重绘有统一调度点。
付出的
- 函数签名大量携带
cx/window; - 异步代码要在
Entity、WeakEntity、AsyncApp间转换; - 重入和 effect cycle 需要框架知识;
- 纯领域逻辑若直接依赖 GPUI,会降低可移植性。
Zed 的应对方式是把可移植协议/数据结构下沉到 core crate,把实体化协调留给产品层。