项目结构Project structure
把构建入口、业务界面、样式和资源放到可持续扩展的位置,并弄清每块内存归谁所有。Put the build entry, feature UI, styles and assets where they can grow — and know who owns each piece of memory.
推荐结构Suggested layout
zenit 不强制应用目录。推荐让构建文件保持稳定,把每个功能的树结构、状态与样式放在同一个目录里,改一个功能时不必在仓库里来回跳。zenit doesn’t impose a directory layout. Keep the build files stable and put each feature’s tree, state and styles side by side, so changing one feature doesn’t mean hopping around the repo.
myapp/
├── build.zig
├── build.zig.zon
└── src/
├── main.zig
├── app_state.zig
├── assets.zig
└── features/
└── dashboard/
├── view.zig
├── state.zig
└── styles.zig文件职责File responsibilities
main.zigAllocator、App 初始化、根挂载Allocator, App init, root mount堆积具体页面 UIPiling up page-level UIview.zig节点树与事件连接The node tree and event wiring裸颜色、复杂数据变换Raw colors, heavy data transformsstate.zig业务状态与方法Business state and its methods长期持有可能被移除或重建的 Node 指针Long-lived pointers to nodes that may be removed or rebuiltstyles.zigToken 驱动的具名样式函数 fn (*const ui.ThemeTokens) ui.BoxStyleToken-driven named style functions, fn (*const ui.ThemeTokens) ui.BoxStyle发起 IO 或改变状态Doing IO or mutating state样式函数是纯函数,由 ui.boxStyled / ui.vstackStyled 等构造器在挂载时求值;切换主题时框架会用新 Token 重放它们,内联字面量样式则不会。Style functions are pure; builders like ui.boxStyled / ui.vstackStyled evaluate them at mount, and replay them with the new tokens when the theme changes — inline literal styles don’t get that.
// features/dashboard/styles.zig
const ui = @import("ui");
pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
return .{
.padding = ui.Padding.all(t.space._6),
.gap = t.space._4,
.background = t.color.bg_secondary,
.corner_radius = t.radius.xl,
};
}
// features/dashboard/view.zig
const S = @import("styles.zig");
const panel = try ui.vstackStyled(cx, S.card, .{});导入边界Import boundary
公开表面分两层:常用类型与构造器位于 ui.X,进阶能力按关注点位于 ui.<group>.X(ui.widgets、ui.fx、ui.events、ui.theme 等)。两层之外的一切都是内部实现。The public surface has two tiers: common types and builders live at ui.X, advanced features are grouped under ui.<group>.X (ui.widgets, ui.fx, ui.events, ui.theme, …). Everything outside those two tiers is internal.
const ui = @import("ui");
// Tier 1: what most UI code needs
const Node = ui.Node;
const Signal = ui.Signal;
// Tier 2: grouped by concern
const Router = ui.fx.Router;
const Button = ui.widgets.Button;
const Event = ui.events.Event;这条边界由构建系统保证:zenit.attach 只给应用加上 ui 与 zenit_app 两个模块,render、gpu 等内部模块在应用里根本导入不到(架构图见手册首页)。The build system enforces this: zenit.attach gives the app only the ui and zenit_app modules, so internal modules such as render and gpu can’t be imported at all (see the diagram on the Overview).
所有权与生命周期Ownership and lifetimes
两个所有者,两种寿命。Cx 是窗口级的:它持有节点树和 cx.bindState 分配的结构体,直到 Cx.deinit。Scope 持有响应式资源:Signal、Memo、Effect、onCleanup 回调和登记的资源,在 dispose() 时释放;子 Scope 随父 Scope 一起释放。Two owners, two lifetimes. Cx is window-level: it owns the node tree and every struct allocated by cx.bindState until Cx.deinit. Scope owns reactive resources — Signals, Memos, Effects, onCleanup callbacks and registered resources — and frees them on dispose(); child scopes go with their parent.
bindState 的结构体——它们活到窗口关闭。Cx.deinit 的顺序是:dispose 根 Scope → 释放节点 → 释放 state。Disposing a page Scope does not free the structs you bindState-ed on that page — they live until the window closes. Cx.deinit runs: dispose the root Scope → free nodes → free state.绑定状态:声明 pub deinit 即可Bound state: declare a pub deinit
如果结构体声明了 pub deinit(*T),框架会在 Cx.deinit 释放它之前自动调用。需要 allocator 的清理,把 allocator 存成字段。If the struct declares a pub deinit(*T), the framework calls it automatically before freeing the struct at Cx.deinit. If cleanup needs an allocator, store one as a field.
const Item = struct { title: []const u8 };
const Model = struct {
allocator: std.mem.Allocator,
items: std.ArrayList(Item) = .empty,
// Must be pub: Cx.deinit detects it and calls it before freeing the struct.
pub fn deinit(self: *Model) void {
self.items.deinit(self.allocator);
}
};
const model = try cx.bindState(Model, .{ .allocator = cx.allocator });
// Do NOT also call scope.onCleanup(Model, model, Model.deinit):
// that would run deinit twice.页面级资源:用 onCleanupPage-lifetime resources: onCleanup
只有两种情况需要 scope.onCleanup:清理函数不是 pub,或者资源必须跟随某个 Scope(例如一个页面)而不是整个窗口释放。这类对象自己分配,不经过 bindState。Reach for scope.onCleanup in just two cases: the cleanup isn’t pub, or the resource must follow a Scope’s lifetime (say, a page) rather than the window’s. Allocate such objects yourself instead of through bindState.
const PageCache = struct {
allocator: std.mem.Allocator,
rows: std.ArrayList(Item) = .empty,
fn release(self: *PageCache) void {
self.rows.deinit(self.allocator);
self.allocator.destroy(self);
}
};
fn mountDashboard(cx: *ui.Cx, parent: *ui.Scope) !*ui.Node {
const scope = try parent.childScope();
// Page-lifetime resource: freed when this scope is disposed,
// not when the window closes.
const cache = try cx.allocator.create(PageCache);
cache.* = .{ .allocator = cx.allocator };
scope.onCleanup(PageCache, cache, PageCache.release) catch |err| {
cx.allocator.destroy(cache);
return err;
};
return ui.vstack(cx, .{ .gap = cx.tokens.space._4 }, .{});
}检查清单Checklist
- ✓
main.zig 保持短小。只做 allocator、
App.init与runWith。Keep main.zig short. Allocator,App.initandrunWith— nothing else. - ✓
只导入 ui 与 zenit_app。需要的能力不在两层公开表面上时,先提 issue,而不是绕进内部目录。Import only ui and zenit_app. If something you need isn’t on the two public tiers, file an issue instead of reaching into internals.
- ✓
窗口寿命用 bindState,页面寿命用 Scope。前者声明 pub
deinit,后者用onCleanup,二者不要叠加。Window lifetime → bindState, page lifetime → Scope. Give the former a pubdeinit, the latter anonCleanup— never both. - ✓
样式写成具名函数。放进 styles.zig,由
*Styled构造器消费,主题切换自动生效。Write styles as named functions. Put them in styles.zig and consume them with the*Styledbuilders so theme switches just work.