拆解 FUI Source Generator:从 Bind 声明到强类型绑定与路由

系列第 07 篇,面向已有 Unity UI 经验、准备设计框架的开发者。前提是理解 C# partial、Attribute 和事件;目标是沿 FUI 源码追踪登录表单从 Bind 声明到 BindingContext、BindingRegistry 与 Routes 的完整路径。

代码分为真实声明、结构化简和验收伪代码。本篇提供源码核对与测试步骤,不把尚未执行的 Unity/Player 验证写成通过;预期结果是能解释每一份生成代码解决了什么问题。

从登录页声明开始

以下声明使用 FUI 的真实 Attribute 与成员名。它是生成输入示例,不是完整登录功能:需要项目已接入生成器、配置对应 View 和 Element,并在程序集声明 ViewModule。SubmitAsync 刻意返回已完成操作,网络认证不在本篇范围内。

csharp 复制代码
using System.Threading;
using System.Threading.Tasks;
using FUI.Binding;
using FUI.Navigation;
using FUI.Presentation;
using FUI.Rendering.UGUI.Elements;

// 一个程序集只声明一次;已有 ViewModule 时复用已有配置。
[assembly: ViewModule("LoginDemo", PropertyMode = PropertyGenerationMode.Pure)]

[ViewContract("LoginView", "UI/LoginView")]
[RoutePolicy(Layer.Panel, CoverageMode = CoverageMode.Hide)]
public partial class LoginViewModel : ViewModel
{
    [ObservableProperty]
    [Bind("UserName", nameof(InputFieldElement.Text),
        bindingMode: BindingMode.TwoWay)]
    string userName;

    [ObservableProperty]
    [Bind("Error", nameof(TextElement.Content))]
    string errorMessage;

    [Command("Submit", nameof(ButtonElement.OnClick))]
    public ValueTask SubmitAsync(CancellationToken token) => default;
}

开发者表达的是关系,不是执行过程:哪一个 View、哪一个资源键、哪个 Element、数据往哪边走、哪个用户事件触发哪个命令。生成器的职责是把这些关系验证后翻译成属性、绑定上下文、工厂和 Route。

第一步:Roslyn 把源码变成语法与语义

Source Generator 不是文本替换器。Roslyn 首先提供两个层次的信息:

  • 语法树能快速回答"这是不是 class""有没有 Attribute 语法";
  • 语义模型能回答"它真正继承了哪个类型""这个 Text 是哪个类型上的成员""同名类型究竟来自哪个命名空间"。

只依赖字符串会留下很隐蔽的问题:

csharp 复制代码
// 错误思路:同名类型、别名和完整命名空间都会破坏判断。
if (classText.Contains("ObservableObject"))
    Generate(classText);

FUI 当前唯一的增量生成器入口是 BindingSourceGenerator。它先从语法节点中过滤 ClassDeclarationSyntax,再通过语义模型确认类型是否为 ObservableObject,并让 partial 类型只由第一个声明触发,避免相同 ViewModel 重复生成 HintName。

第二步:增量流水线筛选候选

下面是入口的教学伪代码,GetPrimaryObservableClass 与 GenerateAll 表示筛选和统一输出阶段,不是可单独编译的完整生成器:

csharp 复制代码
var classes = context.SyntaxProvider.CreateSyntaxProvider(
        static (node, _) => node is ClassDeclarationSyntax,
        static (ctx, _) => GetPrimaryObservableClass(ctx))
    .Where(static declaration => declaration != null)
    .Collect();

var input = context.CompilationProvider.Combine(classes);
context.RegisterSourceOutput(input, GenerateAll);

这里必须准确理解"增量"。无关语法节点会在前面被过滤,宿主也可以缓存流水线节点;但当前实现使用 Collect() 汇总候选,并把集合与整个 Compilation 合并,因为 Route 与 Binding Registry 需要看到程序集范围。

所以合理结论是:它具备增量生成基础,但不能仅凭实现了 IIncrementalGenerator 就宣称"改一个字段只重算一个字段"。如果要继续优化,应把每个 ViewModel 的局部产物和全局注册表拆成不同节点,再用 Roslyn step tracking 或真实编译数据验证。

第三步:先理解绑定关系,再输出源码

如果一边遍历语法,一边在分支里拼出订阅、解绑和注册表,同一条规则很容易被解释三次。先提取绑定信息,可以让这些输出使用同一组事实。下面是便于理解职责的概念模型,不是 FUI 当前某个类的逐字定义,也不表示当前流水线已经完全与 Roslyn 解耦:

