v0.1.0-alpha
官网Home
docs/advanced/multi-window
应用能力 · Native windowsCapabilities · Native windows

多窗口应用Multi-window apps

为编辑器、预览器或工具面板创建彼此隔离的原生窗口,由一个事件循环按 window_id 分发。Create isolated native windows for editors, previews or tool panels, all driven by one event loop that dispatches by window_id.

预计阅读 6 分钟6 min read

何时使用When to use it

单窗口应用继续使用 App,它的 API 不受影响。只有当窗口必须拥有独立的生命周期、输入路由、GPU surface 或辅助功能树时,才升级为 MultiWindowApp(也导出为 zenit_app.Application)。Single-window apps keep using App; its API is unchanged. Move to MultiWindowApp (also exported as zenit_app.Application) only when windows need their own lifecycle, input routing, GPU surface or accessibility tree.

创建窗口Creating windows

main.zig
const std = @import("std");
const ui = @import("ui");
const zenit_app = @import("zenit_app");

fn mountEditor(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    _ = scope;
    return ui.box(cx, .{ .width = .fill(), .height = .fill() }, .{});
}

fn mountPreview(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    _ = scope;
    return ui.box(cx, .{ .width = .fill(), .height = .fill() }, .{});
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var application = zenit_app.MultiWindowApp.init(gpa.allocator(), .{});
    defer application.deinit();

    // createWindowWith = create + mount in one transaction: if mount fails,
    // every native / GPU resource of that window is torn down again.
    const editor = try application.createWindowWith(.{
        .window = .{ .width = 900, .height = 700, .title = "Editor" },
    }, mountEditor);

    _ = try application.createWindowWith(.{
        .window = .{ .width = 480, .height = 700, .title = "Preview" },
    }, mountPreview);

    _ = application.activateWindow(editor.windowId());
    try application.run(); // returns after the last window closes or quit()
}

每次 createWindow* 返回一个 *App,配置项与单窗口 App 相同(window、console、frame_pacing 等)。createWindow 只创建不挂载,需要自己调用 app.mount(mountFn);createWindowWith 把创建与挂载合成一个事务。仓库的 zig build multi-window 示例同时演示两种写法。Each createWindow* call returns an *App and takes the same config as a single-window App (window, console, frame_pacing, …). createWindow creates without mounting, so you call app.mount(mountFn) yourself; createWindowWith does both as one transaction. The repo’s zig build multi-window example exercises both.

隔离保证Isolation guarantees

PER-WINDOW STACK
事件按 window_id 只进入一个窗口;共享的业务模型在窗口之外,由应用显式通知各窗口。An event enters exactly one window, chosen by window_id; the shared model sits outside and the app notifies each window explicitly.
01
每窗独立Separate per window

原生 window、Cx(含自己的响应式图)、字体上下文、Metal surface / renderer / device queue、IME 与辅助功能路由。Native window, Cx (with its own reactive graph), font context, Metal surface / renderer / device queue, IME and accessibility route.

02
事件隔离Isolated events

指针、键盘、IME 与拖拽按原生 window_id 路由;菜单命令按触发时捕获的 key window 路由,而不是按哪个窗口恰好取到了事件。Pointer, keyboard, IME and drag events route by native window_id; menu commands route to the key window captured when they fired, not to whichever window happened to dequeue them.

03
独立销毁Independent teardown

关闭一扇窗口只销毁它自己的资源,其他窗口继续渲染;最后一扇关闭后 run() 返回。Closing a window tears down only its own resources while the others keep rendering; run() returns when the last one closes.

真实的双窗口应用:被观察的 target 与 DevTools 各有独立的 Cx 和窗口。Harness 在工具窗里切到 Console 并过滤,target 窗口持续存活。A real two-window app: the observed target and DevTools each have their own Cx and window. The harness switches the tool window to Console and filters it while the target stays alive.

生命周期Lifecycle

APIAPI作用What it does
run()驱动循环,直到最后一扇窗口关闭或调用 quit()Drive the loop until the last window closes or quit() is called
tick()执行一轮 pump + 按需渲染,适合自己控制循环One round of pump + frame, for driving the loop yourself
closeWindow(id)关闭一扇窗口;在回调里调用时排队到安全边界再提交Close one window; from inside a callback it is queued until a safe boundary
quit()整个应用退出;剩余窗口由 deinit() 统一有序销毁Quit the whole app; remaining windows are torn down in order by deinit()
window(id)按 id 查找存活窗口,已关闭时返回 nullFind a live window by id; null once it has closed
activateWindow(id)把窗口设为活动窗口Make a window the active one
setMenuModel / bindMenuCommand菜单模型是进程全局的;命令回调按窗口绑定The menu model is process-global; command callbacks bind per window
close.zig
const preview_id = preview.windowId(); // preview: *App from createWindowWith

// Safe from inside an input / menu / render callback: the close is queued
// and committed at the loop's iteration boundary.
_ = application.closeWindow(preview_id);

// Look windows up by id instead of holding *App across a close.
if (application.window(preview_id)) |app| {
    _ = app; // still alive
}

共享状态策略Sharing state

跨窗口的业务模型由应用持有,每个窗口只保存自己的视图状态。每个 Cx 有独立的响应式图,因此不要让一个窗口的 Effect 读取另一个窗口 Scope 里的 Signal,也不要跨 Cx 共享 Node 指针或 Scope 资源。The cross-window model is owned by the app; each window keeps only its own view state. Every Cx has its own reactive graph, so don’t let one window’s Effect read a Signal from another window’s Scope, and never share Node pointers or Scope resources across Cx.

  • ✓

    模型在窗口之外。用普通结构体或应用级 Store 保存事实,生命周期长于任何一扇窗口。The model lives outside the windows. Keep facts in a plain struct or an app-level store that outlives any single window.

  • ✓

    每个窗口镜像自己需要的部分。窗口在自己的 Scope 里创建 Signal,模型变化时由应用显式写入各窗口的镜像。Each window mirrors what it needs. A window creates Signals in its own Scope; when the model changes, the app writes each window’s mirror explicitly.

  • ✓

    关闭时解除登记。窗口关闭后,模型不能再持有指向该窗口 Signal 的指针。Unregister on close. After a window closes, the model must not keep pointers to that window’s Signals.

zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30