我们最初在构建 Zed 用户界面时面临的挑战之一是 Rust 严格的所有权系统。在 Rust 中,每个对象都有一个唯一的拥有者,这强烈鼓励所有数据都组织成一个没有循环引用或共享所有权的树。在构建 Zed 之前,我编写 GUI 代码的大部分经验是使用 Web 技术,其中 JavaScript 垃圾回收器意味着你根本不需要考虑所有权。例如,将鼠标事件侦听器附加到捕获 this 引用的 DOM 节点是很容易的,我对构建 UI 的大部分直觉都基于这种范式。在 Rust 中,在事件侦听器中捕获 self 绝非易事。
因此,当我们于 2019 年开始开发 Zed 时,很明显我们需要重新思考我们在使用 Web 和其他框架时学到的大部分东西。我们需要一个与 Rust 很好契合的系统,但我们也需要动态性来表达真实的图形界面。例如,Zed 的工作区可以显示各种类型的模态对话框,并且这些对话框需要能够向工作区发出事件,以指示何时应该关闭它们。我们还需要支持异步更新子树,例如在文件系统更改时在项目面板中。当然还有更多的例子,我们希望在不强制使用奇异数据结构来表示应用程序状态的情况下处理所有这些问题。我们尽可能地希望避免宏并使用纯 Rust 结构体。
在最初尝试使用内置类型(例如 Rc)效果不佳之后,我们开始尝试一种方法,这种方法至今仍存在于 Zed 定制构建的 UI 框架 GPUI 中。在 GPUI 中,应用程序中的每个模型或视图实际上都由一个名为 AppContext 的顶级对象拥有。当您创建新的模型或视图(我们统称为实体)时,您将状态的所有权赋予应用程序,使其能够参与各种应用程序服务并与其他实体交互。
为了说明这一点,请看下面的简单应用程序。我们通过调用 run 并传入一个回调函数来启动应用程序,该回调函数会传递一个指向 AppContext 的引用,该 AppContext 拥有应用程序的所有状态。这个 AppContext 是我们所有应用程序级服务的入口,例如打开窗口、呈现对话框等。它还有一个 new_model 方法,我们会在下面调用它来创建一个模型并将其所有权赋予应用程序。
use gpui::{prelude::*, App, AppContext, Model};
struct Counter {
count: usize,
}
fn main() {
App::new().run(|cx: &mut AppContext| {
let counter: Model<Counter> = cx.new_model(|_cx| Counter { count: 0 });
// ...
});
}对 new_model 的调用返回一个模型句柄,它根据其引用的对象类型携带一个类型参数。就其本身而言,这个 Model<Counter> 句柄不提供对模型状态的访问。它只是一个惰性标识符加上一个编译时类型标签,并且它维护着对应用程序拥有的底层 Counter 对象的引用计数。
就像 Rust 标准库中的 Rc 一样,当句柄被克隆时,它的引用计数会增加;当它被丢弃时,它的引用计数会减少,从而实现对底层模型的共享所有权。但与 Rc 不同的是,它只有在 AppContext 的引用可用时才能访问模型的状态。句柄并不能真正拥有状态,但它可以用来从其真正的所有者 AppContext 访问状态。让我们继续我们的简单示例,并使用上下文来增加计数器。为了简洁起见,我将省略一些设置代码。
App::new().run(|cx: &mut AppContext| {
let counter = cx.new_model(|_cx| Counter { count: 0 });
// Call `update` to access the model's state.
counter.update(cx, |counter: &mut Counter, cx: &mut ModelContext<Counter>| {
counter.count += 1;
});
});要更新计数器,我们调用句柄上的 update 方法,并传入上下文引用和一个回调函数。回调函数会提供一个计数器的可变引用,我们可以使用它来操作状态。
回调函数还提供了第二个 ModelContext<Counter> 引用。此引用类似于提供给 run 回调函数的 AppContext 引用。ModelContext 实际上是 AppContext 的包装器,但它包含一些额外的数据,表明它与特定模型(在本例中为我们的计数器)相关联。
除了 AppContext 提供的应用程序级别服务外,ModelContext 还提供模型级别服务。例如,我们可以使用它来通知该模型的观察者其状态已更改。让我们通过调用 cx.notify() 将其添加到我们的示例中。
App::new().run(|cx: &mut AppContext| {
let counter = cx.new_model(|_cx| Counter { count: 0 });
counter.update(cx, |counter, cx| {
counter.count += 1;
cx.notify(); // Notify observers
});
});接下来,让我们看看如何观察这些通知。在更新计数器之前,我们将构建第二个观察它的计数器。每当第一个计数器更改时,我们将将其计数加倍并分配给第二个计数器。请注意我们如何对属于第二个计数器的 ModelContext 调用 observe,以便在第一个计数器通知时安排它被通知。observe 调用返回一个 Subscription,我们 detach 它以保持此行为,只要两个计数器都存在。我们也可以存储此订阅并在我们选择的时间丢弃它以取消此行为。
observe 回调函数会传递一个观察者的可变引用和一个被观察计数器的句柄,我们可以使用 read 方法访问其状态。
App::new().run(|cx: &mut AppContext| {
let counter: Model<Counter> = cx.new_model(|_cx| Counter { count: 0 });
let observer = cx.new_model(|cx: &mut ModelContext<Counter>| {
cx.observe(&counter, |observer, observed, cx| {
observer.count = observed.read(cx).count * 2;
})
.detach();
Counter {
count: 0,
}
});
counter.update(cx, |counter, cx| {
counter.count += 1;
cx.notify();
});
assert_eq!(observer.read(cx).count, 2);
});更新第一个计数器后,您会发现观察计数器的状态根据我们的订阅保持不变。
除了表示实体状态已更改的 observe 和 notify,GPUI 还提供了 subscribe 和 emit,这使得实体能够发出类型化事件。要选择加入此系统,发出对象必须实现 EventEmitter 特征。
让我们引入一个名为 CounterChangeEvent 的新事件类型,然后指示 Counter 可以发出这种类型的事件。
struct CounterChangeEvent {
increment: usize,
}
impl EventEmitter<CounterChangeEvent> for Counter {}接下来,我们将更新我们的示例,用订阅替换观察。每当计数器增加时,我们将发出一个 Change 事件,以指示其增加的量。
App::new().run(|cx: &mut AppContext| {
let counter: Model<Counter> = cx.new_model(|_cx| Counter { count: 0 });
let subscriber = cx.new_model(|cx: &mut ModelContext<Counter>| {
cx.subscribe(&counter, |subscriber, _emitter, event, _cx| {
subscriber.count += event.increment * 2;
})
.detach();
Counter {
count: counter.read(cx).count * 2,
}
});
counter.update(cx, |counter, cx| {
counter.count += 2;
cx.emit(CounterChangeEvent { increment: 2 });
cx.notify();
});
assert_eq!(subscriber.read(cx).count, 4);
});现在让我们深入了解 GPUI 的内部,探索观察和订阅是如何实现的。
在深入研究 GPUI 事件处理的细节之前,我想回顾一下我过去在 Atom 编辑器上工作时的一次有益经验,当时我在 JavaScript 中实现了一个自定义事件系统。那时,我设计了一个看似直接的事件发射器,其中事件监听器保存在一个数组中,并且当事件发出时,每个监听器都会按顺序调用。
然而,这种简单性导致了一个微妙的错误,直到代码在生产中广泛使用才被发现。问题表现为当一个监听器函数向其订阅的同一个发射器发出事件时。这无意中触发了重入,即发射函数在其完成执行之前再次被调用。这种类似递归的行为与我们对线性函数执行的预期相矛盾,并使我们进入了意想不到的状态。尽管 JavaScript 的垃圾回收器强制执行内存安全,但该语言宽松的所有权模型使我很容易编写这个错误。
Rust 的限制使得这种天真的方法变得更加困难。我们被强烈鼓励走一条不同的道路,这可以防止我上面描述的那种重入。在 GPUI 中,当你调用 emit 或 notify 时,不会调用任何监听器。相反,我们将数据推送到一个效果队列。在每次更新结束时,我们刷新这些效果,从队列的前面弹出,直到队列变空,然后将控制权返回给事件循环。任何效果处理程序本身都可以推送更多的效果,但系统最终会趋于稳定。这为我们提供了运行到完成的语义,而没有重入错误,并且与 Rust 配合得很好。
这是 app.rs 中这种方法的核心。我将在下面解释。
impl AppContext {
pub(crate) fn update<R>(&mut self, update: impl FnOnce(&mut Self) -> R) -> R {
self.pending_updates += 1;
let result = update(self);
if !self.flushing_effects && self.pending_updates == 1 {
self.flushing_effects = true;
self.flush_effects();
self.flushing_effects = false;
}
self.pending_updates -= 1;
result
}
fn flush_effects(&mut self) {
loop {
self.release_dropped_entities();
self.release_dropped_focus_handles();
if let Some(effect) = self.pending_effects.pop_front() {
match effect {
Effect::Notify { emitter } => {
self.apply_notify_effect(emitter);
}
Effect::Emit {
emitter,
event_type,
event,
} => self.apply_emit_effect(emitter, event_type, event),
// A few more effects, elided for clarity
}
} else {
for window in self.windows.values() {
if let Some(window) = window.as_ref() {
if window.dirty {
window.platform_window.invalidate();
}
}
}
break;
}
}
}
// Lots more methods...
}AppContext::update 方法会进行一些簿记,以允许其被重入调用。在退出最顶层的调用之前,它会调用 flush_effects。flush_effects 方法是一个循环。在每次循环中,我们都会释放已丢弃的实体和焦点句柄,从而丢弃引用计数达到 0 的资源的所有权。然后我们从队列中删除下一个效果并应用它。如果不存在下一个效果,我们遍历窗口,并对于任何脏窗口,我们使平台窗口失效,以便它在下一帧中进行绘制。然后我们中断循环。
接下来,让我们使用 AppContext::update 来实现 update_model。我将在下面搭建框架,以便我们可以在进行实现之前讨论其签名。
impl AppContext {
fn update_model<T: 'static, R>(
&mut self,
model: &Model<T>,
update: impl FnOnce(&mut T, &mut ModelContext<'_, T>) -> R,
) -> R {
todo!()
}
}该方法接受一个回调函数,该函数需要两个可变引用,一个指向给定句柄引用的模型状态,另一个指向 ModelContext,如我前面所述,它实际上只是包装了 AppContext。由于 AppContext 拥有模型,这最初似乎需要对同一数据进行多个可变借用,而 Rust 禁止这样做。
我们的解决方法是暂时从 AppContext 中“租借”模型状态,将其从上下文移除并移动到堆栈。在调用回调函数之后,我们结束租借,将所有权恢复到上下文。
impl AppContext {
fn update_model<T: 'static, R>(
&mut self,
model: &Model<T>,
update: impl FnOnce(&mut T, &mut ModelContext<'_, T>) -> R,
) -> R {
self.update(|cx| {
let mut entity = cx.entities.lease(model);
let result = update(&mut entity, &mut ModelContext::new(cx, model.downgrade()));
cx.entities.end_lease(entity);
result
})
}
}如果你尝试重入地更新一个实体,这确实会带来麻烦,但实际上我们发现这很容易避免,而且我们很快就能轻松地检测出错误。
现在我已介绍了 GPUI 中状态管理的基本知识,接下来要介绍的是我们如何使用视图在屏幕上呈现该状态。但这将不得不等到我们的下一期。在此之前,请浏览我们的源代码,并今天加入 Zed 参加我们的第一次炉边黑客活动。正好是我的生日,我想不出比和你在 Zed 中一起度过更好的方式了。