Skip to content

第 4 章:GPUI 的 Entity 世界——状态、事件与异步任务

GPUI 最重要的抽象不是 div(),而是“所有可变应用状态都在 App 所有的实体表中,由带能力的 Context 读写”。渲染只是这套运行时的一种消费者。

1. 为什么不是 Arc<Mutex<State>>

桌面应用同时面对三类压力:

  • OS/UI 回调要求在前台串行修改状态;
  • LSP、文件系统、模型流需要大量异步 I/O;
  • 视图关闭后,旧任务和回调不能继续修改已经消失的状态。

到处使用 Arc<Mutex<T>> 会把锁、线程和生命周期问题扩散到业务代码。GPUI 选择另一种模型:

text
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 管理。典型操作是:

rust
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

它带来的约束

  1. 修改必须拿到 &mut App 或派生 Context;
  2. 同一实体不会被两个前台回调同时可变借用;
  3. 每次 update 的边界清晰,结束后可以统一处理 effect;
  4. 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. notifyemit 不是一回事

notify():状态变了,请重新观察

Observer 只知道“这个实体变了”,需要重新读取状态。渲染实体常用它请求刷新。

源码:crates/gpui/src/app/context.rs:228-231

emit(E):发生了某件明确的事

Event subscriber 收到有类型 payload,例如 BufferEvent::EditedWorkspace::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>

rust
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 WindowVisualContext trait 抽象了“有当前窗口”的上下文。

这解释了常见回调签名:

rust
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. 一次异步加载的完整状态机

text
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
  • 异步代码要在 EntityWeakEntityAsyncApp 间转换;
  • 重入和 effect cycle 需要框架知识;
  • 纯领域逻辑若直接依赖 GPUI,会降低可移植性。

Zed 的应对方式是把可移植协议/数据结构下沉到 core crate,把实体化协调留给产品层。

独立源码学习笔记 · 文档采用 CC BY-SA 4.0