先说一个让我决定把这套东西彻底搞清楚的场景。前阵子把一个内部小工具发布成自包含单文件,顺手开了裁剪,产物从 30 多 MB 压到 10 MB 出头,启动也快了不少。结果工具跑到第三个业务分支,直接抛了一个MissingMethodException。查到最后,是一个按字符串反射查类型的辅助方法,开发环境跑得一切正常,裁剪之后元数据被删了,方法也就没了。从那天起,我把 Native AOT、裁剪和源生成器这三件事放在一起重新理了一遍,才意识到它们根本不该分开看:Native AOT 和 Trimming 负责把绝大多数"运行时才知道"的东西压缩掉,而 Source Generators 负责在编译期把这些信息重新补回来。这套组合现在团队内部简称为 NTLs,也就是标题里那个缩写。
这篇内容适合三类人:被dotnet publish出来的"幽灵异常"折磨过的开发;想给类库做 AOT/裁剪兼容的作者;以及要在启动时间、包体积上有硬指标的交付团队。我尽量少讲废话,把原理、坑位和能直接抄的改造路径都放进来。
1. 为什么"裁剪 + AOT + 源生成器"必须放在一起讲
1.1 裁剪的底层逻辑:静态可达性扫描
裁剪(Trimming)本质上是一个"保守的删减器"。发布时,工具先从程序入口点开始,沿着 IL 里的方法调用、字段访问、类型引用等静态线索,画出一张可达性图,然后保留这张图里能触达的所有类型和成员,其余的一律从程序集中移除。因为它是静态扫描,所以只能看到"代码里写死"的信息。
用搬家来类比:裁剪工在做的事,是把你房间里"明面上能看见"的东西打包带走。你只要在物品上贴了标签,或者放在一个能看得见的箱子里,它就能带走。可反射是什么?反射是你在纸上写了一句"我可能要用到某个柜子里的第三格东西",然后把纸条揣进兜里。搬家工人根本看不见这张纸条,自然以为柜子是不需要的东西,直接扔了。
所以很多项目"开发环境一切正常,开裁剪后隔三差五崩",原因几乎都出在同一条链路上:反射字符串、按类型全名查找成员、PropertyInfo.GetValue(),这些在 IL 里不是对某个目标方法的直接引用,而是对字符串或Type对象的操作。静态扫描工具无法从Type.GetType("SomeNamespace.SomeService")这个字符串里推算出你真正要用哪个类型。结果就是发布成功,运行到那一行为止直接塌方。
1.2 没有 JIT 的世界:动态代码能力从根上消失
如果说裁剪是"把可能用到的元数据删掉",那 Native AOT 更狠:它连运行时解释 IL 的机制都不留。普通发布模式下,程序集里装的还是 IL 和元数据,运行时用 JIT 按需编译,甚至可以动态生成代码,比如AssemblyBuilder、Reflection.Emit、运行时拼接表达式。但 Native AOT 在发布阶段就把 IL 直接编译成了本机代码,目标机器上不再有托管运行时替你兜底。
这带来一个很直接的后果:凡是依赖"运行时生成 IL"的功能,在 AOT 下要么抛异常,要么行为完全不可用。常见的Activator.CreateInstance(Type)配合某个动态构造的类型,在普通运行环境还行,一旦进入 AOT 环境,那个Type对象背后连可执行的元数据都没有,自然没法实例化。更麻烦的是,很多第三方库在初始化路径里藏了这类动态操作,不跑到那一步根本发现不了。
从工程角度看,裁掉的是"多余的元数据",AOT 限制的是"运行时动态解析能力"。两者叠加之后,代码里任何"等运行时再去查名字、找方法、生成调用"的写法全部变成高危行为。而源生成器之所以成为 NTLs 的第三块拼图,就是因为它把这类"运行时查表"动作,硬生生前移到了编译器阶段。
1.3 三者的互补关系:把运行时猜测变成编译期已知
传统反射链路是:程序运行时拿到一个Type,然后靠元数据去查成员、调用。源生成器则是反过来的思路:编译时,Roslyn 编译器已经把源代码里的类型符号看得一清二楚,生成器拿到这些符号,直接帮你生成一段硬编码的代码。这段生成代码编译进程序集后,就是普通的方法调用和属性访问,同理可被 AOT 编译器正常处理。
一个特别直观的例子是序列化。以前写一个通用 JSON 序列化器,往往用T.GetType()加GetProperties()遍历,运行期动态取值。改成源生成器的做法后,生成器会在编译期看到对象的所有属性,为每个类型生成一段固定代码,访问value.Name这种直接成员引用。裁剪时能看到这段代码用到了哪个字段、哪些属性,自然会把它们保留下来;AOT 编译时也不需要任何动态生成能力。
所以我在排查问题时有个习惯:遇到"运行期反射链"就先去想"这个信息编译期是否已经存在"。如果存在,那大概率能用源生成器解决;如果不存在,才轮到DynamicallyAccessedMembers注解、裁剪根配置等手段保命。优先顺序从来不是反射,而是"能生成就生成"。
2. 裁剪与 AOT 到底会拆掉哪些"平时看不见"的能力
2.1 反射调用与 IL 警告:先看懂编译器在喊什么
开裁剪后,编译器往往会在输出里给出一堆IL2026、IL2057、IL207x这类警告。这些不是普通建议,而是裁剪分析和 AOT 编译器在告诉你"这里存在动态访问模式,发布后可能崩"。我见过太多项目把这些警告当噪音忽略,最后全变成线上事故,所以先花点时间看懂它们。
IL2026:调用了某个标注了RequiresUnreferencedCode的成员,说明这一调用点在裁剪环境下不安全。IL2057:出现了编译器无法静态解析的反射模式,最常见的就是字符串拼接出类型名传给Type.GetType。IL2072/IL2075/IL2080:多和DynamicallyAccessedMembers注解相关,表示某个值的动态成员需求无法被满足。
一个典型代码长这样:
[RequiresUnreferencedCode("通过反射校验任意对象,裁剪后可能失效")] public void ValidateByReflection(object target) { Type t = target.GetType(); var prop = t.GetProperty("Status"); object? value = prop?.GetValue(target); if (value != null && !Convert.ToBoolean(value)) { throw new InvalidOperationException("校验未通过"); } }只要编译时看到这段,裁剪分析器就会警告:GetProperty("Status")是字符串查找,linker 不知道你是否还需要属性 Status。所以这类代码要么改成直接强类型访问,要么给调用方传播"这里不安全"的信号,让调用方自己决定怎么处置。
2.2 动态程序集、Emit 与运行时模板的边界
另一个大坑是动态程序集。以前有些框架喜欢在启动时Assembly.LoadFrom一个插件目录里的托管 DLL,然后用反射扫出所有实现某个接口的类型。普通运行时里这是很常规的路数,甚至很多插件化架构都这么干。但遇到 AOT,这套直接失效:发布产物里根本不可能带一个能加载并执行任意托管 DLL 的运行时环境。托管插件只能换成其他方案,比如编译成本机动态库做进程外调用,或者在编译期就把插件类型通过源生成器注册进来。
还有一批 API 是"看着像普通方法调用,实际挂在动态能力上"的。比如:
AssemblyBuilder、ModuleBuilder:运行时构造程序集,AOT 下不可用。Expression.Compile():如果表达式的解析内容涉及大量反射成员查找,部分场景在 AOT 下也会被卡住。- 各种基于
Activator.CreateInstance(type, args)的通用工厂函数:只要type不是编译期常量,基本都会遇到问题。
早些时候我在某个模拟项目 X 里写过一个配置系统,配置文件的PluginType字段存的是程序集限定名,运行时Type.GetType加载插件。这个写法在 Windows 开发机上跑得一帆风顺,一开PublishTrimmed就完蛋。这就是 2.1 节说的 IL2057 警告的典型来源,只不过那时我对警告不敏感,把风险一直带到了交付阶段。
2.3 一次"发布成功但运行崩"的完整复盘
那次事故的排查路径,值得完整复述一遍。工具发布后,用户反馈"一点某个菜单就退出"。我用事件日志抓下来,异常是:
System.MissingMethodException: Method not found: 'ConfigLib.PluginFactory.Create'.
第一反应是版本不一致,检查了半天发现所有程序集版本都对得上。后来把发布配置的PublishTrimmed关掉再发一版,问题消失。这时候才确定是裁剪干的。
顺着源码找,发现创建插件的代码是这样的:
object obj = Activator.CreateInstance( Type.GetType(entry.PluginTypeName, throwOnError: true) ?? typeof(object));问题很清晰:PluginTypeName是配置文件里的字符串,裁剪扫描永远看不到这个类型名,所以PluginFactory.Create这个成员直接被删了。而开发环境没裁剪,元数据齐全,自然能用。最后我做了三件事:给插件的类型清单改成显式注册表,用一个字典把插件名映射到typeof(XxxFactory)这样的静态引用;对必须按名字动态找的类型,补了 XML 裁剪根描述文件;把编译器对反射的动态访问警告全部留档,禁止带警告发布。从那以后,我对 NTLs 的理解就不只是"三个名词放一起",而是一条完整的安全防线。
3. 源生成器如何从根上化解裁剪的"误杀"
3.1 源生成器接入 Roslyn 编译管线的位置
源生成器本质上是编译器的插件。正常的编译顺序是:源码被分词、解析成语法树,然后进入语义分析,生成 IL。源生成器挂在语法树和语义分析之间,它能看到当前编译单元里所有类型的符号信息,并根据这些符号生成额外的 C# 代码,拼到这次编译里。
最早一批生成器要手动写ISyntaxReceiver,比较麻烦。现在推荐直接用ForAttributeWithMetadataName这种增量 API:只要某个类型上挂着指定全名的特性,生成器就能拿到它的INamedTypeSymbol,然后生成代码。写一个最简单的生成器骨架大概是这样:
[Generator] public sealed class MapToTextGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var targets = context.SyntaxProvider.ForAttributeWithMetadataName( "NtlsDemo.MapToTextAttribute", predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol) .Collect(); context.RegisterSourceOutput(targets, static (spc, symbols) => { foreach (var symbol in symbols.Distinct(SymbolEqualityComparer.Default)) { spc.AddSource($"{symbol.Name}.g.cs", BuildCode(symbol)); } }); } }注意,生成器本身要编译成一个独立程序集,目标框架通常是netstandard2.0,消费者项目通过<ProjectReference OutputItemType="Analyzer" ReferenceOutputAssembly="false" />引用它。这样生成器只在编译期运行,不会被带进最终产物,也不参与运行时的任何动态行为。
3.2 从类型符号到生成代码的完整链路
继续拿那个生成器举例子。假设我们手里有一个类需要自动生成"把对象转成文本"的方法。原始类型:
[MapToText] public partial class Person { public string? Name { get; set; } public int Age { get; set; } }生成器拿到Person这个INamedTypeSymbol后,遍历它的公共属性,拼出一个扩展方法,访问value.Name、value.Age这种直接成员。生成代码长这样:
// <auto-generated/> using System.Text; namespace NtlsDemo { public static partial class PersonExtensions { public static string ToText(this Person value) { var sb = new StringBuilder(); sb.Append("Name="); sb.Append(value.Name); sb.Append("Age="); sb.Append(value.Age); return sb.ToString(); } } }注意两个关键点:一是生成代码里的value.Name是强类型成员访问,不是反射查找;二是被标记的类必须声明为partial,生成代码才能和用户代码合并到同一个类型里。这两点做得对,裁剪扫描就能"看见"所有被访问的属性,AOT 编译也不需要任何运行时反射能力。
从整个链路看:用户在源码里写属性 → Roslyn 把属性符号暴露给生成器 → 生成器把"读属性"的行为固化成 IL 里的直接访问指令 → 裁剪保留这些指令涉及到的成员 → AOT 直接编译。这才是 NTLs 真正的威力所在:把本来要依赖运行时元数据的事情,转移到编译期一次性做完。
3.3 为什么源生成器天然"裁剪安全"
源生成器之所以被 AOT 项目偏爱,不是因为它的 API 有多炫,而是因为它完全避开了"运行时查表"这一步。运行时反射需要Assembly、Type、MethodInfo这些元数据对象常驻内存,裁剪后的程序集恰恰在压缩这些;而生成代码不需要元数据,它只需要编译后的字段偏移和函数地址,这对 AOT 来说是再舒服不过的输入。
这也解释了为什么现在生态里大量组件都在往源生成方向迁移。比如很多 JSON 序列化库都提供了"源生成上下文"模式:在某个partial类上声明你需要序列化哪些类型,编译期就生成对应的序列化代码。发布到 AOT 环境下,这些序列化器是硬编译出来的,不会触发裁剪警告,也不会在运行时找属性。这种"声明式 + 编译期生成"的模式,基本成了类库支持 AOT 的标准形态。
但这里必须泼一盆冷水:源生成器不是银弹。它解决的是"编译期可以看见的类型"。如果你业务里确实存在用户运行时自己拼类型名、或者从外部模块动态加载类型这类需求,源生成器帮不了你,只能靠更粗粒度的裁剪根配置或者进程外插件方案。判断标准还是那句:信息是否在编译期已经确定。
4. 实战:把一个反射序列化模块改造成源生成器驱动
4.1 改造前的反射实现与风险点
很多项目里都会有一个"把任意对象转成文本/KV"这种小工具。反射版写起来很快:
public static string ConvertToText(object value) { var type = value.GetType(); var sb = new StringBuilder(); foreach (var p in type.GetProperties()) { sb.Append(p.Name).Append("=").Append(p.GetValue(value)).Append(';'); } return sb.ToString(); }缺点一眼就能看出来:type.GetProperties()返回哪些属性,取决于运行时元数据里留下了哪些;p.GetValue(value)是动态调用。开裁剪后,哪怕value是Person,只要Person的属性没有被其他静态代码直接访问,就可能被剪掉,运行时拿到的是空属性列表。就算属性侥幸保留了,每次GetValue还有装箱、反射调用、安全检测的开销,性能也不好看。
改造的逻辑很简单:我要的是"给指定的强类型生成一段遍历属性的代码"。这正好是源生成器的舒适区。于是把通用反射版本替换成特性标记 + 生成扩展方法,调用方从ConvertToText(obj)变成obj.ToText(),语义没变,底层实现彻底换掉了。
4.2 手写一个最简单的类型映射源生成器
完整实现分三块:特性、生成器、消费者项目配置。先定义特性:
namespace NtlsDemo; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] public sealed class MapToTextAttribute : Attribute { }然后写生成器主体。生成器遍历目标类型的所有公共属性,把每一行断言式地写进生成代码:
private static string BuildText(INamedTypeSymbol type) { var props = type.GetMembers() .OfType<IPropertySymbol>() .Where(p => p.DeclaredAccessibility == Accessibility.Public) .ToList(); var body = new StringBuilder(); foreach (var prop in props) { body.Append(" sb.Append(\"").Append(prop.Name).Append("=\");").AppendLine(); body.Append(" sb.Append(value.").Append(prop.Name).Append(");").AppendLine(); } return $$""" // <auto-generated/> using System.Text; namespace {{type.ContainingNamespace}} { public static partial class {{type.Name}}Extensions { public static string ToText(this {{type.ContainingNamespace}}.{{type.Name}} value) { var sb = new StringBuilder(); {{body}} return sb.ToString(); } } } """; }消费者项目里,按下面的方式引用生成器:
<ItemGroup> <ProjectReference Include="..\MapToTextGenerator\MapToTextGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup>接着在目标类上打特性:
[MapToText] public partial class Person { public string? Name { get; set; } public int Age { get; set; } }最后直接person.ToText()。整个过程完全不需要反射。发布时用:
dotnet publish -c Release -r linux-x64 -p:PublishAot=true产物是原生可执行文件,裁剪倒是可以不用单独开了,因为 AOT 发布默认会启动裁剪分析,而且源生成代码让分析过程顺畅得多。
4.3 集成时的陷阱:partial、命名空间与调试
这类生成器集成有些坑,先说最容易踩的。第一,partial关键字必须写。生成代码要往同一个类型里合并,原类型不写partial就会报"已有定义"的编译错误。我见过不少同事第一次跑生成器项目时卡在这。
第二,生成代码要用type.ContainingNamespace,而不是写死命名空间。如果消费方项目启用了文件级命名空间或全局命名空间,写死的命名空间会让生成代码编不过。让生成器用符号里的ContainingNamespace拼出来,基本能覆盖绝大多数情况。
第三,生成器挂在编译管线里,调试难度比普通代码高一点。建议在消费项目里打开两个属性:
<PropertyGroup> <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <CompilerGeneratedFilesOutputPath>generated</CompilerGeneratedFilesOutputPath> </PropertyGroup>这样每次编译后,生成器产出的.g.cs文件都会落到磁盘上。排查"生成的东西怎么不对"时直接看文件,比在生成器代码里打断点快得多。这也是我个人强烈推荐的做法。
第四,如果生成器报错了,不要用throw,要在生成器里产出Diagnostic。生成器抛异常会导致整个编译崩溃,而且错误信息不友好。用诊断的方式,IDE 里还能像普通编译错误一样提示,维护起来省心很多。
5. 发布配置与验证:让 AOT 产物真正可控
5.1 关键配置项与裁剪根
很多朋友对 AOT 的第一反应是"在 csproj 里加一个属性然后 publish"。没错,基本流程确实就这一步,但可观测性不是靠一个属性就能保证的。我把常用配置整理成了一张表,方便对照:
| 配置项 | 作用 | 建议 |
|---|---|---|
PublishAot | 启用 Native AOT 编译 | 最终交付时开启 |
PublishTrimmed | 启用裁剪 | AOT 会自动开启;单独排查时也可先只开它 |
TrimMode | 裁剪激进程度,partial或full | 默认partial更稳,full体积更小但警告更多 |
TrimmerRootAssembly | 强制保留某个程序集 | 对动态加载的插件程序集使用 |
TrimmerRootDescriptor | 用 XML 描述需要保留的类型/成员 | 有具体反射名单时使用 |
IsAotCompatible | 库项目声明支持 AOT,并开启对应分析 | 给类库做兼容性声明的入口 |
SuppressTrimAnalysisWarnings | 是否屏蔽裁剪警告 | 永远不要设成true |
裁剪根 XML 的写法也很固定,比如某个配置程序集里有一个运行时要反射创建的类型:
<linker> <assembly fullname="ConfigLib"> <type fullname="ConfigLib.PluginRegistry" preserve="all" /> </assembly> </linker>然后在 csproj 里把它挂上:
<ItemGroup> <TrimmerRootDescriptor Include="roots.xml" /> </ItemGroup>这里的原则是:能用源生成器解决的,不要用 XML 根去硬保;必须硬保的,越小越好。根越多,裁剪效果越差,AOT 产物越大。我见过有人图省事把整个程序集preserve="all",等于绕过了裁剪,那还不如不开。
5.2 把警告当错误对待:用注解传播风险
AOT 发布时,编译器会打出两类高危警告:一类是RequiresUnreferencedCode触发的,一类是RequiresDynamicCode触发的。前者表示"这段代码依赖元数据,裁剪会出问题",后者表示"这段代码依赖动态代码生成,AOT 下会出问题"。
自己的公开 API 如果包含这类操作,一定要主动打上注解,而不是等使用者踩坑。比如:
[RequiresDynamicCode("通过类型名创建实例,AOT 下无法保证可用")] public static object CreateByTypeName(string typeName) { return Activator.CreateInstance(Type.GetType(typeName)!); }这样,谁调用这个 API,谁就会在编译期看到警告,然后他才有机会用源生成器或裁剪根去规避。注解的本质是把"运行时风险"转成"编译期可定位风险",这对 NTLs 来说特别重要,因为风险前置才是这套组合的最终目的。
我还会在关键项目里直接把 IL 相关警告提升为错误:
<PropertyGroup> <WarningsAsErrors>IL2026;IL2057;IL2075;IL2080</WarningsAsErrors> </PropertyGroup>虽然过程会有点疼,但比起让用户在运行时看到MissingMethodException,编译时报错显然是更好的选择。
5.3 实测数据与验证流程
NTLs 到底带来多少收益,我拿一个内部模拟项目 X 做了对比。它是个中等规模的控制台工具,带少量网络和序列化逻辑。三组发布配置的测试结果大致如下:
| 配置 | 体积 | 冷启动时间 | 峰值内存 |
|---|---|---|---|
| 自包含单文件 | 约 65 MB | 约 180 ms | 约 80 MB |
| 裁剪后自包含 | 约 22 MB | 约 120 ms | 约 60 MB |
| Native AOT | 约 18 MB | 约 15 ms | 约 35 MB |
数据会随着项目规模浮动,但趋势是一致的:AOT 在启动时间和内存上优势明显,体积在单文件场景下也有收益。当然,不是所有项目都该无脑 AOT。带大量动态反射、第三方库未适配、依赖特定运行时特性的项目,改造成本可能高于收益。
验证流程我一般是三步走:先在干净的虚拟机或容器里跑一遍可执行文件,确认没有外部运行时依赖;再把所有业务分支走一遍,特别关注序列化、配置加载、插件创建这几个高危点;最后用time统计冷启动时间,对比基线。别只看能启动就算完事,高危分支都要实际触发到。
6. 必须接受的代价与几个最容易忽略的坑
6.1 AOT 不是免费午餐:平台限制与功能收敛
第一个代价是平台相关。Native AOT 的发布目标受限于运行时支持的平台组合,不能在任意机器上随意交叉编译。跨平台发布时要提前确认目标 RID 是否支持,并在目标平台上做验收测试。有些项目涉及平台原生库依赖,还需要额外处理链接配置。
第二个代价是功能收敛。凡是依赖"运行时加载托管程序集"的能力,在 AOT 下基本都不存在。插件、模块化、动态扩展这类设计,要么改成编译期静态注册,要么直接换架构。强反射的通用序列化器、ORM 的延迟加载代理,也要仔细核对所依赖的库版本是否提供了 AOT 兼容路径。
第三个代价是构建时间。AOT 编译比普通编译慢得多,因为它要做完整的静态分析和本机代码生成,大项目的发布构建时间可能从几十秒变成几分钟。所以本地开发时不要开 AOT,只在发布流水线里开,配合缓存能缓解一部分成本。我一般会先把PublishTrimmed和分析器警告清干净,最后才开 AOT,这样能把编译错误提前暴露,避免每轮都烧几分钟在构建上。
6.2 反射式类库的兼容清单
做类库的朋友要尤其认真,因为调用你的库的人一旦开 AOT,你代码里的反射习惯就会被成倍放大。我自己整理了一份兼容清单,每次按它过一遍:
- 类型查找:禁止用字符串拼类型名,尽量用
typeof直接引用;确实需要运行时再决定的,必须暴露RequiresUnreferencedCode注解。 - 实例创建:不要到处写
Activator.CreateInstance(obj.GetType()),改成源生成工厂、委托注入或者泛型约束。 - 属性读取:不要用
PropertyInfo.GetValue,改用生成强类型访问器,或者在接口里定义统一访问方法。 - 程序集加载:不要运行时扫描目录加载程序集,AOT 产物没有这个能力,要么静态注册,要么进程外插件。
- 序列化:优先选用带源生成模式的序列化库,并在库内提供
partial上下文类,让使用方声明所需类型。
对照下来,大部分老库存活的唯一出路就是把"运行时反射"替换成"编译期生成"。这也是为什么这两年很多库的作者都在做这类迁移,本质上是整个生态在为 AOT 铺路。
6.3 快速判断"能不能 AOT"的检查单
最后分享一个我实际用来评估项目的快速检查单,不需要展开代码审查,几分钟就能形成判断。
- 搜索源码里的
Type.GetType(、Assembly.Load、Activator.CreateInstance(、PropertyInfo.GetValue(,看一下出现频率和调用链。频率越高,AOT 改造成本越高。 - 跑一次
dotnet publish -c Release -r <rid> -p:PublishAot=true,盯着输出里的IL警告条数。数量超过两位数,说明还有一堆动态路径没处理。 - 检查核心依赖库的版本说明,看它们是否声明了 AOT 兼容或自带源生成器。没有的,先升级或替换。
- 确认产品里没有"运行时下载新托管程序集"这类需求。有的话,AOT 这条路基本要绕道。
- 最后才是看收益:启动时间、内存、体积是否有硬指标要求。收益不明显,就没必要为 AOT 付出迁移成本。
这套清单不能代替完整的兼容测试,但能帮你在动手前快速判断值不值得投入。我在实际使用中的体会是:NTLs 不是必须全盘切换的技术,它更像一条"风险前置"的工程纪律,把编译期能确定的事情做扎实,把运行时的不确定性尽量赶走。只要方向对了,哪怕只是先改掉几处强反射,后续再跑 AOT 的路也会顺畅很多。
最后再留一个小技巧:无论你的项目最终是否启用 AOT,都可以把EmitCompilerGeneratedFiles打开,定期看看生成器到底生成了什么。很多诡异问题的答案,就藏在那些.g.cs文件里。