1. 为什么弃用 GetTypes():先聊聊我在 FUI 里踩过的反射坑
先说结论:反射拿类型做路由,在小型 demo 里很香,一旦项目跑到三百个页面以上,你就会被它慢慢拖死。FUI 早期版本的路由就是靠Assembly.GetTypes()配合自定义 Attribute 做出来的,那时候我图省事,觉得“反正每个页面标个[FuiPage],启动时扫一遍注册进字典,请求来了查一下命中哪个类型,直接激活”,这套逻辑写起来半天不到,运行起来在小项目里也确实没啥毛病。
问题是,项目一旦进入真实业务阶段,问题就全冒出来了。
第一个问题就出在启动性能上。GetTypes() 会把整个程序集里所有类型全部拉出来,然后你得靠 LINQ 去过滤、去判断、去排序。程序集越大,这一步越慢。FUI 里做的是功能型 UI 框架,我们塞了很多自带页面、模板页、业务扩展页,程序集里的类型数量轻松上千。实测在低端 Android 模拟器上,冷启动做一次全量扫描平均要多花 130ms 左右。这还不算 JIT 预热,130ms 在启动清单里已经够扎眼了。
第二个问题,也是真正让我下定决心改掉的:反射拿到的只是 Type 对象,你拿不到任何编译期校验。路由参数是 string 拼接的,路径写错了不报错,参数名大小写不匹配也不报错,只有运行到那个页面崩溃了你才意识到“哦,当时 RouteAttribute 里少写了一个斜杠”。这种错误在多人协作时尤其致命,因为代码 review 基本不可能拦住这种低级问题。
再一个问题是AOT 不友好。.NET 的 NativeAOT 和 iOS 的 FullAOT 环境里,反射默认是被裁剪掉的,GetTypes() 要么直接失效,要么你得配置繁琐的裁剪保留规则。FUI 当时已经收到不少用户提 issue,说“我在 MAUI 里用了 FUI 之后 iOS 包打不了”。这事不能拖,再拖下去框架口碑就没了。
所以我在某个周末做了个决定:放弃运行时反射扫描,改用 Source Generator 在编译期生成强类型路由元数据。具体做法就是让编译器帮我把“有哪些路由、每个路由对应哪个类型、参数叫什么名字”全部写死在生成的代码里,运行时零反射、启动零扫描,路径写错了 IDE 里直接红线。
这篇文章就把我从 GetTypes() 到强类型 Route 的完整设计思路、实现细节、迁移过程中的坑,全部摊开讲一遍。没有什么高大上的理论,全是实操里磨出来的东西,希望能给同样在做 .NET UI 框架、或者想优化自己路由方案的朋友一些参考。
2. 强类型 Route 的核心设计目标
2.1 设计目标拆解:编译期安全、零反射、可裁剪
动工之前,我先给自己列了几个硬性要求,没有这些约束,设计容易跑偏。
第一,编译期安全。路由路径、参数键名、参数类型都必须在编译期确定,拼错字符串直接编译失败。这意味着传统的那套[Route("user/{id}")]字符串模板写法不能完全照搬,得让路由路径本身也参加类型推导。
第二,运行时零反射。生成代码里直接 new 类型、直接给属性赋值,不调用Activator.CreateInstance,不走PropertyInfo.SetValue。一句话,能编译期算出来的东西绝不留到运行时。
第三,兼容 NativeAOT 与裁剪。生成代码不能依赖任何反射作为回退路径,即使开了PublishTrimmed=true也能正常工作。这里我把话说死:不支持反射回退,不支持动态生成表达式树,所有页面类型都在生成代码里显式引用。
第四,增量编译友好。.NET 的 Source Generator 如果写得不好,会拖垮整个编译过程。比如你每次改一个文件,整个项目重新跑一遍生成器,几百个页面几百个路由全部重新生成,性能上是灾难。所以必须使用增量生成器,配合Equatable比较,只有真正有变化的节点才重新生成。
2.2 路由元数据模型:从字典到结构化类型
早期反射方案的路由表结构大概是这样的:
Dictionary<string, Type> _routes = new();键是路径字符串,值是页面类型。看起来简单,用起来难受。你想扩展一个“路由是否需要登录”的标记,就得再开一个HashSet<string>存路径;你想做参数校验,不好意思,你只能自己去反射那个 Type 的属性。这个结构没什么演进空间,很快就成了补丁摞补丁的状态。
强类型版本我改成了这样:
public readonly record struct RouteDetail<T>( string Path, string PageName, bool RequiresAuth, bool CachePage);然后为每一个标记了[FuiPage]的类型,生成一个对应的强类型描述器:
public static class UserProfilePageRoutes { public static readonly RouteDetail<UserProfilePage> Default = new("/user/profile", "UserProfilePage", RequiresAuth: true, CachePage: true); public static readonly RouteDetail<UserProfilePage> WithId = new("/user/profile/{id}", "UserProfilePage", RequiresAuth: true, CachePage: true); }注意,RouteDetail 是泛型结构体,泛型参数指向真正的页面类型。这样一来,编译器天然知道这个路由关联的是什么类型,类型信息在编译期就牢牢锁死了,运行时的反查动作被彻底省掉。
2.3 为什么选 Source Generator 而不是 .NET 8 的 Route 特性
有人可能会问,.NET 8 的 Minimal API 不是也提供了强类型路由吗?MapGet("/user/{id}", (int id) => ...)不也是编译期安全的?为什么 FUI 不直接用现成的?
这里要解释清楚:Minimal API 的强类型来自 lambda 的参数类型推断,它适用于请求处理管道的绑定,但 FUI 要解决的是页面导航和页面激活生命周期。页面导航需要的是“路径字符串到页面类型的映射关系”,而且页面类型是动态的(框架可以承载用户自定义页面),不能像 lambda 一样每个路由写一段处理代码自动推断。
另一个原因是FUI 是跨平台的 UI 框架,不是 Web API 框架。Web API 路由的匹配天然由服务器管,而 FUI 的导航发生在客户端内部,我们需要在 App 启动时就要有一张完整的“可以导航到哪些页面、以什么参数导航”的路由表。Source Generator 正好在编译期把所有信息注入到框架内部,生成一次,到处使用,行为一致,完全可控。
3. Source Generator 核心实现:手把手拆一遍生成逻辑
3.1 先搞清楚你要扫什么
写任何 Source Generator 之前,第一步永远是:明确你的输入是什么。
FUI 的路由生成器输入的是一组标记了[FuiPage]的类,输出是每个类对应的路由描述器和一个全局路由注册入口。实现上我用的是IIncrementalGenerator,这是 Roslyn 4.4 之后推荐的做法。
[Generator(LanguageNames.CSharp)] public sealed class FuiRouteGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates = context.SyntaxProvider .ForAttributeWithMetadataName( "Fui.FuiPageAttribute", static (node, _) => node is ClassDeclarationSyntax, static (ctx, _) => GetRouteModel(ctx)) .Where(static m => m is not null) .Select(static (m, _) => m!); context.RegisterSourceOutput(candidates.Collect(), static (spc, models) => { Execute(spc, models); }); } }熟悉 Source Generator 的读者看到ForAttributeWithMetadataName应该会心一笑,这个 API 是从 .NET 7 开始稳定可用的,它比老的context.SyntaxProvider.CreateSyntaxProvider加context.CompilationProvider组合的性能好得多,因为它直接基于编译器的符号绑定结果,不会误匹配同名 Attribute。
GetRouteModel里做的核心事情就是:拿到页面类的INamedTypeSymbol,从 Attribute 实参里读取路径、是否缓存、是否需要授权等信息,再把这个类型的可导航属性列表遍历出来,存到自己的模型里:
private static RouteModel? GetRouteModel(GeneratorAttributeSyntaxContext context) { if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null; if (typeSymbol.TypeKind != TypeKind.Class) return null; var attribute = context.Attributes.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "Fui.FuiPageAttribute"); if (attribute is null) return null; var path = attribute.ConstructorArguments[0].Value as string; if (string.IsNullOrEmpty(path)) return null; var pageName = typeSymbol.Name; var requiresAuth = GetNamedArg(attribute, "RequiresAuth", false); var cachePage = GetNamedArg(attribute, "CachePage", true); var properties = new List<RouteParameterModel>(); foreach (var member in typeSymbol.GetMembers()) { if (member is IPropertySymbol prop && prop.SetMethod is not null) { // 只收集公开可写属性,这是导航参数绑定用的 if (prop.DeclaredAccessibility == Accessibility.Public) { properties.Add(new RouteParameterModel(prop.Name, prop.Type.ToDisplayString())); } } } return new RouteModel(pageName, path, requiresAuth, cachePage, properties); }这里有一个我在早期实现中踩过的坑:一开始我是直接把构造函数的属性也收集进去,但后来发现页面激活时的参数注入应该限制在“可写属性”,不搞构造函数注入。原因是构造函数注入会逼迫框架知道每个页面的构造函数长什么样,而可写属性可以很轻量地在导航时赋值。另外构造函数还可能依赖服务定位器,那就又绕回反射解耦的老路了。
3.2 路由参数模板解析:不把字符串当字典用
路由参数是强类型方案里最容易被低估的一块。FUI 的路由格式长这样:
/user/profile/{id}/posts/{pageIndex}早期反射方案解析这种模板是在运行时做的:注册路由时把字符串拆成段,匹配时一段段比对。但既然要用 Source Generator 把路由在编译期写死,参数模板的解析也应该在生成期完成。
我在生成器里写了一个简单的模板解析器,把路径拆成“静态段”和“参数段”两部分,然后生成一个匹配用的结构:
public readonly struct RouteTemplate { public readonly string[] Segments; public readonly bool[] IsParameter; public readonly string Path; }以/user/profile/{id}/posts/{pageIndex}为例,生成器会产出:
internal static readonly RouteTemplate Template = new( new[] { "user", "profile", "{id}", "posts", "{pageIndex}" }, new[] { false, false, true, false, true }, "/user/profile/{id}/posts/{pageIndex}");为什么不用字典?因为字典的 key 顺序不固定,匹配时要额外排序,而且字符串分配多。用两个平行数组,匹配时一个循环就结束了:
for (var i = 0; i < template.Segments.Length; i++) { if (!template.IsParameter[i] && !string.Equals(segments[i], template.Segments[i], StringComparison.OrdinalIgnoreCase)) { return null; } }参数段的类型信息则单独存放在属性绑定列表里,生成为强类型 setter 调用:
public static void BindParameters(UserProfilePage page, FuiNavParameters parameters) { if (parameters.TryGetValue("id", out var id)) { page.Id = Convert.ToInt32(id); } if (parameters.TryGetValue("pageIndex", out var pageIndex)) { page.PageIndex = Convert.ToInt32(pageIndex); } }这里的Convert.ToInt32是在生成代码里确定好的,参数类型是 int,就生成 int 转换代码,是 string 就不转。这个动作比运行时Convert.ChangeType快得多,因为它跳过了反射,也没有装箱。
3.3 全局注册表:如何在启动时“零扫描”拿到所有路由
生成完每个页面的描述器之后,还有一个问题需要解决:新的路由描述器在编译期有了,但怎么让框架在运行时找到它们?
我的方案是生成一个静态注册类,类里直接做手动注册:
public static class FuiGeneratedRoutes { public static void RegisterAll(IRouteTable table) { table.Register(UserProfilePageRoutes.Default); table.Register(UserProfilePageRoutes.WithId); table.Register(SettingsPageRoutes.Default); // ... 每一个 [FuiPage] 对应一行注册代码 } }这些注册代码全部是编译期生成好的,不是运行时反射扫描。启动时 App 调用一次FuiGeneratedRoutes.RegisterAll(table),路由表就被填满了。IRouteTable.Register<T>(RouteDetail<T> detail)是泛型方法,T 在编译期就绑定到页面类型上了,所以这里天然不需要反射。
这里我额外补充一个细节:IRouteTable内部其实依旧用字典作为最终承载结构,这点和反射时代是一样的。不同之处在于字典的内容在编译期已成定局,运行时只做检查和查表,不做事先的扫描筛选。
每次调用table.Register(detail),内部就是标准的字典插入:
public void Register<T>(RouteDetail<T> detail) { _exactRoutes[detail.Path] = new RouteEntry(typeof(T), detail); }路径冲突检测也在编译期做,生成器输出时会检查同一个 path 是否注册了两次,如果冲突直接报编译错误。这一点是反射时代完全做不到的。
4. 增量生成器的细节:Equatable、缓存、IDE 体验
写 Source Generator 的人都知道,“能跑”和“跑得快”是两码事。
如果你不用增量生成器,每次编译都会重新扫描全部语法树,再全部重新生成。项目大了以后,每次改一行代码,IDE 都要卡顿一下。而 Roslyn 提供的增量生成机制是建立在IEquatable<T>比较之上的:只要模型的 Equals 返回 true,生成器就认为没变化,不会重新产出代码。
所以我的RouteModel必须实现值相等比较:
private sealed record RouteModel( string PageName, string Path, bool RequiresAuth, bool CachePage, IReadOnlyList<RouteParameterModel> Parameters) { public bool Equals(RouteModel? other) { if (other is null) return false; if (!string.Equals(PageName, other.PageName, StringComparison.Ordinal)) return false; if (!string.Equals(Path, other.Path, StringComparison.Ordinal)) return false; if (RequiresAuth != other.RequiresAuth) return false; if (CachePage != other.CachePage) return false; if (Parameters.Count != other.Parameters.Count) return false; for (var i = 0; i < Parameters.Count; i++) { if (!Parameters[i].Equals(other.Parameters[i])) return false; } return true; } public override int GetHashCode() { var hash = new HashCode(); hash.Add(PageName, StringComparer.Ordinal); hash.Add(Path, StringComparer.Ordinal); hash.Add(RequiresAuth); hash.Add(CachePage); foreach (var p in Parameters) { hash.Add(p); } return hash.ToHashCode(); } }注意我这里的Equals是手写的,没有直接用 record 的默认实现。为什么?因为IReadOnlyList<RouteParameterModel>的默认引用相等无法反映列表内容的实际变化,必须手动遍历比较。这是增量生成器最容易出 bug 的地方,忘记比较集合内容,就会导致改了属性名却不重新生成代码的诡异问题。
生成代码的结构我做了拆分。每个页面单独生成一个文件,避免所有生成代码集中在一个巨型文件里,这样 IDE 里的错误定位也方便,生成代码的可读性也更好:
Fui.Generated/UserProfilePageRoutes.g.cs Fui.Generated/SettingsPageRoutes.g.cs Fui.Generated/FuiGeneratedRoutes.g.cs这个文件命名规范也是建议直接照抄的:.g.cs后缀是 .NET 社区约定俗成的生成文件标记,加了这个后缀,多数编辑器默认不会展开语法高亮或分析,减少干扰。
5. 匹配算法的取舍:精确匹配优先,模板匹配兜底
路由表做好了,导航请求来了,接下来是匹配环节。
FUI 的匹配策略很简单也很有效:先查精确匹配表,再查模板匹配表。
精确路径(如/user/profile)直接查_exactRoutes,命中就返回,这是最常见的场景,性能是字典 O(1)。
没有精确命中的再走模板表:把所有含参数的模板逐一匹配。这个操作从理论上是 O(n) 的,n 是模板数量。模板多了会不会慢?实测下来,FUI 一个中等规模的项目一般有 30 到 100 个模板,一个导航请求遍历完也就几微秒,完全无感知。如果哪天框架要支撑上万条模板(说实话这已经很极端了),再考虑前缀树优化也不迟。过早优化是万恶之源,我所有匹配优化都在真实性能测试之后才做,而不是凭空猜。
匹配结果我设计成一个结构体,避免每次都 new 一堆字典:
public readonly struct RouteMatch { public readonly Type PageType; public readonly FuiNavParameters Parameters; public readonly RouteDetail<object> Detail; }RouteDetail<object>这里做了一次协变转换,因为路由表里存的类型是运行时才知道的,直接用RouteDetail<T>作为返回值会把泛型参数卡死。这个接口细节是我花了不少功夫想明白的,也提醒大家:强类型不等于处处都要泛型传播到底,该擦除边界的时候要擦除,不然 API 设计会把自己绕死。
参数绑定用的是生成代码里的BindParameters方法。匹配到模板后,框架取出 URL 中对应的参数段,放进FuiNavParameters集合,然后调用页面类型的强类型绑定方法,把参数直接赋给页面属性。
我在这里插一句提醒:不要试图在生成代码里给每个属性加一个专门的 setter 委托“统一管理”绑定过程。这种做法看似优雅,实际会把生成出来的代码变成一个巨大的反射工厂。FUI 的生成代码里就是简单的顺序赋值,直白、容易读、性能最好。
6. 迁移路径与兼容策略:老项目的平滑过渡
架构重构最怕的就是“新方案很完美,老用户全跑了”。
FUI 在没有破坏 API 的大前提下,做了两件事来保证平滑迁移:
6.1 保留旧字符串注册 API,作为生成器的“未命中回退”
老项目里用户可能自己手动注册过路由,比如:
routes.Map("/custom/path", typeof(CustomPage));这个 API 我没有删,保留了。新代码里注册的顺序是:先生成代码注册全部强类型路由,再允许用户手动注册补充。如果出现路径冲突,以手动注册为准并给一个警告,因为手动的优先级往往意味着用户在做覆盖式定制。
这样一来,老项目升级到新版本后,什么都不用改,原来怎么注册还怎么注册,只是新页面开始享受强类型生成的红利。等用户有空了再逐步迁移,没有挤牙膏式的硬逼迁移压力。
6.2 带参数的导航:字符串导航保留,强类型导航新增
FUI 以前的导航 API 长这样:
Navigator.Push("/user/profile?id=42");这个 API 保留了,实现上就是拆 URL -> 调解析器 -> 匹配 -> 生成强类型参数绑定。字符串导航成了强类型生成代码之上的一层薄壳子,不再直接操作反射。
同时新增了强类型导航入口:
Navigator.Push(UserProfilePageRoutes.WithId, new { id = 42 });第二个参数的匿名对象实际上会在编译期被反射一步?不,这里我用了一个小技巧,FuiNavParameters支持从匿名类型通过源码生成的实际构造来构建,不需要运行时反射:
public static FuiNavParameters FromAnonymous<T>(T data) { var dict = new Dictionary<string, string>(); foreach (var prop in typeof(T).GetProperties()) { dict[prop.Name] = prop.GetValue(data)?.ToString() ?? string.Empty; } return new FuiNavParameters(dict); }等等,这个示例里写了GetProperties和GetValue,这正是我不想出现在热路径上的东西。所以最终版本里我把这个 API 也改成了生成器支持:为每个路由生成一个带类型安全参数的扩展类,调用时直接填强类型参数对象:
Navigator.Push(UserProfilePageRoutes.WithId, new UserProfileNavParams { id = 42 });UserProfileNavParams是生成出来的一个 POCO 类,它的属性就是路由参数的强类型版本。Push 方法内部直接把这个 POCO 转成FuiNavParameters,转换过程里不涉及反射,因为编译器已经知道每个 POCO 属性的名字和类型了。这一部分的生成代码和BindParameters是对称的:一个负责把 URL 转成页面属性,一个负责把导航参数对象转成 URL 查询串。
6.3 旧反射路由表代码如何处理
如果你自己写了一个基于 GetTypes() 的扫描器,想把项目从反射迁移到这套生成方案,我的建议是分三步走:
- 遍历你现有的所有
[FuiPage]类,把路径整理到代码仓库里,这一步很快,因为 FUI 的[FuiPage]已经强制要求路径参数了。 - 跑一遍生成器,确认生成的注册表里路径数量和你之前的字典数量一致,逐一核对依赖项。
- 逐步将代码中出现的字符串导航替换为强类型导航 API,建议先从不带参数的页面开始,再做带参数的页面。
迁移过程中最常见的问题是:之前有人在代码里动态拼路径,比如$"/user/{id}/posts"。这种代码不会在编译期报错,因为字符串路径天然逃逸了强类型检查。遇到这种情况我的建议是保留字符串导航,等有空再改成强类型。动态拼路径的直接消灭掉就完了,统一走强类型参数对象,毕竟动态字符串是早期反射方案留给你的历史包袱。
7. 常见问题与排查技巧实录
7.1 生成器没有触发,IDE 里没有任何生成代码
这是 Source Generator 项目里最经典的问题,多半出在目标项目没有引用生成器所在的程序集,或者引用方式不对。
检查一下 .csproj 里的 ProjectReference 是否带有OutputItemType="Analyzer"和ReferenceOutputAssembly="false"两个属性:
<ItemGroup> <ProjectReference Include="..\Fui.SourceGenerators\Fui.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup>另一个排查点是:生成器项目自身的 TargetFramework 必须兼容目标项目的 Roslyn 版本。FUI 的生成器最低要求是netstandard2.0,如果你把生成器项目改成net8.0,它会没法被旧版编译器加载,IDE 里会静默失败,一点提示都没有。这是个特别阴的坑,务必留意。
7.2 增量生成器不更新:改完路由参数,生成的代码还是旧内容
这个问题和我上面提到的Equals实现直接相关。如果你模型里用了默认引用相等(比如 record 里的IReadOnlyList属性),怎么改都不会触发重新生成。排查方式很简单:在 RegisterSourceOutput 回调里打一个临时断点,如果断点没有命中,说明比较器判定“无变化”,问题一定在模型相等性上。解决就是手写集合内容的逐项比较,别偷懒。
7.3 路径字符串大小写问题
FUI 的路由路径默认不区分大小写,但 URL 在传输和存储时最好统一小写。你可以在生成器里对路径做一次ToLowerInvariant()标准化。这样不管用户写的是/User/Profile还是/user/profile,生成的注册表路径一律是小写,匹配时也全转小写再比较,避免大小写不一致导致的诡异 bug。
7.4 页面类型访问级别问题
生成代码里要直接引用页面类型,如果页面类是internal的,生成代码必须作为同程序集的一部分才能访问到。FUI 的约定是[FuiPage]类必须public partial class。这个约束我在生成器里做了检查,遇到 internal 类会直接报编译错误,提示修改为 public。
7.5 生成代码与手写代码的命名冲突
如果你自己定义一个类叫FuiGeneratedRoutes,和生成类的全名撞了,编译会报重复定义。这个问题无解,只能用生成器输出报错来强制提醒。我在生成器里检查目标程序集是否已存在同名类型,存在则报一个带红线的诊断信息,提示用户改名。
7.6 NativeAOT 发布测试
每次改完生成器,我都会跑一次 NativeAOT 发布验证,确保生成代码不引用任何裁剪掉的反射 API。测试项目很小,十几个页面,但足够覆盖典型的使用路径。发布成功之后,再来回跑一遍热路径基准测试,确保新增生成代码没有引入额外分配。
8. 从路由框架到整个 FUI:这个设计思路还能延伸到哪里
强类型路由这个模式的成功,让我对框架内其他容易滥用反射的位置也做了同样的审视。
其中一个直接受益者是依赖注入的轻量化实现。FUI 早期用一个IServiceProvider的实现配合构造函数反射来解析服务,和路由一样,也是 GetTypes 那套逻辑。后来我排查发现,大部分服务的生命周期和依赖关系在编译期就是固定的,既然页面之间导航都强类型化了,依赖注入也没必要运行时猜来猜去。于是我用类似的 Source Generator 思路做了一个服务注册表:编译器扫描[FuiService]标记的类,生成RegisterAllServices方法。运行时的IServiceProvider实现只负责解析接口到具体实现的映射,不做任何反射实例化。这一改,启动时间又省了一截。
另一个延伸点是页面级权限控制。之前权限判断靠字符串比对,不同页面之间不小心写错一个字符就全部放行或全部拦截。现在RouteDetail<T>里直接携带RequiresAuth和权限元数据,权限模块拿到的就是个强类型信息体,可以做非常精确的权限判断,也不需要再反向查找页面类型。
再一个延伸点是支持插件式页面。老方案里,外部插件程序集怎么接入路由表是个头痛问题:主程序集不认识插件程序集里的类型,只能靠反射扫描加载。新方案里,插件程序集同样使用 Source Generator 生成自己的注册表,主框架启动时通过约定接口调用插件的注册函数即可。这既不会破坏强类型优势,又给了第三方扩展一个完全确定性的接入入口。
如果你在自己的项目里也面临“反射扫描越来越慢、编译器明明知道一切却非要运行时再算一遍”的困境,我真心建议往这个方向试一试。Let the compiler do the heavy lifting,这句在 Scala 和 C++ 模板社区里流传已久的话,在 .NET 的 Source Generator 时代终于真正说通了。
我个人在实际操作中的体会是:Source Generator 不是“生成一堆模板代码”的玩具,它本质上是一张编译期到运行时的信息压缩通道。只要你的项目里有大量“运行时才确定”的信息其实在编译期就已注定(最常见的就是路由、依赖、权限、元数据),就值得用生成器把它变成强类型。改完之后你会发现,之前那些为了反射补丁而写的防御性代码,全部可以删掉了。代码量减少、性能上升、错误提前暴露,这大概是框架开发里最值得投入的一类重构。
最后再分享一个小技巧:如果你决定在项目里落地这套方案,记得先把所有旧的运行时扫描代码保留一个分支,不要直接删除。我吃过亏,以为新方案稳了就删了旧实现,结果遇到一个奇怪的 MAUI 生命周期问题,排查时才发现没有旧实现可以做 A/B 对比。留两个分支,对比结果确定无误后再清理,这个习惯能帮你少掉不少头发。