Mono vs IL2CPP:数据结构在两种 Unity 脚本后端中为什么不能只测一次
系列:C# 与常用数据结构源码剖析 · Unity 实战篇
阅读时间:约 60 分钟
前置知识:C# 到 IL 的编译链、泛型、GC 和 Dictionary/List 内部结构
版本边界:“Mono”和“IL2CPP”在本文中指 Unity Editor/Player 实际提供的脚本后端。Unity 会维护自己的运行时、类库与 GC 组合,不能用上游 Mono 或 CoreCLR 的某个版本直接替代。
一、先纠正概念:IL2CPP 不是“另一个 C# 语言”
Unity 脚本先由 C# 编译器生成托管程序集和 IL。选择 Mono 后端时,运行时加载这些程序集,在允许 JIT 的环境中将方法编译成本机代码,并提供类型、反射、异常和托管内存服务。选择 IL2CPP 时,构建管线会分析托管程序集,将 IL 转换为 C++ 表示,再由目标平台 C++ 工具链编译、链接成 Player。运行时仍需要 IL2CPP 支持库完成类型元数据、GC、异常、反射与互操作。
因此两种后端运行的仍是同一套 C# 语义与大致相同的类库 API,但代码生成时机、可见程序集、泛型实例化、裁剪、调试信息和平台工具链不同。对 List<T> 或 Dictionary<TKey,TValue> 而言,公开功能契约应一致,内联、代码共享、分配时序与机器码却可不同。
“Editor 为 Mono,真机一定 IL2CPP”也不是一个足够精确的模型。后端可用性取决于 Unity 版本和目标平台,Editor 的 Play Mode 又不代表 Player 配置。每份报告都应记录 Unity Editor 精确版本、平台、Scripting Backend、API Compatibility Level、架构、Development/Release、Managed Stripping Level 和关键 Player Settings。
二、从源码到 Player:两条管线的差异
Mono 路径中,C# 编译器已经决定语法降级和 IL,Mono 运行时则在加载、JIT/AOT 或解释策略下执行。具体平台是否允许 JIT 不能仅根据“Mono”名称推断;某些平台限制运行时代码生成,Unity 可使用其他执行策略或不提供该组合。
IL2CPP 路径在构建期承担更多工作:收集程序集,做托管链接/裁剪,分析必要类型与泛型实例,生成 C++,再使用 Xcode、Clang、MSVC 或目标平台工具链编译。这带来更长的构建时间、更多平台相关产物和更复杂的符号化,同时也让发布平台无需在玩家设备上 JIT 业务方法。
“AOT 必然比 JIT 快”与“JIT 必然优化更好”都不是可用结论。JIT 可根据运行硬件和信息做特化,AOT 可使用成熟原生工具链并移除首次 JIT 成本;但 Unity Mono、IL2CPP、CoreCLR 与不同 C++ 编译器的优化集并不相同。必须以具体方法、构建配置和目标设备数据回答。
三、数据结构语义应相同,不要依赖私有布局
Dictionary<TKey,TValue> 的键唯一性、List<T> 的顺序与 Queue<T> 的 FIFO 是公开契约,应在两种后端上保持。但内部字段名、容量增长、枚举器版本号、哈希随机化和机器对齐不是应用契约。Unity 随附的 BCL 实现也不必与任意 dotnet/runtime tag 完全相同。
不要反射 Dictionary 的 _entries 生成存档,不要依赖某次 Editor 中观察到的枚举顺序,不要用 Unsafe 猜测结构体布局后直接跨平台序列化。如果网络回放或存档需要稳定顺序,应在边界显式排序;如果需要固定二进制格式,应逐字段定义字节序、宽度和版本。
同样的大 O 复杂度也不保证同样常数。同一个 List 循环在两条管线中可以有不同内联、边界检查消除和指令布局;同一个 Dictionary 查找的 comparer 调用可以有不同的虚调用消除。性能差异应被当作需测量的实现结果,不是修改容器业务语义的理由。
四、泛型:代码共享、AOT 实例化与裁剪是三个问题
Mono JIT 可在方法首次需要时创建相应机器码,但它也可对某些泛型实例共享代码,并非每个 List<SomeClass> 都必然有完全独立的本机方法。IL2CPP 必须在构建时为可达泛型使用生成足够的代码,同样可使用泛型共享减少代码量。值类型大小、布局与操作不同,往往需要更多特化;引用类型更容易共享表示,但具体规则是实现细节。
反射构造泛型不是一概“IL2CPP 必然 MissingMethodException”。MakeGenericType 可以创建元数据层面的闭合类型,能否成功实例化或调用方法,还取决于必要元数据是否被保留、相应 AOT 代码是否已生成、类型是否被裁剪,以及目标平台支持。异常类型也可因失败点不同而异,不应只记一个名字。
// 反射路径示意:功能必须在 IL2CPP Player 中做集成测试。
Type closed = typeof(Handler<>).MakeGenericType(messageType);
object? instance = Activator.CreateInstance(closed);
link.xml 和 [Preserve] 主要向托管链接器表达保留意图,不应简化成“注册后一定生成任意泛型机器码”。对反射驱动的序列化、DI、网络消息和热更框架,应同时阅读所用 Unity 版本的托管裁剪与 AOT 泛型文档,然后构建真实 IL2CPP Player 运行端到端测试。
4.1 建立泛型覆盖矩阵
不要只在代码中写一个从不执行的 new List<MyType>() 就认为问题永久解决。应列出业务会动态使用的“开放泛型 + 类型参数 + 构造/方法”组合,例如 Serializer<T>.Read、Handler<T>.Invoke、Dictionary<TKey,TValue> 的键值类型,并让 CI 的 IL2CPP 冒烟测试真正执行每条路径。新增消息类型时,测试与保留配置应同步更新。
五、反射、dynamic 和运行时代码生成
Reflection.Emit 要求在运行时构造新 IL 并将其转换为可执行代码,这与 IL2CPP 的静态 AOT 管线不匹配,应视为不可依赖的能力。普通反射查询与调用则可以被 IL2CPP 支持,但前提是相关元数据和代码未被裁剪,且不依赖临时生成新机器码。
dynamic 不能简单写成“IL2CPP 完全不支持”。C# 编译器会将 dynamic 操作降级为调用站点和 runtime binder 协作的代码,能否在某 Unity/IL2CPP 组合中工作取决于操作形状、类型可达性、类库与裁剪。即使能运行,它也容易将缺失成员和类型错误推迟到玩家端。对游戏核心数据结构,明确接口、泛型约束或生成代码通常更容易审查。
表达式树也要区分“构造数据结构”和“动态编译为委托”。查看、遍历或解释表达式树不等于可以在 AOT 环境中任意调用 Compile() 生成机器码。使用 ORM、规则引擎或序列化库时,要核对它选择的具体路径。
六、GC:不要编造一张“Mono 保守、IL2CPP 精确分代压缩”表
原文将 Mono 后端统一写成 Boehm 保守、不分代、不压缩,又将 IL2CPP 统一写成精确、分代、部分压缩。这种对照没有给出 Unity 版本、平台和实现证据,不能作为工程结论。Unity 的 Mono 与 IL2CPP 组合可以共享 Unity 集成的托管 GC 技术基础,具体行为受版本、平台和 Incremental GC 设置等影响,不能从“代码是 C++”推出 GC 就是分代精确实现。
两种后端中,托管 List<T>、数组、字典和装箱值都仍由托管内存系统处理。IL2CPP 生成 C++ 不会自动将这些对象变成栈对象,也不会让 new List<T>() 变成无 GC 操作。优化可以依赖的是更一般的原则:热循环中可避免的持续分配会增加内存管理工作和帧时风险,应用 Profiler 与目标 Player 验证。
“Editor 内存增长通常是保守 GC 误认指针”也是错误的快速归因。更常见的调查对象还包括静态集合、事件订阅、UnityEngine.Object 原生端生命周期、资源引用、缓存上限、Profiler/Editor 自身开销与碎片。要用 Memory Profiler 比较快照和根路径,而不是根据后端名称猜测。
七、结构体、装箱与内存布局
“用 struct 替代 class 就零 GC”忽略了存储位置。结构体作为数组或 List 元素时内联存储,可减少逐元素对象;但 List 和底层数组仍是托管对象。结构体转换到 object 或某些接口路径可装箱,大结构体在按值传递、List 索引读取和 Dictionary value 返回时又可能复制更多字节。
结构体的布局还受字段类型、对齐、架构与明确 StructLayout 影响。不应把 Editor x64 中用 Unsafe.SizeOf<T>() 得到的数字当成所有 IL2CPP 平台 ABI 与序列化契约。原生互操作结构需要同时核对 C# 声明、生成的 C++/平台 ABI、布局和 marshaling 规则。
对数据结构做优化时,要同时测量元素尺寸、容器容量、遍历和修改方式。将一个带大量冷字段的 class 机械换成巨大 struct,可能降低局部性并放大复制。更好的方案可能是将热数据拆到紧凑结构,让冷数据保持间接引用。
八、异常、调试和发布差异
异常在两种后端上都是功能机制,但抛出、展开栈、保留调试符号与生成堆栈文本的成本可不同。应当用异常表示非预期失败,不用它代替字典未命中、队列为空或数值越界等高频正常分支。TryGetValue、TryDequeue 与显式边界检查能更清楚表达这些语义。
IL2CPP 崩溃或托管异常要得到可读 C# 调用栈,需要保存与上传对应构建的符号和元数据产物。只保留 C++ 地址而不保留 build ID、符号和版本映射,会让线上数据结构越界问题难以还原。性能埋点也应使用稳定 Marker,让 Mono 和 IL2CPP 报告可对齐,而不是依赖后端私有方法名。
九、一个可复现的后端对照实验
选择一个真实子系统,例如每帧对 20,000 个实体做列表扫描、按 ID 查字典并将结果写入复用列表。数字 20,000 只是实验输入,不是性能结论。两个 Player 必须使用同一 Unity 版本、目标平台、架构、数据、随机种子、画质、帧率策略与功能代码,只改变 Scripting Backend。如果平台不同时提供两种后端,不能把两台不同设备的差异归因于后端。
场景应包含预热、固定采样窗口与多次重复。记录子系统 Marker 的 CPU 时间分布,每帧 GC.Alloc,整个 Player 的驻留内存,GC 事件,包体/代码尺寸与构建时间。对移动设备还要控制温度和频率,避免后测的构建因降频天然吃亏。
性能实验前先做功能哈希或断言:最终实体数、查询命中数、排序顺序与关键数值必须一致。如果某个后端因裁剪少执行了一条反射路径,“更快”可能只是功能丢失。只有功能等价后,时间和分配对比才有意义。
十、发布前的交叉后端检查表
- 所有反射读取的类型、字段、属性和构造函是否在 IL2CPP 裁剪后仍存在?
- 动态闭合的泛型组合是否在真实 Player 中被执行,而不是只在 Editor 中被测试?
- 序列化器、DI、热更和规则库是否依赖 Reflection.Emit、表达式动态编译或运行时代码生成?
- 是否把
link.xml当成解决所有 AOT 泛型问题的万能开关?保留元数据与生成可执行代码是否已分开验证? - 存档、回放和网络协议是否依赖 Dictionary 枚举顺序、哈希码或私有内存布局?
- 结构体的原生互操作布局是否在每个目标 ABI 上验证,是否把 x64 Editor 尺寸当成通用事实?
- 主线程分配、GC 尖峰和内存驻留是否在目标 Player 上采集,而不是从假设的 GC 对照表推导?
- 异常、崩溃和 Profiler Marker 是否有可对齐的符号、build ID 和稳定命名?
- 每个后端的功能回归是否包含非法输入、空集合、峰值容量、重复键和动态注册?
- 性能结论是否记录了 Unity 版本、后端、平台、架构、构建配置、输入和原始数据?
十一、选择后端时真正要权衡什么
后端选择首先受目标平台支持、商店规则、架构和项目发布策略约束。在可选范围内,Mono 可为迭代、托管调试或特定平台工作流提供价值;IL2CPP 用更长的构建、原生符号管理和 AOT/裁剪约束,换取符合目标平台的静态发布产物。这不是“开发快 vs 运行快”的单轴比较。
同一个项目完全可以把 Mono Editor/Player 当作快速反馈环,同时在 CI 中持续构建 IL2CPP 冒烟包。如果等到上线前才第一次执行 IL2CPP 的反射、泛型与序列化路径,就把构建管线差异变成了发布风险。交叉后端测试的意义不是证明它们速度相同,而是证明功能契约一致,并尽早发现各自的失败模式。
十二、本篇结论
Mono 和 IL2CPP 的核心差异是托管 IL 到可执行代码的时机与管线:前者由 Mono 运行时在目标环境提供执行服务,后者在构建期转为 C++ 并编译为原生 Player。这个差异会影响泛型实例、裁剪、反射、运行时代码生成、调试与机器码,却不允许业务代码依赖两种不同的 List/Dictionary 功能语义。
不应用“Mono=Boehm,IL2CPP=精确分代 GC”、“dynamic 完全不可用”、“link.xml 能修复所有泛型”或“AOT 必然更快”作为决策。这些说法忽略 Unity 版本、平台、类库、裁剪与实际代码路径。更可靠的做法是固定构建坐标,为反射和泛型建覆盖矩阵,保存 IL2CPP 符号,并用功能等价的目标 Player 数据比较性能。
对数据结构来说,最值得跨后端保持的是正确建模:不变键、明确顺序、可控容量、低分配生命周期和不依赖私有布局的存档协议。在这个基础上,再分别测量 Mono 和 IL2CPP 的代码生成结果,才能得到可执行的优化结论。
建议实验:对同一 List 扫描 + Dictionary 查找子系统构建功能等价的 Mono/IL2CPP Player,固定设备、输入和 Marker,同时比较 CPU 分布、分配、GC、驻留内存、构建时间与代码尺寸。
下一篇:List 和 Dictionary 在 Unity 中的性能调优实战。

575

被折叠的 条评论
为什么被折叠?



