Entity

当一份状态需要由多个 View、handler 或异步任务共同使用时,把它放进 GPUI 提供的 Entity<T>。例如,Chat 可以用 Entity<Chat> 保存消息;任何持有其 clone 的代码都能通过 GPUI Context 访问同一份 Chat。

使用 cx.new 创建 Entity,使用 read 读取状态,使用 update 修改状态。当 Chat 实现 Render 时,Entity<Chat> 还可以直接作为 View 渲染;不需要渲染时,它就是一个共享状态 model。

Entity<Chat>
    ├── read(cx)       → &Chat
    ├── update(cx, …)  → &mut Chat + Context<Chat>
    └── downgrade()    → WeakEntity<Chat>

clone Entity 会复制句柄,不会复制其中的状态。GPUI 会以同步方式增加强句柄计数;它不会 clone T 或其内部集合。句柄 clone 比深度复制模型轻得多,但并非零成本,而且每个保留的副本都会延长 Entity 的生命周期。Entity 只能通过 GPUI Context 访问,因此 GPUI 可以统一协调状态更新、渲染、订阅和 Entity 生命周期。

#数据模型或持久 View

Entity<T> 是状态及身份的句柄;T 不需要实现 Render。纯数据 Entity 可以保存模型或组件状态,供多个所有者读取或修改。若 T 实现了 Render,它的 Entity<T> 还可以放进元素树,成为持久的 View。View 的 Entity 会继续存活,而 render 返回的元素会在绘制时重新构建。

Entity<MessageStore> (model; no Render)
          ↑ read/observe
Entity<MessagePanel>(Render View)
          ↓ render
      Element tree
use gpui_kit::*;

struct MessageStore {
    messages: Vec<SharedString>,
}

struct MessagePanel {
    store: Entity<MessageStore>,
    _subscription: Subscription,
}

impl Render for MessagePanel {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let count = self.store.read(cx).messages.len();
        div().child(format!("{count} messages"))
    }
}

// In a GPUI context outside render:
let store = cx.new(|_| MessageStore { messages: vec![] });
let panel = cx.new(|cx| {
    let _subscription = cx.observe(&store, |_, _, cx| cx.notify());
    MessagePanel { store: store.clone(), _subscription }
});
store.update(cx, |store, cx| {
    store.messages.push("Hello".into());
    cx.notify();
});
// Use `panel` as a child view; `store` is data, not an element.

模型调用 notify() 后,保存的观察关系会通知 MessagePanel,后者再通知自己的 View。即使以后给该 View 加上缓存边界,这个显式连接也能保持内容更新。RenderOnce 是生成元素的另一条路径:它描述渲染时被消费的值类型组件,本身既不会创建也不要求 Entity。其生命周期见 RenderOnce。

#动手练习:一个模型与一个观察它的 View

从仓库根目录开始,将 examples/hello_world/src/main.rs 暂时替换为下面的完整代码,然后运行 cargo run -p hello_world。练习复用现有示例包,无需添加依赖。如果还要保留原来的 Hello World 示例,请先保存原文件。

use gpui_kit::base::StyledExt;
use gpui_kit::component::button::Button;
use gpui_kit::*;

struct MessageStore {
    count: usize,
}

struct MessagePanel {
    store: Entity<MessageStore>,
    _subscription: Subscription,
}

impl MessagePanel {
    fn new(cx: &mut Context<Self>) -> Self {
        let store = cx.new(|_| MessageStore { count: 0 });
        let _subscription = cx.observe(&store, |_, _, cx| cx.notify());
        Self {
            store,
            _subscription,
        }
    }
}

impl Render for MessagePanel {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let count = self.store.read(cx).count;

        div()
            .v_flex()
            .gap_2()
            .size_full()
            .items_center()
            .justify_center()
            .child(format!("Messages: {count}"))
            .child(
                Button::new("add-message")
                    .label("Add message")
                    .on_click(cx.listener(|this, _, _, cx| {
                        this.store.update(cx, |store, cx| {
                            store.count += 1;
                            cx.notify();
                        });
                    })),
            )
    }
}

