← 返回博客

GPUI 中的所有权和数据流

2024 年 1 月 25 日


在构建 Zed 的用户界面时,我们最初面临的挑战之一是 Rust 严格的所有权系统。在 Rust 中,每个对象都有一个唯一的拥有者,这强烈鼓励将所有数据组织成一个没有循环引用或共享所有权的树。在构建 Zed 之前,我编写 GUI 代码的大部分经验都来自于 Web 技术,在 Web 技术中,JavaScript 垃圾回收器意味着您实际上不需要考虑所有权。例如,很容易将鼠标事件监听器附加到 DOM 节点,该节点捕获对 this 的引用,而我关于构建 UI 的大部分直觉都基于这种模式。在 Rust 中,在事件监听器中捕获 self 与直接捕获是相反的。

因此,当我们在 2019 年开始开发 Zed 时,很明显我们需要重新思考我们使用 Web 和其他框架所学到的大部分知识。我们需要一个与 Rust 非常契合的系统,但我们也需要动态性来表达真实的图形界面。例如,Zed 的工作区可以显示各种类型的模态对话框,这些对话框需要能够向工作区发出事件,以指示何时应该关闭它们。我们还需要支持异步更新子树,例如在项目面板中,当文件系统更改时。当然还有很多其他的例子,我们希望处理所有这些例子,而无需强制使用特殊的数据结构来表示应用程序状态。我们希望尽可能地避免宏,而使用纯 Rust 结构体。

在最初尝试使用内置类型(例如 Rc)效果不佳后,我们开始尝试一种方法,该方法至今仍存在于 Zed 的自定义 UI 框架 GPUI 中。在 GPUI 中,应用程序中的每个模型或视图实际上都由一个称为 AppContext 的单个顶级对象拥有。当您创建一个新的模型或视图(我们统称为实体)时,您会将状态的所有权交给应用程序,使其能够参与各种应用程序服务并与其他实体交互。

为了说明这一点,请考虑下面的简单应用程序。我们通过调用 run 和回调来启动应用程序,该回调传递对 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);
});

更新第一个计数器后,您会看到观察计数器的状态是根据我们的订阅维护的。

除了 observenotify 之外,它们指示实体的状态已更改,GPUI 还提供 subscribeemit,使实体能够发出类型化的事件。要选择加入此系统,发出对象必须实现 EventEmitter trait。

让我们引入一个名为 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 实现了一个自定义的事件系统。那时,我设计了一个看似简单的事件发射器,事件监听器被保存在一个数组中,并且当一个事件被触发时,每个监听器都会被顺序调用。

然而,这种简单性导致了一个微妙的 bug,直到代码被广泛应用于生产环境中才被注意到。问题在于,当一个监听器函数向它所订阅的同一个发射器发出事件时,就会出现这种情况。这无意中触发了重入,即发射函数在完成其执行之前再次被调用。这种类似递归的行为与我们对线性函数执行的期望相悖,并使我们进入了一个意想不到的状态。尽管 JavaScript 的垃圾回收器强制执行内存安全,但该语言宽松的所有权模型使我很容易编写这个 bug。

Rust 的约束使得这种简单的方法变得更加困难。我们强烈建议走一条不同的道路,这条路可以防止我上面描述的那种重入。在 GPUI 中,当你调用 emitnotify 时,不会调用任何监听器。相反,我们将数据推送到一个效果队列中。在每次更新结束时,我们刷新这些效果,从队列的前面弹出,直到它变空,然后将控制权返回给事件循环。任何效果处理程序本身都可以推送更多的效果,但系统最终会静止。这给了我们 run-to-completion 语义,没有重入 bug,并且与 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_effectsflush_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 上闲逛更好的度过方式了。