text 复制代码
ViewContractModel
  ViewModelTypeName
  ViewName / AssetKey
  RoutePolicy
  Presenter
  PropertyBindings[]
  CommandBindings[]
  Dependencies[]

FUI 的 ContextInfoByAttributeGenerator 会读取 Attribute,形成 BindingManifestBindingContextGenerator 根据清单分别积累绑定、初始同步和解绑代码。RouteGenerator 则收集 ViewModel、Presenter、策略与依赖。当前入口仍将 Compilation 和语法声明传给这些内部生成阶段。把所有阶段进一步改成稳定值对象流水线,是可以继续做的优化,不应写成已经完成的能力。

一个常见坑是把 ISymbol 直接塞进所有缓存节点。Roslyn 对象不适合作为稳定的长期模型;需要细粒度增量时,应投影成具备稳定相等性的值对象,否则输入看似没变,下游仍可能重新计算。

第四步:让错误停在生成阶段

生成器最重要的产物不只是 .g.cs,还包括 Diagnostic。当前 RouteGenerator 中可以直接核对两条规则:

  • FUI0011:依赖指向没有可用 ViewContract 的类型。
  • FUI0012:静态 Route 依赖形成环。

其他错误要区分来源:nameof 引用了不存在的成员,通常由 C# 编译器报告;绑定声明相关规则由 Analyzer 和生成阶段共同承担;最终生成代码的类型不兼容,也可能在后续 C# 编译中暴露。不能把所有红线都说成同一种生成诊断。

如果 LoginViewModel 写了不存在的依赖,正确结果不是跳过并在运行时空引用,而是在源码位置给出可修复的编译错误。

Diagnostic 设计要回答三件事:哪里错、为什么错、怎么改。只有编号没有动作建议,开发者最后还是要钻进生成器源码猜规则。

第五步:生成 Observable Property

[ObservableProperty] string userName; 会被补成可观察属性。下面是只表达"比较、赋值、通知"顺序的教学伪代码;通知事件名与签名不作为真实 FUI API 引用:

csharp 复制代码
public string UserName
{
    get => userName;
    set
    {
        if (EqualityComparer<string>.Default.Equals(userName, value))
            return;

        var oldValue = userName;
        userName = value;
        UserNameChanged?.Invoke(this, oldValue, value);
    }
}

准确生成属性的难点不在模板,而在命名、继承、重复声明、泛型、可访问性以及 Pure/Mixed 属性生成模式。生成器只能增加源码,不能修改用户已经写下的类型,因此 partial 是连接手写声明和生成实现的关键机制。

第六步:生成强类型 BindingContext

BindingContext 生成器按目标路径与类型去重,为 Element 建立强类型字段。每轮绑定时缓存目标,解绑时清空字段;不是在整个应用生命周期里永久查找一次。下面保留实际 TryGetElement 调用形式,省略所在类型与命名空间:

csharp 复制代码
InputFieldElement userNameElement;
TextElement errorElement;
ButtonElement submitElement;

void CacheElements()
{
    userNameElement = View.TryGetElement<InputFieldElement>("UserName");
    errorElement = View.TryGetElement<TextElement>("Error");
    submitElement = View.TryGetElement<ButtonElement>("Submit");
}

随后分别生成:

  • ViewModel 属性变化时写入 Element;
  • TwoWay 绑定从 Element 事件写回 ViewModel;
  • Command 将 Element 事件连接到方法;
  • 初始同步;
  • 对称解绑;
  • 列表绑定与转换器所需的辅助函数。

运行时没有必要再读取 [Bind],也不需要每次更新时通过 PropertyInfo 查成员。Attribute 是编译器插件的输入语言,生成结果才是运行时执行路径。

第七步:生成 Route 与注册表

FUI 的 RouteGenerator 不只生成资产名字。它把 ViewModel 工厂、BindingContext 工厂、Presenter 工厂和 RoutePolicy 一起固化到 Route<TViewModel>

csharp 复制代码
// 教学化简:展示工厂的组成,省略命名空间与策略的其他参数。
public static Route<LoginViewModel> LoginView { get; } =
    GeneratedRouteFactory.Create<LoginViewModel>(
        "UI/LoginView",
        typeof(EmptyPresenter),
        static () => new LoginViewModel(),
        static (view, vm) => new LoginViewModel.LoginViewModel_LoginView_BindingContext(view, vm),
        static () => new EmptyPresenter(),
        new RoutePolicy(
            layer: Layer.Panel,
            coverageMode: CoverageMode.Hide));

