起点:我为什么会盯上这件事
这几年我在 Unity 里做过不少编辑器工具:行为树编辑器、角色编辑器、多面板配置界面。它们都跑在 Unity Editor 里,UI 层选择的是 UITK——Unity 2022 内置的新一代 UI 系统,用 UXML(类 XML 的标记语言)描述结构、USS 描述样式、事件系统处理交互,心智上和网页的 HTML / CSS / DOM 很接近。
UITK 比老一代的 IMGUI 舒服得多,但真开始写编辑器界面时,我还是会反复撞到几类老问题:
- 样板构树:手写一棵树往往意味着
new Label(...)、new Button(...)、Add(...)的连环调用。结构稍微深一点,代码就淹没在构树过程里,读不出「这个界面长什么样」。 - 手动同步:界面上一个开关的状态来自某个配置字段;字段变了,往往还得自己找到那个控件再赋值。改一处漏一处是常态。
- 粗粒度刷新:当界面结构随状态变化时,工程上很容易退回到
Clear()后整棵重建的写法。这样虽然直接,但滚动位置、展开折叠、输入焦点这些控件内部状态也会一起清零。 - 生命周期分散:元素挂到 panel、从树上移走、重新挂回时,事件注册、解绑与时机控制常常散落在各处
RegisterCallback里,顺序和边界需要开发者自己兜住。
一句话:UITK 给了我「文档结构 + 样式 + 事件」的底层,但「把数据变成界面、把界面变回数据」这件事,还是要我一次次自己拼。
背景:这是一个从兴趣里长出来的实验
先交代一下这个系列的背景:我做了 10+ 年游戏开发,最近几年又大量接触 Web 技术栈。Vue 的模板与响应式、TypeScript 的类型系统、前后端工程里那些「小而锐利」的设计,我都越来越喜欢。
这种喜欢没有停留在 Web 里。回到 Unity 编辑器写工具界面时,我总会反复想到一件事:能不能也用更声明式的方式描述界面,把增量交给框架,把复杂度关进编译器?
所以这套系统从一开始就不是某条业务需求直接推出来的结果,而是一个从个人兴趣里长出来的实验。我想看看,那些我在 Web 世界里喜欢的设计哲学,到了 UITK / C# 这块地基上,究竟能长成什么样。它的骨架也因此天然带着 Web 的印记,比如「编译前端与运行时分工」「语法糖在生成期消化、运行时保持最小模型」「所有权与生命周期显式化」。本系列后面会反复回到这几条线。
为什么是 Vue:一张对照表,和它背后的一套分工
如果你长期写 Vue,前面那一列痛点会立刻弹出熟悉的解法:
| 痛点 | Vue 3 的答案 | 我们想要的东西 |
|---|---|---|
| 样板构树 | 模板编译 | authoring 里写结构,编译器负责变成树 |
| 手动同步 | 响应式系统 | 值变了,依赖它的文本/属性自动更新 |
| 粗粒度刷新 | 声明式结构更新 + keyed identity | 用条件/循环声明结构变化,并尽量保住已有节点身份 |
| 生命周期分散 | 生命周期阶段 + 副作用清理 | 明确的挂载/完成/卸载阶段与统一清理边界 |
尤其吸引我的是 Vue 3 那种「模板先编译、运行时再消费」的分工。{{ }} 插值、v-if / v-for、v-model、slot 都不是魔法,而是一个编译前端 + 运行时的组合:前者把 authoring 变成可消费的编译产物,后者据此建立绑定、驱动结构更新与内容投影。
于是问题就变成:这些概念能不能平移到 Unity 编辑器里,用 C# + UXML 重新做一遍?
我的做法:给 UITK 叠一层「编译前端 + 运行时」
我没有发明新的底层,整套东西完全长在 UITK 之上,大致分成三段:
- Authoring(编写侧):还是 UXML 文件,但允许在里面写模板指令——纯文本子节点、
{{ }}插值、v-if、v-for、on-click、v-model、ref、v-slot。 - 编译前端(生成期):编辑器导入时,一套收集器(collector)解析 UXML,做语法糖规范化(比如把裸文本变成
ui:Label)、表达式编译、结构蓝图生成,并把产物落盘成生成资产与元数据索引。 - 运行时(构造期装配 + 挂载后激活):模板组件最终落在
TemplateElement这层;构造时按类型查索引、加载自己的生成 UXML / USS、克隆成树,挂载稳定后再由指令编译器建立响应式绑定,并由完成链驱动生命周期。
整套系统的最短链路,是一条再简单不过的链:类 → 模板资源 → UI。最小形态像这样:
// 一个「空壳」模板组件类 —— 没有任何构树代码
public class MyTemplateComponent : TemplateElement
{
public new class UxmlFactory : UxmlFactory<MyTemplateComponent, UxmlTraits> { }
public new class UxmlTraits : VisualElement.UxmlTraits { }
}
对应地,界面写在 authoring 的 UXML 里——类只声明自己的身份,剩下的接驳工作全部交给编译前端与运行时。对组件使用者来说,通常只需要拿到最外层那个模板容器实例;从它往下的静态树生成、以及模板里声明的自定义子组件实例化,都会在模板系统内部递归发生。后面你会看到,真正的运行时链也不是单点动作,而是先装配出静态树,再在稳定挂载点接上绑定与结构语义。
取舍的原点:它首先是一个编辑器侧实验
先划清边界:这套系统不是给游戏运行时做 UI 的。玩家看到的 HUD、菜单属于 Runtime UI,是另一条线。它的使用场景只有一类:编辑器工具。
这个坐标几乎解释了后面的大部分取舍:编辑器界面相对小,迭代快,服务的是内容生产侧的工作流,所以我可以接受编译前端、生成资产和元数据索引这类机制层的存在,让 authoring 侧获得更自然的表达。很多设计决定——为什么敢做生成期规范化、为什么指令编译要后置到稳定挂载点——都能从这里一路推回去。
系列路线图
篇章沿「实现」这条线展开:
| # | 主题 | 讲什么 |
|---|---|---|
| 00 | 前言(本篇) | 背景与动机 |
| 01 | Hello World:第一个模板组件 | 最小模板组件、最小 UXML、最小宿主窗口,以及第一张无 USS 的运行截图 |
| 02 | 继承链:模板组件由哪四层组成 | 运行时基座逐层拆解:从 VisualElement 到 TemplateElement |
| 03 | 组件配方:UXML / C# / USS 如何拼成一个组件 | 三块资产、根组件实例化后的自动装配、为什么这样设计 |
| 04 | 嵌套组件:父模板里如何声明子组件 | 父模板里声明子组件,递归装配如何开始成立 |
| 05 | 模板 token 规格:authoring 语言的契约 | token 全集、语义契约与管线归属 |
| 06 | token 开始生效:先看几类最小结果 | 先看一个 token 版的 hello world:文本插值、事件和值变化、条件分支怎样开始落到界面上 |
| 07+ | 更多 | 接下来会继续进入表达式抽象、collector / metadata、运行时分层,以及最后的整体回看 |
这篇先把背景、动机和实验边界讲清楚;后面的文章再一步步回到实现细节。