用 Source Generator 重写 UI 框架装配链路:600ms 到 20ms 的编译期优化实践
2026/9/12 4:10:19 网站建设 项目流程

在最近的版本性能专项里,我顺手把团队内部维护的 FUI 框架启动阶段的装配链路重写了一遍。重写之前,FUI 的装配还是典型的反射注册:程序集扫描、特性解析、构造函数调用,全在启动时发生;重写之后,这一切被挪到了编译期,通过 Source Generator 生成注册代码。整个过程走下来,启动装配耗时从 600ms 掉到了 20ms。这篇文章不是讲某个现成框架的用法,而是完整记录我自己做编译期装配的技术选型、生成器实现、迁移踩坑和实测数据。C# 项目里被 DI 容器或 UI 注册性能折磨过的同学,应该会有些收获。

1. 启动热力图上那 600ms 的反射注册,到底花在哪了

先说清楚背景:FUI 是团队内部维护的、基于 C# 的轻量 UI 框架,负责界面路由、视图绑定和模块装配。所谓装配,就是框架启动时把界面视图、依赖关系、路由信息注册进容器的过程。过去很长一段时间,这部分逻辑都跑在运行时,结果就是每次冷启动都要白白忙活一阵。

1.1 FUI 装配最初的样子:特性声明加启动扫描

