11-02-Unity-Mono-vs-IL2CPP-两种脚本后端的数据结构行为差异

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>.ReadHandler<T>.InvokeDictionary<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,可能降低局部性并放大复制。更好的方案可能是将热数据拆到紧凑结构,让冷数据保持间接引用。

八、异常、调试和发布差异

异常在两种后端上都是功能机制,但抛出、展开栈、保留调试符号与生成堆栈文本的成本可不同。应当用异常表示非预期失败,不用它代替字典未命中、队列为空或数值越界等高频正常分支。TryGetValueTryDequeue 与显式边界检查能更清楚表达这些语义。

IL2CPP 崩溃或托管异常要得到可读 C# 调用栈,需要保存与上传对应构建的符号和元数据产物。只保留 C++ 地址而不保留 build ID、符号和版本映射,会让线上数据结构越界问题难以还原。性能埋点也应使用稳定 Marker,让 Mono 和 IL2CPP 报告可对齐,而不是依赖后端私有方法名。

九、一个可复现的后端对照实验

选择一个真实子系统,例如每帧对 20,000 个实体做列表扫描、按 ID 查字典并将结果写入复用列表。数字 20,000 只是实验输入,不是性能结论。两个 Player 必须使用同一 Unity 版本、目标平台、架构、数据、随机种子、画质、帧率策略与功能代码,只改变 Scripting Backend。如果平台不同时提供两种后端,不能把两台不同设备的差异归因于后端。

场景应包含预热、固定采样窗口与多次重复。记录子系统 Marker 的 CPU 时间分布,每帧 GC.Alloc,整个 Player 的驻留内存,GC 事件,包体/代码尺寸与构建时间。对移动设备还要控制温度和频率,避免后测的构建因降频天然吃亏。

性能实验前先做功能哈希或断言:最终实体数、查询命中数、排序顺序与关键数值必须一致。如果某个后端因裁剪少执行了一条反射路径,“更快”可能只是功能丢失。只有功能等价后,时间和分配对比才有意义。

十、发布前的交叉后端检查表

  1. 所有反射读取的类型、字段、属性和构造函是否在 IL2CPP 裁剪后仍存在?
  2. 动态闭合的泛型组合是否在真实 Player 中被执行,而不是只在 Editor 中被测试?
  3. 序列化器、DI、热更和规则库是否依赖 Reflection.Emit、表达式动态编译或运行时代码生成?
  4. 是否把 link.xml 当成解决所有 AOT 泛型问题的万能开关?保留元数据与生成可执行代码是否已分开验证?
  5. 存档、回放和网络协议是否依赖 Dictionary 枚举顺序、哈希码或私有内存布局?
  6. 结构体的原生互操作布局是否在每个目标 ABI 上验证,是否把 x64 Editor 尺寸当成通用事实?
  7. 主线程分配、GC 尖峰和内存驻留是否在目标 Player 上采集,而不是从假设的 GC 对照表推导?
  8. 异常、崩溃和 Profiler Marker 是否有可对齐的符号、build ID 和稳定命名?
  9. 每个后端的功能回归是否包含非法输入、空集合、峰值容量、重复键和动态注册?
  10. 性能结论是否记录了 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 中的性能调优实战。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

淡海水

感谢支持 共同进步 好运++

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值