从 GetTypes() 到强类型 Route:FUI Source Generator 的设计演进
先交代一下背景。FUI 是我们内部的一个跨端 UI 框架,核心模块之一就是路由。早期版本的路由注册走的是最典型的反射方案:程序启动时扫描程序集,拿到所有标记了 Route 特性的类型,再动态构建映射表。这套方案在项目规模和页面数量都不大的时候完全够用,但随着 FUI 在越来越多的业务线落地,我们逐渐被几个反射方案的固有问题卡住了脖子,最终做出了一个比较彻底的决定:改用 Source Generator,在编译期完成从路由声明到强类型 Route 注册代码的生成。
这篇文章不是一篇纯科普,也不打算重复微软文档里关于 Roslyn 增量生成器的基本用法。我想记录的是 FUI 在这个演进过程中真实踩过的坑、做过的取舍,以及最终沉淀下来的设计思路。如果你正在做类似的方向——想把运行时反射替换成编译期代码生成,或者你在设计一个依赖 Source Generator 的框架级 API,这篇文章应该能帮你少走不少弯路。
1. 早期的 GetTypes() 路由方案:能跑,但痛点很明显
1.1 初始化时如何通过反射扫描程序集
第一版 FUI 路由的 API 长这样:开发者给页面类打一个RouteAttribute,然后框架在启动时扫描所有程序集,把带特性的类型收集起来,建立映射关系。简化后的代码如下:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] public sealed class RouteAttribute : Attribute { public RouteAttribute(string path) => Path = path; public string Path { get; } } public static class Router { private static readonly Dictionary<string, Type> _map = new(); public static void RegisterAll(Assembly assembly) { foreach (var type in assembly.GetTypes()) { var attrs = type.GetCustomAttributes<RouteAttribute>(); foreach (var attr in attrs) { _map[attr.Path] = type; } } } public static Type Resolve(string path) { return _map.TryGetValue(path, out var type) ? type : null; } }这套设计在当时的背景下是合理的。它解决了页面和路由表之间的硬耦合问题,新增页面只需要加一个特性,不需要改注册表,符合开闭原则。业务接入方也不需要理解复杂的框架初始化流程,Router.RegisterAll(typeof(App).Assembly)一行代码搞定一切。
说实话,这个方案在最开始的两个项目里运行得很平稳。直到我们开始接大型业务模块,路由数量从几十个涨到几百个,问题才慢慢浮出水面。
1.2 三个让人不太舒服的痛点
第一个痛点是启动性能。assembly.GetTypes()看起来简单,实际上会把整个程序集的所有类型都加载并反射出来,哪怕你只需要其中几十个带路由特性的类。在热启动和冷启动场景下,这段扫描代码对 FUI 初始化耗时的贡献越来越明显。我们也试过用缓存、并行加载等方式优化,但反射本身的成本摆在那里,治标不治本。
第二个痛点是裁剪环境下的脆弱性。我们有一部分业务需要跑在高度裁剪的 AOT 环境里,而这种环境对反射非常不友好。类型可能被裁剪掉,GetTypes()扫描结果不完整,运行时的行为不可预测。为了保证裁剪安全,你得写DynamicDependency、Preserve之类的特性来手工标记,维护成本直线上升。
第三个痛点最为致命,就是错误出现得太晚。路由路径拼错了、类型名字写错了、两个页面用了同一个 Path,这些错误在编译期完全感知不到,全部要等运行到这个页面时才报异常,甚至只在特定页面被访问时才暴露出问题。更烦的是 IDE 的支持几乎为零——字符串 Path 不具备可跳转性,你无法从Router.Resolve("/user/profile")跳到真正的页面类。
现在回头想,这三个痛点其实指向了同一个本质:路由的元数据天然存在于编译期,框架却非要在运行时通过反射重新获取一遍。既然编译器已经知道所有类型和特性信息,为什么不让它在编译阶段就把注册代码生成好呢?
2. 把问题交给编译器:FUI Source Generator 的整体设计思路
2.1 设计目标:用编译期内省替代运行时反射
Source Generator 提供的能力,简单说就是让开发者写一段在编译时运行的代码,它能查看项目里的语法树和语义模型,然后往编译单元里追加新的代码文件。这样,原来需要在运行时通过反射才能拿到的信息,可以在编译期就变成静态代码的一部分。
我喜欢把 Source Generator 类比成“编译期的代码织入器”。它本质上是一个观察者,编译器在编译过程中会告诉它“这里有个新文件”“那里有个类带了特性”,它再把收集到的信息转化为新的代码。生成的代码作为项目的一部分被编译进程序集,运行时不承担任何扫描成本。
基于这个理念,FUI 路由模块的设计目标可以拆成三条:
- 所有路由声明在编译期被发现,运行时零反射扫描。
- 生成代码提供强类型 Route 对象,路径、处理器类型、参数信息都以静态方式表达。
- 错误尽量前移到编译报错阶段,IDE 中能通过生成代码获得跳转能力。
要说明的是,这一步不是简单地把原来的反射逻辑翻译成生成器代码,而是需要重新设计 API 的整个形状。反射方案中“运行时遍历程序集”的自然对应物,在源生成器方案中变成了“声明在源代码中、可被生成器发现的静态集合”。这个转变是所有后续设计的基础。
2.2 三段式流水线:收集、验证、生成
在设计生成器的时候,我参考了编译器的三阶段模型,把整个处理流程拆成三个阶段,这样既方便单独测试,也让后续的维护清晰很多。
- 收集阶段:通过语义模型找到所有标记了
RouteAttribute的类,提取路径、处理器类型、路由名称、顺序等元数据。 - 验证阶段:检查路径是否合法、是否有重复路由、引用的类型是否存在、是否为 partial 类等。所有问题在这里变成编译诊断信息。
- 生成阶段:根据验证通过的元数据,生成一个静态路由注册类,包含路由表、
RegisterRoutes()方法等。
这三个阶段在实现上对应生成器里的三个方法重载。值得一提的细节是,收集和生成必须严格分离。我有一次为了省事,在收集阶段顺手把代码字符串拼好了,结果后面调试元数据问题的时候就非常痛苦——类型名、命名空间这些信息散落在一堆字符串拼接代码里,根本没法单独验证。拆开之后,每个阶段都能单独写单元测试,出错时也能快速定位是数据问题还是模板问题。
2.3 接收端 API 约定:路由如何声明和暴露
源生成器的方案最终要落地到开发者怎么使用。我们的目标之一是保持 API 的连续性——原来的RouteAttribute仍然保留,开发者不需要改页面类。变化的只是注册方式,从运行时的RegisterAll(assembly)改成编译期生成的Router.RegisterRoutes()调用。
为了让生成器能拿到完整的路由信息,我们引入了两个约定:
第一,页面类保持装饰器风格:
[Route("/home")] public partial class HomePage : FuiPage { } [Route("/user/profile")] public partial class UserProfilePage : FuiPage { }第二,路由表收集入口是一个 partial 静态类,生成器会往里面填充注册方法:
public static partial class AppRoutes { }开发者只需要在初始化时调用AppRoutes.RegisterAll(),其余工作全部交给生成器。为什么用 partial 而不是让生成器直接新建一个类?因为这样可以在同一命名空间下无缝补充和合并,而且开发者可以在同一个类里追加自定义逻辑——比如给某些路由动态添加额外配置——而不会被生成器覆盖。在下一节我会详细展开生成器内部是怎么处理这些声明并在编译期拼装路由表的。
3. 生成器核心实现:收集路由声明与装配强类型 Route
3.1 增量管线与 ForAttributeWithMetadataName 的用法
在微软推出增量生成器 API 之后,我们第一时间就把 FUI 的生成器从旧的ISourceGenerator迁移到了IIncrementalGenerator。两者的差别对于大型代码库来说很重要:旧 API 每次编译都会全量走一遍,而增量 API 可以在语义模型没有变化时跳过大量计算,显著提升编辑器里的反馈速度。
我们的入口管道设计如下:
[Generator(LanguageNames.CSharp)] public sealed class RouteGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var routeTargets = context .SyntaxProvider.ForAttributeWithMetadataName( "FUI.Routing.RouteAttribute", static (node, _) => true, static (ctx, ct) => RouteDeclaration.FromContext(ctx, ct)) .Where(static route => route is not null) .Collect(); context.RegisterSourceOutput(routeTargets, static (spc, routes) => { var validator = new RouteValidator(); validator.Validate(routes, spc); var emitter = new RouteEmitter(); emitter.Emit(spc, routes); }); } }需要特别说一下ForAttributeWithMetadataName。它是 Roslyn 团队专门为“找带特性的类型/成员”这个高频场景设计的 API,底层走索引优化,比自己在CompilationProvider里遍历所有语法树再去GetSemanticModel高效得多。而且它天然支持增量:只有语法树中相关节点的变化才会触发重新计算。
RouteDeclaration.FromContext负责从AttributeData和INamedTypeSymbol中提取路由信息。这里有一个细节值得注意:ctx.TargetSymbol并不一定是类,可能是方法、属性等所有能挂特性的符号,所以第一步要做类型过滤和 is 判断,避免生成器在非预期目标上误报。
3.2 路由元数据模型与编译期校验
在RouteDeclaration里,我们为每条路由建模了如下字段:
| 字段 | 含义 |
|---|---|
| 路由路径 | 例如/user/profile |
| 处理类型全名 | 页面类的 fully qualified name |
| 路由顺序 | 用于路由表排序,优先级高的先匹配 |
| 原始语法位置 | 用于生成诊断信息时的定位 |
| 所属命名空间 | 影响跨命名空间引用时的代码生成 |
校验发生在生成之前。常见校验规则包括:
- 路径不能为空,必须以
/开头,不能以/结尾。 - 同一个路径不允许重复注册。
/user/{id}这种带参数的路由必须满足参数命名要求(例如参数部分要进入强类型 Route 的 Parameters 集合)。- 如果 RouteAttribute 标记在非 partial 类上,需要提醒开发者在类上加上 partial 关键字。
这里我最想强调的是“路径重复校验”。在反射时代,重复注册会导致运行时后注册的覆盖先注册的,很难排查。编译期做掉之后,一旦有人复制粘贴路由路径忘了改,编译会直接报红色波浪线 + 诊断错误,连编译都过不了,问题被彻底杜绝。
3.3 强类型 Route 注册代码的组装逻辑
设计成强类型 Route 的本质,是让路由元数据从“字符串 + Type 反射”变为“静态类的静态字段引用”。我们把每条路由生成成一个FuiRoute实例,它的属性都是直接赋值,不经过任何反射查找。下面是一个简化的生成结果:
// <auto-generated /> public static partial class AppRoutes { public static readonly FuiRoute Home = new FuiRoute { Path = "/home", HandlerType = typeof(HomePage), DisplayName = "首页", Order = 0 }; public static readonly FuiRoute UserProfile = new FuiRoute { Path = "/user/profile", HandlerType = typeof(UserProfilePage), DisplayName = "用户资料", Order = 1 }; public static void RegisterAll(IRouteContainer container) { container.Register(Home); container.Register(UserProfile); // 保持声明顺序 } }生成器在模板里需要拼接出:路由字段声明、RegisterAll方法、以及一个可选的GetAllRoutes()静态方法。字符串的生成我建议使用StringBuilder而不是$""插值——当生成的路由数量大时,StringBuilder在反复 append 大量代码时的表现和可读性确实更好,尤其是代码里含有大括号(比如局部函数、lambda 表达式)的时候,$"" 会把你折磨到怀疑人生。
另外,因为注册表是强类型的FuiRoute,所以容器在拿HandlerType之后,还能直接用Activator.CreateInstance(HandlerType)或者从依赖注入容器里解析实例化。这里 Type 仍然存在,但不再是扫描来的,而是从属性直接拿到的——AOT 裁剪器能识别这种静态引用,不会误删类型。
3.4 对 IDE 静态分析的支持与设计期体验
源生成器相比于反射方案,一个隐藏优势是 IDE 层面的体验大幅提升。AppRoutes.Home是一个真实的静态字段,点击它可以跳转到生成文件;修改路径后,所有引用它的调用点都能用“查找所有引用”看到;右手边也不会出现“此调用可能为 null”的无意义提示。
还有一个比较妙的点:我们生成RouteNames常量类,把字符串路径以常量形式暴露出来,业务代码可以这么做:
[Route(RouteNames.Home)] public partial class HomePage : FuiPage { }这样连字符串都变成了强类型引用,路由路径被改名时编译器能捕捉到所有不一致,这比任何约定都可靠。
4. 运行时装配:从生成代码到容器的最后一步
4.1 生成的 RegisterAll 如何注入依赖体系
生成出来的RegisterAll(IRouteContainer container)没有直接操作静态字段,而是把路由实例传给实现了容器接口的参数。这样做的好处是路由表不用绑定在某一个全局单例上,切换容器实现(比如测试环境换成 mock 容器)非常方便。
容器端的核心注册逻辑:
public static void RegisterAll(IRouteContainer container) { var routes = new List<FuiRoute> { Home, UserProfile, // ... }; foreach (var route in routes.OrderBy(r => r.Order)) { container.Register(route); } }实话讲,这里OrderBy是否需要,取决于你的路由匹配规则。FUI 的规则是最长匹配优先,所以 Order 只在部分场景(比如通配路由)有用。但对于一个通用框架来说,保留排序能力是安全的。
4.2 处理参数路由、生命周期和工厂方法
路由不止静态页面,还有一些需要传入参数的动态页面,比如/user/{id}。强类型 Route 里增加了Parameters属性:
public sealed class FuiRoute { public string Path { get; set; } public Type HandlerType { get; set; } public IReadOnlyList<string> Parameters { get; set; } }生成时,解析器把{id}从路径中抽离出来,生成到参数列表里。运行时匹配/user/123时,框架会按顺序把 URL 片段填充到参数对象中,再交给页面构造函数或OnNavigatedTo。
生命周期问题比参数更隐蔽。一个页面类如果被容器注册为 Singleton,每次导航都应该复用一个实例;如果注册为 Transient,则需要每次创建。反射方案中我们通常是Activator.CreateInstance(HandlerType)一把梭,源生成器方案里不能这么做——因为你不确定构造函数依赖哪些服务。
我们的做法是:页面类如果声明了构造函数注入,生成器会在它上面生成一个CreateInstance(IServiceProvider)方法,内部从IServiceProvider中反射解析每个参数。注意,这里还是会有ActivatorUtilities.CreateInstance这类 API 的调用,但调用的目标是生成器精确指定的类型,不是扫描全部程序集,而且依然能被裁剪器静态识别。
4.3 生成文件与手写代码共存时的注意事项
有读者可能踩过这个坑:生成器生成了partial class AppRoutes,开发者也手写了一部分partial class AppRoutes,两边同时定义了同名成员,编译冲突。为了避免这种问题,FUI 生成器在生成代码中增加了一些硬性约束:
- 生成文件顶部必须带
// <auto-generated />标记,IDE 默认不会把该文件纳入重构范围。 - 生成类中被生成的成员统一使用
internal而不是public,减少 API 面。 - 手写部分使用另一个分部方法名,比如
OnRoutesRegistered,让开发者在路由注册完成后做二次加工,而不会和生成成员直接冲突。
另一个隐性问题:命名空间不一致。如果项目启用了文件范围命名空间(namespace X;),而生成器按块作用域的namespace X { }输出,代码风格会不统一。生成器要动态检测项目是否用了文件范围命名空间,这个不容易直接探测,我们是通过读取项目中占主导的语法模式来粗判。若判断不出来就默认块作用域,保证能编译。
重要的经验之一:始终假设开发者会同时编写生成器相关的同名成员,把所有关键成员都做成可合并的分部方法或字段,而不要用一成不变的静态类填充,否则很容易出现“怎么会重复定义”的困惑。
5. 落地效果:启动耗时、AOT 兼容性与可调试性的变化
5.1 性能前后对比
我们选了一个包含 380 个页面的中型业务 App 做基准测试。冷启动时,从进程启动到首页可交互,反射方案约 1120ms,其中路由扫描占了 260ms。源生成器改造后,启动总耗时降到 880ms,路由扫描时间降为接近 0ms。也就是说,仅仅是去掉GetTypes()扫描,就省掉了大约 120ms 的启动时间——这还没有计算 JIT 预热和反射元数据解释的开销。
当然,启动总耗时不全是路由模块的贡献,还有框架初始化、首屏渲染等。但如果你的项目主要是页面路由导向,这个优化幅度在移动端用户感知上非常明显,尤其是低端机。
5.2 AOT 裁剪环境下的差异
反射方案的裁减处理,通常要在页面类上写一堆[DynamicallyAccessedMembers]或[Preserve]。这些特性的维护成本高,而且容易漏写。漏写的后果非常隐蔽:Debug 下一切正常,Release 裁剪后页面无法跳转,只能打开日志才能看到类型被裁掉的信息。
换成源生成器后,情况完全不同。因为生成的代码里有typeof(HomePage)这种直接的静态引用,裁剪器能无条件保留该类型的元数据。我们在原生 AOT 环境里验证过:不需要添加任何 Preserve 特性,所有页面类型正常保留。
5.3 排查一个真实问题的角度差异
举个例子,假设有人手滑把两个页面的路径都写成了/home。
老方案下的排查过程:App 启动成功,首页正常,但从某个入口导航时,发现跳到了一个完全不相关的页面。你需要怀疑是不是注册顺序问题、字典覆盖问题、缓存问题,甚至可能翻代码找半天。整个流程动辄半小时。
新方案下的排查过程:编译时直接出现红色诊断:
错误 FUI1001: 路由路径 '/home' 被重复声明。 来源: HomePage (HomePage.cs: 12) 冲突: LoginPage (LoginPage.cs: 34)定位问题用不了一分钟。这种差异在多人协作时尤其重要——它把潜在 bug 消灭在了 CI 阶段,而不是留给测试人员去“偶现”。
6. 这套演进对技术选型的启发
6.1 反射不是原罪,问题是使用场景
我并不是要求大家把项目里的反射都换成源生成器。反射是极其灵活的运行时机制,在处理动态类型、插件体系时依然不可替代。但在“程序集扫描 + 特性元数据遍历 + 启动阶段集中实例化”这个模式里,它的缺点被放大了——因为元数据是确定的,运行时的动态性完全用不上。
选择技术方案时,可以先问问自己:这份信息是编译期就确定,还是运行时才可变?如果是前者,尽量让编译器参与;只有在信息必须运行时动态变化时,才把反射/动态代码留在运行时。
6.2 源生成器的边界:它也不是银弹
源生成器带来的约束也不少。首当其冲的是它的输出只能追加新代码,不能修改已有代码,这决定了它适合做“代码增强”而不是“代码重构”。其次,增量管线和ForAttributeWithMetadataName在跨项目引用时行为有差异,你需要理解Compilation和AdditionalFiles的语义边界。最后,生成器本身也是你组织的技术债务——如果没人维护,坏掉的生成器比坏掉的反射代码更难看懂。
在 FUI 的演进中,我们把生成器当成一个独立的小团队项目来维护,写了接近 300 行的单元测试覆盖用例,这才敢在不同的业务项目里铺开。
6.3 个人判断源生成器是否适合当前项目的三个标准
如果你的项目满足下面三条中的两条,我认为值得认真考虑:
- 你在启动阶段存在对程序集类型的全量扫描,且扫描结果变化频率很低。
- 你的核心元数据以 Attribute 或静态声明形式存在于源码中。
- 你正在为 AOT/裁剪做准备,害怕运行时反射的噩梦。
FUI 的这次演进,从GetTypes()到强类型 Route 的跨越,本质上是一种思维的转变:别等运行时去猜,编译期能确定的就让它彻底确定。生成的代码不优雅,但胜在稳定、可调试、可裁剪。哪怕你不在做 UI 框架,这套“先收集、再验证、后生成”的打法,放在配置解析、API 客户端生成、依赖注入容器等领域,也是一条值得复用的技术路线。