FUI 的设计目标很朴素:业务方新加一个界面时,只要给类打上一个特性,框架就能自动完成注册。开发期的体验确实舒服,视图类大概长这样:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public sealed class FUIViewAttribute : Attribute { public FUIViewAttribute(string alias) { Alias = alias; } public string Alias { get; } public string Path { get; set; } = "default"; public int Lifetime { get; set; } } [FUIView("login")] public class LoginView : FUIView { // 登录界面逻辑 }

启动时由框架统一扫描程序集:

var assembly = typeof(LoginView).Assembly; foreach (var type in assembly.GetTypes()) { var attribute = type.GetCustomAttribute<FUIViewAttribute>(); if (attribute == null) { continue; } var descriptor = new FUIViewDescriptor( attribute.Alias, type, attribute.Path, attribute.Lifetime); container.RegisterView(descriptor); }

这个模型在模块少、界面少的阶段完全够用,启动阶段几十毫秒的消耗根本没人会在意。而且新加一个 View 只需要拖一个类文件进来,不用碰任何注册表。也正是这个“零配置”体验,让团队迭代速度一直很舒服,直到界面数量涨到上百个,启动耗时彻底藏不住了。

1.2 六百毫秒的去向:元数据遍历、特性解析、构造函数调用

我对启动链路做了细分计时,数据大致如下:

环节耗时产生原因
程序集 GetTypes 遍历约 90ms加载并枚举程序集内全部类型元数据
GetCustomAttribute 解析约 180ms每个类型都要实例化特性对象,并且走整条反射管线
Activator/构造函数调用约 250ms注册时需要拿类型实例或 Type 句柄,反射调用开销大
注册表构建、别名去重约 80ms字典写入、字符串哈希、描述对象分配

同时,GC 分配接近 15MB,主要来自临时产生的 Type[]、特性实例和大量字符串。这里面最亏的是 GetCustomAttribute 和构造函数调用这两块:FUIViewAttribute 里的 Alias、Path、Lifetime 全都是代码里写死的字面量,类型之间的引用关系也是编译期就确定的,但程序一直到启动那一刻,才通过反射把这些明明白白的信息又“重新读了一遍”。

1.3 一个关键判断:注册清单在编译期就是完整可枚举的

决定动手前,我先逼自己回答一个问题:FUI 的装配到底是不是必须发生在运行时?结论是否定的。组装一份注册清单,需要的信息无非三类:视图类型有哪些、每个类型的元数据是什么、注册的先后顺序是什么。这三类信息在项目编译完成时就已经全部确定了,运行时扫描只是把编译期已知的声明再读一遍,本质上是在为“开发期少写几行注册代码”买单。

这个判断很关键,因为它把问题从“怎么优化反射”变成了“怎么把运行时重复劳动挪到编译期”。后者才是更根本的解法。

2. 为什么把目标锁定在 Source Generator:同赛道方案横评

确定“编译期解决”之后,我并没有直接冲上去写生成器。先把候选方案都过了一遍,包括运行时缓存、T4 模板、手写注册表,最后才是 Roslyn 的 Source Generator。每个方案的取舍点,值得先展开聊聊。

2.1 运行时缓存:只解决一半问题

当时团队里有人提议:把扫描结果静态缓存,第二次启动直接读缓存。这个方案实现最简单,性能上也能提升不少,但它有两个绕不开的问题。

第一,第一次启动仍然要完整扫描。而客户端冷启动本身就包含大量首次操作,用户感知最强的恰恰就是第一次跑起来的耗时;第二,游戏客户端这类场景里,AOT 和代码裁剪非常普遍。反射调用在裁剪后经常拿不到类型,缓存也救不回来。这属于只治标不治本,直接被我排除了。

2.2 T4 和手写注册表:维护成本会失控

T4 模板虽然也是“生成代码”,但它运行在 IDE 或构建脚本里,对项目里的类型变动完全无感知。新增一个带 FUIView 特性的类,如果不记得跑一遍 T4,生成的注册表就是过期的,运行时就会报“视图未注册”。刚开始项目里可能只有一两天手动跑一次,但一旦模块多了,总有人会忘记,最终还是会退化成“又慢又错”的状态。

手写注册表就更不用提了。上百个 View 来回维护,新增时忘写注册、删除时忘删注册,都是迟早的事。这类方案不光没有把问题编译期化,反而把开发期的维护成本又加了回去。

2.3 Source Generator 不可替代的点:编译期感知类型变动

Source Generator 是 Roslyn 编译管线的一部分,它能直接读取语法树和语义模型。新增一个带特性的 View 类,下一次编译时生成器就会自动把注册代码补上;删除一个类,注册代码也会对应减少。它把“类型清单”和“注册代码”始终锁定在同一次编译里,从机制上杜绝了注册表过期的问题。

还有一点容易被忽略:Source Generator 生成的是普通 C# 代码,天然参与编译优化。类型是可裁剪的,调用是强类型的,最终落地到 AOT 环境也没有障碍。正是这两个特性,让它成为这次改造的核心方案。

3. 动手实现 FUI 装配生成器:从收集 FUIView 到产出注册代码

这里进入正题。我会按一个最小可用的增量生成器来讲,代码都做过简化,但结构是真实项目里能跑的。

3.1 用 IIncrementalGenerator 搭起骨架

生成器需要单独放在一个类库里,目标框架建议 netstandard2.0,并引用 Microsoft.CodeAnalysis.CSharp 包。入口类长这样:

[Generator] public sealed class FUIViewRegistryGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var viewTypes = context.SyntaxProvider .ForAttributeWithMetadataName( "Fui.Attributes.FUIViewAttribute", predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, _) => GetViewInfo(ctx)) .Where(static info => info is not null) .Select(static (info, _) => info!); context.RegisterSourceOutput( viewTypes.Collect(), static (spc, infos) => GenerateRegistry(spc, infos)); } }

在 Roslyn 4.0 之前,老接口是 ISourceGenerator。如果项目还停留在旧版本,可以用它实现,但增量编译效果会差不少。有条件建议直接上 IIncrementalGenerator。

3.2 收集视图类型,并保留元数据

ForAttributeWithMetadataName 是增量生成器里非常顺手的 API,它会自动帮我们做语法节点匹配和语义模型解析。transform 阶段拿到的 GeneratorAttributeSyntaxContext 已经带好了特性参数和目标类型符号,可以在这里把最终需要的信息一次性抽出来:

private sealed record ViewToRegister( string FullName, string Alias, string Path, int Lifetime); private static ViewToRegister? GetViewInfo(GeneratorAttributeSyntaxContext context) { if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) { return null; } var attr = context.Attributes[0]; var alias = attr.ConstructorArguments[0].Value as string; var path = attr.NamedArguments.FirstOrDefault(x => x.Key == "Path").Value.Value as string; var lifetime = attr.NamedArguments.FirstOrDefault(x => x.Key == "Lifetime").Value.Value as int?; return new ViewToRegister( typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), alias ?? string.Empty, path ?? "default", lifetime ?? 0); }

