FUI 验证实战:从 Prefab 节点改名到生成诊断与构建门禁

系列第 14 篇,面向已有Unity/C#经验的框架开发者。目标是从一个被改名的登录按钮出发,建立可定位、可测试的验证链路。代码沿用当前FUI验证API;教学伪代码与未执行的测试会明确标记。

一个按钮改名,为什么能绕过全部 C# 编译

主案例是登录面板:ViewModel 的提交命令绑定名为 Submit 的按钮。程序员只检查C#编译成功,美术把Prefab里的节点改成Confirm;这时生成器看到的源代码一字未改,根本没有新事实可以报告。直到玩家走到登录失败后的重试分支,才发现按钮没响应。

下面是"看起来兼容、实际掩盖损坏"的错误教学代码,不是 FUI 实现:

csharp 复制代码
// 错误:把必需控件丢失降级成静默无功能。
var button = root.transform.Find("Submit")?.GetComponent<Button>();
if (button != null)
    button.onClick.AddListener(Submit);
// 缺失时也向外报告页面打开成功。

加一个空判断只能阻止异常,不会让用户完成登录。换成全局Find或找"第一个Button"更危险:页面不崩了,可能绑定到了取消按钮。框架应该把"必需目标不存在"反馈给资产作者,而不是让每个业务页自行发明容错规则。

现在做四次独立变更,每次只改一个事实。第一次把C#成员名拼错,预期由编译或Analyzer指出;第二次只改Prefab节点名,预期由资产验证报告缺失;第三次保持名字却替换Element类型,预期报告类型不匹配;第四次把正确节点放到嵌套容器里,预期报告跨边界。这样可以证明检查真的看到了对应事实,而不是所有用例都被同一个更早的空引用截断。

验证器怎样把代码契约和资产接在一起

ViewValidator.ValidateAll 先发现 Route,再通过 IViewAssetResolver 找资产。默认解析器从 AssetKey 取文件名,在 AssetDatabase 中筛出同名Prefab,只有候选恰好一个时成功。它不是任意资源后端的完整解析器:项目用了自己的Addressables标签、同名资源或远程映射,就应提供准确的Editor解析策略,不能把"运行时能加载"当成默认Editor解析必然成功。

进入 ValidatePrefab 后,代码用 LoadPrefabContents 打开资产,并在 finally 中 UnloadPrefabContents。这很重要:验证失败不能在编辑器里遗留一批加载出来的临时对象。随后分别收集全部Element与不越过容器的可达Element,BindingCatalog 提供生成的 BindingManifest,验证器逐一比对属性和Command的目标。

这里不是简单判断"名字有没有"。匹配先找精确类型,没有才用 IsAssignableFrom 查兼容类型;重名检查以名字和具体类型分组,绑定目标还会检查可赋值候选是否产生歧义。不能写成"所有同名节点一律禁止",也不能只比较路径字符串就宣布唯一。

若可达集合没有目标,而全量集合中存在同名兼容类型,错误会指出嵌套边界;可达集合有同名但类型不对,则报告期望与实际类型;两者都没有才是缺失。它们修复动作不同:恢复名字、恢复组件、调整边界或修改绑定契约。把这几种都折叠成 NullReferenceException,就失去了最有价值的上下文。

还要区分两个入口。ValidateAll 沿 Route 进入时会检查页面 Canvas、CanvasGroup、GraphicRaycaster 等结构,并要求相应 Manifest 存在;公开 ValidatePrefab(path) 走独立Prefab路径,没有同样的 Route 上下文。单独验证一个Prefab没有报错,不代表已验证所有Route映射和默认页面的完整性。

最小可执行入口:先让失败能被机器消费

下面是项目新增的Editor菜单,不是框架已有菜单名。放到Editor程序集,引用 FUI.Editor。例子验证当前选中的Prefab并汇总结构化错误;没有调用任何假想的 ViewContract 构造函数。注意它是局部检查,不替代ValidateAll的构建门禁。

csharp 复制代码
using System.Linq;
using FUI.Editor.Validation;
using UnityEditor;
using UnityEngine;

