系列 · 为 UITK 写一套模板系统 第 2 / 7 篇

Hello World:第一个模板组件

最小样本

先看一个最小的模板组件样本,也就是这套模板系统里的 Hello World。

一个最小的模板组件样本,其实只需要三样东西:组件类、同名 UXML,以及一个负责挂载实例的宿主窗口。把这三样接起来,模板系统里最短的那条链就成立了:类 → 模板资源 → UI。本篇只看这条链,不引入状态、指令或结构更新。

对应到这个示例 app,目录结构如下:

TemplateBlogApp/
├── TemplateBlogAppWindow.cs
└── sfc/
    ├── TemplateBlogAppLayout.cs
    └── TemplateBlogAppLayout.uxml

这里的组件资产只包含 cs + uxml。

最小模板组件

组件类本身保持在最小形态。它不手写构树,也不声明额外语义,只把自己放到模板装配链上:

using UnityEngine.UIElements;
using Zhujing.Editor.UI.Framework;

namespace Zhujing.Editor.UI.Examples.TemplateBlogApp
{
    public class TemplateBlogAppLayout : TemplateElement
    {
        public new class UxmlFactory : UxmlFactory<TemplateBlogAppLayout, UxmlTraits> { }

        public new class UxmlTraits : VisualElement.UxmlTraits { }
    }
}

这个类只说明两件事:组件已经落到 TemplateElement 这一层,所以构造期会进入模板装配链;同时它自己不写任何构树代码,结构完全交给同名 UXML。

与之配对的模板文件同样很小:

<?xml version="1.0" encoding="utf-8"?>
<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement>
        <ui:Label text="Hello World" />
    </ui:VisualElement>
</ui:UXML>

这里只有一个 Label。这不是为了追求简陋,而是为了把问题收窄到最基本的判据:模板有没有被装配出来。只要窗口里能看到这句 Hello World,就说明“同名类 + 同名 UXML”的接驳已经成立。

宿主窗口只负责提供挂载位置。它不参与模板装配,只做 EditorApp.Create(this) 和 Mount(new TemplateBlogAppLayout()) 这两件事。这里的 EditorApp 是项目里的宿主脚手架,用来处理 EditorWindow 的挂载与释放;它不是模板系统本身的一部分。

using UnityEditor;
using UnityEngine;
using Zhujing.Editor.Core.Hosting;

namespace Zhujing.Editor.UI.Examples.TemplateBlogApp
{
    public sealed class TemplateBlogAppWindow : EditorWindow
    {
        private EditorApp _app;
        private TemplateBlogAppLayout _layout;

        [MenuItem("Zhujing/Examples/UI/Template Blog App")]
        public static void ShowWindow()
        {
            var window = GetWindow<TemplateBlogAppWindow>();
            window.titleContent = new GUIContent("Template Blog App");
            window.minSize = new Vector2(520f, 320f);
        }

        public void CreateGUI()
        {
            // Host scaffold only: mount the root element into the EditorWindow.
            _app?.Dispose();
            _app = EditorApp.Create(this);

            _layout = new TemplateBlogAppLayout();

            _app.Mount(_layout);
        }

        private void OnDisable()
        {
            _layout = null;

            _app?.Dispose();
            _app = null;
        }
    }
}

职责边界到这里已经很清楚:窗口层只拿根组件实例,真正把 Hello World 长出来的,是 TemplateElement 构造期内部那条“按类型找模板、再把模板树克隆出来”的链。

在这个版本的样本里,组件目录下还没有独立的同名 USS 资产,也没有额外加入代码侧样式定制;界面结果如下:

Template Blog App 的 Hello World 初始截图
在未额外引入独立样式资产时,Hello World 以较接近引擎默认的布局出现在窗口里。

这个结果已经足够说明本篇的核心事实:模板组件不是靠手写 new Label() 长出来的,而是通过类与同名 UXML 自动接上的。

加上样式

结构接上之后,再看样式这一层。样式资产落在同名 USS 上。为了让样式有稳定的挂载点,UXML 这里给根节点和文字各加一个 class:

<?xml version="1.0" encoding="utf-8"?>
<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement class="template-blog-root">
        <ui:Label text="Hello World" class="template-blog-title" />
    </ui:VisualElement>
</ui:UXML>

对应的同名 USS 写法如下:

.template-blog-root {
    flex-grow: 1;
    align-items: center;
    justify-content: center;
}

.template-blog-title {
    font-size: 20px;
    white-space: normal;
}

加入样式资产之后,目录结构变成:

TemplateBlogApp/
├── TemplateBlogAppWindow.cs
└── sfc/
    ├── TemplateBlogAppLayout.cs
    ├── TemplateBlogAppLayout.uxml
    └── TemplateBlogAppLayout.uss

这份 USS 的作用很直接:根节点撑满可用空间,Label 被推到窗口中心,同时字号也从默认值抬高了一档。模板装配链没有任何变化,变化只发生在样式层。

Template Blog App 补上 USS 后的 Hello World 截图
补上同名 USS 后,Hello World 获得了一个明确的居中布局。

小结

这个 Hello World 例子把模板组件最基本的几件事放在了一起:组件类提供类型入口,同名 UXML 提供结构,宿主窗口负责挂载,而同名 USS 只处理样式结果。模板装配与样式表达在这里已经分工清楚。

有了这个最小样本,后面的继承链、组件配方和 token 规格才有共同的落脚点。

本文在 AI 协作下完成。