注意,这里拿到的类型全名要长成global::Fui.Sample.LoginView这样,不能只取 typeSymbol.Name,因为不同命名空间下完全可能重名。

还有一个容易被忽略的细节:增量生成器里参与缓存的数据必须实现值相等性,否则没法正确判断“这次编译有哪些输入没变”。所以我特意把 ViewToRegister 定义成了 record,值相等是默认行为;如果定义成普通 class 而不重写 Equals/GetHashCode,生成器会退化成每次全量执行,增量优化就白做了。

3.3 生成 partial FUICompositionRoot 注册代码

收集完信息,就可以把注册代码拼出来。生成代码的原则是:只生成容器注册调用,不覆盖任何用户手写的逻辑。因此我选择生成一个 partial 静态类,用户可以在另一个同名的 partial 类里写自己的扩展注册。

private static void GenerateRegistry( SourceProductionContext spc, ImmutableArray<ViewToRegister> infos) { var writer = new StringBuilder(); writer.AppendLine("// <auto-generated />"); writer.AppendLine("namespace Fui.Generated"); writer.AppendLine("{"); writer.AppendLine(" public static partial class FUICompositionRoot"); writer.AppendLine(" {"); writer.AppendLine(" public static void RegisterViews(FUIContainer container)"); writer.AppendLine(" {"); foreach (var view in infos) { writer.AppendLine(" container.RegisterView(new FUIViewDescriptor("); writer.AppendLine($" \"{view.Alias}\","); writer.AppendLine($" typeof({view.FullName}),"); writer.AppendLine($" \"{view.Path}\","); writer.AppendLine($" (ViewLifetime){view.Lifetime}));"); } writer.AppendLine(" }"); writer.AppendLine(" }"); writer.AppendLine("}"); spc.AddSource("FUIViewRegistry.g.cs", writer.ToString()); }

生成结果大致是:

// <auto-generated /> namespace Fui.Generated { public static partial class FUICompositionRoot { public static void RegisterViews(FUIContainer container) { container.RegisterView(new FUIViewDescriptor( "login", typeof(global::Fui.Sample.LoginView), "default", (ViewLifetime)0)); } } }

这段代码放到客户端启动流程里,在最前面调用一次即可。因为全部是显式类型引用和直接方法调用,编译器可以做正常裁剪,运行期也彻底没有了反射调用。

3.4 给生成器加编译期诊断,把错误提前暴露

既然已经能拿到语义模型,就不要只闷头生成代码。我在生成器里加了几个 Diagnostic,把过去运行时才暴露的问题提前到编译期:

  • FUIViewAttribute 用在接口或抽象类上:直接报编译错误。
  • 视图别名重复:报编译错误,并列出两个类型的完整名称。
  • 特性参数是 null:报编译警告,提示检查别名是否漏填。

这些诊断的成本几乎为零,但能省掉后面大量联调时间。运行时崩溃变成编译期报错这件事,越早做越值。

4. 生成代码和容器对接:兼容旧逻辑的过渡方案

替换装配链路不能上来就一刀切。反射版本跑了那么久,里面可能藏着一些隐性的顺序依赖和行为细节,直接删掉风险太高。我采用了双轨并行,确保任何时刻可以一键回退。

4.1 统一注册入口,按程序集做命名空间隔离

FUICompositionRoot 本身是 partial 类,用户侧可以在另一个同名 partial 类里写手动扩展注册,生成文件里的内容不会覆盖这些手写逻辑。多程序集场景下,每个程序集分别生成一个 RegisterViews 方法,放在各自程序集专属的命名空间里,模块初始化时自行调用自己的根。

这个设计解决了一个很实际的问题:生成器不知道用户想在注册前后做什么。有的人需要先订阅某个事件再注册视图,有的人需要注册后追加路由规则。保留 partial 入口,就是保留和现有容器逻辑的对接空间。

4.2 条件编译开关控制反射逻辑的去留

迁移的前几个版本,我保留了旧的反射装配方法,但用一个编译常量控制走哪条路:

#if !DONT_USE_SOURCE_GENERATED_REGISTRY Fui.Generated.FUICompositionRoot.RegisterViews(container); #else AssemblyReflectionRegistrar.RegisterAllViews(container); #endif