fn main() {
    gpui_kit::application().run(|cx| {
        gpui_kit::init(cx);
        gpui_kit::open_window(WindowOptions::default(), cx, |_, cx| {
            cx.new(MessagePanel::new)
        })
        .expect("failed to open window");
    });
}

窗口最初显示 Messages: 0。每点击一次,handler 通过 update 修改模型 Entity;模型的 notify() 安排已保存的 observer 运行,observer 再对面板调用 notify()。下一次 render 从同一模型句柄读取计数,文字依次变成 Messages: 1、Messages: 2。模型没有实现 Render,也没有被放进 Element tree。面板长期持有模型句柄和 Subscription;若在 render 中重新创建它们,就会失去稳定的关系。

如果计数始终为零,检查两处通知调用及 _subscription 字段。删除模型的 notify(),observer 就收不到信号;若 new 结束时丢弃 subscription,观察关系会被取消。改动练习时,让 store.update(...) 留在点击 handler 中,让 store.read(cx) 留在 render 中,并避免在更新同一个 store 的过程中再次读取它。

#创建 Entity

可以在任意 GPUI context 中使用 cx.new:

use gpui_kit::*;

struct Chat {
    messages: Vec<SharedString>,
}

let chat: Entity<Chat> = cx.new(|_cx| Chat {
    messages: Vec::new(),
});

let same_chat = chat.clone();
assert_eq!(chat.entity_id(), same_chat.entity_id());
same_chat.update(cx, |chat, cx| {
    chat.messages.push("Hello".into());
    cx.notify();
});
assert_eq!(chat.read(cx).messages.len(), 1);

闭包会收到 Context<Chat>,因此初始化时也可以创建子 Entity 或注册订阅。示例中的两个句柄指向同一份 Chat:经一个句柄修改,另一个也能读到。clone 句柄需要同步更新所有权计数,但无需复制模型及其消息。只有所有权或回调确实需要时才复制句柄,避免在高频循环里无故反复 clone。SharedString 也方便共享文本,但使用不同的存储机制。

当子 Entity 应该与 owner 同时存活时,owner 保存一个强引用 Entity<T>:

struct Workspace {
    chat: Entity<Chat>,
}

impl Workspace {
    fn new(cx: &mut Context<Self>) -> Self {
        let chat = cx.new(|_cx| Chat {
            messages: Vec::new(),
        });

        Self { chat }
    }
}

GPUI Kit 广泛采用这种强所有权关系:父 View 持有它所渲染、协调的子 View 或 model。

#在功能边界间传递句柄

一个功能可以把业务数据保存在一个模型 Entity 中,再将 clone 后的 Entity<Model> 句柄交给面板、命令和任务。这些所有者引用的是同一份状态,不会各自获得一份大型消息列表或文档的副本。因此,有类型的 Entity 句柄适合在同一功能的模块间传递。clone 仍会更新 GPUI 的强句柄计数,并延长模型生命周期;反向引用或不应持有模型的工作应使用 WeakEntity。

文档、工作区等功能特有的状态,应由该功能自己的 Entity 持有。只有值或服务的范围确实覆盖整个应用时,才使用 Global,例如多个功能或窗口共同读取的偏好设置。一个类型的 Global 在应用中只有一个存储位置,不能把它当作传递逐文档 Entity 的捷径。若 Global 变化需要更新 View 输出,View 还需显式观察变化。

跨 crate 时,通过功能的公开边界暴露预期的句柄、命令或事件,不要让兄弟功能依赖其模型内部字段。只有能力具有清晰契约、且确有多个所有者时,才提取共享 crate。完整的功能与 crate 布局规则见 Coding Guides。

#在 render 外保留组件状态

GPUI Kit 的有状态组件也遵循这一原则。输入框状态只构造一次,把 Entity 存在字段中;每次 render 再将句柄传给值类型的 Input 元素:

use gpui_kit::*;
use gpui_kit::component::input::{Input, InputState};

struct SearchPane {
    query: Entity<InputState>,
}

impl SearchPane {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let query = cx.new(|cx| InputState::new(window, cx));
        Self { query }
    }
}

impl Render for SearchPane {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div().child(Input::new(&self.query))
    }
}