public static class SelectedFuiPrefabCheck
{
    [MenuItem("Project/Check Selected FUI Prefab")]
    static void Check()
    {
        var path = AssetDatabase.GetAssetPath(Selection.activeObject);
        if (string.IsNullOrEmpty(path) || !path.EndsWith(".prefab"))
        {
            Debug.LogError("请在Project窗口选择一个Prefab资产");
            return;
        }
        var issues = ViewValidator.ValidatePrefab(path);
        foreach (var issue in issues)
        {
            if (issue.Severity == ValidationSeverity.Error)
                Debug.LogError(issue.ToString(), issue.Context);
            else
                Debug.LogWarning(issue.ToString(), issue.Context);
        }
        var errors = issues.Count(x => x.Severity == ValidationSeverity.Error);
        Debug.Log($"检查完成:{path},错误 {errors},问题总数 {issues.Count}");
    }
}

ValidationIssue 当前包含 Severity、AssetPath、Message、Context,没有独立稳定Issue ID字段。消息能给人读,CI若长期按英文字符串匹配会比较脆弱;可以为自己的报告层加稳定分类,但不能宣称框架已经提供该字段。

实际 ViewValidationPipeline 已实现菜单、导入回调与构建前回调。构建前 ValidateAll 后统计 Error,只要大于零就抛 BuildFailedException。导入路径则会在 isCompiling 或 isUpdating 时直接返回,且只处理传入的已导入Prefab列表。这里没有一个保证补跑所有遗漏资产的复杂队列,因此"刚保存没日志"不是全项目验收证据;构建前的全量检查不可省略。

用真正的错误资产测试验证器,而不是Mock掉它

下面NUnit示例针对真实 ValidatePrefab。创建唯一命名的临时Prefab,故意缺少View组件,断言验证确实报告这项错误,并在finally只清理本测试创建的资产。测试需放在Unity Editor测试程序集,引用NUnit与FUI.Editor。本次未执行这些测试,以下是代码和预期,不是通过报告。

csharp 复制代码
using System;
using System.Linq;
using FUI.Editor.Validation;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;

public sealed class FuiAssetGateTests
{
    [Test]
    public void MissingView_IsReportedByRealValidator()
    {
        var path = AssetDatabase.GenerateUniqueAssetPath(
            "Assets/FuiGate_" + Guid.NewGuid().ToString("N") + ".prefab");
        var root = new GameObject("MissingView", typeof(RectTransform));
        try
        {
            PrefabUtility.SaveAsPrefabAsset(root, path);
            var issues = ViewValidator.ValidatePrefab(path);
            Assert.That(issues.Any(x =>
                x.Severity == ValidationSeverity.Error &&
                x.Message.Contains("missing a View component")), Is.True);
        }
        finally
        {
            Object.DestroyImmediate(root);
            AssetDatabase.DeleteAsset(path);
        }
    }
}

不要直接对生产Prefab做破坏性测试。其他用例在独立测试目录复制样本:同名同类型目标、可赋值类型歧义、跨嵌套边界、列表缺模板,各自断言期望问题,随后恢复测试资产。仓库 ViewValidatorTests 已有缺根View、同名同类型、根容器隔离及列表配置等用例,可沿其结构补自己的扩展测试;有这些用例不代表你项目的Variant和自定义Provider已覆盖。

生成器测试也一样。若直接比较"输出字符串非空",即使输出了一份永远不订阅事件的绑定也能过。应分开断言生成字段类型、路径、订阅与退订配对,再编译生成结果;Diagnostic用例还要检查位置和严重级别。FUI101描述符是Warning,不能把出现警告自动等价为构建已被阻断;项目是否把警告升级成门禁,需要明确配置。

框架 Demo 能打开设置页,只能证明一条顺序路径暂时工作。真正交付时还要面对 Prefab 被美术改名、生成器版本不兼容、异步加载中途取消、IL2CPP 裁剪、缓存压力,以及几十个旧页面不能一次重写。

