Skip to main content

异步任务与 UI 安全

桌面应用最怕主线程阻塞。HTTP 请求、文件读取、JSON 格式化、大量数据加载和图片解码都可能让界面卡住。GPUI 提供了与事件循环集成的异步执行能力,但你仍然需要区分“后台工作”和“UI 更新”。

不要在 render 中做 IO

render 可能被频繁调用。这里应该只做轻量状态判断和元素构建,不要做这些事:

  • 同步 HTTP 请求。
  • 同步读写文件。
  • 解析超大 JSON。
  • 扫描目录。
  • 长时间压缩、加密、图片处理。

错误示例:

fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let body = std::fs::read_to_string("history.json").unwrap_or_default();
div().child(body)
}

正确方向是:在事件、初始化或异步任务中更新状态,render 只展示状态。

Entity 任务

当前 GPUI 示例中常见写法是 cx.spawn(async move |this, cx| { ... })

impl ApiTab {
fn reload(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
let response = fetch_data().await;

this.update(cx, |tab, cx| {
tab.loading = false;
tab.response = Some(response);
cx.notify();
})
.ok();
})
.detach();
}
}

this.update(...) 返回可失败结果,因为异步任务回来时实体可能已经被关闭。这里的 .ok() 是合理的:用户关闭了 Tab,就不需要再更新它。

HiPoster 的请求流程

HiPoster 的子视图发出请求事件,根视图启动异步任务:

pub fn execute_request(&mut self, tab: Entity<ApiTab>, request: HttpRequest, cx: &mut Context<Self>) {
self.add_history(request.clone());

cx.spawn(move |_this: WeakEntity<Self>, cx: &mut AsyncApp| {
let mut cx = cx.clone();
async move {
let result = http::execute_request(&request).await;

let _ = tab.update(&mut cx, |tab, cx| {
tab.loading = false;
match result {
Ok(response) => tab.set_response(response, cx),
Err(error) => tab.set_response(error_response(error), cx),
}
});
}
})
.detach();
}

这段体现了几个关键原则:

  • 请求模型在启动任务前 clone 出来,后台任务不读 UI 输入框。
  • 网络请求在异步任务中执行,不阻塞窗口绘制。
  • 回写 UI 时通过 Entity::update 回到 GPUI 上下文。
  • Tab 可能已经被关闭,所以更新失败要能接受。

升级到最新 Zed main 时,可以优先尝试当前示例中的 cx.spawn(async move |this, cx| { ... }) 形式;如果项目使用的 GPUI revision 与 HiPoster 锁定版本一致,则保持 HiPoster 现有写法。

Tokio 与 GPUI executor

GPUI 自带与平台事件循环集成的 executor。很多 UI 内部任务可以直接用 cx.spawncx.background_spawn。但 reqwest 默认依赖 Tokio,因此 HiPoster 在启动时创建 Tokio runtime:

let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build tokio runtime");

let _guard = runtime.enter();

实践建议:

  • 如果依赖 reqwesttokio::fs、Tokio channel 或 Tokio timer,就显式持有 Tokio runtime。
  • 如果只是 GPUI 内部延迟、状态更新、轻量后台计算,优先使用 GPUI executor。
  • 不要在每次请求时创建 runtime。

loading、错误和取消

请求开始:

self.update_request_state(cx);
self.loading = true;
cx.emit(self.request.clone());
cx.notify();

请求结束:

tab.loading = false;
tab.set_response(response, cx);

错误也应该转换成可显示的 HttpResponse 或 UI 状态:

fn error_response(error: anyhow::Error) -> HttpResponse {
HttpResponse {
status_code: 0,
status_text: format!("Error: {error}"),
..Default::default()
}
}

请求取消可以通过保存任务句柄或使用 cancellation token 实现。最简单的做法是给 ApiTab 增加 request_generation: u64

  1. 每次发送请求时自增 generation。
  2. 异步任务捕获当前 generation。
  3. 回写前检查 generation 是否仍然匹配。
  4. 不匹配说明用户又发了新请求或关闭了旧请求,直接丢弃旧结果。

文件选择与平台命令

HiPoster 当前用平台命令做文件选择:

  • macOS:osascript
  • Windows:PowerShell + System.Windows.Forms.OpenFileDialog
  • Linux:zenitykdialog

这类命令可能阻塞,最好放到后台任务中,或者换成更标准的跨平台文件对话框库。更新 UI 时仍然回到 Entity::update

cx.spawn(async move |this, cx| {
let path = open_file_dialog_in_background().await;
this.update(cx, |this, cx| {
this.apply_selected_file(path);
cx.notify();
})
.ok();
})
.detach();

异步安全清单

  • UI 线程不做网络、磁盘和重计算。
  • 异步任务只捕获必要数据,不捕获 &mut Window&mut Context<T>
  • 回写 UI 必须通过 Entity::update
  • 任务回来时实体可能消失,所有回写都要容错。
  • loading、错误和取消状态都要进入模型或视图状态。
  • 多次请求并发时要避免旧响应覆盖新响应。

异步做对了,GPUI 的高性能才真正能发挥出来。