如果在 render 里新建 InputState,View 重绘时就会重置草稿、选区及与焦点相关的行为。Input 这个值可以重建;它的状态 Entity 应继续存活。

#读取状态

直接同步访问状态时使用 read:

let message_count = chat.read(cx).messages.len();

返回引用的生命周期受 cx 限制。需要长期使用某个值时,应复制或 clone 该值,不要保存这个引用。

当代码接收泛型 AppContext,或希望用闭包明确读取范围时,可以使用 read_with:

let last_message = chat.read_with(cx, |chat, _cx| {
    chat.messages.last().cloned()
});

read 接收 &App(Context<T> 可以解引用为 App);read_with 接收任意 AppContext,返回闭包的结果。它们都不会再复制一份 Entity。读取范围应尽量短:正在被 update 或 render 借用的 Entity,必须等本次访问结束后才能再次读取。不要把 &T 留到后续 update 或 await 之后;离开读取范围前先取出拥有所有权的值。

#更新状态

使用 update 获得可变状态和对应的 Context<T>:

chat.update(cx, |chat, cx| {
    chat.messages.push("Hello".into());
    cx.notify();
});

cx.notify() 表示这个 Entity 发生了变化,渲染过或正在观察它的 View 随后可以更新。仅修改字段不会自动产生通知;当新状态需要反映到观察者或界面上时,应调用它。

update 闭包可以返回结果。handler 可以先修改一个 Entity,结束对它的借用,再用返回值修改另一个,避免形成回调循环:

let new_count = chat.update(cx, |chat, cx| {
    chat.messages.push("Hello".into());
    cx.notify();
    chat.messages.len()
});
// Chat's update has ended; `new_count` is now a plain usize.

始终使用 update 闭包传入的内部 cx。它是当前 Entity 的 Context<Chat>。

#使用 WeakEntity 表示反向引用和 callback

clone Entity<T> 会创建另一个强句柄,并让 Entity 继续存活。若父 View 持有子 Entity,又把强引用 Entity<ParentView> 交给子级保存,两边就会相互持有:

External owner → ParentView ──strong reference──→ ChildView
                 ↑                    │
                  └──────strong reference──────┘

外部 owner 被释放后,环中的强句柄仍然存在,所以两个 Entity 都不会释放。把父级句柄传给子级临时使用没有问题;当子级长期保存强句柄、父级又长期持有子级时才形成环。反向引用应保存为 WeakEntity<ParentView>:

use gpui_kit::*;

struct ParentView {
    child: Option<Entity<ChildView>>,
    clicks: usize,
}

struct ChildView {
    parent: WeakEntity<ParentView>,
}

impl Render for ParentView {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div()
            .child(format!("Clicks: {}", self.clicks))
            .children(self.child.clone())
    }
}

impl Render for ChildView {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div().child("Notify parent").on_click(cx.listener(|this, _, _, cx| {
            let Some(parent) = this.parent.upgrade() else { return; };
            parent.update(cx, |parent, cx| {
                parent.clicks += 1;
                cx.notify();
            });
        }))
    }
}

// In a GPUI context outside render:
let parent = cx.new(|_| ParentView { child: None, clicks: 0 });
let child = cx.new(|_| ChildView { parent: parent.downgrade() });
parent.update(cx, |parent, cx| {
    parent.child = Some(child);
    cx.notify();
});

现在父级拥有子级,但子级的反向引用不会让父级继续存活:ParentView ──strong──→ ChildView ──weak──→ ParentView。upgrade() 返回 Option<Entity<ParentView>>;父级已释放时,点击处理器直接返回。处理器在渲染之后运行,所以可以更新父级,而不会重入父级 render 时的借用。GPUI Kit 的嵌套弹出菜单也采用父级强持有子级、子级弱返指父级的所有权方向。

弱句柄可能比目标存活得更久。可以尝试 upgrade,或者使用它提供的可失败访问方法:

let workspace = cx.weak_entity();

cx.spawn(async move |_, cx| {
    let conversations = load_conversations().await;

    workspace
        .update(cx, |workspace, cx| {
            workspace.set_conversations(conversations);
            cx.notify();
        })
        .ok();
})
.detach();