一个 UI 框架是否成熟,最终要看它能否把这些风险变成稳定的反馈,而不是依赖作者记住所有规则。

先给结论

推荐建立四层验证:

  1. 编译层:Generator/Analyzer 检查 C# 契约和静态关系。
  2. 资产层:Editor Validator 检查 Prefab 中的 Element 与边界。
  3. 运行层:EditMode/PlayMode 测状态机、绑定、取消和所有权。
  4. 交付层:目标平台 Player/IL2CPP、裁剪、性能与包安装验证。

本文聚焦验证,不把所有列出的门禁都视为当前FUI已交付能力。现有实现、源码推断与项目应补的验证分别说明;渐进迁移留到第15篇。

为什么只靠单元测试不够

每种验证工具能看见的事实不同:

层级 看得见 看不见
Roslyn 类型、成员、Attribute、静态依赖图 Prefab 是否存在对应节点
Editor Validator AssetDatabase、Prefab 层级、组件类型 异步操作真实交错
EditMode/PlayMode 状态迁移、Unity 对象行为、场景集成 最终 AOT/裁剪差异
Player Build IL2CPP、Linker、平台资源 所有异常时序组合

把所有检查塞进运行时 Debug.LogError,反馈最晚;强行让 Source Generator 读取 Prefab,又会破坏编译输入边界。

第一层:编译期 Generator 与 Analyzer

从框架设计角度,以下是适合放在编译层的检查清单,并不意味着当前生成器都已给出专用诊断:

  • ViewModel 是否 partial、是否继承正确基类;
  • ViewContract 是否重复;
  • Observable Property 命名冲突;
  • Binding 源成员和目标能力是否类型兼容;
  • TwoWay 回写的 ViewModel 属性是否有 setter;
  • Command 签名是否受支持;
  • Presenter 工厂是否可生成;
  • RoutePolicy 枚举和数值是否合法;
  • 静态 Route 依赖是否成环。

Diagnostic 设计要包含:

text 复制代码
稳定 ID + 严重级别 + 精确位置 + 原因 + 修复动作

不要把所有失败都变成一个通用 Generator failed。当前 DiagnosticDescriptors 中 FUI005 描述的是 TwoWay 源属性 setter,FUI008 是生成器异常兜底,FUI101 是潜在类型不匹配警告;RouteGenerator 另外定义 FUI0011 缺少依赖和 FUI0012 依赖成环。描述符存在不等于每条输入路径都会触发它,应该继续追踪 ReportDiagnostic 的实际调用与测试。AttributeBindingAnalyzer 还有自己的规则,不能仅按编号表推断所有错误输出。RoutePolicy 部分数值仍由构造时校验,不能统一声称编译阶段已拦截。

Generator 测试怎么写

至少包含三类:

Golden Output

给定源码,比较生成的 .g.cs。适合验证稳定结构,但避免快照过大导致审查失焦。

Diagnostic

每条规则都有最小错误样例,检查 ID、位置和消息关键内容。

Incremental/Determinism

同一输入运行两次输出一致;修改一个 ViewModel 后观察 tracked steps,确认哪些节点重算。不要只测试"最终能编译"。

第二层:Prefab Validator

Roslyn 看不见以下事实:

  • Volume Element 是否真的存在;
  • 是否出现两个同名 Element;
  • 节点上实际是 SliderElement 还是 TextElement;
  • 父 View 是否越过嵌套 View 边界;
  • 资源键能否解析到正确 Prefab。

当前 FUI 的公开入口是 ValidateAll(IViewAssetResolver resolver = null) 与 ValidatePrefab(string assetPath)。下面这个签名只是一种其他框架可采用的概念设计,不是FUI现有API:

csharp 复制代码
public static IReadOnlyList<ValidationIssue> Validate(
    ViewContract contract,
    GameObject prefab)

返回结构化 Issue,而不是只打日志,才能被 Inspector、CI 和构建报告共同消费。

Prefab 验证最难的边界

嵌套 View

FUI 当前扫描在节点存在 IContainerElement 时停止深入子节点,容器本身仍被收集;根节点上的容器也形成边界。不是运行时读取 C# ViewContract Attribute 来决定是否停止。嵌套 View 若没有容器隔离,会单独报错。

