ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

gpui-kit Shell Hosting 完全指南:运行时生命周期、View 挂载、刷新、指标与 Hot-reload 的 Rust 接口全解

gpui-kit Shell Hosting 完全指南:运行时生命周期、View 挂载、刷新、指标与 Hot-reload 的 Rust 接口全解 gpui-kit Shell Hosting 完全指南运行时生命周期、View 挂载、刷新、指标与 Hot-reload 的 Rust 接口全解【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit本指南面向要在自己的 GPUI 应用中嵌入 JavaScript 脚本 View 的 Host宿主开发者。gpui-kit的 Shell 模块把脚本 View 放上屏幕只需要四行代码见 Getting Started但这一页讲的是那四行之外的完整 Rust 接口该调什么、什么时候调以及那两三处看起来该调的那个其实是错的的经典陷阱。读完本文你将掌握ShellRuntime的创建与隔离、加载与实例化的两条路径、ShellRoot挂载语义、Host 状态变更后正确的刷新姿势、脚本能力边界、RuntimeMetrics指标解读、debug 构建的性能配置、退出请求处理与 hot-reload 的完整机制。运行时一个ShellRuntime拥有一台 VMShell 的宿主模型以ShellRuntime为核心。从源码看crates/shell/src/engine/quickjs/mod.rs一个ShellRuntime拥有一个 JavaScript VMQuickJS通过rquickjs绑定并带 JIT 配置。它本质是一个带内部可变性的Rc——既不是Send也不是Sync——因此它必须待在拥有App的那个线程上不能跨线程移动。gpui_kit::shell::init(cx); // gpui-base、默认 token 调色板、样式表 let runtime ShellRuntime::new(cx)?; // 一个 VM并注册为当前 App 的默认 runtime第一行gpui_kit::shell::init(cx)完成 Shell 的静态初始化装载 gpui-base、默认 token 调色板与样式表。第二行ShellRuntime::new(cx)创建 VM 并将其安装为当前App的默认 runtime。源码中new在已有默认 runtime 时会返回错误提示a default gpui-shell runtime is already installed; use ShellRuntime::new_isolated() for an additional VM这正是隔离机制的入口new(cx)让回调、HostModule 注册与 hot reload 不必由 Host 层层传递句柄也能通过全局机制找到默认 runtime。new_isolated()不安装为默认 runtime由 Host 自行保留句柄用于刻意管理多个 VM 的场景。源码注释明确指出More than one may be alive on a thread because authority travels on the call frame rather than in runtime-global state.——即多个 VM 之所以可以共存是因为授权随调用帧传递而非依赖运行时全局状态。release 构建的一个隐藏代价gpui-shell通过 GPUI 的 inspector reflection table 暴露 fluent style 方法release 构建也不例外。因此依赖这个 crate 会为 Cargo 统一后的依赖图启用gpui-base/inspectorfeature。这是 JavaScript 样式接口正常工作的必要条件嵌入方应把 release 构建中新增的检测代码与依赖计入构建成本——这不是可选优化而是功能正确性的前提。加载与实例化从目录/源码到活的 View常规路径一次load直达ShellRoot普通应用窗口只需一次加载并直接获得它的ShellRootcx.open_window(options, move |window, cx| { let root runtime.load(app_root, window, cx); #[cfg(debug_assertions)] if let Ok(watch) runtime.watch(root, window, cx) { watch.forget(); } root })?;load的行为要点与 Getting Started 的四行开场呼应存在gpui-shell.json时load会验证其中的身份信息id、name、entry 等并采用其 entrycapabilities 是能力请求不等于 Host 已经批准。两条路径都按 Host 当前的默认 policy 运行没有 manifest 时入口为main.js。无论哪条路径都会刷新gpui-kit.d.ts类型声明。加载失败渲染可选择文字的错误界面而不是让 Host panic。需要自行处理结构化错误的 Host 使用try_load。失败状态的 root 没有可供监听的应用因此watch会返回Err上面代码里忽略这个错误才能保留可选择的失败界面。低层路径load_app/load_source/instantiate下面的低层方法只供需要把脚本 View 装进既有 Rust 组合的 Host 使用。核心心智模型是类型与对象的两级分离加载把源码变成一个View 类型——脚本 default 导出的那个类实例化把这个类型变成一个View 对象即一个活的实例。let view_type runtime.load_app(root, main.js)?; // 一个目录 let view_type runtime.load_source(inline, source)?; // 一个字符串测试用 let object runtime.instantiate(view_type, window, cx)?;load_app会解析目录、读取入口文件、求值该模块。源码层面的支撑是 crates/shell/src/runtime.rs 中的resolve_app_root——它专门处理被指到入口文件本身或被指到真实应用目录的父目录这两种最常见起始错误而不是抛一个裸的 no such file。这里的每一种失败都是一个带着脚本自身调用栈的ShellError语法错误、解析到应用根目录之外的 import、缺失或形态不对的 default 导出。实例化会执行脚本的init因此它需要一个活的Windowinit里可能会创建InputState这类留存状态没有 Window 就无法正确初始化。挂载脚本 View 必须挂在ShellRoot之下脚本 View 和别的 GPUI View 没有两样但它必须挂在一个ShellRoot之下cx.open_window(options, move |window, cx| { let object runtime.instantiate(view_type, window, cx).expect(view); let content cx.new(|_| ScriptView::new(runtime.clone(), object)); cx.new(|cx| ShellRoot::new(content.into(), window, cx)) })ShellRoot见 crates/shell/src/root.rs持有 dialog 栈、sheet、toast 栈、焦点恢复与 Tab 导航——正是Root对一个gpui-component窗口所起的作用。window.open_dialog这一类调用要经由它找到根 View所以挂在别的根 View 之下的脚本会拿到一条讲清原因的拒绝而不是悄无声息地没反应。Host 也可以直接驱动同样这几个界面插件面板与 Host 自己的 UI 因此落在同一个栈里root.update(cx, |root, cx| { root.open_dialog(view.into(), window, cx); root.push_toast(ToastRequest::new(Saved).with_level(ToastLevel::Success), window, cx); root.close_all_dialogs(window, cx); });这是Host 与脚本共享同一套界面堆栈的关键能力插件面板并非悬停在窗口之上的异类而是与 Host 原生 UI 完全同构的栈成员。Host 状态变了怎么刷新 View最容易调错且不会报错的一个这是全篇最经典的陷阱而且调错了不会报错cx.notify() ── 把这个 View 再画一遍 不跑脚本 view.refresh(cx) ── 而且它的描述已经过期了 脚本会跑因为脚本的一次render不等于一帧渲染详见 state.md光调cx.notify()重绘的是已经存在的那份 Snapshot。如果 Host 改动的是脚本会读到的东西——某个 HostModule 背后的实体、一项设置、一份文档——就必须告诉 View描述本身已经过期了。runtime.refresh(root, cx)?;runtime 会先确认root装载的是它自己的应用再让脚本 View 失效并安排重绘。ScriptView::refresh见 crates/shell/src/view.rs正是这一失效的实现入口。刻意保持类型化的ScriptView私有Host 代码就无法手工 downcast root 内容、也不会因混用另一个 runtime 的 View 而刷新错误对象。反过来调错则立刻看得见——界面就是不更新——这与 GPUI 里忘了调cx.notify()是同一种失败方式。判断口诀只改了渲染输入 →notify改了脚本会读取的语义数据 →refresh。脚本能碰到什么三项 Host 设置与生命周期差异三项 Host 设置的生命周期不同Capabilities会在每个新 View 加载时冻结store handle与HostModule registry则是该 View 共享的实时Host 配置替换后会在下一次调用生效。gpui_kit::shell::set_capabilities( Capabilities::new() .read_roots([app_root.clone()]) .write_roots([data_dir.clone()]) .store(true), ); gpui_kit::shell::set_store_path(data_dir.join(store.json)); gpui_kit::shell::export_module(market_module(market))?;三项的默认都是什么都没有没有文件访问、没有存储位置、没有 HostModule 注册。set_capabilities的源码注释crates/shell/src/lib.rs说得更直白Nothing is permitted until this is called: a script gets no file, storage, clipboard or process access by default。它设置的是默认 policy——调用时若无更窄的 policy 在生效则继承它。一个同时运行多个应用的 Host 应改为给每个应用构造各自的Policy让两个应用同时持有两份不同的 grant。详见 Capabilities 与 HostModule。独立二进制还会检查root/gpui-shell.json。其中已识别的字段提供应用身份、可选的应用/Shell 版本元数据、entry 与 capability 请求只有id、name和entry必填。Embedder 若要让每个加载的应用拥有不同 grant 与 HostModule registry也可以直接构造Policy。观察它花了多少RuntimeMetrics的两类计数运行时把两件事分开计数而这两个数之间的差就是重点let reading runtime.read_metrics(); reading.script_renders(); // 跟着 cx.notify()、重载、主题变化走 reading.materializations(); // 跟着帧走 reading.script_render_time(); // 脚本 render 里的总耗时 reading.native_time(); // 其中花在 HostModule 里的部分 reading.slowest_script_render(); reading.structure_repeat_rate(); // 一次重建产出的结构与它替换掉的那份是否相同对应的实现全部可在 crates/shell/src/metrics.rs 的RuntimeMetrics中找到其中还包含文档未展开的frame_script_calls、mean_script_only、mean_native、mean_script_render、mean_materialize等均值类方法。script_renders()与materializations()的差值衡量的是脚本描述产出与帧物化之间的节奏差——脚本被要求重算的次数不等于实际出帧的次数。RuntimeMetrics::since(earlier)给出两次读数之间的差值每秒速率就是这么算的。这里没有 reset计数器属于运行时把它们清零会把正在读它们的其他人一起挪动。要量某一段就自己留一个基线再相减——Shell story 每次切换 feed 都会取一次基线所以它的读数回答的是这个 feed 要花多少而不是这个窗口从打开到现在干了多少。回归测试可以直接对script_renders做断言engine.md 里基准测试的第三个数靠的正是这一点。structure_repeats()与structure_changes()回答的是另一个问题在那些有上一份描述可比的重建里有多少次产出的结构完全相同——相同的组件、相同的 builder 方法、相同的树只有其中的取值变了。运行时不会因为这个答案而少做任何事它存在是为了给 Snapshot 缓存止步于哪里 量个尺寸。View 的第一次构建没有前一份可比两个计数都不计它。开发构建的配置debug 慢三倍的真正元凶Host 的 debug 构建单次脚本渲染大约比 release 慢三倍而差距全部来自两个依赖。这是一个在实时应用上实测的结果——一个每笔行情都重渲染的行情终端用运行时自带的RuntimeMetrics测得[profile.dev.package]平均脚本渲染平均物化不配或只写rquickjs31.5 ms3.9 msrquickjs-sysrquickjs-core11.3 ms1.2 msrelease对照11.0 ms1.2 ms所以[profile.dev.package] rquickjs-sys { opt-level 3 } rquickjs-core { opt-level 3 }只写rquickjs没有任何作用这正是坑所在它是一个薄门面把rquickjs-core重新导出而已写它既没优化到解释器也没优化到绑定。rquickjs-sys编译的是 QuickJS 本体——C 源码经cc构建而cc读的是那个包在 profile 里的优化级别rquickjs-core则是每一个跨界值的转换所在。没优化的解释器正是让 debug 构建用起来像另一个产品的原因。llrt_*那批不需要这么做。同一个应用上实测它们带来的差异在噪声范围内fs、net、crypto之类根本不在渲染路径上优化它们换不来脚本作者能感知的东西。这些设置只在构建出二进制的那个 workspace 根生效。库无法替依赖它的应用设定 profile所以gpui-shell没办法替你配好——每个 Host 都得自己写一遍。退出请求process.exit是请求绝不是exit(2)脚本里的process.exit(code)是一个请求绝不是exit(2)。一个插件不能把 Host 进程带走而 Host 可能还有未保存的状态。运行时把这个请求交给 Host由 Host 决定怎么办gpui_kit::shell::on_exit_request(|request, window, cx| { match request.view() { Some(view) close_the_panel_showing(view, window, cx), None cx.quit(), } });request.code()实现见 crates/shell/src/runtime.rs 的ExitRequest::code是脚本要求的退出码request.view()在有的情况下会指出请求来自哪个 View——插件 Host 关掉的应该是那个插件的面板若换成关窗口就等于让一个插件终结了别人的工作。授权了 exit 却没装处理器的 Host会在调用现场被告知而不是永远不知道process.exit()会抛出异常并点名on_exit_request。一个没人回应的请求是朝着讨好方向说的谎——脚本拿到了成功而什么都没发生。Hot-reload一个调用开起来--watch用的也是它runtime.watch(root, window, cx)?.forget();runtime.watch实现入口在 crates/shell/src/watch.rs从已加载的 root 读取解析后的目录与 manifest entry不再让 Host 维护第二份可能漂移的元数据。它不暗藏构建模式策略CLI 在解析到--watch后启用监听嵌入式 Host 则可以把调用放进#[cfg(debug_assertions)]。返回的Watcher持有这次监听把它 drop 掉循环就停.forget()则让它跟随 View 继续运行。View、运行时或窗口任意一个消失时循环也会自己结束因为它对三者都持有弱引用。一次重载会重新读取每一个模块入口也在内——一个悄悄用了旧 import 的 hot-reload 比没有更糟因为它看起来是成功的。它会先把所有可能失败的活干完再去碰活着的那个 View新代码加载失败时上一个 View 继续运行错误进tracing窗口里由一条固定 id 的 toast 报出来下一次成功的重载会撤掉这条 toast。View 本身能挺过重载。ScriptView::replace_object只换掉脚本产出的那部分实体保留下来随之保留的还有窗口、焦点与元素身份。插件 unload 是比移除单个 view 更强的生命周期边界manager 会在丢弃插件前取消所有携带该插件Policy的 outstanding task包括没有 owner 的工作。任何 continuation 都不能继续保留或使用已卸载插件的权限。脚本出错的时候失败不带走界面抛异常的脚本不会把界面一起带走。最后一份可用的 Snapshot 仍然挂在那里失败信息报在它上面读者的滚动位置、焦点、正在读的内容都还在。在有什么让 View 失效之前运行时不会重跑那个失败的render。记得装一个tracingsubscriber。运行时通过tracing报告脚本错误、未处理的 promise rejection 与非法 phase 调用target 是gpui_kit::shell::script没有 subscriber 的话这些全部被丢弃症状就是一个悄悄不再响应的 View。排查界面突然不动了类问题第一步永远是确认 subscriber 已安装、日志里有没有脚本错误。还没有的东西诚实的边界给卡住的脚本做监管。解释器自己的中断会切断一次调用但没有东西会去重启一个反复撞上中断的运行时。如果你需要脚本挂死自动恢复的能力当前版本需要 Host 自己实现。小结Host 侧 Rust 接口的决策速查场景正确调用常见错误启动init(cx)ShellRuntime::new(cx)忘记init导致缺样式表多 VMnew_isolated()自行持有句柄再次调用new拿到报错普通窗口runtime.load(root, …)一次到位手工拼load_appinstantiateShellRoot装进既有组合load_app/load_sourceinstantiate在无 Window 时实例化Host 改了脚本读的数据runtime.refresh(root, cx)?只调cx.notify()重绘旧 Snapshot脚本退出on_exit_request处理器不装处理器脚本看似成功debug 性能rquickjs-sys/rquickjs-coreopt-level3只写rquickjs热重载runtime.watch(...).forget()维护第二份入口元数据故障排查安装tracingsubscriber无 subscriber错误全部丢弃这套接口设计的统一原则是脚本永远不拥有 Host 的进程与权限——加载失败给可读界面、退出请求交给 Host 裁决、错误进 tracing、hot-reload 失败保留旧 View。把本文涉及的源码与文档串起来读可以按此路径深入先看 ShellRuntime 实现、再读 RuntimeMetrics、随后对照 Host 入口 API 与 失败界面/退出请求最后用 Getting Started 和 engine.md 补齐上下文。【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表