系列第 06 篇。面向准备设计 Unity UI 框架的开发者,用结算页说明如何把固定装配关系从运行期发现改成编译期生成。FUI 当前源码是实现依据,教学伪代码和改进建议会单独标明。
预期收益是让绑定、工厂、策略和注册表共享一份声明,而不只是少写几个属性。代价包括编译宿主兼容、生成诊断设计,以及必须另外处理的 Prefab 验证。
先给结论
Source Generator 适合解决同时满足三项的关系:
- 编译时已经知道;
- 手写高度重复;
- 出错后希望尽早反馈。
FUI 把 ViewContract、Observable Property、BindingContext、Command、Presenter Factory、RoutePolicy 和 Route Registry 放进同一条编译链,得到的不只是少写代码,而是:
- 核心装配路径不依赖运行时全程序集反射;
- 类型、构造器和成员关系参与 C# 编译检查;
- 不合法声明可以用 Diagnostic 指向源码;
- IL2CPP/代码剥离更容易沿静态引用分析;
- 声明成为唯一事实来源,生成物和运行时使用同一模型。
1. 先看六种自动装配方案
| 方案 | 发生时间 | 优点 | 代价与风险 | 适用场景 |
|---|---|---|---|---|
| 手工注册 | 编码/启动 | 最透明、调试简单 | 重复、漏注册、维护两份事实 | 小项目、模型未稳定 |
| 运行时反射 | 启动/首次使用 | 灵活、开发快 | 错误晚、裁剪规则、扫描与缓存复杂 | 插件、真正动态内容 |
Editor 菜单生成 .cs |
手动/导入时 | Unity 资产可见,可检查 Prefab | 生成文件易脏、触发时机和 CI 一致性难 | 强依赖资产输入 |
| Source Generator | C# 编译时 | 类型语义、Diagnostic、静态引用 | 看不到 Prefab,Unity 集成有门槛 | 稳定 C# 装配关系 |
| IL Post Processor | C# 编译后 | 可改写 IL,使用端代码更简洁 | 调试困难、版本/兼容风险高 | 属性通知等受控增强 |
| 运行时表达式/Emit | 运行时 | 动态、可缓存委托 | AOT/IL2CPP 受限,错误更晚 | JIT 环境工具,不宜作为 Unity Player 主路径 |
关键不是只选一个。FUI 的思路是:Source Generator 负责可见的 C# 关系,Editor Validator 负责 Prefab 事实;可选 ILPP 可以增强属性通知,但核心导航不依赖运行时 IL 生成。
2. 装配关系为什么会一步步前移
这类框架通常不是一开始就需要完整生成器。结算页最初只有分数文本和继续按钮,手写查找、赋值与注册最直观;页面增加后,重复代码促使团队做外部代码生成;但如果生成结果只包含控件字段,运行时仍要反射发现 ViewModel、Presenter 与 Route,错误只是从一部分路径前移;最终,当这些关系在 C# 声明阶段已经确定,就有理由把属性、绑定、命令、工厂、策略和注册表放进同一次编译。
这个演化过程改变的不是"用哪一种模板工具",而是关系在什么时候成为事实:
text
调用时临时决定
→ 启动时扫描并决定
→ 手动执行工具后决定
→ C# 编译时决定
越接近运行期才决定关系,通常越灵活,但错误反馈越晚、运行时设施越重;越早在编译期确定,越容易获得类型检查和确定性,却不能直接覆盖真正动态的插件。FUI 最终把固定页面装配前移,同时把 Prefab 事实交给 Editor Validator,把运行期才知道的实例状态留给 Navigator。这种分工比"所有东西都生成"更重要。
3. 为什么手工注册不是长期答案
手工方案其实很好理解。下面是说明重复装配关系的伪代码,参数名和注册入口不是 FUI 当前 API:
csharp
Routes.Register(new Route<SettlementViewModel>(
assetKey: "SettlementView",
viewModelFactory: () => new SettlementViewModel(),
bindingFactory: (view, vm) => new SettlementBindingContext(view, vm),
presenterFactory: () => new SettlementPresenter(),
policy: SettlementPolicy));
它的主要问题不是行数,而是同一事实分散在多处:
- ViewModel 上定义属性;
- BindingContext 再写一次成员名;
- 注册表再写一次 ViewModel 与 BindingContext 的关系;
- 页面策略可能还在另一个配置文件;
- Prefab 依赖又藏在 Inspector。
手写代码中的强类型引用和构造器调用本身仍受编译检查;真正难检查的是字符串资源键、重复配置、遗漏注册,以及多个合法类型之间被接错的业务关系。不能把这些问题笼统归结为"手写就没有编译检查"。
4. 为什么运行时反射很诱人,却不适合作为核心路径
下面是运行时扫描方案的教学代码,并非 FUI 当前实现:
csharp
foreach (var type in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(x => x.GetTypes()))
{
var contract = type.GetCustomAttribute<ViewContractAttribute>();
if (contract != null)
Register(contract, type);
}
但省掉的代码会变成运行时基础设施:
- 处理
ReflectionTypeLoadException; - 筛选 Editor/测试/生成器程序集;
- 缓存 Attribute、PropertyInfo、构造器和委托;
- 处理泛型、继承和重复声明;
- 为 UnityLinker 维护
[Preserve]或link.xml; - 到运行时才发现构造器或成员不匹配。
在 Unity Player 中,依靠反射发现的类型还要额外处理代码剥离与保留规则。反射仍适合"运行前无法知道"的插件,但固定页面不属于这类问题。FUI 因此把核心页面关系固化为生成代码和静态引用,让运行时不再扫描整个程序集。
5. Source Generator 到底在编译流程的哪里
Roslyn 编译器先解析语法,再建立语义模型。Source Generator 通过这些只读输入产生新的 C# 源码,并把源码加入同一次 Compilation:
text
用户源码 + AdditionalFiles
↓
Syntax Tree / Semantic Model
↓
Source Generator 生成 .g.cs
↓
用户源码 + .g.cs 一起编译
↓
程序集
它是"只增加"模型:可以添加代码,不能修改用户已写源码;不同生成器也不能依赖另一个生成器本轮产生的文件顺序。
这决定了设计方式:用户声明必须在没有生成物时仍可被解析;生成物通过 partial、额外类型或注册表补齐能力。
6. FUI 的输入:把意图写成声明
示例经过教学化简:
csharp
using FUI.Binding;
using FUI.Navigation;
using FUI.Presentation;
using FUI.Rendering.UGUI.Elements;
[assembly: ViewModule(
"Game.UI",
PropertyMode = PropertyGenerationMode.Pure)]
[ViewContract("SettlementView")]
[RoutePolicy(Layer.Popup, CoverageMode = CoverageMode.KeepVisible)]
public partial class SettlementViewModel : ViewModel
{
[ObservableProperty]
[Bind("Score", nameof(TextElement.Content))]
string scoreText;
[Command("Continue", nameof(ButtonElement.OnClick))]
public void Continue() { }
}
Attribute 不是给运行时扫描的标签,而是编译器插件的输入语言。
7. 生成器的五个阶段
7.1 候选筛选
SyntaxProvider 先从语法上筛选可能相关的 class,避免所有节点进入昂贵语义分析:
csharp
var candidates = context.SyntaxProvider
.CreateSyntaxProvider(
static (node, _) => node is ClassDeclarationSyntax,
static (ctx, _) => GetCandidate(ctx))
.Where(static candidate => candidate is not null);
如果候选必须带有某个 Attribute,可以评估按 Attribute 元数据名筛选的 API。FUI 当前入口还需要发现继承 ObservableObject 的类型,因此实际采用 class 语法筛选再做继承关系的语义检查;不能直接把两种入口等同替换。
7.2 语义建模
不能依赖 node.ToString() 判断类型。要通过 INamedTypeSymbol、IPropertySymbol、IMethodSymbol 解析:
- 完整命名空间和嵌套类型;
- 是否继承 ObservableObject;
- 字段生成后的属性名;
- Binding 目标成员类型;
- Command 参数和返回类型;
- Presenter 构造器是否满足约束。
设计生成器时,可以把这一步提取成与 Roslyn 对象解耦、可比较的中间模型,例如教学中的 ViewContractModel。这是一种可扩展的设计建议,不代表 FUI 当前已经完整拆成这种模型流水线。
7.3 规则验证与 Diagnostic
生成器不要"尽量生成"。下面两条是当前 RouteGenerator 中实际定义的诊断编号:
text
FUI0011 Route 依赖指向未声明 ViewContract 的类型
FUI0012 Route 依赖形成静态环
Diagnostic 必须定位到用户声明的位置,并给出修复动作。好的生成器首先是编译期 UX,其次才是模板引擎。
7.4 输出普通 C#
以下属性代码只演示变化比较与通知机制,不是对 FUI 实际生成输出的逐字引用:
csharp
public float Volume
{
get => volume;
set
{
if (EqualityComparer<float>.Default.Equals(volume, value))
return;
volume = value;
OnPropertyChanged(nameof(Volume));
}
}
生成 BindingContext、工厂和 Route Registry。运行时看到的只是普通类型、委托和静态调用,不需要知道它们是生成的。
7.5 确定性输出
相同输入必须得到字节级稳定的源码:
- 对类型和成员排序;
- 不写时间戳、绝对路径或随机 ID;
- HintName 唯一且稳定;
- 使用全限定类型名,避免 using 冲突;
- 统一换行和编码。
否则会产生无意义重编译和难以比较的 CI 输出。
8. 为什么"生成 Binding"还不够
如果只生成下面这一段:
csharp
label.Text = vm.Title;
运行时仍然需要回答:
- 哪个 ViewModel 对应哪个资产;
- BindingContext 怎么创建;
- Presenter 选哪个构造器;
- RoutePolicy 从哪里读取;
- Registry 何时初始化。
所以完整装配应该一起生成:
text
ViewContract
├─ Observable Property
├─ BindingContext
├─ Command Binding
├─ Presenter Factory
├─ RoutePolicy
└─ Route / Binding Registry
这样 Attribute 只在编译阶段解释一次,运行时直接消费生成结果。
9. IIncrementalGenerator 的真正含义
增量生成器定义一条不可变转换流水线。宿主可以缓存各节点结果,在输入未变化时复用。
text
Syntax candidates
→ semantic symbols
→ stable models
→ per-view output
→ global registry output
但"实现了 IIncrementalGenerator"不等于"改一个页面只重算一个页面"。
FUI 当前入口先筛选候选 class,再通过语义模型识别目标类型;随后使用 Collect() 汇总候选,并与整个 Compilation 组合,因为全局 Binding Registry 和 Route Catalog 需要观察程序集范围。
这带来两个应如实说明的结论:
- 无关语法不会全部直接进入后续模型,流水线具备增量基础;
- 候选集合变化时,全局阶段仍可能重新计算,不能只凭接口名称宣称细粒度性能收益。
进一步优化可以把单页输出与全局清单拆开,并让中间模型具有稳定相等性,再用 Roslyn step tracking 和实际编译测量验证。
10. Unity 集成最容易踩的坑
10.1 Generator DLL 必须作为 Analyzer
生成器不是运行时程序集。若 DLL 被当作普通 Plugin 加载,Unity 可能报告依赖缺失或把 Roslyn 依赖带进 Player。Package 需要正确配置 Analyzer 标签与平台导入。
10.2 目标框架和 Roslyn 版本
Generator 通常要针对 Unity 可加载的兼容框架,并避免引用宿主没有的 Roslyn 版本。构建机、IDE 和 Unity Editor 都要验证。
10.3 生成器看不到 Prefab
C# 编译器能通过 nameof(SliderElement.Value) 检查成员名,生成器还能进一步分析相关类型关系,但不知道 Prefab 是否真的有名为 Volume 的唯一 Element。不要在生成器里偷偷读取 AssetDatabase:它不是稳定编译输入,也会破坏独立测试。
正确分工是 Source Generator 验证 C#,Editor Validator 验证资产。
10.4 生成代码调试体验
必须让开发者能查看 .g.cs、看到稳定 HintName,并在报错中定位用户源码。不要把异常吞掉后只输出"生成失败"。
10.5 Pure 与 ILPP 混合模式
如果 Pure 模式由生成器创建属性,最透明、最易调试;如果 Mixed 模式用 IL Post Processor 改写通知,可以减少显式声明,但调试和兼容成本更高。两条路径需要相同语义测试,不能悄悄产生不同通知行为。
11. Source Generator 不适合什么
- 远端配置决定的页面;
- 运行时下载的脚本类型;
- 必须扫描未知第三方插件的扩展;
- 只能从 Prefab/场景得知的结构;
- 需要修改用户方法体的功能。
这些可以保留动态注册、Editor 生成或 ILPP。好的边界不是"全部静态",而是让稳定关系静态、动态关系显式动态。
12. 如何验证生成器真的带来收益
- 删除或改名绑定成员,编译期 Diagnostic 指向声明位置。
- 生成 Route 能直接创建 ViewModel、BindingContext 和 Presenter。
- 核心打开路径不做全程序集类型扫描。
- 两次相同输入生成完全相同源码。
- Generator 单元测试覆盖合法输出和每条错误规则。
- 使用 Roslyn step tracking 测量改动一个页面后的重算范围。
- Unity 重新导入后 Generator DLL 只作为 Analyzer 工作。
- IL2CPP + 目标裁剪级别能打开核心页面,不依赖大量
link.xml兜底。
下一篇会沿一条 Bind 声明,追踪候选筛选、语义分析、规则验证、源码输出和注册装配,展开 Source Generator 的底层流水线。