这样回归测试时可以随时切回旧逻辑对比行为。万一新逻辑漏了某个注册,也能快速定位是不是反射顺序导致的。等线上跑过两三个版本,确认没有差异后,再把反射相关代码整体删掉。

4.3 更进一步:连视图工厂也编译期生成

反射注册里开销最大的其实是 Activator.CreateInstance。很多框架注册完类型之后,运行期加载界面时还会再反射一次创建实例。迁移期我把这块也一起处理了,生成器为每个 View 生成一个静态工厂方法:

private static global::Fui.Sample.LoginView CreateLoginView() => new global::Fui.Sample.LoginView();

注册时直接把方法组传给容器:

container.RegisterView(new FUIViewDescriptor( "login", typeof(global::Fui.Sample.LoginView), "default", (ViewLifetime)0, new ViewFactory<FUIView>(CreateLoginView)));

这样一来,连创建实例都不走反射了,AOT 和代码裁剪环境下也彻底放心。

5. 迁移路上翻过的车:五个值得记下来的细节

这一节才是真正的实战部分。理论讲得再好,落地时照样会遇到一堆莫名其妙的问题。我把这次迁移过程中踩过的坑按价值从高到低列一下。

5.1 注册顺序依赖丢了,被两个页面替身坑了一次

旧反射实现依赖 Assembly.GetTypes() 的返回顺序。团队里有些模块是靠“后注册覆盖先注册”来实现默认视图替换的。生成器并行收集之后,顺序完全不确定,迁移第一天就有两个页面被替换错了,线上数据直接异常。

解决办法是给特性加一个显式的排序字段,并让生成器在输出前按这个字段统一排序:

[FUIView("home", Order = 10)]

收集阶段把 Order 读出来,生成时按 Order、命名空间、类型名排序后再输出。这是所有从反射迁移到编译期的项目最容易忽略的问题,强烈建议一开始就给特性预留排序键。

5.2 类型全名不能漏 global:: 前缀

最开始生成代码里直接写 typeSymbol.Name,遇到嵌套类、不同命名空间同名类,很快翻车。后来全部改成SymbolDisplayFormat.FullyQualifiedFormat,它输出的形式是global::Fui.Sample.LoginView。加上这个前缀能避开一切歧义,无论是嵌套类型还是跟 using 引入的同名类型冲突,都不会再有问题。

5.3 增量生成器加载失败时,错误指向非常迷

第一次接入时,生成器引用没配对,编译报“FUICompositionRoot 不存在”。这个报错其实是正常的,因为项目根本没有生成任何代码。排查方向有这么几个:

  • 确认生成器项目引用了 Microsoft.CodeAnalysis.CSharp 包,并且版本和主项目的 Roslyn 版本匹配;
  • 在生成器项目里写一个临时 flag 文件,通过环境变量控制是否把生成内容额外输出一份到磁盘,方便检查;
  • 开发阶段可以用 Debugger.Launch() 挂调试器,但是提交前一定要删掉,否则 CI 构建会卡住。

5.4 多程序集重复注册

FUI 允许把 View 拆分到多个程序集,生成器会分别收集。如果同一个类型被多个特性标注,生成代码里就可能出现重复注册。我在生成器里按类型全名做了去重,并在发现重复时生成一个编译期错误诊断,提示开发者检查是否贴了多个特性。这件事越早知道越好,否则运行时容器会一脸茫然地接受两个描述符,等到使用时才暴露出别名指向问题。

5.5 老框架版本兼容问题

IIncrementalGenerator 需要 Roslyn 4.0 配合。如果合作项目还在旧版 IDE 或者老 .NET 版本时代,建议留一个基于 ISourceGenerator 的兼容实现。旧接口 API 简陋一些,但核心功能都能做,只是增量编译的性能差不少。实际做法是用条件编译符号在不同项目里各取所需,保证老项目也能享受到编译期装配的好处。

6. 迁移后的实测:性能提升之外还捡到了什么

数据是迁移最有说服力的部分。我拿同一份 UI 模块,分别跑反射注册版本和编译期装配版本,结果如下:

指标反射注册编译期装配
启动装配耗时约 600ms约 22ms
GC Alloc约 15MB0MB(注册表自身少量对象除外)
首次打开页面耗时约 120ms约 35ms
错误发现时机启动或运行时编译期

