FUI 早期已经可以生成 BindingContext,但一次页面创建仍要经过程序集扫描、Attribute 解析和 Activator.CreateInstance。这形成了一个很容易被忽略的中间状态:绑定实现来自编译期,装配关系却仍由运行时重新推导。
这次重构的目标不是简单替换反射 API,而是让 Source Generator 同时生成属性通知、绑定生命周期、工厂注册和强类型 Route。本文用重构前后的实际调用链说明这条边界为什么重要,以及最终方案仍然保留了哪些代价。
最初的问题不是反射,而是重复劳动
一个设置页面,看起来只有几个字段:标题、音量、震动开关和关闭按钮。真正接到 UI 上以后,却会迅速长出一批机械代码:
- 属性变化时更新控件;
- 控件变化时回写 ViewModel;
- 按钮事件调用命令;
- 页面打开时完成初始同步;
- 页面关闭时成对取消订阅;
- 创建匹配的 BindingContext 和 Presenter。
这些代码不难,但它们有一个很典型的框架问题:业务含义很少,结构重复很多,而且遗漏一次解绑、写错一个成员名,都可能要等页面真的运行起来才能发现。
FUI 在 2024 年 7 月 4 日的初始提交中,已经出现了一个名为 FUICompiler 的独立项目。它不是嵌入 Unity 编译管线的生成器,而是一个 net6.0、win-x64、SelfContained 的外置可执行程序。它用 Roslyn 遍历类和 Attribute,把绑定描述整理成配置,再拼出 BindingContext 源码。
当时的核心思路已经成形:
csharp
var classDeclarations = root.DescendantNodes()
.OfType<ClassDeclarationSyntax>();
foreach (var classDeclaration in classDeclarations)
{
if (!Utility.TryGetClassBindingAttribute(classDeclaration, out var attributes))
continue;
// 读取属性绑定,构建 BindingConfig,再生成 BindingContext
}
这是一次很合理的起点:先把最重复、最容易漏的代码交给工具生成。
但外置编译器也把另一类复杂度带进来了。它必须独立启动,必须知道去哪里读取工程、把源码输出到哪里,还要处理工具版本、平台和 Unity 导入时机。更关键的是,当时不少信息直接来自语法文本,例如 property.Type.ToString()。它能"读到写了什么",却还没有完全利用编译器已经解析好的类型关系。
这个原型在同一天的下一次提交中就被删除了,因此不能把它描述成一个长期生产方案。它更像 Git 历史里保留下来的一次方向试验:代码生成值得做,但生成工具最好成为编译的一部分,而不是编译之外的另一个流程。
第一次转向:让 Source Generator 接管生成
后来 FUI 把生成过程迁进 Roslyn Source Generator。相比外置工具,这一步解决了三个实际问题:
第一,输入不再是工具自己寻找的一批源码文件,而是当前正在编译的 Compilation。生成代码天然跟随这次编译,不需要再维护"扫描、输出、导入"的额外顺序。
第二,生成器可以通过 SemanticModel 和 INamedTypeSymbol 理解真实类型。一个类是不是可观察对象、某个成员来自哪个基类、Attribute 参数指向什么类型,都可以在编译器的符号系统里判断,而不只是比较字符串。
第三,生成结果会回到同一次 C# 编译中。生成代码引用错类型、构造函数不匹配或命名冲突,编译器可以直接把问题暴露出来。
到架构重构前,FUI 已经有三个生成入口:
ObservableObjectGenerator生成属性通知;BindingContextGenerator生成绑定和解绑代码;BindingContextInfoGenerator生成供 Editor 使用的绑定清单。
这一阶段已经能解决大量样板代码,也支持了更丰富的能力。Git 历史可以看到,绑定系统随后逐步增加了 View 到 ViewModel 的双向回写、事件命令绑定、Descriptor 信息以及泛型 BindingContext。
问题也在这里开始变化:框架不再只是"生成一段属性监听",而是在维护一个不断扩展的关系网络。
中间形态的问题:生成器和运行时各有一套真相
如果只看生成出来的 BindingContext,这时似乎已经完成了编译期化。属性变化会调用生成的处理方法,双向绑定会生成反向订阅,解绑时也会移除相同委托。
然而,旧版运行时还保留着一个 BindingContextTypeResolver。它的静态初始化会遍历当前进程的全部程序集和类型:
csharp
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
ResolveAllBindingContext(type);
ResolveViewModelDefaultPresenter(type);
}
}
扫描之后,Resolver 再读取 BindingContext 上的 View、ViewModel Attribute,识别 Presenter 实现,并建立多组字典:
text
View 名称 -> BindingContext 类型
ViewModel -> BindingContext 类型
ViewModel -> Presenter 类型
派生类型 -> 可复用的基类 BindingContext
真正创建页面时,旧版 UIEntity.Create 先向 Resolver 查询类型,再调用:
csharp
var viewModel = Activator.CreateInstance(resultViewModelType);
var bindingContext = Activator.CreateInstance(
contextType, view, viewModel);
var presenter = Activator.CreateInstance(presenterType);
这暴露了一个比"反射有开销"更根本的问题:生成阶段知道一次类型关系,运行时又重新发现了一次。
生成器认为某个 ViewModel 应该生成哪个 BindingContext;运行时则通过 Attribute 和类型扫描重新推导。两边的规则一旦不同步,问题不会发生在写代码的地方,而会发生在页面第一次创建的地方。即使两边永远同步,运行时仍然承担了一项理论上已经做完的工作。
所以,FUI 面对的并不是简单的"反射还是生成代码"二选一,而是一种更容易被忽略的半编译期架构:
text
编译期:生成具体绑定实现
运行时:再次发现这些实现,并决定如何装配
这也是为什么"已经用了 Source Generator"还不等于"已经获得编译期架构"。如果运行时仍需要解释元数据,生成器只是减少了局部样板代码,并没有成为系统关系的唯一来源。
为什么功能越多,这个问题越明显
早期只有单向属性绑定时,运行时解析器要回答的问题还比较少:ViewModel 对应哪个 View,应该创建哪个 BindingContext。
随着功能增加,装配关系开始横向扩展:
- 双向绑定需要确定反向事件、参数和
ConvertBack; - Command 需要连接方法、事件和可选参数;
- 同一 ViewModel 可能投影到多个 View;
- Presenter 可能有默认实现,也可能显式指定;
- 页面增加层级、缓存、历史栈和依赖策略;
- 列表项、Prototype 和动态 View 也需要复用相同的绑定工厂。
每增加一个维度,都有两个选择:要么让运行时 Resolver 继续理解更多规则,要么在生成阶段把最终答案写出来。
前者的短期改动往往更小,因为已有扫描和字典可以继续扩展;长期代价却是 Resolver 逐渐变成第二个编译器。它不仅要"找类型",还要决定优先级、处理歧义、兼容继承,并在 AOT 和代码裁剪环境中确保这些通过反射触达的类型不会消失。
真正的转折因此不是把经典 ISourceGenerator 换成 IIncrementalGenerator,而是重新定义生成器的产物:生成的不只是 BindingContext,而是完整且可直接执行的装配关系。
最终形态:让生成代码成为运行时入口
现在的 FUI 只保留一个带 [Generator] 的入口:BindingSourceGenerator。它用 CreateSyntaxProvider 先筛选类声明,再通过语义模型确认可观察类型,最后在同一条输出管线中生成五类产物:
text
ViewModel + Attribute
│
├─ 可观察属性:PropertyChanged.g.cs
├─ 绑定执行:<VM>.<View>.BindingContext.g
├─ Editor 清单:<VM>.<View>.BindingInfo.g
├─ 类型工厂:BindingRegistry.g.cs
└─ 页面入口:Routes.g.cs
前三项回答"数据怎么流动",后两项回答"对象怎么被创建和打开"。这两个问题由同一个编译输入得出,不再分别由生成器和运行时解释。
以 Settings Sample 为例,开发者只描述意图:
csharp
[ViewContract("SettingsView")]
[RoutePolicy(Layer.Popup, CoverageMode = CoverageMode.KeepVisible)]
public partial class SettingsViewModel : ViewModel
{
[ObservableProperty]
[Bind("Volume", nameof(SliderElement.Value),
bindingMode: BindingMode.TwoWay)]
float volume = 0.5F;
[Command("Close", nameof(ButtonElement.OnClick))]
public void Close()
{
SettingsSampleRuntime.Close();
}
}
这些 Attribute 不是运行时脚本,而是一门嵌入 C# 的声明语言。生成器在编译期把它翻译成三个层次的答案。
第一层:把字段变化翻译成确定的方法调用
[ObservableProperty] 让生成器补出属性 setter、变更委托和通知调用。运行时赋值走的是普通字段访问和普通委托,不需要 PropertyInfo.SetValue。
第二层:把绑定描述翻译成对称的生命周期代码
生成的 BindingContext 会缓存目标 Element,订阅 ViewModel 变化;对于 TwoWay 绑定,再订阅控件的值变化并回写 ViewModel。OnUnbinding 中生成相反的取消订阅路径。
这里的价值不只是少写代码,而是绑定和解绑来自同一份结构化描述。人工实现最常见的错误之一------加了监听却忘记移除------被转换成生成模板的一致性问题。一旦模板验证通过,所有页面都复用这份对称性。
第三层:把类型发现翻译成直接构造委托
BindingFactoryGenerator 会为程序集生成显式注册代码:
csharp
GeneratedBindingRegistry.Register<SettingsViewModel>(
static (view, viewModel) =>
new SettingsViewModel_SettingsView_BindingContext(
view, viewModel),
static () => new SettingsPresenter());
运行时注册表保存的是 Type -> Func,页面创建时直接调用已经编译好的构造委托。这里仍然使用 Type 作为字典键,因为动态列表项需要按 ViewModel 的实际类型查找工厂;但它不再遍历全部程序集寻找实现,也不再用 Activator.CreateInstance 猜测构造方式。
RouteGenerator 则继续把页面资源、ViewModel 工厂、BindingContext 工厂、Presenter 工厂和 RoutePolicy 固化为 Route<TViewModel>:
csharp
public static Route<SettingsViewModel> SettingsView { get; } =
GeneratedRouteFactory.Create<SettingsViewModel>(
"SettingsView",
typeof(SettingsPresenter),
static () => new SettingsViewModel(),
static (view, vm) =>
new SettingsViewModel_SettingsView_BindingContext(view, vm),
static () => new SettingsPresenter(),
/* 编译期生成的页面策略 */);
于是业务侧拿到的不再是一个等待运行时解释的字符串,而是包含完整创建关系的强类型页面对象。Routes.Initialize() 同时完成 Binding Registry 的幂等初始化,页面入口和绑定入口也不会再各自维护一套启动顺序。
这次演化真正获得了什么
如果只说"没有反射,所以更快",会把这次架构变化讲窄。仓库没有提供一组可用来声称"快了多少"的公开基准,因此不应该虚构性能百分比。源码能够确认的,是下面几类工作从 Player 主路径中被直接移除了:全程序集 GetTypes()、运行时 Attribute 发现,以及 BindingContext/Presenter 的 Activator.CreateInstance。
更重要的收益其实发生在四个时间点。
| 阶段 | 旧架构要做的事 | 最终架构的变化 | 直接收益 |
|---|---|---|---|
| 开发时 | 手写或分散维护装配代码 | Attribute 表达意图,nameof 保留符号引用 |
重命名和代码审查更可追踪 |
| 编译时 | 只生成局部绑定实现 | 同时生成属性、绑定、工厂和 Route | 一份输入得出完整关系,减少双重规则 |
| 页面创建时 | 扫描类型、读取 Attribute、反射构造 | 查表后调用直接构造委托 | 删除整类启动发现工作,执行路径更确定 |
| 框架演进时 | 每个新维度都扩展 Resolver | 扩展中间模型和生成产物 | 复杂度留在可测试、可检查的编译产物中 |
错误更早出现,而不只是执行更快
运行时反射可以把不完整的关系保留到页面打开;生成代码必须通过 C# 编译。类型不兼容、构造器不存在、Route 依赖无法解析,都有机会在构建阶段暴露。
当前生成器还会主动报告部分架构级诊断,例如 Route 依赖缺失、静态依赖成环,以及 Pure 模式下不适合生成的属性。它并没有覆盖所有错误:仓库中的 AttributeBindingAnalyzer 仍有若干语法分析注册处于注释状态。因此,准确的说法是"错误边界显著前移",而不是"所有绑定错误都能在编译期发现"。
AOT 和裁剪更容易理解这条路径
Unity 的 Player 构建经常涉及 IL2CPP 和代码裁剪。反射不是不能用,但通过字符串和运行时扫描触达的类型,通常需要额外的保留信息。生成代码中的泛型引用、new 表达式和直接委托,把实际调用关系写进了编译产物,工具链更容易看见。
这不等于 FUI 完全消灭了反射。Editor 侧的 Binding Catalog、Validator 和 IL 后处理协调仍会扫描已加载程序集;它们服务于编辑器检查和构建工具,而不是 Player 的页面绑定与导航主路径。把边界放在这里,比追求一句"零反射"更实际。
强类型 Route 把页面身份从字符串提升为契约
旧系统里,View 名称、ViewModel 类型、Presenter 类型和页面策略分散在不同位置,运行时再把它们拼起来。最终生成的 Route<TViewModel> 把这些信息收敛到同一个对象中。
它带来的并不只是补全体验。页面依赖、同步打开能力、缓存和历史策略都可以在生成阶段决定,导航系统收到的是可执行契约,而不是一组待解释参数。随着页面系统变复杂,这种确定性比省几行调用代码更重要。
生成代码成为可检查的架构结果
反射解析器的最终结果存在于运行时字典里,通常需要启动程序才能观察;生成器的结果则是普通 C#。开发者可以直接检查某条绑定订阅了什么、某个 Presenter 为什么被选中、一个 Route 最终包含哪些依赖。
当框架行为出现偏差时,排查路径也因此缩短:先看 Attribute 输入,再看生成代码,最后看运行时执行。不必先猜测某次程序集扫描得到了什么顺序。
Source Generator 也决定了 Pure 与 Mixed 的边界
Source Generator 有一个经常被忽略的限制:它只能向 Compilation 追加源码,不能改写开发者已经写下的方法体。
这意味着,如果开发者写的是字段:
csharp
[ObservableProperty]
float volume;
生成器可以在同一个 partial 类型中补出完整属性,这就是 Pure 模式。
但如果开发者已经写了自动属性:
csharp
[ObservableProperty]
public float Volume { get; set; }
生成器不能再声明一个同名属性,也不能把通知逻辑塞进现有 setter。Mixed 模式因此需要可选的 IL Post Processing,由 Mono.Cecil 在编译后改写 setter;Source Generator 仍负责生成通知入口和绑定代码。
这不是两套风格上的装饰选项,而是 C# 编译模型决定的技术边界。FUI 把 Cecil 依赖留在可选的 Editor/ILPP 路径,主 Package 不必为了 Pure 模式承担直接的运行时依赖。对希望生成关系尽可能透明的项目,Pure 更容易检查;对已经大量使用自动属性的工程,Mixed 则降低迁移成本。
最终方案并不意味着生成器已经没有改进空间
当前入口使用了 Incremental Generator API,但它在筛出候选类型后调用 .Collect(),再与完整 Compilation 合并。它实现了统一入口和增量模型,却不是最细粒度的缓存结构:某些输入变化仍可能让程序集级产物整体重新生成。
这是一项可以继续优化的工程细节,但不会改变当前架构最重要的结果------运行时不再承担第二套类型发现规则。
同样,Source Generator 也不是所有 UI 项目的默认答案。如果项目页面很少、绑定关系简单,手写直接代码可能更容易;如果关系高度动态、类型只有运行时才知道,反射或数据驱动注册仍然有价值。生成器最适合的是这样一类系统:规则已经稳定、重复关系很多,而且希望尽可能早地验证这些关系。
回头看:选择的不是一种代码生成工具,而是错误发生的时间
FUI 的这条演化线可以压缩成三句话:
text
外置编译器:先消除重复绑定代码。
早期 Source Generator:生成代码进入编译,但运行时仍重新发现关系。
最终编译期装配:生成器直接产出属性、绑定、工厂与 Route,运行时只执行结果。
因此,FUI 使用 Source Generator 的核心理由不是"反射一定慢",也不只是"Attribute 写起来更短"。真正的理由是,View、ViewModel、BindingContext、Presenter 和 Route 之间的大部分关系,在开发者写下代码时就已经确定了。
既然关系已经确定,就没有必要等到页面打开时再猜一次。
把这部分工作前移到编译期,最终得到的是一条更短的运行时路径、一份更明确的类型契约,以及一个更早暴露错误的系统。少写样板代码只是最容易看到的表面结果;让生成代码成为唯一装配事实,才是这轮演化真正完成的事情。