为何在IL2CPP下对Span使用foreach会产生装箱

解读

国内Unity项目普遍用IL2CPP出包(iOS强制、Android为了安全与64位合规)。
面试官问此题,核心想验证两点

  1. 你是否真的在IL2CPP真机上跑过性能敏感代码;
  2. 你对“零GC”这条手游红线有没有方法论,而不是只会背“Span是值类型”这种八股。
    答不出“为什么foreach会装箱”,基本会被判定“没踩过真机坑”,直接降档。

知识点

  1. Span<T>本身是ref struct,栈上值类型,GetEnumerator()返回的也是值类型Span<T>.Enumerator
  2. foreach的C#语法糖会被编译器翻译成
    E e = ((IEnumerable)span).GetEnumerator();   // 关键行
    try { while (((IEnumerator)e).MoveNext()) … }
    
  3. IL2CPP的泛型共享规则
    • 对值类型T,每个T都会生成一份独享的native代码(避免代码膨胀)。
    • 接口调用一律走object虚方法槽,IL2CPP必须把值类型Enumerator当成IEnumerator接口来用,于是在共享边界插入box指令
  4. Mono后端里JIT能即时生成非共享的特化代码,直接调用值类型方法,不装箱;而IL2CPP是AOT,为了二进制体积与编译速度,牺牲了这一路径,导致装箱无法避免。
  5. 结果:真机Profiler里看到每次foreach都会new一个System.Object,几十字节GC.Alloc,在战斗帧里足以触发GC.Collect,造成卡顿。

答案

在IL2CPP下,foreach依赖IEnumerable接口,而Span<T>.Enumerator是值类型;IL2CPP的泛型共享策略要求把值类型当接口使用,必须在运行期装箱成IEnumerator。Mono后端JIT可为具体T生成非共享代码,直接调用值类型方法故不装箱;IL2CPP是AOT,为了代码体积放弃特化,导致foreach Span<T>必然装箱
规避方案

  • 手写for循环;
  • 或while + MoveNext/Current;
  • 或封装成ref struct自定义可枚举器并禁止接口转换。
    只要不用foreach语法糖,就能保证零GC

拓展思考

  1. Unity 2021.2+增加了“Fast Span Enumerator”内部hack,在部分内置模块里把Span<T>.Enumerator直接内联成for,但对开发者写的业务代码仍不生效,所以面试时别拿版本号当挡箭牌。
  2. 如果题目再追问“List<T>的foreach为什么不装箱”,要答出:List<T>.Enumerator实现了IEnumerator<T>接口,但它是class,本身就是引用类型,无需装箱;而Span的Enumerator是struct,接口调用才触发装箱,这是值类型与接口交互的经典陷阱。
  3. 国内大厂性能审查清单已把“IL2CPP下foreach Span/Memory”列为强制扫描规则,SonarQube+自定义Roslyn Analyzer流水线会直接阻断合并;面试时提到“我们CI加了Roslyn规则检测foreach Span”是加分项,表明你不仅懂原理,还能把规范落地到工程化流程。