示例没有声明自定义 Presenter,因此这里采用当前生成器的默认 EmptyPresenter;真实 BindingContext 是 ViewModel 内的嵌套类型。以上代码用于阅读生成结构,不应手写一份与生成文件并存。

此外还会生成 Routes.Initialize()、资产键解析和 Binding Registry。这样 Navigator 消费的是普通 Route 和委托,不需要扫描程序集来猜装配关系。

把输入和输出接起来:登录页最小验证闭环

仅仅看到工程里出现 .g.cs,还不能证明绑定可用。可以按下面顺序验证这个教学输入,避免把资源配置问题误判成 Roslyn 问题。

先在已有 FUI 工程中加入上面的 ViewModel,确认生成的 UserName 和 ErrorMessage 能被普通 C# 引用。然后检查 BindingContext 中是否出现输入框、错误文本和按钮的缓存字段,以及与这些目标对应的订阅和解绑代码。再检查生成的 Routes 是否包含 LoginView,以及资源键是否为 UI/LoginView。

接着配置登录 View:UserName 对应项目采用的具体 InputFieldElement 实现,Error 对应 TextElement,Submit 对应 ButtonElement。通过项目已经初始化的导航入口打开生成的 Route。不要把这个步骤误写成"生成器自动创建 Prefab":生成器只固化声明,资源创建与加载配置仍要由展示层和 Provider 完成。

以下是验收行为的伪代码,不是 FUI 测试辅助 API:

text 复制代码
打开登录页
设置 ViewModel.UserName = "alice"
断言:输入框显示 alice
模拟输入框输入 bob
断言:ViewModel.UserName == "bob"
点击 Submit
断言:测试版 SubmitAsync 的计数增加一次
关闭并重新打开页面,再点击一次
断言:计数只增加一次,而不是每次打开多增加一次

最后一个断言很重要:正向同步证明"连接上了",关闭再打开才能验证"断开过了"。如果只测文本显示,很容易漏掉旧监听累积的问题。真实测试还应验证绑定取消令牌与异步命令的异常观察;它们属于 BindingContext 生命周期,不靠按钮多加一个 try/catch 就能替代。

为什么不是几个字符串模板就够了

手写绑定适合关系很少、变化不频繁的界面,优势是断点直接、没有生成器维护成本。但每一条 TwoWay 都要同步维护正向、反向、初始值和解绑,少写任何一侧都可能暂时看不出来。

Editor 外部生成可以利用 Prefab 事实,适合把节点导出成代码;代价是必须管理"什么时候重新生成",并处理资产与 C# 的一致性。运行时反射更适合真正运行期才发现的类型,但把元数据解释、错误处理与 AOT 兼容成本留在了运行时。

Source Generator 的优势不是模板语法更高级,而是它可以在同一次编译输入上理解类型、报告错误并产出强类型连接。代价也明确:编译宿主兼容、稳定输出、诊断质量和增量粒度都由框架维护者承担。对 FUI 这样的固定页面关系,集中承担这份复杂度,能让业务开发者不用每页重新处理同一批容易漏写的细节。

为什么生成器看不见 Prefab

在 FUI 当前这条生成路径里,核心输入是 C# Compilation 与候选语法节点。它能确认 InputFieldElement.Text 的类型,却不知道 UI/LoginView 资产里是否真的存在名为 UserName 的节点。

因此 FUI 把验证拆开:

text 复制代码
C# 声明与类型关系 ------ Generator / Analyzer
Prefab 节点与组件事实 ------ Editor Validator
生命周期与资源时序 ------ EditMode / PlayMode 测试
AOT 与裁剪事实 ------ Player 验证

Source Generator 也可以通过 AdditionalFiles 接收外部数据,并非天生只能看 C#。如果先把 Prefab 导出成稳定快照,再显式交给生成器,也是一种可扩展方案;但需要保证快照更新、资源导入和编译顺序一致,且不能直接在编译宿主里假定可用 Unity AssetDatabase。FUI 当前将这部分交给 Editor Validator。只做其中一层,都不能覆盖另一层的事实。

自定义 Element 为什么不需要生成器特判

