
如果你维护过一套基于 C# 的 UI 框架大概率对“启动时那几百毫秒的装配时间”不陌生。FUI 是我们业务线里沉淀的一套轻量跨端 UI 框架最早为了快速迭代视图注册走的是反射程序集一加载遍历所有类型筛出带 FUIViewAttribute 的类再灌进路由表和视图工厂。这个方案在前几十个页面时没什么感觉等页面和组件逼近一百个、启动链路开始被性能团队盯上之后反射就变成了第一个要优化的对象。这篇文章是我们把 FUI 的装配层从反射注册迁移到 Source Generator 编译期装配的完整复盘包含方案选型、生成器实现、实测数据以及一堆只有踩过才知道的坑。1. 反射注册哪里疼FUI 的启动装配困局1.1 FUI 装配层到底在做什么先解释 FUI 的装配层。和很多 UI 框架一样FUI 内部有两张核心表路由表把字符串路由对应到具体页面类型和视图工厂把类型映射到可创建的组件实例。传统做法是框架启动时扫描入口程序集凡是打了 FUIViewAttribute 的类就自动注册到这两张表里。这样开发者只要在页面类上挂一个特性不需要手动维护注册清单新增页面成本极低。这是反射方案最吸引人的地方也是后来换掉它最舍不得的地方。但装配层在框架里的位置非常尴尬它不像渲染逻辑那样高频执行却决定了整个应用能否正常启动。FUI 的装配过程发生在路由第一次命中之前冷启动时必须把所有候选类型找出来并建立映射。我们早期的实现很朴素入口程序集里直接循环Assembly.GetTypes()再用GetCustomAttributeFUIViewAttribute()判断。这个写法放到单元测试里完全没问题放到真实 App 的启动链路里就成了定时炸弹。等到项目里页面从 30 个涨到 90 多个组件类翻了几倍之后问题开始显现冷启动装配耗时从最初的 20 毫秒左右一路涨到 100 毫秒以上。更麻烦的是这个耗时还不太稳定随程序集大小、JIT 预热状态波动性能团队在低端设备上测出过接近 300 毫秒的峰值。对一个以“轻量”为卖点的框架来说这已经没法接受了。1.2 反射扫描的三笔成本很多人觉得反射慢是因为调用方法慢其实在装配这个场景里真正的成本发生在更早的地方。第一笔是类型遍历成本。Assembly.GetTypes()会返回程序集里所有公开和非公开类型哪怕你已经用特性过滤底层也要把元数据表完整走一遍。程序集越大这个线性扫描越贵而且没有任何缓存可以利用——每次冷启动都要重来一遍。第二笔是特性查找成本。GetCustomAttributeT()不是简单的内存读取它背后要实例化 Attribute 对象、解析构造函数参数、处理继承链。一个类上如果挂了多个特性代价还会成倍上涨。我们在 90 个页面规模下实测光特性查找就占了整个装配耗时的 40% 以上。第三笔是隐性成本JIT 和内存分配。反射 API 会触发额外的类型加载和元数据访问首次调用时 JIT 需要为相关方法生成机器码扫描过程还会产生大量临时对象和迭代器分配。这些在单次执行里看不出来但放在启动路径上每一毫秒都是在跟用户体验抢时间。成本明细可以看下面这张表成本项触发场景实测占比90 视图规模类型遍历程序集加载后扫描全部类型25% - 30%特性查找对每个类型执行 GetCustomAttribute40% - 45%对象分配与 JIT反射 API 第一次调用、临时对象25% - 30%那段时间我们试过一些折中手段比如缓存特性查找结果、用预编译委托替代反射调用、把扫描结果序列化到本地文件。这些方案能缓解症状解决不了本质问题发现这些类型的行为发生在运行时不管用什么手段优化都要付出一次全量扫描的代价。2. 编译期装配的整体设计把发现提前到编译2.1 为什么选 Source Generator而不是配置文件或代码生成脚本既然运行时扫描太贵第一个想到的就是把注册清单放到配置文件里启动时直接反序列化一个列表省掉扫描动作。这个方案我们评估过也放弃了一半。配置文件的问题是它和代码本身脱节新增一个页面类你要记得去配置文件里加一条记录漏加或者路径写错编译期完全不会发现只有启动时路由命不中才会暴露排错成本很高。这等于把框架“自动发现”的优点丢掉换来的只是一次反序列化的性能提升。代码生成脚本T4、手写 MSBuild Task是另一个方向。它的思路是在编译前跑一个工具扫描源文件生成注册代码。问题是这些脚本大多基于文本匹配对 C# 语法和语义的理解很弱而且它们跑在编译之前拿不到完整的符号信息遇到泛型、继承、部分类这些情况容易误判。维护一套自定义代码生成管线本身也是一笔长期成本团队里愿意碰的人不多。Source Generator 是 Roslyn 提供的第一方扩展点它直接嵌在编译器里能拿到完整的语法树和语义模型。跟前面的方案相比有三个独一无二的优势第一它能看到真实的类型符号不需要猜“这个类是不是页面”第二它的产物是真正的 C# 源码编译进当前程序集没有额外的运行时依赖第三它是增量的IDE 里每次改动只会重新处理受影响的文件开发体验远好于外部脚本。选型逻辑如果展开讲就是一句话我们真正想要的不是“一份静态清单”而是“让编译器替我们维护这份清单”。Source Generator 恰好提供了这个能力而且它是微软官方长期维护的技术不用我们自建轮子。2.2 生成器产出什么从“扫描”到“表驱动”切换前后框架获取视图注册信息的方式完全不同。反射模式是“运行时问程序集有哪些类型挂了特性”编译器帮我们生成代码之后变成“运行时直接读一张写死的表所有页面都在这里”。我把这个模式叫表驱动装配。生成器要做的事情可以拆成三步找到所有带 FUIViewAttribute 的类、读取每个类的路由和生命周期参数、生成一段包含这些信息的 C# 代码。这段生成代码不需要做任何花哨的事只要在运行时暴露一个静态属性返回注册列表即可。核心逻辑从“扫描”变成了“查表”耗时理论上可以降到微秒级。这里有一个设计重点是生成代码的形态。最初我打算每个视图生成一个注册方法再用反射把所有方法找出来——这显然是把问题绕回去了。后来改成生成一个静态方法返回IReadOnlyListViewRegistration。这个列表在数组里直接写死typeof(...)引用编译器能明确看到类型引用对整个程序集的裁剪和 AOT 都非常友好。后面讲到 NativeAOT 兼容性的时候这个设计带来的收益会非常明显。3. 从零落地FUIViewGenerator 核心实现3.1 先定义“注册契约”特性与生成器入口动手写生成器之前先把契约定清楚。FUI 现有的注册特性长这样namespace FUI; [AttributeUsage(AttributeTargets.Class, AllowMultiple false, Inherited false)] public sealed class FUIViewAttribute : Attribute { public string Route { get; } public bool Singleton { get; } public FUIViewAttribute(string route, bool singleton false) { Route route; Singleton singleton; } }用法是在页面类上挂特性[FUIView(home, Singleton true)] public partial class HomePage : FUIView { // ... }为什么加Inherited false因为注册规则只认类上直接声明的特性子类继承父类的注册信息容易产生歧义。比如一个 BasePage 挂了特性所有派生页面都会被注册成同一个路由这大概率不是你要的结果。生成器在识别时也只取直接挂载的特性避免隐式继承造成误判。生成器的入口是一个实现IIncrementalGenerator的类using Microsoft.CodeAnalysis; namespace FUI.Generators; [Generator(LanguageNames.CSharp)] public sealed class FUIViewGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { // ... } }从接口名能看出来我们用增量生成器而不是老的ISourceGenerator。增量生成器可以在多次编译之间复用缓存对 IDE 体验至关重要。如果用的是老的ISourceGenerator每次敲一个字符都会重新跑一遍全量管线项目大了以后编辑器会肉眼可见地卡顿。新项目没有理由再走老路。3.2 用增量生成器提取视图元数据增量生成器的核心是管线。我们要做的第一件事是拿到所有应用了 FUIViewAttribute 的类。这里有一个非常关键的 API叫ForAttributeWithMetadataName它从 .NET 7 的 Roslyn 版本开始提供专门用来按特性名筛选语法节点并且自动带上特性的语义信息。完整实现如下using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace FUI.Generators; [Generator(LanguageNames.CSharp)] public sealed class FUIViewGenerator : IIncrementalGenerator { private const string AttributeName FUI.FUIViewAttribute; public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates context.SyntaxProvider.ForAttributeWithMetadataName( AttributeName, static (node, _) node is ClassDeclarationSyntax, static (ctx, _) GetViewInfo(ctx)); var allViews candidates.Collect(); context.RegisterSourceOutput(allViews, static (spc, views) Emit(spc, views)); } private static ViewInfo? GetViewInfo(GeneratorAttributeSyntaxContext ctx) { if (ctx.TargetSymbol is not INamedTypeSymbol typeSymbol) { return null; } var attribute ctx.Attributes[0]; var route attribute.ConstructorArguments.Length 0 ? attribute.ConstructorArguments[0].Value as string : null; if (string.IsNullOrEmpty(route)) { return null; } var singleton false; foreach (var namedArg in attribute.NamedArguments) { if (namedArg.Key Singleton namedArg.Value.Value is bool b) { singleton b; } } return new ViewInfo( typeSymbol.FullName(), route, singleton); } private static void Emit(SourceProductionContext spc, ImmutableArrayViewInfo views) { if (views.IsDefaultOrEmpty) { return; } var sb new StringBuilder(); sb.AppendLine(// auto-generated/); sb.AppendLine(#nullable enable); sb.AppendLine(using System.Collections.Generic;); sb.AppendLine(using FUI;); sb.AppendLine(); sb.AppendLine(namespace FUI.Generated); sb.AppendLine({); sb.AppendLine( internal static class FUIGeneratedViews); sb.AppendLine( {); sb.AppendLine( public static IReadOnlyListViewRegistration Views { get; } new ListViewRegistration); sb.AppendLine( {); foreach (var view in views) { sb.AppendLine($ new ViewRegistration(\{view.Route}\, typeof({view.FullName}), {view.Singleton.ToString().ToLowerInvariant()}),); } sb.AppendLine( };); sb.AppendLine( }); sb.AppendLine(}); spc.AddSource(FUIGeneratedViews.g.cs, SourceText.From(sb.ToString(), Encoding.UTF8)); } private sealed record ViewInfo(string FullName, string Route, bool Singleton); }这里面有几个细节值得单独讲。首先是FullName()这是我给INamedTypeSymbol写的一个扩展方法用来拿到带命名空间的完整类型名。对于泛型类型或者嵌套类型直接调ToString()会输出带程序集信息的冗长字符串直接拼进代码里会编译报错所以必须显式处理。我这里是自己拼internal static string FullName(this INamedTypeSymbol symbol) { var ns symbol.ContainingNamespace; var prefix ns.IsGlobalNamespace ? string.Empty : ns.ToDisplayString() .; return prefix symbol.Name; }第二个细节是ViewInfo用了 record 而不是 class。增量生成器会对每个步骤的输入做相等比较来判断缓存是否有效record 自动实现了基于值的相等修改了路由参数会产生新的实例没改就不会触发下游重算。如果你用一个普通的 class 不重写 Equals缓存机制会失效每次编译都全量执行生成器性能就白优化了。第三个细节是Collect()。它把多个候选结果合并成一个集合RegisterSourceOutput只在集合整体变化时执行一次。这样所有视图的注册代码都在同一个文件里逻辑清晰也方便我们做去重检查。如果视图特别多想拆分成多个文件也可以在转型之后RegisterSourceOutput遍历输出但当前场景没有这个必要。3.3 生成的装配代码写法生成出来的代码长这样// auto-generated/ #nullable enable using System.Collections.Generic; using FUI; namespace FUI.Generated { internal static class FUIGeneratedViews { public static IReadOnlyListViewRegistration Views { get; } new ListViewRegistration { new ViewRegistration(home, typeof(MyApp.Views.HomePage), false), new ViewRegistration(settings, typeof(MyApp.Views.SettingsPage), true), }; } }运行时注册逻辑只需要一行foreach (var view in FUIGeneratedViews.Views) { registry.Register(view.Route, view.PageType, view.Singleton); }相比原来的反射扫描这里没有任何条件判断、没有特性实例化、没有GetTypes()的全量遍历。启动时只是把一个静态列表里的数据读出来做注册。这里我故意用的是ListViewRegistration而不是数组原因是 FUI 后续可能允许外部程序集追加注册用一个可变列表在未来会方便扩展。如果你确定注册表完全不可变用数组甚至可以用 C# 12 的集合表达式直接初始化还能进一步减少分配。有个小细节是生成文件的头部写了auto-generated/。这行不是装饰是给 Roslyn 的分析器和 IDE 看的。有了这行标记用户代码里的格式化和重构工具默认不会去动这个文件代码分析器也会自动跳过一些不必要的默认规则。没有这行团队里有人随手格式化代码的时候可能把生成文件的格式改得乱七八糟下一次编译又会被覆盖纯属给自己找麻烦。3.4 项目引用与 SDK 配置要点生成器写完之后落地要过的第一关是项目配置。生成器本身必须引用 Microsoft.CodeAnalysis.CSharp 包并且要打包成一个独立的分析器项目再被业务项目引用。刚开始做的时候很容易踩一个坑直接把生成器代码塞进业务项目里结果编译报错说找不到ISourceGenerator。原因很简单业务项目可能没有引用 Roslyn 相关程序集。正确的做法是建两个项目一个专门放生成器TargetFramework 用netstandard2.0因为编译器插件必须兼容老框架另一个是业务项目通过OutputItemTypeAnalyzer引用生成器Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework Nullableenable/Nullable LangVersionlatest/LangVersion /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /Project业务项目里的引用方式Project SdkMicrosoft.NET.Sdk ItemGroup ProjectReference Include..\FUI.Generators\FUI.Generators.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup /ProjectReferenceOutputAssemblyfalse很关键它告诉编译器生成器只是一个编译期助手不能在运行时被业务程序集引用。少了这个属性生成器的程序集也会被当成普通引用打进去无端增大体积。用PrivateAssetsall同理是为了防止分析器依赖项被传递下去。这些配置网上文档都有但实际操作中组合起来容易出错我建议直接按上面这份抄。SDK 版本方面ForAttributeWithMetadataName要求 .NET SDK 7.0 以上。如果你还在用 .NET 6有两个选择升级 SDK不一定要升 TargetFrameworkSDK 版本和运行目标可以分离或者退回用传统的SyntaxProvider手动查特性。手动查特性写法繁琐且更容易漏掉缓存优化我的建议是能升级就升级没必要为了旧 SDK 牺牲开发效率。4. 性能实测反射与编译期装配的差距4.1 测试方案与数据技术选型不能光靠“感觉”我习惯把方案落地前后的数据摆出来说话。这次对比测试我们在一个真实的 FUI 示例应用上做的这个应用有 92 个页面122 个带 FUIViewAttribute 的类覆盖了单例模式和普通模式两种注册方式。测试机是一台中端 Android 模拟器和一台 Windows 10 的 i5 机器各跑了 20 次冷启动取中位数。反射模式的测试代码非常简单就是之前线上跑的版本不再贴了。编译期装配模式用的是上一节生成的代码运行时只是遍历一个静态列表。关键指标看启动时装配阶段的耗时和内存分配指标反射注册编译期装配Source Generator装配耗时Android 模拟器118.5 ms0.3 ms装配耗时Windows 桌面64.2 ms0.2 ms托管堆分配1.7 MB0.1 MB首次页面路由命中耗时141 ms21 ms这张表里最夸张的不是耗时而是内存分配从 1.7MB 降到了 0.1MB。反射扫描产生的大量临时对象都被移除了这对低配设备非常友好不仅启动更快GC 压力也会大幅下降。首次页面路由命中耗时因为省掉了整个扫描阶段降幅更加明显从用户可感知的 141 毫秒变成几乎无感。有人可能会说 0.3 毫秒太理想化了毕竟生成代码里列表初始化也要分配一点内存。这里的数据确实是静态属性初始化后的读取耗时实际启动时还包含 JIT 首次编译FUIGeneratedViews类型的成本但即便把 JIT 算进去大几百微秒的量级也远远优于反射的几十上百毫秒。这个差距的本质是“运行时要做的计算被挪到了编译期”运行时只有纯读取。4.2 顺带的体积、裁剪与热重载收益除了启动性能还有三个隐藏收益是这次迁移白捡的。第一个是程序集裁剪友好。反射扫到的类型对裁剪器来说是不可见的引用一旦在 NativeAOT 环境下部署Assembly.GetTypes()拿到的是裁剪后的集合注册就会漏掉一半页面。而生成代码里typeof(HomePage)这种写法是显式的类型引用裁剪器和 AOT 编译器都能准确识别并保留从根本上解决了“反射代码在 AOT 下失效”的问题。第二个收益是可诊断性大幅提升。手滑把两个页面配了同一个路由反射模式下只有跑到注册阶段才会抛异常而且你要从几十个类型里排查是谁跟谁冲突了。编译期装配则可以在生成器里直接检查重复路由发现问题时用Diagnostic报给 IDE写代码的同时就能看到错误。这个能力我们在第二版生成器里加了后面遇到冲突不用再靠猜。第三个收益藏在开发体验里。反射模式下新增一个页面想验证注册有没有生效必须完整启动应用编译期装配模式因为注册信息是编译产物IDE 的 IntelliSense 可以直接看到生效情况配合热重载改路由甚至不用重启进程。第一次在日志里看到“装配耗时 0.3ms”的时候团队里有人开玩笑说这功能已经快到没法测了但这确实是真实体验的提升。5. 落地过程中踩过的坑5.1 增量生成器不跑或缓存失效第一个坑出现在刚把生成器接入项目的时候代码里明明加了[FUIView(test)]生成文件却始终没有更新甚至完全没有生成。排查了半天最后发现问题是生成器的项目没有重新编译。生成器代码被改动后Visual Studio 有时不会自动重建分析器项目导致引用的始终是旧版本的生成器。解决方案简单粗暴每次改完生成器手动重新生成 FUI.Generators 项目或者同时按 CtrlShiftB 构建整个解决方案。第二个坑和增量生成器的缓存机制有关。我开始时为了图方便在GetViewInfo里把整个INamedTypeSymbol对象塞进了ViewInfo里然后发现每次编译生成器都会被全量触发完全没有增量效果。原因就是类默认的引用相等不满足缓存比较条件任何微小改动都会让所有候选对象“不相等”下游重算就避免不了了。后来改成只提取string这种值类型字段配合 record 的值相等语义增量缓存才真正生效。第三个坑比较隐蔽ForAttributeWithMetadataName的谓词里如果不判断ClassDeclarationSyntax可能会把记录类型、结构体甚至接口都当成候选目标。node is ClassDeclarationSyntax这个过滤看起来简单实际作用是提前排除非类型节点减少语义模型的调用。不写这个条件功能也不会出错但性能会差一些编译期间大量节点都要进入语义层浪费不必要的时间。5.2 生成代码中的可见性与命名冲突生成器输出的代码如果只有内部类没有 public 修饰在.Net Framework 项目里可能遇到“类型一致性”方面的问题。更常见的是页面类是 public 的但生成代码所在程序集和业务程序集不是同一个这时候内部类型无法被引用。我们的解决方式是在生成代码里定义一个public static class FUIGeneratedViews并标注[EditorBrowsable(Never)]既保证跨程序集可访问又能在 IDE 里隐藏掉不干扰开发者。命名冲突的问题出现在大型项目里不同命名空间下有可能存在两个同名页面类比如App.Pages.Home.HomePage和App.Modules.Home.HomePage。如果在生成代码里只拼HomePage编译直接报重复定义。我们后来统一用FullName()扩展方法拼完整命名空间彻底解决了这个问题。还有一个细节是不要把生成文件命名为FUIViewGenerator.cs这种和生成器类同名的文件容易让调试器混淆。再有一个我反复遇到的问题开发者手工编辑生成文件。哪怕文件头已经写了自动生成标记还是会有人按 CtrlZ 撤销导致生成代码里出现半截注册项编译报出奇奇怪怪的错误。后来我们在生成代码的注释里额外加了一行// 本文件由 FUIViewGenerator 自动生成请勿手工修改并在 CI 里加了一个检查脚本凡是检测到生成文件被改动就报警。技术手段能解决一部分问题但规范化的流程才能真正杜绝这类低级失误。5.3 常见问题速查表现象原因解决方案生成文件完全没有输出生成器项目未重新编译重新生成生成器项目或重建整个解决方案生成文件存在但内容是旧的增量缓存未命中或失效检查 ViewInfo 是否包含不可比较的引用类型改用 record 和值类型字段生成代码报“路由重复”多个视图使用了相同路由在生成器中遍历时检查重复项并上报 Diagnostic页面是 internal 类无法访问生成代码引用了 internal 类型将页面类改为 public或对生成程序集添加 InternalsVisibleTo生成器在 Linux CI 上不工作SDK 版本过低确保 CI 镜像使用 .NET SDK 7.0 以上生成代码里类型名重复不同命名空间存在同名类使用完整命名空间 类型名不要只取类名热重载后注册不生效生成代码未随热重载更新检查 IDE 是否支持增量生成器热更新必要时重启调试会话这个表是我们团队内部经验手册里的内容每一次踩坑都对应一次线上环境或开发环境的真实故障。把这些记录下来不是为了文档好看是为了下次再有人掉进同一个坑时能直接查表解决不用重新摸索一遍。6. 从反射迁移到 Source Generator 的工程化建议6.1 分阶段迁移别一步到位很多团队在引入新方案时喜欢“大爆炸式”重写把所有反射一把梭换成生成器。我的建议恰恰相反分阶段迁移每步都可验证出问题随时回滚。第一阶段只加特性不改逻辑让反射仍然作为注册的唯一来源第二阶段把生成器接入并输出注册列表同时保留反射模式用一个静态开关控制走哪条路第三阶段才切换默认路径为编译期装配并观察线上指标。这里要强调“同时保留两条路径”的价值。我们在第二阶段做了几周的 A/B 对比把两种模式跑出的注册列表打印出来做 diff确认完全一致之后才切流量。别看生成器逻辑简单遇到泛型页面、嵌套类、基类带特性这些边界情况时手写解析和编译器语义解析很容易出现细微差异提前对比能在一开始就发现这些不一致而不是等用户报 bug。迁移过程中还要顺手做一件事把旧的反射代码抽成一个独立的ReflectionRegistryBuilder和新代码放到不同类里。这样切换路径时只需要改一行配置不用在多个模块里翻来翻去。等新方案稳定运行一两个版本后再删除反射实现顺便删掉反射配套的测试用例代码库反而更干净了。6.2 保留反射回退开关工程上我对任何“不可回退”的重构都持有戒心。Source Generator 虽然性能好但它对编译环境有要求万一某个客户用的旧构建工具链不支持增量生成器整个应用会直接构建失败运营事故。因此我们保留了反射回退开关放到配置中心里线上出问题可以立即切回旧模式不用发版。实现上很简单注册时的选择逻辑类似这样if (FeatureFlags.UseCompileTimeRegistration) { RegisterViews(FUIGeneratedViews.Views); } else { RegisterViews(ReflectionRegistryBuilder.ScanFUIViewAttribute()); }当环境不支持生成器比如 CI 镜像特别老时构建脚本会自动把开关设为 false保证版本仍然可以正常发布。这个保险让我们在推广生成器的过程中没有任何压力因为最坏情况也就是回退到老性能而不是系统不可用。稳是生产环境最重要的品质功能炫酷都是次要的。6.3 配套约定与测试引入生成器之后团队需要建立一些新的约定。最核心的一条是新增页面必须挂 FUIViewAttribute否则框架不会自动发现它。这句听起来像废话但真实项目里就是会有代码评审漏掉的情况页面能编译能跑点击入口却提示找不到路由。我们在生成器里加了一条诊断规则检测到 FUI 的命名空间下存在Page结尾的类且没有挂特性时就直接给出 Warning把问题消灭在编译期。单元测试方面Source Generator 是可以非常优雅地做测试的。用 Roslyn 提供的CSharpGeneratorDriver在内存里构造一个包含若干虚拟页面类的语法树运行生成器再断言生成代码里包含预期的路由字符串。整个过程不需要启动真实应用毫秒级完成适合在每个页面新增时自动跑一遍。这套测试在 CI 上跑得非常稳定已经成为我们最快的回归防线之一。还有一个细节经验生成器内部的错误处理一定要克制。生成器一旦抛异常编译器会直接把整个编译失败影响非常大。所以我们所有解析代码都遵循“拿不到默认值就返回 null 并跳过”的原则绝不让单个错误类型阻塞整个编译。等到调用端需要拿到所有注册信息时再在业务代码里统一抛出可读的异常信息这个设计让生成器本身非常健壮几乎不会成为构建的故障点。我自己在这段时间里最大的体会是性能优化做得再花哨都不如把运行时要做的事情真正挪走来得实在。反射注册从设计上优雅但优雅的代价是每个启动的夜晚都要多等几百毫秒这在现代应用里已经是不被允许的奢侈。Source Generator 并不是什么黑魔法它只是把“发现类型”这件事交给了更擅长做静态分析的编译器。最后再分享一个小技巧生成代码里尽量使用auto-generated/标记和固定的命名空间然后给生成器项目写一个小的冒烟测试每次编译后自动检查产物是否包含关键注册项。看似多了一步实际帮我们省掉了大量手工核对的时间。这套从反射注册迁移到编译期装配的方案现在已经在我们项目里稳定运行了接近半年下一步我打算把同样的思路推广到依赖注入的自动装配上原理完全一致收益应该会更明显。