
上个月排查 FUI 冷启动卡顿性能分析器把所有矛头都指向了启动阶段的反射注册ScanAssemblies()在 52 个程序集上做GetTypes()加特性过滤光这一项就吃掉 120 毫秒。当时第一反应是优化反射缓存比如把扫描结果序列化落地或者改用更轻的元数据读取方式。但越查越觉得不对——FUI 这套组件装配机制从第一天起就在用运行时发现的方式做事类型之间的注册关系明明是编译期就确定的却要每次启动都重新考古一遍程序集。于是才有了这篇文章把 FUI 的装配从反射注册完整迁移到了 Source Generator 编译期装配。这不是一篇只讲怎么用 Source Generator的入门教程而是一次真实迁移的复盘从哪个痛点切入、怎么设计生成代码的边界、增量生成器有哪些缓存上的坑、以及迁移前后的实测数据。如果你手头也有类似的自绘 UI 框架、依赖容器或插件系统还在用反射做启动期装配这篇文章应该能给你一条可以照抄的迁移路线。1. 反射注册的痛启动缓慢只是最轻的那一层1.1 反射注册到底慢在哪里FUI 早期的装配逻辑很典型程序启动后遍历所有已加载程序集过滤带[FUIComponent]特性的类型然后逐个调用container.Register(...)。这段逻辑单独看单次执行并不算太慢但问题是它每次冷启动都要完整执行一遍。Assembly.GetTypes()的开销不在于拿数组而在于它会强制 CLR 解析程序集的完整元数据。如果你加载了 52 个程序集其中有几个是几百个类型的大程序集这一下就是全量元数据扫描。再加上自定义特性的匹配还需要读取每个类型的Attribute元数据耗时是线性放大的。我们在 FUI 里实测是 120ms 起步如果程序集数量翻倍这个数字不是线性涨而是接近指数上涨——因为每个程序集之间还有依赖关系的元数据解析。一个很反直觉的现象反射扫描慢往往不是慢在你自己的业务类型上而是慢在那些为了扫描你不得不解析的框架类型和基础类型上。程序集引用链越长无关成本越高。1.2 比性能更麻烦的三个隐性成本时间久了你会发现性能问题反而是最好解决的真正磨人的是下面这三件事类型安全缺失。反射注册的代码往往长这样Register(componentType, path)其中componentType是个Type对象。如果组件路径写错、注册类型拼错编译期完全不会报错只有跑到启动阶段才会炸。更隐蔽的是有些人图省事会直接把某个基类或接口注册进去运行时才暴露出这个组件根本不能被实例化的问题。依赖关系成了黑盒。反射注册意味着没有显式的调用点。你想知道MainPage在哪里被注册只能在启动代码里打断点或者加日志。IDE 的查找所有引用根本帮不上忙全局搜索MainPage只能搜到定义处和 XAML 引用处。重构组件名的时候纯粹靠人肉确认装配关系漏一个就是线上事故。AOT/裁剪环境直接傻眼。近两年 .NET 的 NativeAOT 和 iOS 裁剪越来越普及反射注册在这种环境下是最难受的。因为裁剪器看不到反射调用关系会把程序集里看起来没用的类型整个剪掉然后你运行时GetTypes()拿不到类型。要解决就得维护一大坨DynamicDependency和裁剪配置文件比反射代码本身还难维护。我整理了一张对比表看完基本就能理解为什么要动这个手术了维度反射注册Source Generator 编译期装配启动注册耗时毫秒级随程序集数量膨胀近似零生成代码直接执行类型安全运行期才暴露编译期生成逻辑写在生成代码里依赖可见性黑盒无调用点显式注册代码IDE 可跳转AOT/裁剪友好度差需额外配置文件好静态类型引用重构影响容易漏改编译器直接报错或自动跟随调试体验断点难打启动链路长生成代码可读断点清晰2. 迁移前的总体规划把运行时发现改写成编译期断言2.1 先盘点哪些装配信息是编译期可以确定的迁移之前我花了整整一天时间把 FUI 的装配逻辑全部摊开分类标记每一行代码的信息可得时间。你会发现一个有意思的事实绝大多数装配信息在编译期就已经是确定的了。FUI 里需要注册的东西大概分四类带[FUIComponent]的页面/组件类型类型名、命名空间、继承关系编译期全确定。组件路径和生命周期策略这些是特性构造函数里的常量编译期也确定。组件之间的导航关系如果导航目标是通过typeof(SomePage)硬编码的编译期确定如果是字符串路由那要单独做路由表。启动时所需的初始化顺序这部分往往依赖外部配置不能全塞进编译期。我们的结论是前两类完全可以交给编译期生成第三类可以先做静态登记再保留运行时查表第四类保持现状。这个分类法比一上来就闷头写 Generator 重要得多。它的本质是重新审视你的装配逻辑哪些是事实哪些是策略。事实交给编译期策略留给运行时。2.2 生成代码还是生成数据两种产物的取舍确定了迁移范围后紧接着要回答一个设计问题Source Generator 到底应该生成什么是生成一堆直接执行的注册代码还是生成一个可以被运行时消费的数据表生成代码的特点是显式、可读、可以利用编译器的静态检查。比如直接生成container.RegisterMainPage(/main, ComponentLifetime.Singleton);这种写法最大的好处是如果MainPage不存在了编译直接报错如果你改了构造函数签名编译器立刻告诉你。但缺点是生成代码和你的容器 API 耦合了——容器将来改接口生成器也得跟着改。生成数据的特点是通用、稳定。你可以生成一个静态数组internal static readonly ComponentRegistration[] Registrations new[] { new ComponentRegistration(typeof(MainPage), /main, ComponentLifetime.Singleton), };运行时由框架统一遍历这个数组做注册。好处是生成逻辑简单不会因为容器接口调整就崩坏处是typeof(MainPage)的引用丢失后编译期不会立刻报警——虽然比反射强但还没有把静态检查的红利吃到极致。FUI 最终选择了生成代码方案。原因有三个一是我们的容器接口相当稳定短期内不会改二是我们希望在迁移期间尽可能早地暴露装配错误三是生成代码可以直接在调试时单步进去看团队协作时的认知成本最低。如果你更看重框架和生成器解耦生成数据也完全可行。关键是这条路你在动手前就要想清楚不然后面返工很痛苦。2.3 为什么用增量生成器而不是传统 ISourceGeneratorRoslyn 里有两代 Source Generator 接口老的ISourceGenerator和新的IIncrementalGenerator。只做一次性 Demo 的话两者看不出差别但放进真实项目里差别非常大。老生成器每次编译都会对整个编译对象重新执行一遍生成逻辑哪怕你只改了一个文件里的一个字符。这意味着在 IDE 里每敲一次键盘生成器就要全量跑一次。程序集一多Visual Studio 直接卡成幻灯片。增量生成器通过IncrementalValueProvider做到了缓存复用。语法树、语义模型、特性信息都可以被拆成独立的数据流水线只有上游发生变化的下游才会重新计算。所以虽然第一次全量构建还是慢但增量编译时几乎没有任何感知。这也是为什么现在新写的 Generator 基本都默认IIncrementalGenerator。后面实现的全部代码都基于这个接口。3. 手写 Source Generator 的完整流程从标记特性到自动装配3.1 定义 FUIComponentAttribute 与运行库侧接口迁移的第一步不是写 Generator而是先把标记这件事规范化。我们定义了FUIComponentAttribute放在 FUI.Abstractions 程序集里[AttributeUsage(AttributeTargets.Class, AllowMultiple false, Inherited false)] public sealed class FUIComponentAttribute : Attribute { public string? Path { get; set; } public ComponentLifetime Lifetime { get; set; } ComponentLifetime.Transient; }注意几个细节Inherited false很关键否则基类带特性会导致所有派生类都被扫描到这在实际项目里会造成重复注册。AllowMultiple false也是常识一个类型的注册语义不应该有歧义。与此同时我定义了生成代码要对接的容器接口保持最基本的样子public interface IFUIContainer { void RegisterTComponent(string? path null, ComponentLifetime lifetime ComponentLifetime.Transient); }这里有一个早期的反模式有人在FUIComponentAttribute里塞了Type类型的参数比如[FUIComponent(typeof(IMainPage))]。能跑但这会让特性参数失去常量语义生成器读取时也麻烦。能用字符串路径和枚举解决的问题不要在特性里塞复杂类型。3.2 创建 Generator 项目并配置工程引用Generator 项目本身必须目标netstandard2.0因为 Roslyn 要在各种 .NET 环境下加载它包括 .NET Framework 的 MSBuild 进程。代码里不能随便用最新 C# 语法至少类库层面要保守。项目文件长这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /ProjectEnforceExtendedAnalyzerRules这个属性建议加上它会启用针对 Analyzer/Generator 的额外代码分析规则提前暴露一些隐患。Microsoft.CodeAnalysis.CSharp的版本也不要追新——你的 Generator 要在目标机器上的 Roslyn 版本范围里跑太新的包版本可能无法被老编译器加载。然后在 FUI 宿主项目里引用这个 GeneratorItemGroup ProjectReference Include..\FUI.Generator\FUI.Generator.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse PrivateAssetsall / /ItemGroup关键点OutputItemTypeAnalyzer把项目引用作为分析器加载而不是程序集引用。ReferenceOutputAssemblyfalse不要给宿主项目暴露 Generator 里的类型。PrivateAssetsall防止这个 Analyzer 引用被传递到下游项目。3.3 核心生成逻辑ForAttributeWithMetadataName Collect RegisterSourceOutput直接上最核心的 Generator 实现。以下代码就是 FUI 编译期装配的骨架using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; namespace FUI.Generator; [Generator(LanguageNames.CSharp)] public sealed class FUIComponentIncrementalGenerator : IIncrementalGenerator { private const string AttributeFullName FUI.Abstractions.FUIComponentAttribute; public void Initialize(IncrementalGeneratorInitializationContext context) { var componentInfos context.SyntaxProvider .ForAttributeWithMetadataName( AttributeFullName, static (node, _) node is ClassDeclarationSyntax, static (ctx, _) GetComponentInfo(ctx)) .Where(static info info is not null) .Select(static (info, _) info!); context.RegisterSourceOutput(componentInfos.Collect(), static (spc, infos) { var source BuildSource(infos); spc.AddSource(FUIComponentRegistration.g.cs, SourceText.From(source, Encoding.UTF8)); }); } private static ComponentInfo? GetComponentInfo(GeneratorAttributeSyntaxContext ctx) { if (ctx.TargetSymbol is not INamedTypeSymbol symbol) return null; var fullName symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var path / symbol.Name.ToLowerInvariant(); foreach (var attribute in symbol.GetAttributes()) { if (attribute.AttributeClass?.ToDisplayString() ! AttributeFullName) continue; foreach (var namedArg in attribute.NamedArguments) { if (namedArg.Key Path) path namedArg.Value.Value?.ToString() ?? path; } } return new ComponentInfo(fullName, path); } private static string BuildSource(IReadOnlyListComponentInfo infos) { var builder new StringBuilder(); builder.AppendLine(// auto-generated/); builder.AppendLine(#nullable enable); builder.AppendLine(namespace FUI.Generated); builder.AppendLine({); builder.AppendLine( internal static class FUIComponentRegistration); builder.AppendLine( {); builder.AppendLine( public static void RegisterAll(global::FUI.Abstractions.IFUIContainer container)); builder.AppendLine( {); foreach (var info in infos) { builder.AppendLine($ container.Register{info.FullName}(\{info.Path}\);); } builder.AppendLine( }); builder.AppendLine( }); builder.AppendLine(}); return builder.ToString(); } } internal sealed record ComponentInfo(string FullName, string Path);这段代码的核心在ForAttributeWithMetadataName。这个 API 是 Roslyn 4.3.1 之后引入的它做的事情是在编译中查找所有标记了指定全名特性的语法节点并直接提供语义模型信息。这里必须单独强调一个点不要自己在SyntaxProvider.CreateSyntaxProvider里手写找特性的逻辑。早期我见过很多生成器代码先从ClassDeclarationSyntax里找AttributeListSyntax再去解析AttributeSyntax的名称最后用SemanticModel.GetSymbolInfo拿语义符号。这套做法在老代码里很常见但性能差且容易踩语法层面的坑比如global::FUI.Abstractions.FUIComponent这种写法就会让字符串匹配翻车。ForAttributeWithMetadataName直接从语义层匹配符号干净利落。3.4 生成代码的文件与可读性设计生成文件的hintName我固定用了FUIComponentRegistration.g.cs没有按类型拆分。理由很实际FUI 的组件数量在几千量级以内一个文件生成 2000 行注册代码完全可接受生成一个文件还能减少编译器的文件处理开销。可读性方面有几个原则文件头必须带// auto-generated/告诉工具链和同事这个文件不要手改。生成代码的缩进要规范不要为了省字符串拼接把代码全怼成一行。类型名用global::前缀避免命名空间冲突。#nullable enable要带上防止消费方项目因为可空上下文不一致报警。我还额外做了一个增强生成器在发现两个组件的 Path 相同时会输出一个编译诊断FUI001直接编译报错。这个在反射时代是完全做不到的因为运行时才能遍历出完整注册表。写法和普通诊断一样var duplicatedPaths infos.GroupBy(i i.Path).Where(g g.Count() 1); foreach (var group in duplicatedPaths) { spc.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor(FUI001, Duplicate component path, The component path {0} is registered more than once., FUI, DiagnosticSeverity.Error, true), location: null, group.Key)); }3.5 在宿主入口接入自动装配最后一步是修改 FUI 的启动代码把原来的反射扫描换成一行生成代码调用var container new FUIContainer(); FUI.Generated.FUIComponentRegistration.RegisterAll(container);原先 50 行ScanAssemblies、FilterTypes、GetCustomAttributes、TryRegister的链路直接从启动路径上消失了。剩下的就是把RegisterAll方法体里的注册记录当作装配清单——FUI 的组件关系从此跃然纸上。如果你有多个入口比如不同的宿主进程记得每个入口都要调用RegisterAll。或者更省事在FUIContainer的构造函数里直接调用这样任何入口创建容器都会自动装配。FUI 最终选的是后者因为团队里确实有人会忘。4. 增量生成器的两个大坑缓存失效与配置项读取4.1 为什么不能在 Generator 里静态缓存注册表迁移过程中的第一个大坑来自于一个写惯了普通库代码的直觉把收集到的组件信息缓存到静态字典里避免重复计算。但增量生成器有一条铁律所有跨编译会话的状态都必须放在语法树和语义模型推导出的 value provider 里任何静态容器都不要碰。我之前在调试一个重复注册问题的时候发现自己写了一个static ConcurrentDictionarystring, ComponentInfo来缓存已收集的信息。结果就是第一次构建正常第二次改了个组件路径再构建生成代码里依然保留旧路径。因为静态字典在整个 MSBuild 进程生命周期里是常驻的增量编译时 provider 会复用上次的结果但我自己又往静态字典里塞了新的两边不一致生成代码就处于薛定谔状态。正确的做法是让你的 transform 函数保持纯函数。输入是语法节点或符号输出是ComponentInfo不要依赖任何外部可变状态。增量生成器自己会做缓存和失效判断你只需要把每个类型的信息算干净。4.2 Collect 的增量损失与取舍第二个坑是我自己选的但值得说清楚。上面的实现里用了.Collect()这个操作会把所有ComponentInfo汇总成一个ImmutableArray再统一生成一个源文件。好处是代码简单坏处是任何一个组件变更都会导致整个RegisterAll方法重新生成增量的粒度被放到了所有组件级别。FUI 项目目前的规模下全量重生成也只需要几毫秒完全可接受。但如果组件数量上到几十万比如编辑器类软件的扩展点系统你就必须考虑拆文件每个类型生成一个独立的注册方法再生成一个汇总调用的骨架。那种方案的增量粒度小但生成代码的复杂度会上一个台阶。我的建议很简单先按规模选方案不要为了更增量而把复杂度堆高。规模大了再拆有实测数据支撑的迁移才是理性的。4.3 调试技巧从 Debugger.Launch 到最小复现工程Generator 卡死、生成代码不对、诊断不触发这些调试起来很痛苦因为 Generator 是在编译进程里跑的不会跟你的 IDE 调试会话自动挂钩。第一个入门技巧是Debugger.Launch()#if DEBUG System.Diagnostics.Debugger.Launch(); #endif把这行放在Initialize或某个 transform 的开头。编译时它会弹出一个选择调试器的对话框你选当前 Visual Studio 实例就能命中断点。注意本地调试分支要用#if DEBUG包住否则 CI 上的编译也会卡在断点提示上。第二个技巧是我强烈推荐的建一个极小的复现工程。比如在tests/FUI.Generator.Tests里只放一个MainPage类和特性的最小定义用 Roslyn 的CSharpGeneratorDriverAPI 直接跑生成器var compilation CreateCompilation(source); GeneratorDriver driver CSharpGeneratorDriver.Create(new FUIComponentIncrementalGenerator()); driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); var runResult driver.GetRunResult();这样不用启动整个 FUI 宿主应用几秒钟就能验证生成器逻辑是否正确。等到生成代码符合预期再回到大项目里做集成验证。4.4 提示名称冲突与多目标框架下的生成文件问题还有一个容易忽略的点一个项目如果同时TargetFrameworks多个 TFM比如net8.0和net48生成器的AddSource会在每个 TFM 的编译里各执行一次如果hintName相同不同 TFM 间不会冲突但同一个编译里如果多个生成器都产生同名文件就会报错。为了避免这种问题如果将来生成的文件不只是一个建议在hintName里加入稳定的哈希后缀。比如把所有组件类型完整名拼起来算一个 SHA256取前 8 位var hash string.Concat(infos.Select(i i.FullName ;)).ComputeSha256().Substring(0, 8); spc.AddSource($FUIComponentRegistration.{hash}.g.cs, ...);这样生成文件名的可预测性差一点但基本杜绝了冲突。FUI 目前只有一个文件我保留的是固定文件名主要是为了 Git 差异对比时容易判断生成内容的变化。5. 迁移前后实测启动耗时、调试体验与交付规范的变化5.1 启动数据对比一次彻底的性能释放迁移完成后我在同样的测试机上跑了三次冷启动数据如下环境Windows 11.NET 8Release 配置RyuJIT指标反射注册Source Generator 编译期装配注册阶段耗时123ms0.3ms冷启动完成时间412ms268ms启动期程序集元数据解析数52 个仅加载实际用到的启动期峰值内存增量48MB20MB注册阶段从 123ms 降到 0.3ms这个幅度其实不意外因为生成代码就是一组直接的方法调用连循环都没有。整体冷启动缩减了 144ms对桌面端来说体感非常明显尤其是在老机器上。但我要泼一盆冷水如果你的系统本身启动已经很快比如反射注册只花 5ms这个迁移带来的性能红利可能远小于你的改造投入。这时候迁移的真正价值不在性能而在第二节和第五节的这些维护性收益。不要纯粹为了炫技把一个 10ms 的反射改成 Generator性价比不划算。5.2 调试体验与合作协作的变化数据之外有个我没有预料的收益是团队协作层面的。反射时代新人入职看 FUI 装配逻辑基本是一脸懵启动代码里就一行RegisterAllComponents()根本不知道哪些类型被注册了只能跑起来后看日志。现在直接打开FUIComponentRegistration.g.cs所有组件、路径一目了然。代码评审也轻松了很多。以前合并请求里如果改了某个组件的路径评审人根本没法判断这个改动会不会影响启动装配。现在路径在特性上写着而且生成器在编译期就会因为重复路径直接报错评审人的注意力可以完全集中在业务逻辑上。排查问题更是这样。反射时代某个组件注册失败了你得在启动链路里加日志、看异常堆栈定位到具体是哪个类型哪一步炸的。现在生成代码就是一组静态调用IDE 单步进去谁先谁后、哪个路径传错了全摆在明面上。5.3 生成器诊断规则带来的质量前移这轮迁移让我对编译期质量前移有了新的理解。过去很多运行时才能发现的问题其实都是因为类型关系没有被静态化。编译器不是不能查是你压根没告诉它哪些信息是重要的。生成器天然适合在这层做断言。目前 FUI 的 Generator 里除了重复路径检查还加了两条诊断规则FUI002FUIComponentAttribute标记在非partial类上时给提示虽然生成方案不依赖 partial但框架规范建议组件类都声明为 partial便于未来做代码注入扩展。FUI003Path以/开头但包含空格时给警告这种路径在导航解析里会产生歧义。这些规则放在 Generator 里有一个天然优势它能在语义模型层面做事比单纯的正则表达式匹配可靠得多。而且规则和生成过程共享同一份ComponentInfo数据不需要写第二套解析逻辑。这套思路也延伸到了新组件开发流程里新同事写一个页面加上特性编译一过组件就自动进入了装配清单不需要记得去某个启动文件里改注册代码。从记得注册变成了声明即注册。这体验上的差别用过的团队都回不去了。6. 后续可以继续扩展的三个方向迁移做完之后我又往前多想了几步。这些不算 FUI 线上必须做的但对想要进一步榨干编译期装配价值的人来说是很好的延展方向。方向一把装配清单导出为静态路由表。既然 Generator 已经收集了所有组件的 Path 和类型映射那就没必要只在内存里用一次。可以顺手生成一个 JSON 或 CSV 文件交给路由工具、文档系统甚至自动化测试用例消费。注意这需要 Generator 不但AddSource还要通过AdditionalText或者EmitCompilerGeneratedFiles的方式把非源码产物输出到项目目录。方向二把组件生命周期校验提前。比如某个组件标记了Singleton但构造函数依赖另一个Transient组件这在反射时代是运行期才会暴露的问题。生成器可以读取构造函数参数配合一个简单的依赖规则分析器直接在编译期给出诊断。这就是把一套迷你依赖分析器搬到了编译期。方向三与热重载/热更新机制打通。编译期生成的注册表是静态的但业务系统往往希望某些组件可以动态替换。一个可行的折中是生成器把所有组件注册到一个静态字典运行时热更新只需要替换字典里的映射关系不需要重新生成代码。FUI 目前还没有走到这一步但架构上已经留好了口子。最后分享一个实用的经验。迁移到编译期装配之后我踩到最多的问题反而不是 Generator 本身的写法而是引用关系ProjectReference里漏了OutputItemTypeAnalyzer、或者把 Generator 项目直接ProjectReference进了发布项目。建议迁移初期先在独立的分支上做专门用一个几行的 Demo 宿主验证引用关系确认生成代码能正常产出再合并回主线。生成器这种东西一旦引用关系错了症状是没有任何效果而不是编译报错排查起来非常反直觉。整个实践下来我的体会是编译期装配的本质不是简单地把反射逻辑翻译成生成代码而是把类型关系从运行时重新提升为编译期可见的静态事实。对于任何有装配需求的框架——不管是 UI 框架、依赖注入容器还是插件系统——只要类型关系在写代码时已经确定Source Generator 就值得一试。先小范围验证再逐步扩大覆盖面你会看到一个更快速、更安全、也更可阅读的系统。