生成器通过语义模型读取绑定目标的真实类型与成员。自定义 Element 要满足 FUI 的 Element 契约,并使用生成器支持的绑定成员形态。比如 InputFieldElement.Text 实际是 BindableProperty<string>,并不是普通的 string 属性;反向绑定需要变化通知,Command 则需要匹配的命令契约。仅仅增加一个普通可写属性,不等于 TwoWay 就能工作。

错误方案是维护名字列表:

csharp 复制代码
if (typeName == "SliderElement") GenerateSlider();
else if (typeName == "ToggleElement") GenerateToggle();

每增加一个控件都要发布新生成器,扩展性实际上被锁死。语义驱动生成让框架依赖契约,而不是穷举具体控件。

如何验证这条流水线

下面是读者可以执行的验收清单,不是本次发布已经跑过全部 Unity/Player 测试的声明:

  1. 给定登录页声明,生成可编译的 Observable Property、BindingContext 和 Route。
  2. TwoWay 绑定同时包含正向同步、反向订阅和对称解绑。
  3. 给 Command 制造不兼容的签名,记录错误来自 Analyzer、生成器还是生成代码编译,确认能定位回声明。
  4. 制造静态 Route 依赖缺失或成环,检查 FUI0011 / FUI0012 以及错误位置。
  5. 相同输入连续运行得到稳定输出。
  6. 修改无关 class 后,生成结果内容不变化。
  7. 自定义 Element 不修改生成器源码也能生成绑定。
  8. Unity 导入后的 DLL 只作为 Analyzer,不进入 Player 运行时。

理解这条流水线以后,Source Generator 就不再是"会自动写代码的黑盒"。它更像一个小型编译器:前端理解声明,中间层建立模型并验证,后端输出普通 C#,而运行时只执行已经固化的关系。

Unity 集成的几个真实坑

Generator DLL 不是运行时插件

它必须作为 Roslyn Analyzer 导入,而不是进入 Player 运行时程序集。导入设置错误会让运行时尝试加载 Microsoft.CodeAnalysis 依赖,并引出程序集加载异常。

Roslyn 版本必须匹配宿主

本机 dotnet 能运行,不代表 Unity 内置编译宿主能加载相同依赖。生成器项目目标框架、Roslyn 包版本和 DLL 依赖都要以 Unity 实际宿主验证。

输出必须确定

生成代码不应包含时间戳、绝对路径或随机 ID;成员按稳定顺序输出,HintName 必须唯一。否则同样输入也会反复触发无意义编译。

不要让生成器互相依赖本轮输出

Roslyn 不保证一个生成器先产生的文件能被另一个生成器在同一轮按预期消费。FUI 把入口统一到一个生成器中,由内部阶段基于同一个 Compilation 和候选声明生成各自的输出。它们可以输出对其他生成类型的引用,随后一起编译;这不等于后一个阶段的语义模型已经包含前一个阶段刚 AddSource 的文件。

资料与源码索引

动手验证时,建议先让登录表单跑通正向、反向与重复打开三项检查,再测错误输入与增量重算。先证明行为正确,再谈缓存命中率。

下一篇预告:围绕血量文本、音量滑块和确认按钮,解释 OneWay、TwoWay、Command 的数据方向与选择边界。第 08 篇发布后补充同平台跳转。

相关推荐
古少侠2 小时前
DeepSeek长回答怎么保存成Word?避免逐段复制和漏内容
c#·word
玖玥拾2 小时前
Lua 基础语法(五)Unity xLua基础配置与 C# 访问 Lua
开发语言·unity·c#·lua
我是唐青枫2 小时前
C#.NET StructureMap 从依赖注入到项目实战
开发语言·c#·.net
z落落2 小时前
C#简易聊天室(服务器+客户端)
运维·服务器·c#
dcrenl2 小时前
C# 程序发布时加入发布时间
c#·版本号·发布时间
影寂ldy2 小时前
C# UdpClient 单播 三种通信版本
stm32·单片机·c#
qq_150841993 小时前
移植CVI2010+MySQL+CH341DLL到C#+SQLite+CH341DLLA64(更新)
sqlite·c#·ch341dlla64
Lost of 程序猿3 小时前
C# 12 / .NET 9 新特性机制深度:编译器替你写了什么,哪些糖吃下去会出事
windows·c#·.net
z落落6 小时前
C# WinForm Socket 转串口设备服务器+Modbus CRC16校验+Socket 客户端
服务器·开发语言·c#