6.1 性能对比:为什么差距能拉得这么大

生成版本里剩下的 22ms,主要是容器注册表构建本身的开销,包括字典写入、哈希计算这些没法再省的基础操作。而反射版本多出来的那些毫秒,几乎全部消耗在元数据遍历、特性实例创建和反射方法调用上。这两类开销在编译期方案里根本不存在的。

6.2 构建时长增加多少,值不值

迁移后全新编译大约增加了 400ms,增量编译因为增量缓存的存在只增加了 30ms 左右。对 CI 来说完全可接受。这里要特别提醒:生成器自身如果收集逻辑写得不好,比如在 transform 阶段做复杂度很高的语义分析,会让 IDE 的 IntelliSense 明显变卡。优先使用 ForAttributeWithMetadataName,少手动遍历 SyntaxTree,是保住编辑器流畅度的关键。

6.3 运行时崩溃变成编译期报错,收益最实在

生成器里那几条编译期诊断,上线后帮我们逮住了不少问题。过去最容易犯的两类错误:把 FUIView 特性写到接口上、别名重复。这些在反射时代都要等到启动装配那一刻才会崩溃,现在写错代码直接编译不过,测试反馈链路缩短了一大截。

6.4 给生成代码加一道 CI 兜底检查

建议在 CI 构建脚本里加一步生成代码变更检查:正常执行一次构建,然后 git diff 生成目录,如果发现 .g.cs 文件有未提交的变动就直接失败。这能防止有人改了特性定义或者生成器逻辑,却忘了提交对应的生成结果。看起来是个笨办法,但对团队协作场景特别管用。

7. 编译期装配的思路其实不局限于 UI 框架

FUI 迁移做完后我复盘了一下,这套方法本质上是一个通用模式:任何“运行期扫描 + 反射调用”的注册工作,都可以尝试编译期化。而且判断标准非常清晰——注册信息在编译期是不是已经完全确定了。

7.1 可复用的场景清单

我顺手梳理了团队里其他可以套用同样思路的地方:

  • 消息处理器注册:EventBus 启动时扫描 IMessageHandler 实现类,完全可以生成显式注册表;
  • 模块启动器排序:模块之间的依赖关系是静态的,排序逻辑放到编译期生成,启动时少做一堆反射读取特性;
  • API 端点自动发现:类似 ASP.NET Core 的 Controller 发现机制,控制器类型和路由特性在编译期都是确定的;
  • 数据迁移脚本扫描:数据库迁移版本列表完全可以在编译期确定,根本不需要运行时去读程序集;
  • 配置文件绑定模型注册:一个 POCO 对应一个配置节,编译期直接生成绑定代码,省掉一堆属性反射。

这些场景的共同点和我前面判断 FUI 时一模一样:运行时扫描只是在重复劳动,而编译期已经把答案写好了。

7.2 下一步:把依赖注入解析也搬进编译期

FUI 目前还残留了一块反射在构建阶段:容器解析构造函数参数时,仍然通过反射读取参数列表。我正打算把构造参数提取也挪进生成器,为每个类型生成一个强类型的解析方法,让整个装配链路彻底告别反射。做完这一步,启动链路和首次页面打开链路里就再也找不到 Type.GetMethods 或 Activator.CreateInstance 了。

从我个人的实际体会来看,改到这一步最重要的收获还不是省出的那几百毫秒,而是整个装配过程变得可理解、可调试、可裁剪了。现在团队里每次讨论要不要再加一个运行时特性,我都会先问一句:这个信息编译器是不是已经知道了?如果答案是肯定的,那它就不该等到运行时再去翻兜。

7.3 不是所有反射都要消灭,知道什么时候停手

编译期装配也不是银弹。插件系统、热更新模块、用户自定义类型的加载,这些动态场景在编译期确实拿不到类型清单,强行生成代码只会把架构搞复杂。我的经验是:判断标准不是“性能差就编译期化”,而是“这段信息在编译期是否已经确定且有穷尽”。如果是,就值得搬;如果不是,保留反射反而是正确设计。把握好这个边界,Source Generator 才能用在刀刃上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询