Variant 与实例覆盖

基 Prefab 正确不代表 Variant 没删除组件。需要确定验证的是最终可实例化资产,还是所有变体。

动态节点

运行时创建的列表项不能按静态 Prefab 强制存在。设计上需要表达模板和动态实例的区别,但不能虚构FUI已有一个通用 Optional 特性。当前列表模板与容器有专门验证;兼容 View 的可选端点查询与默认 Route 的完整性要求不同,不能把缺失都当成错误,也不能为了兼容而把所有缺失都忽略。

名称稳定性

用名字绑定需要重命名工具或 Validator 反馈。更稳定的 GUID 方案会增加序列化与复制复杂度,没有绝对免费答案。

第三层:运行时状态与所有权测试

Navigator 应尽量依赖测试替身,而不是每个测试都真的加载场景。以下仅为构造思路伪代码,省略号不是可编译的FUI构造函数:

csharp 复制代码
var provider = new FakeViewProvider();
var transition = new ControlledTransition();
var navigator = new Navigator(provider, transition, ...);

ControlledTransition 可以手动决定 await 何时完成,从而构造竞态:

text 复制代码
Open → 卡在 Loading → Close → 完成 Loading
Open → 卡在 Entering → Back → 完成 Entering
Close → 卡在 Exiting → 再次 Close

断言的不只是 State,还包括:Lease Dispose 次数、事件订阅数、历史顺序、Owners 集合和终态 Handle。

PlayMode 要验证哪些 Unity 事实

  • SetActive 与 Animator/Coroutine 的实际行为;
  • Canvas sorting、Raycast 和焦点恢复;
  • SetValueWithoutNotify 是否真的不产生用户回写;
  • Destroy 延迟到帧末时是否访问已销毁对象;
  • 场景切换和 Domain Reload 下静态注册表是否正确初始化;
  • Addressables/Resources Provider 的真实释放配对。

PlayMode 测试数量可以少于纯逻辑测试,但要覆盖引擎语义差异。

第四层:Player、IL2CPP 与代码剥离

Editor 使用 Mono 成功,并不代表 IL2CPP 构建安全。UnityLinker 会删除静态分析认为不可达的代码,反射路径可能需要 [Preserve]link.xml

Source Generator 产生的直接类型引用通常更容易被静态分析,但仍要在项目实际裁剪级别验证:

  • 核心 Route Registry 是否初始化;
  • Presenter/Converter 构造器是否保留;
  • 可选反射插件是否有精确保留规则;
  • 不同 asmdef 和 Package 导入是否正确;
  • Generator DLL 没有进入 Player 运行时程序集。

门禁应该构建一个最小真实 Player 并自动打开代表性页面,而不是只检查 Build 成功。

性能应该测什么

不要用"没有反射所以零开销"或"有绑定所以一定慢"下结论。分开测:

编译期

  • 修改单个 ViewModel 后 Generator wall time;
  • tracked steps 重算范围;
  • Unity Domain Reload/脚本编译时间;
  • 生成源码体积。

运行时

  • 首次 Open 与缓存 Open 的耗时;
  • 每次 Bind 的分配;
  • 高频属性通知吞吐;
  • 列表滚动时 GC 与 Instantiate 数;
  • 缓存容量与内存占用;
  • Close/回滚后的资源引用计数。

性能测试要固定硬件、Unity 版本、构建后端和样本规模,并报告中位数/分位数,而不是只截一帧 Profiler。

可观测性:框架要能回答"现在为什么这样"

下面是建议提供的只读诊断快照格式,不是当前 FUI 的实际输出,也不对应一组可直接复制的枚举值:

text 复制代码
Handle 42
Route: SettingsViewModel
State: Covered
Layer: Popup
HistoryIndex: 3
Cache: LRU(2/5)
Owners: none
Dependencies: CurrencyBar#39
PendingOperation: none

