NativeAOT 带来的启动速度和部署简化非常有吸引力,但只要项目稍微复杂一点,产物体积就会迅速膨胀。很多时候我们以为问题出在运行时,其实真正占空间的是自己引入的依赖、反射路径和默认调试符号。做体积优化之前,先把“哪些东西必须存在”讲清楚,比盲目删配置更重要。
先建立可比较的基线
我通常会先固定三样东西:目标 RID、编译配置以及是否包含调试信息。没有基线,后续每一轮优化都很难判断到底是谁起作用。建议把发布命令明确写下来,并保留几次构建产物的大小记录。
dotnet publish -c Release -r win-x64 \
-p:PublishAot=true \
-p:StripSymbols=true \
-p:DebuggerSupport=false
如果项目要跨平台发布,就不要把不同平台的体积直接放在一起比较。Windows、Linux 和 macOS 的链接器行为不同,NativeAOT 产物大小本来就不会完全一致。
先收敛依赖,再谈参数
我踩过最典型的坑,是为了图方便直接把整个工具库、日志库和 JSON 扩展包一起带进去。NativeAOT 对依赖的“显性程度”很敏感,引用越宽,编译器越难安全裁剪。体积优化的第一优先级通常不是研究神秘 MSBuild 参数,而是审视这些问题:
- 有没有只是为了一个小功能却引入整包的情况。
- 是否依赖了大量反射、动态代理或运行时扫描。
- 能否把通用库拆成更小、更明确的项目边界。
对小型命令行工具来说,依赖收缩常常比任何编译开关都更有效。把“一把梭”的通用基础设施换成针对场景的最小实现,收益非常直接。
反射和序列化是隐形体积来源
NativeAOT 最大的不同,是它不会默认帮你保留所有运行时路径。只要代码依赖反射推断、配置绑定或基于约定的序列化,编译器就倾向于保守处理,结果就是更多元数据被保留下来。我的经验是,能改成源码生成就尽量改成源码生成。
如果你无法说明某段运行时发现逻辑在 AOT 下如何被证明安全,那它大概率也会让体积和可预测性一起变差。
例如 `System.Text.Json` 的源生成器通常是最值得优先落地的一步。它不只减少运行时反射开销,也让链接器更容易裁掉不必要的类型信息。
几个真正值得关注的发布选项
在依赖边界清晰之后,再去看发布参数才有意义。我这次保留下来的选项不多,但每个都很明确:
- StripSymbols:去掉默认符号,立刻就能看到文件体积下降。
- DebuggerSupport=false:适合完全离线、部署后不做远程调试的工具。
- InvariantGlobalization=true:如果应用不依赖复杂本地化,收益通常稳定。
<PropertyGroup>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<DebuggerSupport>false</DebuggerSupport>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
这些选项不是越多越好。尤其 `InvariantGlobalization`,如果你的程序要处理文化相关格式化、排序或多语言输入,先写测试再决定,不要为了省一点体积换来功能回退。
把优化沉淀成团队流程
真正有价值的不是某次把可执行文件压小了 3MB,而是让团队以后每加一个依赖、每开一个功能,都能预估对发布体积的影响。我后来把发布命令、产物大小和关键参数都纳入 CI 产物记录,这样回归时很快就能知道是哪次改动把二进制带大了。
如果只能记住一条经验,我会选这句:先把程序变简单,再让编译器替你裁剪。NativeAOT 很适合奖励边界清晰、动态行为少的代码结构。