WeakEntity::upgrade 返回 Option<Entity<T>>;read_with 和 update 返回 Result,因为目标 Entity 可能已经释放。GPUI Kit 会在异步任务、callback、delegate 和父级引用中使用这一模式,避免这些关系意外延长 View 的生命周期。

Context<Self>::spawn 已经将 WeakEntity<Self> 作为异步闭包的第一个参数传入。若希望 owner 被释放时取消工作,就把返回的 Task 存在所属 Entity 中;若工作应独立继续,就调用 .detach()。两种情况下,await 之后的弱引用更新失败都是正常的取消情形:等待期间 View 可能已关闭。

长期保存的闭包也可能闭合所有权环,但前提是其所有者链最终回到被强引用捕获的 Entity。GPUI 的 cx.observe、cx.subscribe 和 cx.listener 内部使用订阅者或 View 的弱句柄;仅仅保存它们返回的 Subscription 并不会自动形成强 Entity 环。额外用 move 捕获强 Entity 仍可能闭合这个环。cx.processor 与 cx.listener 不同:它会捕获当前 View 的强句柄,因此不要不加处理就把生成的闭包存回同一个 View。排查长期未释放的状态时,先检查强 Entity 环,以及生命周期脱离预期 owner 的订阅或任务;这些是常见的所有权线索,而非全部可能原因。

#观察变化与订阅 Event

一个 Entity 可以通过两种相关方式与另一个 Entity 协作:

  • cx.observe(&entity, ...) 在目标 Entity 调用 cx.notify() 时执行。只关心“状态变了”时使用。
  • cx.subscribe(&entity, ...) 接收类型化 Event。需要知道变化的含义和数据时使用。

它们是两种独立的信号:notify() 不会发送 Event,emit(event) 本身也不会通知渲染者。某次状态变化如果既需要重绘,又需要语义事件,可以明确地各触发一次。观察者可以用收到的句柄读取目标 Entity,但仍须避免重入回调链中已被借用的 Entity。GPUI 通过 effect cycle 分发这些回调,此时当前 update 的借用已结束;不要假设回调会在 update 闭包内部执行。

应把 observe 或 subscribe 返回的 Subscription 保存在发起订阅的 Entity 上,放在 _subscription 字段或 _subscriptions: Vec<Subscription> 字段中:

enum ChatEvent {
    MessageSent,
}

impl EventEmitter<ChatEvent> for Chat {}

impl Chat {
    fn send_message(&mut self, cx: &mut Context<Self>) {
        self.messages.push("Hello".into());
        cx.notify();
        cx.emit(ChatEvent::MessageSent);
    }
}

struct Workspace {
    chat: Entity<Chat>,
    sent_count: usize,
    _subscriptions: Vec<Subscription>,
}

impl Workspace {
    fn new(cx: &mut Context<Self>) -> Self {
        let chat = cx.new(|_cx| Chat {
            messages: Vec::new(),
        });

        let _subscriptions = vec![
            cx.observe(&chat, |_workspace, _chat, cx| {
                cx.notify();
            }),
            cx.subscribe(&chat, Self::on_chat_event),
        ];

        Self {
            chat,
            sent_count: 0,
            _subscriptions,
        }
    }

    fn on_chat_event(
        &mut self,
        _chat: Entity<Chat>,
        _event: &ChatEvent,
        cx: &mut Context<Self>,
    ) {
        self.sent_count += 1;
        cx.notify();
    }
}

调用 chat.update(cx, |chat, cx| chat.send_message(cx)) 会同时发出两种信号:观察者因模型变化使 Workspace 失效,Event 订阅者则递增 sent_count。GPUI Kit 的 View 使用这一模式。Workspace 被释放时,_subscriptions 也会随之释放,callback 随即取消。局部的 let subscription = ... 若在函数结束时被 drop,问题是过早取消,并非泄漏。Subscription::detach() 则相反:它消耗 handle 但不取消注册,订阅可一直保留到被订阅的 Entity 释放。对长寿命 Entity 反复 detach 订阅,callback 和捕获资源可能不断累积。只有确实需要这种更长的生命周期时才 detach;它与让异步工作独立继续的 Task::detach() 不同。callback 强捕获 Entity 只有在所有权链回到 owner 时才形成环。

