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

组件配方:一个类如何带上它的 UXML 与 USS 资产

一个组件 = 一个类 + 可选的 UXML / USS

Vue.js 里,一个组件通常把结构、行为、样式收在同一个单文件组件里:.vue 内的 <template> / <script> / <style> 三段一起组成完整组件。import 一个组件,拿到的就是三段齐全的一份组件定义:模板交给编译器,样式跟随组件。这里想接近的是同样的编写体验和心智形态:拿到一个组件时,默认就把结构与样式一起考虑进去。只是编辑器资产体系里没有「一个文件放三段」的格式,所以只能拆开。在我们的体系里,组件 = 一个至少落到 TemplateElement 这层的类 + 它的 UXML / USS 资产。资产怎么和类对上(默认约定与显式标注)是下文「资产从哪来」的主题,这里先不展开。上一篇讲的是模板装配的基础继承链;本篇往前走一步,看装配的对象与装配本身。

先看脚本——一个最小组件的形状可以写成这样,类里没有任何构树代码,也没有任何装配语句:

// MyTemplateComponent.cs
public class MyTemplateComponent : TemplateElement
{
    public new class UxmlFactory : UxmlFactory<MyTemplateComponent, UxmlTraits> { }
    public new class UxmlTraits : VisualElement.UxmlTraits { }
}

对应的模板可以很简单:

<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement class="card">
        <ui:Label text="Hello Template" />
    </ui:VisualElement>
</ui:UXML>

结构写在模板里。模板文件默认与类同名、同目录。以这里这个最小例子来说,目录树长这样:

.../SomeFeature/
├── MyTemplateComponent.cs     # script:组件类(继承 TemplateElement)
├── MyTemplateComponent.uxml   # template:结构树声明

这个例子没有自己的 .uss,正好也说明:样式资产是可选的。Vue 把三段写进同一个文件;我们让三段各有落点——cs 就是类本身,UXML / USS 是跟在类后面的可选资产,一一对应:

Vue 组件(.vue)我们资产职责
<template>uxmlXxx.uxml结构长什么样
<script>csXxx.cs类、行为、对外契约
<style>ussXxx.uss长什么样

三类文件不要求齐全:USS 是可选的一行——很多组件没有自己的样式文件,装配照常发生,只是跳过样式那一步。缺哪类,哪一步就不发生。

实例化根组件后,资产自动归位

当最外层模板组件实例化时,装配就会自动发生,因为装载点在基类构造器里:

// TemplateElement.cs(节选)
public TemplateElement()
{
    LoadAssets();
}

private void LoadAssets()
{
    var type = GetType();                    // 派生的真实类型:MyTemplateComponent
    var metadata = ResolveTemplateMetadata(type);

    var vta = GetOrLoadVTA(type);
    if (vta != null)
    {
        StructuralRenderScope.CloneTree(vta, this);   // uxml → 挂成子元素树
        if (childCount > 0)
            TemplateRoot = this[0];                   // 模板根引用就位

        TemplateRuntimeAnnotations.MarkTemplateOwnedSubtree(this);
        TemplateMetadataResolver.ApplyRuntimeAnnotations(this, metadata);
    }

    var uss = GetOrLoadUSS(type);
    if (uss != null)
        styleSheets.Add(uss);                 // uss → 进样式表链
}

C# 的构造从基类往派生类执行,所以 TemplateElement() 先跑:拿到 GetType()(此刻已是派生类),先解析元数据,再克隆模板结构树,最后在存在样式资产时把 USS 挂进样式链。装配完成后构造函数才往下走,之后 UxmlTraits 的 Init 才有现成的树可找。TemplateElement 自己的注释把这个时序写得很硬:装载发生在基类构造期(LoadAssets),先于 UxmlTraits.Init 与任何后代注入——模板树必须先于属性注入存在,Init 才能按 name 找 到元素并赋值。通常只会在外层手动实例化一次根组件;从根组件往下的模板树和子组件装配,都是这条构造链的内部工作。

注意 LoadAssets 里取出的 metadata:它来自生成期收集器,记录了这个组件的模板里写了哪些 token。对这样一个最小例子来说,元数据可以是几张空表(无指令、无插值、无 ref、无 slot)——模板里什么都没写,蓝图就是空的。写了 token 会变成什么,是下一篇(token 规格)之后每一篇实现的主题。

资产从哪来:以类型为键

LoadAssets 不猜路径。真正找文件的是解析链,而它的输入只有 Type:

// TemplateElement.cs(节选)
public static string ResolveTemplatePath(Type type, string extension)
{
    for (var current = type; current != null && current != typeof(object); current = current.BaseType)
    {
        var assemblyName = current.Assembly.GetName().Name;
        if (!TemplateAssemblyIndexRegistry.TryGet(assemblyName, out var index))
            continue;

        if (!index.TryGetAsset(current, out var entry))
            continue;

        var path = extension == ".uxml" ? entry.UxmlPath
                 : extension == ".uss"  ? entry.UssPath  : null;

        if (!string.IsNullOrEmpty(path))
            return path;
    }
    return null;
}

两个设计点:

接驳点是类型,不是文件名约定。collector 在生成阶段把「Assembly → 类型 → 资产路径」整理成 [InitializeOnLoad] 的生成类,编辑器启动时注册进 TemplateAssemblyIndexRegistry——索引的键永远是 Type,路径只是值;同目录同名也好、[TemplatePath] 标注也好,最终都摊平成这一张「类型 → 路径」的表,形状近似这样:

