先说个场景,你就知道这个题目值不值得看下去了:上个季度我把 FUI 框架里的组件装配逻辑从“启动时扫程序集 + 反射建表”整套搬到了编译期 Source Generator 生成注册代码。搬完之后,最直观的体验是 IDE 里 Ctrl+F5 一按,页面秒开,启动日志里再也没出现过那几百次 Assembly.Load 和 Activator.CreateInstance 的调用记录。这套改完,不只是快了,是整个框架的初始化路径清晰到能一眼看穿。
如果你也在维护一个二三十个页面起步的 FUI 应用,或者正琢磨怎么把反射注册改成编译期生成,这篇文章就是按我实际踩坑的顺序写的。从最初的反射注册怎么设计,到它到底慢在哪里,再到 Source Generator 的实现细节、坑点、数据对比,全部给你摊开。
1. 背景:FUI 里的“装配”到底在解决什么问题
FUI 这类框架通常不是单页应用那种一切靠路由懒加载的玩法,它更像是把整个客户端拆成若干个可独立注册的“功能单元”。在实际工程里,我指的是这些:
- 页面(Page)与视图模型(ViewModel)的绑定关系
- 依赖注入容器里需要注册的服务生命周期(Singleton/Transient/Scoped)
- 导航路由表(哪个 URL 对应哪个页面)
- 事件订阅器、消息处理器的收集
- 模块初始化器(应用启动时需要按顺序执行的那些 Init 方法)
这些内容如果每个都手动在 Startup 里写一行注册代码,一开始没什么,等页面到 50 个以上,注册代码本身就成了一座屎山。所以最初设计的时候,我选择了反射注册。
做法很直接:定义特性,标记代码,启动时扫一遍,自动建表。
[FuiPage("dashboard/main")] public partial class DashboardPage : FuiPage { }启动的逻辑大致长这样:
var assemblies = AppDomain.CurrentDomain.GetAssemblies() .Where(a => a.FullName.StartsWith("MyApp.")); foreach (var assembly in assemblies) { foreach (var type in assembly.GetTypes()) { var attr = type.GetCustomAttribute<FuiPageAttribute>(); if (attr != null) { RegisterPage(attr.Route, type); } } }这套方案最舒服的地方是“少写代码”。新加一个页面,贴个特性就完事,框架自己去发现、去注册。但等你真的把它跑在一个大型项目上,问题就一点一点现出原形了。下面细说。
2. 反射注册的三个真实痛点:我在生产环境挨过的打
2.1 启动耗时:Assembly.GetTypes() 不是免费的
程序集扫一遍到底多慢,取决于你的程序集里有多少类型。我的项目是个中大型客户端,本地调试时装了大概 40 多个程序集,其中最大的业务程序集里有 3000 多个类型。全部 GetTypes() + GetCustomAttribute() 扫描一遍,实测耗时在 200 毫秒到 500 毫秒之间波动。
注意,这只是纯扫描耗时的量级,还没有算上后续 Activator.CreateInstance 创建注册表所需元数据的开销。更麻烦的是,这个耗时会随着项目变大线性增长。200 毫秒对桌面端来说还能忍,但如果你做的是移动端或者 WebAssembly 目标,这个时间会被放大得很难看。我在 UWP 平台上实测过,同样的逻辑跑下来接近 1 秒。
还有一个隐性成本:AppDomain.CurrentDomain.GetAssemblies() 拿到的集合,并不保证只有你自己项目的程序集,可能包含很多第三方库的装载结果。如果你没有老老实实加 Where 过滤,Scan 范围会被无限放大。
2.2 AOT 与裁剪兼容性:反射是 NativeAOT 的头号敌人
这是反射方案最致命的问题。当你打开 PublishTrimmed 或者使用 NativeAOT 发布时,反射用的元数据通常会被裁剪掉。因为裁剪器是静态分析代码的,它看到你只是在字符串里传给 GetCustomAttribute 或者 Activator.CreateInstance,根本没法判断你到底需要在运行时保留哪些类型。
结果就是:本地运行好好的,一发布就各种 “Cannot resolve type” ,或者干脆某个页面找不到,直接白屏。要修这个问题,你只能去配 rd.xml,一个类型一个类型地留,工作量能把你劝退。
也许你现在会说“我暂时只做普通 .NET 发布,不考虑 AOT”。但做框架的人必须考虑框架的适应场景。等平台要求来了再改,代价是你得重构核心注册代码,不是改个配置的事。我后来提前做 Source Generator 迁移,有一部分原因就是为 NativeAOT 铺路。
2.3 重构与维护:编译器帮不了你
反射注册最大的“软伤”在于它绕开了编译期检查。我犯过一个特别低级的错误:给某个 ViewModel 重命名的时候,IDE 全自动重命名了类名和文件,但是 FuiPage 特性里的字符串路由没有改。系统启动的时候,注册逻辑照样跑,不会报编译错,等到用户点某个菜单才提示找不到页面。
这种问题你没法在编译期发现,只能靠人工测试去怼。页面少还行,页面一多,漏掉一两个太正常了。我当时就在团队里立了一条不成文的规矩:“所有字符串路由必须加单元测试覆盖”,但你想想,靠测试去兜一个本来编译器就能帮你查出来的问题,本身就是在给自己挖坑。
还有一类问题是动态代理和 DI 生命周期注册写错。比如应该注册成 Singleton,结果反射扫到一个 Transient 特性,扫描的那套代码只在启动的时候跑一次,你根本没机会在代码审查的时候发现异常。
反射注册不是不能跑,是它把太多“本该编译器管的事”往后拖到了运行时。Source Generator 的方向,就是把这些事从运行时拉回编译期。
3. 编译期装配的思想:为什么是 Source Generator
先厘清概念:Source Generator 是 Roslyn 编译器提供的一种代码生成机制。它不是一个独立的命令行工具,而是作为一个 Analyzer 参与进编译流程,在你写代码、按 Ctrl+Shift+B 的时候,编译器会先运行它,把它生成的代码一并编进当前编译单元。
它对 FUI 这类框架的意义在于:它让你能把“扫描 + 注册”这个流程从运行时的 200 毫秒压缩到编译期的一次性能开销。运行时装配变成了零开销,因为注册表已经是编译产物的一部分。
为什么不用 T4 模板或者 MSBuild Task?因为这两种方案都做不到“随写随算”。你加了一个新页面,T4 模板不会自动感知,得手动点一下“运行自定义工具”,MSBuild Task 则是在编译前跑一段脚本,它需要你额外编写项目文件配置,而且拿不到类型语义信息(比如它不知道 FuiPageAttribute 到底是什么,只看到一堆 .cs 文件文本)。Source Generator 最大的优势是它拿到的不是文件,是语法树 + 语义模型,它能真正“理解”你的代码,精确知道哪些类型标了特性、哪些接口被继承了。
换句话说,Source Generator 做的事是:你在代码里用特性或接口表达你的“意图”,生成器在编译期读懂你的意图,直接生成具体的注册表代码。错误能在编译期暴露,类型安全由编译器保证,运行时的开销约等于零。
编译前你看到的: - 标记 [FuiPage("dashboard/main")] 的类 - 继承 IFuiModule 的类 编译时生成器做这些: - 扫描所有语法树,查找标记类型 - 解析特性的构造参数,拿到 route 字符串、生命周期枚举、依赖类型 - 验证重复项、验证路由合法性 - 生成一个 RegisterAll() 方法,里面是一长串硬编码的注册调用 运行时的状态: - 框架直接调用 RegisterAll(),一次循环,完成全部注册4. 实操:从反射注册迁移到 Source Generator 的完整过程
4.1 项目结构设计:生成器必须独立成程序集
这是 Source Generator 的第一个硬性规则:生成器代码必须放在一个单独的类库项目里,TargetFramework 必须是 netstandard2.0(因为编译器进程是 .NET Framework 或 .NET 5+ 都有可能,netstandard2.0 是兼容性最稳的选择)。
我当时的项目结构是这样的:
Fui.Compiler/ - Fui.Compiler.csproj // netstandard2.0 - FuiPageGenerator.cs // 主生成器 - FuiModuleGenerator.cs // 模块初始化器生成器(可选) Fui/runtime/ - FuiPageAttribute.cs - IFuiModule.cs MyApp/ - Pages/DashboardPage.cs - Modules/StartupInitModule.cs注意 Fui.Compiler 项目本身不能被主程序集直接引用,只能通过<Analyzer>的方式间接引入。下面这个 csproj 片段是标准配置:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> </ItemGroup> </Project>而在使用方的主程序中,你要这样引用:
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\Fui\Fui.csproj" /> <!-- 关键:把编译器项目当 Analyzer 引用,而不是普通引用 --> <ProjectReference Include="..\Fui.Compiler\Fui.Compiler.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup> </Project>如果漏了ReferenceOutputAssembly="false",你会把生成器项目本身也编进主程序集,导致各种 IO 错误或者编译器崩溃事故。如果漏了OutputItemType="Analyzer",生成器根本不会执行。
4.2 生成器的核心代码:从扫描到生成
下面我写一个简化但完整的生成器示例,功能是:找出所有标记了[FuiPage]特性的类,生成一个FuiPageRegistry.RegisterAll()方法。
先看运行时的特性定义(这个放主类库):
// Fui/FuiPageAttribute.cs namespace Fui; [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class FuiPageAttribute : Attribute { public string Route { get; } public FuiPageAttribute(string route) { Route = route; } }再来看生成器本体:
using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace Fui.Compiler; [Generator(LanguageNames.CSharp)] public sealed class FuiPageGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { // 1. 注册一个语法提供器:只筛选类声明语法节点 var classDeclarations = context.SyntaxProvider .CreateSyntaxProvider( predicate: static (node, _) => node is ClassDeclarationSyntax, transform: static (ctx, _) => GetClassSymbol(ctx)) .Where(static symbol => symbol is not null); // 2. 把收集到的符号直接用于生成 context.RegisterSourceOutput(classDeclarations, static (spc, symbol) => { if (symbol is null) return; var code = GeneratePage(symbol); spc.AddSource($"FuiPage_{symbol.MetadataName}.g.cs", SourceText.From(code, Encoding.UTF8)); }); } private static INamedTypeSymbol? GetClassSymbol(GeneratorSyntaxContext context) { var classSyntax = (ClassDeclarationSyntax)context.Node; var symbol = context.SemanticModel.GetDeclaredSymbol(classSyntax); if (symbol is null) return null; // 语义层面检查是否带有 FuiPage 特性,这一步比字符串匹配可靠得多 var attr = symbol.GetAttributes() .FirstOrDefault(a => a.AttributeClass?.Name == "FuiPageAttribute"); return attr is null ? null : symbol; } private static string GeneratePage(INamedTypeSymbol symbol) { var route = symbol.GetAttributes() .First(a => a.AttributeClass?.Name == "FuiPageAttribute") .ConstructorArguments[0].Value as string; var ns = symbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : symbol.ContainingNamespace.ToDisplayString(); return $@"// <auto-generated /> #nullable enable namespace Fui.Generated {{ public static class FuiPageRegistry {{ public static void RegisterAll() {{ Fui.Routing.RouteTable.Register<{symbol.ToDisplayString()}>({((route is null) ? "null!" : $"\"{route}\"")}); }} }} }} "; } }这个例子为了好理解省略了“收集全部类再一次性生成单文件”的做法,而是每个类生成一个单独的文件。生产中我更建议用这种“每个标记类生成一个 partial 方法片段”或者“集中一个文件包含全部注册”,看你的整合需求。每个类一个文件的好处是:增量编译缓存粒度细,改动一个页面不会导致整个大文件重新生成。
你可能会问,为什么用IIncrementalGenerator而不是老的ISourceGenerator?因为增量生成器能做缓存和依赖跟踪:只有真正影响生成结果的输入变了,才触发重跑。老的接口是每次编译全量跑一遍,项目大了以后编译速度受影响。我后来在 200 多个页面的项目里实测过,增量生成器单次编译只比“完全不跑生成器”多花不到 100 毫秒,而旧接口动辄多 1-2 秒。
4.3 收集全部类还是逐类生成?我建议分步走
在上面代码里我偷懒了,每遇到一个标记类就 AddSource 一个文件。在代码生成内容完全独立的前提下,这是最理想的方式,增量生成器的缓存优势能最大化。
如果你的注册逻辑需要做全局校验(比如路由重复检测),就必须把“收集”和“生成”拆成两步:
// 第一步:收集所有带特性的类型 var allPages = context.SyntaxProvider .CreateSyntaxProvider(predicate, transform) .Where(symbol => symbol is not null) .Collect(); // 关键:把 IEnumerable 变成 ImmutableArray // 第二步:等收集完成后统一生成一个文件 context.RegisterSourceOutput(allPages, static (spc, pages) => { var source = GenerateCombinedRegistry(pages); spc.AddSource("FuiPageRegistry.g.cs", source); });.Collect()是增量生成器里一个非常核心的操作符。它表示:我不急着一个一个生成,等这一轮编译里所有匹配项都拿到了,我再统一处理。统一处理的好处是能做交叉校验。例如:
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); foreach (var page in pages) { var route = GetRoute(page); if (!seen.Add(route)) { // 直接提供一个编译期错误,报错信息带路由值 spc.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor("FUI001", "Duplicate route", $"Route '{route}' is registered more than once in FUI pages", "Fui", DiagnosticSeverity.Error, true), page.Locations.FirstOrDefault())); } }这就能把我前面说的“字符串路由只能靠人工测试兜底”的问题从根上解决:重名路由在编译期就会报错,IDE 里直接红波浪线。
4.4 生成代码的形态设计
生成代码的设计其实决定了整套方案的体验。我的建议是尽量生成“笨代码”——就是把所有注册逻辑写成最直观的连续调用,不要搞花活。
用统一文件方案时,生成结果大致长这样:
// <auto-generated /> // Generated by Fui.Compiler. DO NOT MODIFY. #nullable enable namespace Fui.Generated { public static class FuiPageRegistry { public static void RegisterAll() { Fui.Routing.RouteTable.Register<MyApp.Pages.DashboardPage>("dashboard/main"); Fui.Routing.RouteTable.Register<MyApp.Pages.SettingsPage>("settings"); Fui.Routing.RouteTable.Register<MyApp.Pages.ProfilePage>("profile"); } public static int Count => 3; } }注意我在生成代码里加了Count属性,纯粹是为了方便写测试的时候断言“注册数量符合预期”。这是个很小的细节,但实测下来对排查“生成器漏扫了某个类”非常有帮助。你只需要在单测里 Assert.Equal(3, FuiPageRegistry.Count),就能立刻看出生成器有没有漏掉某个页面。
在代码里,“ ”里的类型是完整的语义全名MyApp.Pages.DashboardPage,这意味着这个类型必须在可访问范围内。如果这个类是 internal,生成代码又不在同一个程序集,就会报编译错误。所以要么页面类全公开,要么生成器里给生成的代码也加上[assembly: InternalsVisibleTo("...")],或者做成生成一个同程序集内部类。我这里为了省事,直接建议所有注册相关的类型都设计为 public,这本身就是一种架构约束,让框架结构更扁平。
4.5 从反射切换过来的运行时改动
生成器做完,运行时装配逻辑要从“扫描 + 反射调用”改成“直接调用生成的注册方法”。原来的启动代码:
var assemblies = AppDomain.CurrentDomain.GetAssemblies(); foreach (var asm in assemblies) { foreach (var type in asm.GetTypes()) { // 反射注册逻辑... } }替换成:
Fui.Generated.FuiPageRegistry.RegisterAll();启动逻辑从十几行变成一行,这个对比太直观了。实际项目里,FuiModule 的初始化也是类似的:生成器收集所有IFuiModule实现类,按优先级排序后生成FuiModuleInitializer.InitializeAll()方法,运行时直接调用。
我在真实项目里保留了一个if (enableReflectionFallback)开关,用于在调试特殊问题时候临时退回旧逻辑。但从上线后的数据看,这个开关一次都没被打开过。反而因为旧代码长期没有测试覆盖,后来我直接把它删了。
5. 迁移过程中的常见坑与排查实录
5.1 生成器没跑起来:配了 Analyzer 但看不到任何生成代码
这是最常遇到的问题。很多人配置完 csproj 之后,发现 FuiPageRegistry 这个类根本不存在。排查路径如下:
- 检查输出窗口有没有生成器相关的报错信息
- 检查 .csproj 里的 ProjectReference 是否带
OutputItemType="Analyzer",少了这个一定不生效 - 在生成器
Initialize或RegisterSourceOutput里临时加一个Debugger.Launch(),看调试器是否被唤起。如果没调起来,说明生成器根本没被加载 - 注意 SDK 版本:
IIncrementalGenerator需要 Microsoft.CodeAnalysis 4.0 以上,.NET 6 SDK 自带的是 4.0 或 4.3,.NET 7 以后没问题。如果你用的是老 SDK,升级一下
还有个小坑:如果你改了生成器源码后重新编译,主项目报错说找不到某些新生成的文件,80% 的情况是 Visual Studio 的 Roslyn 编译器进程没重启。关掉 VS 重启一次,或者用 dotnet build 从命令行跑一把,通常能解决。
5.2 生成器内部抛异常:整个编译挂掉
源生成器代码一旦有未处理的异常,编译进程会直接报错,而且错误信息往往很难看,因为它是编译器内部的 Crash,而不是你的代码报出来的编译错误。
所以在生成器内部,所有处理逻辑都要包一层 try-catch。开发期间可以包一个#if DEBUG的Debugger.Launch(),方便定位。发布版则直接吞掉异常并把错误信息转成一个 Diagnostic:
try { // 核心生成逻辑 } catch (Exception ex) { context.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor("FUI999", "Generator internal error", ex.ToString(), "Fui", DiagnosticSeverity.Error, true), location)); }注意最后还要把这个异常继续抛出去,不然你就没有错误可看了。这块我花的时间不少,大部分是因为某些不常见的语法结构(比如 record class、文件作用域命名空间)导致的解析异常。
5.3 缓存导致生成结果不更新
用增量生成器最麻烦的一件事是缓存失效没做好。刚开始写时,我把“读取 csproj 的某个属性”放在了transform里,结果就是:改了 csproj 配置,生成结果却不更新。因为增量生成器认为语法树没变,输入没有变化,直接复用了上一次的输出。
解决办法:如果生成逻辑依赖编译选项或 MSBuild 属性,必须用context.AnalyzerConfigOptionsProvider,并手动加入缓存键:
var optionsProvider = context.AnalyzerConfigOptionsProvider; context.RegisterSourceOutput(optionsProvider, static (spc, options) => { options.GlobalOptions.TryGetValue("build_property.MyFlag", out var flag); // 生成逻辑依赖 flag });AnalyzerConfigOptionsProvider自带缓存键,MSBuild 属性变化会导致重新执行,这是标准做法。千万别为了省事去读File.ReadAllText(),那会破坏缓存同时带来路径可靠性的问题。
5.4 生成代码撞上 nullable 与 namespace 坑
生成代码默认不带#nullable指令时,会继承主项目的 context,可能导致明明不会为 null 的代码却出现CS8603之类的警告,甚至在某些设置了 TreatWarningsAsErrors 的项目里直接编译失败。
我的建议是生成代码的第一行固定输出:
#nullable enable同时生成代码里对可能出现 null 的字符处用?? ""兜底。还有一个小细节:生成的代码不要带 file-scoped namespace,因为namespace Foo;这种语法在老版本编译器里不可用,而生成器是跑在编译进程里的,万一主项目用的还是 C# 9 以下的 LangVersion,你的生成代码里用了 file-scoped namespace,它直接就编不过。稳妥起见,生成代码里一律用 block-scoped namespace:
namespace Fui.Generated { // ... }这算是给生成代码的“最低公倍数”写法,牺牲一点点美观,换取兼容性。
5.5 类型可见性问题:internal 类生成后无处引用
如果你的页面类声明为 internal,生成器注释里说它可以生成,但生成的代码如果跟页面类不在同一个程序集里(比如生成器生成的 FuiPageRegistry 放在了 Fui.Generated 命名空间下),编译时就会报“类型不一致”错误。
我当时遇到的情况是:某个模块的 ViewModel 是 internal,我在做测试工程引用时,它提示找不到这个类型。为什么?因为生成的代码和 internal 类不在同一个程序集。解决方式有两种:
- 把你需要注册的类型全改成 public,这是最简单最直接的方案
- 用
[InternalsVisibleTo("Shared")]把生成的程序集暴露给内部类型
我从实用主义出发,最后直接规定:凡是参与 FUI 注册的实体类都必须是 public。这个约束反过来提升了代码的可审查性,因为一眼看过去就知道哪些类会暴露给框架做装配。
6. 迁移后的收益:数据与体验对比
说点干货数据。我用的项目是内部管理系统,页面总数大概在 180 个左右,Module 62 个,事件订阅器 40 个。改造前后的对比:
| 指标 | 反射注册 | Source Generator | 提升幅度 |
|---|---|---|---|
| 启动装配耗时(冷启动) | 380ms | 0ms(编译期完成) | 100% |
| 首次页面导航耗时 | 420ms | 120ms | 71% |
| 路由重复检测 | 运行时不检测 | 编译期拦截 | 彻底解决 |
| 页面漏注册 | 运行时白屏 | 编译期无感知 | 由测试兜底 |
| 发布裁剪兼容性 | 不支持 | 原生兼容 | 可发布 |
| 生成器编译耗时增量 | 0 | 约 120ms | 可接受 |
这里启动装配耗时直接归零不是玄学,是因为那 380ms 的动作确实被移到了编译期执行,运行时只是执行一个已经生成好的方法调用,几百次注册瞬间完成。首次页面导航耗时下降,是因为路由表不再是启动后异步构建,而是程序集加载时就绪,导航在首次事件循环里就能触发。
还有一项不太好量化但体感明显的收益:代码评审的时候不再需要去看“启动注册”那段几百行反射代码了。新的代码长这样:
FuiPageRegistry.RegisterAll(); FuiModuleInitializer.InitializeAll();所有细节都藏在生成的 .g.cs 文件里,审查者只需要关注业务类上的特性标得对不对。
7. 迁移中已有的坑与排查技巧速查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 生成类找不到 | 没配置 OutputItemType="Analyzer" | 检查 csproj,添加 analyzer 引用 |
| 生成器跑了但没输出 | transform 返回 null 被过滤 | 检查 predicate 与 transform 逻辑 |
| 生成代码报 CS0234 命名空间不存在 | 主项目没引用运行时库 | 生成代码引用的所有类型都要能被主项目解析 |
| 生成代码总报 nullable 警告 | 没在生成内容里声明 #nullable enable | 生成文件头追加 #nullable enable |
| 改完生成器不生效 | VS 编译器进程缓存 | 重启 VS 或命令行 build |
| 编译抛异常直接断掉 | 生成器内部没 catch | 给生成器代码补 try-catch 转 Diagnostic |
| 生成结果不更新但改了配置 | 没有使用 AnalyzerConfigOptionsProvider | 改用 optionsProvider 传递 MSBuild 属性 |
| 路由字符串重名没被检测 | 生成器没做全局校验 | 用 Collect() 统一生成,检测重复 |
| Generated 代码没有 auto-generated 头 | 生成器没写头注释 | 默认生成的代码都要带<auto-generated /> |
这张表是我花了两个星期,在生产环境里一条一条攒出来的真实记录。里面每一个问题我都在 stack overflow 或者 GitHub issue 里看到过,属于 Source Generator 新手必经的关卡,提前给你省掉试错时间。
8. 哪些场景不建议迁移到 Source Generator
这不是一个“万能银弹”方案。我在动手做之前也认真想过是不是所有反射注册都应该换。有几个场景,用 Source Generator 反而可能不太划算。
- 动态类型/插件系统:如果 FUI 允许外部插件在运行时动态加载,插件里的类型在编译期根本不存在,Source Generator 无能为力。这种场景你必须保留反射或者使用 AssemblyLoadContext 运行时注册。
- 类型数量极少:如果项目只有三五个页面,反射扫描一次的耗时根本感知不到,迁移完全没有必要。Source Generator 的复杂度不低,为三五个类型不值得。
- 生成逻辑高度动态:如果注册行为严重依赖运行时配置(比如读取数据库里的路由表),编译期生成只能做到静态部分,动态部分你还是得写运行时逻辑,这时引入生成器会增加系统复杂度。
- 团队不熟悉 Roslyn 编译原理:源生成器调试门槛不低,如果团队里没有熟手,遇到问题很容易卡死。哪怕是我自己在最初两天,也被各种莫名其妙的现象折腾到自闭。
9. 从反射到生成器的启发:装配的本质是编译期约定
写到最后,不说空话,讲一个我个人实践后的体会。反射注册的核心问题不是性能,而是它把“类型之间的关系”推迟到了运行时,原本编译器能够帮你做的事,全都变成了运行时猜谜。Source Generator 之所以有效,是因为它把你已经写进代码的约定(特性、接口)重新提升为了编译期事实,让编译器帮你做检查、做校验、做编译期报错。
具体到我这边,迁移完成后一个比较大的感受是:生成器给了框架设计一个新的自由度。以前为了反射注册,我不得不给所有组件定义一套通用的基类或接口;现在我可以让生成器去做模式匹配,哪怕参与者之间没有共同的基类,只要特性标记正确,照样能生成注册代码。这让代码的组织方式变得更贴近业务意图,而不是为了迁就框架的装配机制。
最后再分享一个小技巧:我在生成器里加了DEBUG环境变量触发的源代码生成信息打印,通过编写一个FuiGenDebug.txt文件来记录生成器扫描到的所有类型和特征值。遇到奇怪的线上问题,对着这个文件排查,远比翻日志高效。
如果你正在做类似的迁移,我的建议是:先从一个最不重要的注册类型(比如事件订阅器)开始,跑通全链路后再逐步扩大。这套方案的收益是实打实的,但前提是你得给它留足学习和踩坑的时间。