Context<Self>::observe 和 subscribe 使用订阅者的弱句柄,因此注册本身不会持有该 View。把 Subscription 放在订阅者中,明确它的生命周期。如果上例中两个回调都为一次操作调用 cx.notify(),GPUI 可以合并失效请求,但仍应判断是否真的需要两种响应。

EventEmitter、emit 和类型化订阅的设计请继续阅读 Event。

#生命周期

只要还有一个强引用 Entity<T>,Entity 就会继续存活。最后一个强句柄被 drop 后,WeakEntity<T>::upgrade() 就会失败。GPUI 随后在 effect cycle 中运行 release callback 并释放状态;不要依赖状态的析构函数或 release callback 在 drop(handle) 语句处同步执行。

大部分清理工作应该直接跟随所有权关系:

  • 使用 Entity<T> 持有子 Entity;
  • 使用 WeakEntity<T> 表示非拥有关系;
  • 把 View 级 Subscription 放在同一个 View 的 _subscription 或 _subscriptions 字段中;
  • View 被 drop 时,一并释放订阅及 callback 捕获的资源。

如果集成代码需要在 GPUI 释放状态前访问它,Context 还提供了 cx.on_release(...) 来观察当前 Entity,以及 cx.observe_release(...) 来观察另一个 Entity。两种回调都在 GPUI 处理释放时执行;observe_release 只有在订阅者仍存活时才会执行。它们返回的 Subscription 也应该只保存到 release callback 所需的生命周期结束为止。

#练习:共享句柄与弱引用生命周期

把这个测试放进启用了 gpui-kit 的 test-support feature 的 package(参见测试)。它不需要窗口。断言依次验证 clone 共享状态、剩余的强句柄维持生命周期,以及最后一个强句柄被 drop 后弱引用失效:

use gpui_kit::{AppContext, TestAppContext};

struct Counter {
    value: usize,
}

#[gpui_kit::test]
fn handles_share_state_and_control_lifetime(cx: &mut TestAppContext) {
    let counter = cx.new(|_| Counter { value: 0 });
    let another_owner = counter.clone();
    let weak = counter.downgrade();

    another_owner.update(cx, |counter, cx| {
        counter.value += 1;
        cx.notify();
    });
    assert_eq!(counter.read(cx).value, 1);

    drop(counter);
    assert!(weak.upgrade().is_some());
    drop(another_owner);
    assert!(weak.upgrade().is_none());
}

再用上面的 Chat/Workspace 示例做第二项检查:先只调用 notify() 更新一次 Chat,再只调用 emit(ChatEvent::MessageSent) 更新一次。预测哪次会改变 sent_count,然后在 #[gpui_kit::test] 中断言。把 _subscriptions 保存在 Workspace 上,否则测试面对的是已经取消的监听器。

#Entity 身份与 View 缓存

Entity<T: Render> 可以直接作为子 View 嵌入。它的 EntityId 让 View 跨帧保有稳定身份;cx.notify() 会使展示它的 View 失效。状态保存在 Entity 中,而普通元素树会在绘制时重新构建。因此,保留 Entity 与缓存其渲染子树是两回事。

对于父级频繁重绘、自己却经常不变的昂贵子 View,GPUI 还提供 child.clone().cached(style) 和等价的 AnyView::cached(style)。父级必须保留同一个子 Entity,style 也必须给出确定的外层尺寸,因为 GPUI 在布局阶段可能跳过内容的 render。未变化的缓存子级可以重放先前的子树;通知、边界尺寸或继承的绘图上下文变化则会触发重建。缓存边界与元素状态、虚拟列表的区别,见 View Cache。

CC BY 4.0 · GPUI Kit 有权授权的原创文档正文与图示可在注明署名后复制或改编。 许可条款。请署名 GPUI Kit,链接到 本页原文及 CC BY 4.0 许可,并注明是否修改。 代码示例与软件源码仍适用 Apache-2.0。既有 Apache 权利不受影响;第三方材料保留各自许可。