
前阵子给团队内部的 FUI 框架做了一次装配机制升级把原来跑在运行时的反射注册整套逻辑替换成了基于 Source Generator 的编译期代码生成。起因很朴素新接手的项目模块数量涨到了七十多个每次冷启动光做程序集扫描和类型注册就要吃掉四百多毫秒而业务代码真正跑起来才几十毫秒。这个比例放在桌面端还能忍放到低配机器上直接能感受到明显的卡顿。当时查了一圈发现问题的根源并不是某个算法写得差而是“反射注册”这个机制本身把太多本可以在编译期完成的工作拖到了运行期。这篇博文就围绕这次从反射注册到 Source Generator 的完整改造过程展开讲清楚为什么反射在工程化场景下会越来越别扭、FUI 的装配约定该怎么重新设计、增量生成器怎么写才不会踩坑以及迁移过程中我遇到的几个能让你怀疑人生的疑难杂症。适合正在维护 UI 组件框架、插件化系统、或自研依赖注入容器的 .NET / C# 开发者参考特别是那些已经对反射的启动耗时和排错体验感到头疼的同学。1. 为什么要把装配机制从运行时搬到编译期1.1 反射注册在工程化场景下的真实痛点反射注册本身没问题小项目里它甚至是最快上手的方案。写一个特性标在类上启动时扫一遍程序集实例化并注册到容器里完事。这套路用了十几年稳定可靠真正的问题出在规模上来之后。第一个痛点是启动耗时。FUI 框架里每个面板、每个模块都依赖一个“注册中心”来告诉容器哪些服务存在、哪些视图可以打开。用反射实现的时候启动阶段要遍历所有程序集、拿全部类型、再逐个检查有没有注册特性这个过程随着模块数量增长不是线性涨而是几何级数涨。因为我们不仅扫描一次还要对找到的类型做属性判断、构造参数解析、生命周期计算七七八八加起来几百毫秒就没了。第二个痛点是错误可见性太差。反射注册的失败极少在注册阶段直接炸出来往往是运行到某个功能模块时才突然报“服务未注册”或者“视图找不到”。如果这个功能是用户点开某个几乎没人用的高级设置页那这个 bug 就会在测试阶段漏掉、在生产环境爆雷排查链路又臭又长。第三个痛点是工具链不友好。反射注册的调用关系对 IDE 来说完全是黑盒你想用“查找引用”看看某个模块被谁加载了找不到任何结果。代码评审的时候也很难判断一个服务到底有没有被正确注册只能靠人肉翻启动日志。大型项目里这种不确定性是非常难受的。第四个痛点也是未来越来越要命的痛点就是 AOT 和 NativeAOT 的兼容性问题。反射在 AOT 裁剪模式下会被大量屏蔽依赖反射的程序在发布时会丢失大量类型元数据。虽然 Unity 的 IL2CPP 和 .NET 的 NativeAOT 是两套不同的技术路径但它们对反射都不友好。FUI 如果以后想往游戏客户端、移动端甚至嵌入式领域走反射注册这套机制基本就是死路。1.2 编译期装配的核心思路与收益Source Generator 做的事情通俗地讲就是在编译器把源码变成中间语言的这个过程中插入一道“窃听器”读取你项目里的语法树和语义模型然后吐出一些额外的 .cs 文件跟你的源码一起编译进程序集。编译期装配的思路就是把这个能力用在“注册”上我们不用在运行时扫描类型而是在编译阶段就把所有带注册特性的类找出来直接生成一段强类型的注册代码。程序跑到注册环节时调用的全都是硬编码的container.RegisterXxxService();这样的语句没有任何反射操作。这套方案带来的收益很直观启动耗时从 O(程序集内类型数量) 降到了 O(注册类型数量)省掉了扫描和元数据读取的开销。注册错误在编译期就能暴露。你标了一个模块特性但生成器发现这个类没有实现必要的接口编译器会直接报错不需要等用户点击某个角落里的功能。IDE 的跳转、引用查找变得好用了因为生成代码是真实存在的你可以 Ctrl点击 看到注册逻辑到底做了什么。AOT 友好程度大幅提升没有反射裁剪工具就可以放心把用不到的元数据删掉。我做个简单的对比表方便你直观感受两种方案的差异维度反射注册Source Generator 编译期装配启动耗时随程序集规模增长几百毫秒起几乎为零运行期只执行生成好的注册语句错误发现时机运行期经常延迟到功能调用时编译期编译器直接报错IDE 代码导航不可见黑盒可跳转生成代码引用关系清楚AOT / 裁剪兼容差元数据容易被裁剪好无运行时反射动态加载新模块灵活无需重新编译需要重新编译并连代码一起发布实现复杂度低中高需要写生成器表格最后一行的“动态加载”其实是反射的看家本领也是我在改造中反复权衡的地方。后面专门用一节来讲怎么给它留后路。2. FUI 装配框架的架构设计与注册约定2.1 模块、服务与视图的定义方式动手写生成器之前我先把 FUI 的装配约定重新整理了一遍。之前的反射版约定混乱三个模块库各自为政有的用字符串标识有的直接拿 Type 做 key。这次彻底统一成三个核心特性[FuiModule]标记一个类为功能模块这个类负责初始化模块内的服务注册、路由绑定和资源加载。等价于其他框架里的 Module 入口类。[FuiService]标记一个类为可注入服务生成器会为它创建容器注册条目。可以指定生命周期比如单例、瞬态、作用域。[FuiView]标记一个类为 UI 视图会注册到视图导航表里绑定视图名和视图类型的关系。实际使用大概长这样[FuiModule(order)] public sealed class OrderModule { public static void DefineServices(IServiceRegistry registry) { registry.ForOrderRepository().Singleton(); registry.ForOrderService().Transient(); } } [FuiService(typeof(IOrderRepository), Lifetime ServiceLifetime.Singleton)] public sealed class OrderRepository : IOrderRepository { } [FuiView(order/list)] public sealed class OrderListView : FuiViewBase { }为什么这么设计核心原因是让生成器好归纳。[FuiModule]标注的类作为生成入口生成器会为它生成一个模块初始化方法[FuiService]可以直接从特性构造函数里拿到服务接口[FuiView]的字符串名字会成为视图字典的 key。每个特性承担职责单一生成器做静态分析的时候逻辑就非常清晰。我用模块特性的另一个原因是给手工装配留口子。有些模块内容比较特殊不适合全自动注册那就只标[FuiModule]在DefineServices里面自己写注册逻辑。这个设计在后来的插件兼容期帮了大忙后面细说。2.2 需要生成的核心代码结构生成器的产出不是随便吐一段能跑的代码就完事而是要让它在可读性、扩展性、调试友好性上都过关。我最终把生成内容收敛成三个部分第一是模块清单。生成一个静态类包含所有[FuiModule]标记模块的元数据列表运行时会按这个列表依次加载模块。internal static partial class FuiGeneratedModuleCatalog { public static IReadOnlyListFuiModuleDescriptor Modules { get; } new[] { new FuiModuleDescriptor(order, MyApp.OrderModule, typeof(MyApp.OrderModule)), // ... }; }第二是服务注册条目。所有标记了[FuiService]的类型会生成对应的注册描述包括服务类型、实现类型、生命周期。第三是视图映射表。生成一个只读字典把视图名映射到视图类型。internal static partial class FuiGeneratedViewTable { public static IReadOnlyDictionarystring, Type Create() { var table new Dictionarystring, Type { [order/list] typeof(MyApp.OrderListView), // ... }; return table; } }生成这些代码的时候我刻意让它们保持“人能看懂”的风格。因为生成器一旦出错你需要读生成后的文件来排查问题。如果生成代码糊成一坨调试成本反而比反射还高。所以我让生成器输出的代码都带清晰命名和缩进并且每个注册条目都附带上写源文件路径的注释比如// from /path/to/OrderRepository.cs查问题的时候能直接溯源。3. Source Generator 实现细节与实操步骤3.1 增量生成器的项目搭建与依赖配置生成器本身是独立的 .NET 项目被编译成分析器后注入到目标项目里。我选择的是IIncrementalGenerator也就是增量生成器而不是老旧的ISourceGenerator。增量生成器能按输入分块缓存结果第二次编译的时候只会重新跑有变化的部分体验好得多。项目的基础配置我直接贴在下面几个关键点都写在注释里了Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion IsRoslynComponenttrue/IsRoslynComponent EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules IncludeBuildOutputfalse/IncludeBuildOutput Nullableenable/Nullable /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.Analyzers Version3.3.4 PrivateAssetsall / PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /Project三个属性值得单独解释。TargetFramework必须是netstandard2.0因为生成器要跑在编译器进程里编译器本身是基于 .NET Framework 或 .NET Core 运行的只有 netstandard2.0 才能保证两边都能兼容。IsRoslynComponent必须设成 true它告诉构建系统这个程序集是 Roslyn 组件打包的时候会被正确放到 analyzer 目录下而不是普通引用目录。EnforceExtendedAnalyzerRules是 Roslyn 团队推荐的生成器工程规范它会开启一批针对生成器的代码分析规则防止写出会崩编译器的代码比如在并发场景下访问共享可变状态。生成器项目写好之后有两种方式引用到业务项目里。一种是项目引用时把输出项当作 analyzer 注入ItemGroup ProjectReference Include..\FuiGenerator\FuiGenerator.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup另一种是打成 NuGet 包在包里配置tools/analyzer目录。团队内部我们用的 NuGet 包方式因为要同时给五个业务项目使用统一走包管理更省心。开发调试时则直接用ProjectReference 加 OutputItemTypeAnalyzer省得每次改生成器还要重新打包。3.2 遍历语法树与语义模型的实现要点增量生成器的核心逻辑在一个实现了IIncrementalGenerator的类里。它需要完成两件事一是从编译输入里找到所有带[FuiService]、[FuiView]、[FuiModule]标记的类型二是拿到这些类型的语义信息以便生成正确的代码。最早我用的是SyntaxProvider去遍历所有TypeDeclarationSyntax然后再到语义模型里查特性。这个方案能用但有两个问题性能不好所有类型都要过一遍语法树过滤逻辑又重复每次编译时GetSymbolInfo的消耗也不小。后来换成ForAttributeWithMetadataName这是 Roslyn 4.3.1 之后提供的 API专门为“按特性名找目标节点”这个高频场景优化过我强烈建议你直接从它入手。[Generator(LanguageNames.CSharp)] public sealed class FuiIncrementalGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var services context.SyntaxProvider .ForAttributeWithMetadataName( Fui.Attributes.FuiServiceAttribute, static (node, _) node is ClassDeclarationSyntax, static (ctx, _) GetServiceModel(ctx)) .Where(static m m is not null); context.RegisterSourceOutput(services, static (spc, model) { ExecuteService(spc, model); }); } }ForAttributeWithMetadataName的签名有三个参数特性全名必须是全限定名不能省略命名空间、谓词返回这个语法节点我们是否感兴趣、变换函数把语法节点和语义模型转成我们自己定义的数据模型。变换函数里最关键的是context.TargetSymbol它直接返回应用了这个特性的类型符号也就是INamedTypeSymbol类型的实例。有了它就不需要自己去调用SemanticModel.GetDeclaredSymbol了static ServiceModel? GetServiceModel(GeneratorAttributeSyntaxContext ctx) { if (ctx.TargetSymbol is not INamedTypeSymbol typeSymbol) return null; // 检查类型是否 partial不是 partial 则无法生成部分类扩展 if (!typeSymbol.DeclaringSyntaxReferences .Any(r r.GetSyntax() is ClassDeclarationSyntax c c.Modifiers.Any(m m.IsKind(SyntaxKind.PartialKeyword)))) { return null; } return new ServiceModel( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ServiceType: GetServiceInterfaceName(typeSymbol), Lifetime: GetLifetime(typeSymbol)); }这段代码里有个小坑也是老生常谈的生成器想扩展一个类目标类必须声明为partial否则生成的部分类会和原类型冲突。这里检查一下并在不满足条件时给 diagnostic 提示比生成完再让编译器报个莫名其妙的 CS0260 要友好得多。3.3 生成代码模板与辅助方法拿到语义模型之后下一步就是把数据渲染成代码文本。我见过有人用手动拼接字符串的方式写生成器小规模还行一旦生成逻辑复杂引号、花括号、缩进混在一起会让人原地爆炸。推荐用法是先定义好注册数据的中间模型再用一个专门的代码渲染类去拼字符串保证生成逻辑和语义分析逻辑分离。我的渲染类长这样internal static class ServiceEmitter { public static string Emit(ServiceModel model) { var sb new StringBuilder(); sb.AppendLine(// auto-generated /); sb.AppendLine(#nullable enable); sb.AppendLine(namespace Fui.Generated); sb.AppendLine({); sb.AppendLine( internal static partial class FuiGeneratedRegistrar); sb.AppendLine( {); sb.AppendLine($ public static void Register{model.TypeName}(IServiceRegistry registry)); sb.AppendLine( {); if (model.ServiceType is not null) sb.AppendLine($ registry.For{model.ServiceType}().ImplementedBy{model.TypeName}().As{model.Lifetime}();); else sb.AppendLine($ registry.For{model.TypeName}().As{model.Lifetime}();); sb.AppendLine( }); sb.AppendLine( }); sb.AppendLine(}); return sb.ToString(); } }这里我故意没有直接把所有注册语句揉进一个大方法而是每个类型生成一个独立的静态方法然后由一个总入口统一调用。好处很明显生成的代码和源类型一一对应调试时你可以直接跳到某个类型的注册方法里以后如果只想重新生成某几个类型的注册也不用动其他代码。注册方法的名字直接拼上类型名其实会有类型重名冲突的风险。正规一点的写法应该用model.TypeName model.MetadataName或者带 Guid 的哈希值。我在实现时是用类型全名的 MD5 后八位做后缀保证绝对不重名。这里为了展示简洁省掉了这个细节但你如果照着做建议加上。4. 从反射注册迁移到 Source Generator 的踩坑记录4.1 增量生成器缓存导致的“代码不更新”迁移过程中第一个让我挠头的问题改完源码重新编译生成的代码纹丝不动。原因是增量生成器的缓存机制在起作用。IIncrementalGenerator会根据你的输入管线做缓存当它比较两个ServiceModel实例时如果类型没有实现值相等性比较每一轮都会被认为是“新的”或者“没变化”从而产生错误行为。我当时在GetServiceModel里返回的ServiceModel是个普通 class没有重写Equals。结果就是 Roslyn 拿它做缓存比较时引用地址变了就认为模型变了于是每轮都重新生成反过来如果某个环节错误地返回了同一个引用又会走到另一端模型没变但实际源文件已经改了导致生成代码永远不更新。解决办法是把中间模型改成record类型并在模型字段里加入足够多的信息让值相等比较能准确反映源文件变化internal sealed record ServiceModel( string TypeName, string Namespace, string? ServiceType, string Lifetime);record自带基于属性的值相等比较所有字段变化都会触发重新生成字段不变则复用缓存效率更高。4.2 语义模型拿不到正确类型信息第二个坑发生在生成器刚开始集成到业务项目时。业务项目里有个服务实现了IFooService接口定义在另一个被引用的公共库里。生成器拿到[FuiService]标记的类型符号后进一步访问它实现的接口时返回的INamedTypeSymbol总是带一堆?或者直接变成ErrorType。排查了半天才确定问题生成器项目本身引用了Microsoft.CodeAnalysis.CSharp包但业务项目是 net8.0-windows 的 WPF 项目。生成器跑在编译 host 进程里宿主进程加载的运行时版本和 WPF 项目的引用体系不一样语义模型在解析跨程序集类型时若遇到未加载的依赖程序集就会得到错误符号。解决方式有两个。一是把生成器里能用到的所有类型都放到生成器项目和业务项目共同引用的一个独立程序集里这样语义模型解析时大概率已经加载了对应元数据。二是实在不行就在生成器初始化时用context.CompilationProvider拿到当前编译单元把关键程序集引用额外塞给它。其实更干净的方案是避免在生成器里访问太深的类型关系。我后来把[FuiService]的服务接口直接写进特性构造函数里从特性里拿字符串去解析类型而不是走访类型实现链绕开了大部分语义解析的坑。4.3 调试生成器的几种有效手段Source Generator 跑在编译器内部你不能在生成器代码里打断点然后按 F5 直接启动业务程序。我试过三种有效的调试方式按实用程度排序。第一种单测驱动。用CSharpGeneratorDriver在测试项目里手动触发生成器传入一段写死的源码验证生成代码是否符合预期。这是最可靠、效率最高的方式适合逻辑迭代期。[Fact] public void Service_should_emit_registration() { var source using Fui.Attributes; namespace Demo; [FuiService(typeof(IFoo), Lifetime ServiceLifetime.Singleton)] public partial class Foo : IFoo { } public interface IFoo { } ; var compilation CSharpCompilation.Create(demo, new[] { CSharpSyntaxTree.ParseText(source) }, new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var generator new FuiIncrementalGenerator().AsSourceGenerator(); var driver CSharpGeneratorDriver.Create(generator); driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); var generated outputCompilation.SyntaxTrees .Single(t t.FilePath.Contains(FuiGeneratedRegistrar)).ToString(); Assert.Contains(registry.ForIFoo(), generated); }第二种真机调试。在生成器代码里加一行if (!Debugger.IsAttached) Debugger.Launch();第一次运行会弹出“是否附加调试器”的对话框确认之后就真的能打断点了。这个方式很粗暴但有时候源项目环境复杂、单测复现不出来只能靠真机。第三种日志输出。生成器不方便写文件但可以生成一个// debug: xxx的注释到代码里或者直接把信息写进context.ReportDiagnostic用编译器错误列表来看输出。我一般把重要的中间计算结果都打出来尤其是语义模型解析失败时的类型名有了它在错误列表里能节省大量猜谜时间。4.4 性能对比与回归验证改造完成后最关心的问题自然是到底快了多少我拿团队内部最大的一个仓库做了对比测试这个项目包含 7 个程序集、73 个模块、416 个服务注册。改造前冷启动时反射扫描和注册耗时大约 380ms其中一半时间花在程序集级GetTypes()调用上。改造后启动注册阶段耗时降到 3ms 左右基本可以忽略。整体冷启动从 1 秒左右降到 500ms省下来的时间几乎都来自注册环节。这里有个细节需要说明寄存器本身的耗时下降不是全部收益更关键的是我们把“扫描所有类型”这个 O(n) 的潜在风险从运行期移除了。以后模块从 73 个涨到 700 个注册耗时依然是线性的、极小的增量而反射方案的耗时大概率会涨到秒级。回归验证我分两步做。第一步是编译期清单比对写了一个自动化测试在改造前后分别导出模块清单、服务清单、视图清单逐一对比确保生成的注册集合跟反射扫描到的集合完全一致。第二步是运行期日志对比启动成功后打印实际注册成功的服务数量连续几轮确认数字没变。这两步做完我才有信心把改动合进主干。5. 兼容性设计与扩展建议5.1 如何保留运行时注册作为兜底方案Source Generator 方案有一个天生的短板只能看到编译期已知的代码。如果业务里有插件式扩展让第三方程序集在运行时动态加载并注册服务生成器就无能为力了。这也是当时团队里讨论最激烈的地方。我的处理方案是双轨制生成器负责静态模块的注册同时在容器里保留一个IRegistrationSource的扩展点允许外部程序集在运行时主动把注册信息“喂”给容器。public interface IExternalRegistrationSource { void Register(IServiceRegistry registry); }容器初始化时先生成器生成的静态注册再遍历所有实现了IExternalRegistrationSource的类型手动调用它们的Register方法完成动态注册。反射在这个流程里只作用于极少数外部程序集类型而不是全量扫描启动耗时影响可以忽略。这个设计的妙处在于FUI 的核心集和常用模块走编译期装配享受编译期安全和高性能第三方插件走显式接口注册不依赖黑魔法也不会拖慢核心链路。两条路各取所长不用做非此即彼的选择。5.2 后续可以做的扩展方向这次改造做完之后我又顺着 Source Generator 的能力往外想了想有几个方向值得继续做。第一个是依赖检查。生成器既然在编译期能拿到所有服务依赖关系就可以顺手做静态依赖分析。某个模块引用了未注册的服务编译器直接报 error这个价值比运行时 log 大得多。我目前的实现里已经接入了部分检查但还比较粗糙后续值得完善。第二个是生成代码的再收敛。现在每个注册方法都是独立生成的注册总数到上千之后程序集元数据大小会明显增加。可以考虑把同类型的注册改造成数据驱动生成底层注册表用一组紧凑的数据来描述服务和视图进一步压缩体积。第三个是对接 AOT。生成器产出的代码天然对裁剪友好但 FUI 里还有一些边界逻辑用了少量反射主要集中在反序列化和路由解析上。把这些地方也换成生成代码后理论上可以跑通 NativeAOT 发布流程。这个计划已经排进团队的 roadmap 了。最后分享一个我这次过程中得到的小心得重构这类基础设施不要上来就全面替换最好的路径是“双轨并行、逐步迁移”。先让生成器生成所有注册但运行时依然先走反射流程两条路径结果对比一致后再切换默认链路。等生产环境稳定跑两周再把反射代码删掉。这样每一步都有验证、有回退余地团队对改造的信任感也会强很多。希望这篇文章能给你的编译期装配改造提供一些参考少走几个我趟过的坑。