调试面板还可以显示当前 Binding、资源 Lease 和最近状态迁移。日志应包含 Handle、Route 和 OperationVersion,避免只输出"UI open failed"。

诊断 API 必须只读,不能成为修改内部状态的后门。

验证体系的 Definition of Done

一个版本准备交付前,按项目交付目标逐项验证,未执行项保留为未完成,而不是看到源码有测试就打勾:

  • 核心 API 和状态迁移有文档;
  • Generator/Analyzer 规则有测试;
  • Prefab Validator 可在 CI/构建前运行;
  • Open/Close 异常与取消路径所有权归零;
  • 缓存、历史和依赖组合有测试;
  • 代表页面通过 PlayMode;
  • 目标平台 IL2CPP/裁剪构建通过;
  • 性能结论有基线数据;
  • 诊断能解释当前状态;
  • Diagnostic 或验证结果能指向具体源码/Prefab 位置并说明修复动作。

验证的目标不是证明 Demo 能跑,而是让错误尽可能在成本更低、上下文更完整的层次失败。下一篇会单独讨论如何把这些能力接入旧项目,而不是把验证与迁移挤在同一篇里。

为什么不是只做一种测试

只做运行时断言的好处是接入快、能看到动态事实,适合原型;代价是用户走到分支才暴露资产错误。只做Editor验证可以把资产问题提前,但不能证明关闭中途异步结果不会迟到,也不能证明目标平台裁剪安全。全靠端到端录制接近真实体验,却运行慢、失败定位难,而且容易漏掉很短的竞态窗口。

FUI的分层让验证也可以分层:生成代码与Manifest承担静态装配描述,Editor把它与Prefab事实对齐,核心状态机可以用可控替身测试,引擎和Player测试负责剩下的运行环境差异。从设计上推断,这种安排既缩短反馈,也让新手按照错误提示回到正确入口,而不是靠私下找GameObject"修到能跑"。

四层验证不是四倍重复劳动。每层应负责别人看不到的事实。相同Bug需要多层防御时,也要解释为什么:比如Close重复调用的幂等在核心测,真实Destroy延迟和组件事件在PlayMode测;不要把一个Mock测试复制四份就当成覆盖增加。

最后,增量生成器的名字不是性能证据。相同输入输出一致属于确定性,修改一个文件仅重算相关步骤属于增量范围,两者要分开测。源码里有 IIncrementalGenerator 不足以证明每个ViewModel独立缓存命中;tracked steps、编译时间和生成体积要用实际工具采样,本篇没有给出未经测量的提速倍数。

资料与源码索引

先用测试资产确认验证器会失败,再修复后确认对应错误消失,最后跑全量Route、运行时和目标Player验证。不要把一次Editor菜单通过当成全部交付证据。

下一篇预告:不推倒重来,把传统 UIManager 按 Route、ViewModel、Binding 与 Provider 分阶段迁移。第 15 篇发布后补充同平台跳转。

相关推荐
_zhourui_h_18 小时前
Unity AssetBundle 打包极限治理:依赖环、重复资源、包体膨胀,怎么在上线前全部揪出来?
unity3d
_zhourui_h_2 天前
Unity AssetBundle 极限管理:依赖、异步加载、引用计数、自动卸载到底怎么串起来?
unity3d
SmalBox2 天前
01-09-认知篇-对比-方案选型矩阵
unity3d·游戏开发
鑫鑫哥adam3 天前
用一个 struct 给游戏关键数值上锁:ProtectedInt 反作弊实践
unity3d
fujisheng6613 天前
FUI 导航实践:拆开 Layer、History、Coverage 与 Cache 的组合语义
c#·unity3d
SmalBox3 天前
01-08-认知篇-对比-其他资源管理方案
unity3d·游戏开发
SmalBox4 天前
01-07-认知篇-对比-原生AssetBundle工作流
unity3d·游戏开发
SmalBox5 天前
01-06-认知篇-对比-Addressable Assets深度解析
unity3d·游戏开发
fujisheng6615 天前
FUI 绑定生命周期实战:可靠解绑、失败回滚与列表项换绑
c#·unity3d