// GeneratedTemplateAssemblyIndex_AssemblyName.cs(示意)
{ "Namespace.MyTemplateComponent, AssemblyName, ...",
  new TemplateAssetIndexEntry(
      "Assets/.../SomeFeature/MyTemplateComponent.uxml",
      null) },          // uss 是 null:这个组件没有自己的样式表

路径的最终裁决发生在生成索引那一刻,运行时只查表。摊平进这张表时,路径的来源有两个通道,显式优先。显式通道是这个标注——类上的 [TemplatePath]:

// TemplatePathAttribute.cs
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class TemplatePathAttribute : Attribute
{
    public string UxmlPath { get; }
    public string UssPath { get; }

    public TemplatePathAttribute(string uxmlPath, string ussPath = null) { ... }
}

一个属性同时给两个槽:.uxml 查 UxmlPath,.uss 查 UssPath(可选)——组件两边的资产都可以脱离默认位置被指走。标注指向的资产缺失时不报错,只记一条 warning、落回默认约定:显式指路是逃生舱,不是第二套约定。绝大多数组件连标注都不需要——默认约定是「脚本同目录、找同名文件」,文件摆对地方,索引自然收录。收录之后,根组件实例化触发的加载就是纯查表 + 资产加载,没有任何目录扫描。索引里 UxmlPath 为 null 的条目同样常见——纯样式组件只出 .uss,一样被收录、一样能被装配。

模板可以引用别的组件,装配递归发生。一个组件的 .uxml 里完全可以嵌着另一个组件标签:

<!-- MyTemplateComponent.uxml(节选) -->
<local:ChildComponent />

CloneTree 解析到组件标签时,会由模板系统内部递归实例化一个 ChildComponent——它同样落在 TemplateElement 这层,于是子组件的构造器再次走 LoadAssets:父装载 → 子装载 → 父继续。前置规则在每一层递归生效。也就是说,手动触发点通常只在根组件这一处;子组件的实例化与装配不需要手工介入。

解析沿继承链上溯:类型自己没被收录时,去祖先里找第一个收录过的。子类不吃自己的模板时,自动继承最近祖先的那份——模板跟着类走。

缓存与失效

编辑器里组件会被高频 new,所以加载结果按类型缓存:

private struct VtaCacheEntry
{
    public VisualTreeAsset Asset;
    public long LastWriteUtcTicks;
}
private static readonly Dictionary<Type, VtaCacheEntry> s_VtaCache = ...;

缓存条目带文件最后写入时间戳,这是踩过坑之后的形状:磁盘文件更新(collector 重写生成物、Reimport)后,时间戳失配,就强制 AssetDatabase.ImportAsset 再加载——否则会「改了文件不生效,旧模板树驻留到重启」。两个配套约束:只缓存加载成功的非 null 结果(资源暂缺时缓存 null 会永不失效);文件已被清理时先丢弃缓存条目再返回 null(避免陈旧对象驻留)。

为什么是这种拼装方式

为什么拆成三个文件,而不是一段代码里建树。 三个文件是三个关注点,也对应三种变更频率与三种工具面:结构(uxml)可以被 UI Builder 这类可视化工具打开,可以人肉 diff;样式(uss)走引擎样式级联,主题与状态(hover、disabled)在样式层表达;行为留在 C#,可测试可调试。改结构不动代码,改代码不动样式。更重要的一面:模板与样式是可选的——大量组件只出一份 .uss(或只出脚本),不需要为一个 Label 写模板。

为什么接驳点是类型,而不是每个组件手动装配。 如果每个组件构造里都写「加载我的 uxml、加我的 uss」,装配逻辑就散落在每个类里,无法统一演进;接驳收敛在一个点上:类本身。默认按类名找同目录同名文件,需要脱轨时在类上标 [TemplatePath]——两条都是写在类上的声明,摊平成同一张索引表。整条链的输入只是一个 Type,中间没有字符串资产名、没有手动注册、没有「忘了加载」。而索引本身成为全组件目录的单一真相——后面 collector 的校验、检查器工具都对着这张表。

为什么装载点在基类构造器。 两个理由。时序上:模板树必须先于 UxmlTraits.Init 存在,Init 才能按 name 找到元素并赋值——装载若拖到挂载期,属性注入就失去落点。语义上:实例化根组件即拿到完整壳体——使用组件的人拿到的是一个已经装配好的实例,不需要再自己去拼模板;而模板里声明的子组件,也会在同一条递归链上继续自动完成装配,正如 Vue 里 import 一个组件,拿到的是结构、行为、样式齐全的实例。而这条待遇的基础前提,是组件至少已经落到 TemplateElement 这层:落到这里,模板装配才会发生。

小结

组件配方 = 一个至少落到 TemplateElement 这层的类 + 它的 UXML / USS 资产——资产默认与类同名同目录,要脱轨时用 [TemplatePath] 显式指路;根组件实例化后,基类构造器完成自动装配,模板里声明的子组件再沿着同一条链递归装配。装配的输入只有类型,路径靠生成期索引,加载靠时间戳缓存。到这里,「模板系统」里的“模板”已经不只是静态文件,而是会随着组件实例化自动接上结构与样式的一套装配机制。下一篇(嵌套组件)就把这条递归装配链放进一个最小容器里,直接看父模板里声明子组件之后,子组件如何沿着同一条规则继续完成装配。

本文在 AI 协作下完成。