用匿名方法实现一个带捕获列表的闭包并说明其IL生成

解读

国内Unity面试中,**“闭包+IL”**是区分“只会写业务”与“能深入引擎”的试金石。
主考官想确认三件事:

  1. 你是否真的用匿名方法写过带捕获列表的闭包(不是lambda语法糖);
  2. 能否把C#编译器生成的隐藏类字段映射到Unity生命周期(如Update、协程、热更新);
  3. 能否通过IL预判GC.Alloc装箱,从而指导AssetBundle或Lua热更框架的内存策略。

答得越贴近Unity真机 profiling 场景,分数越高。

知识点

  1. 匿名方法(delegate {})在C# 2.0引入,编译器始终生成私有嵌套类+实例字段来保存捕获变量;
  2. 捕获列表=所有被匿名方法读取或写入的外部局部变量+this引用
  3. IL层面:
    • 生成<>c__DisplayClassX类,每个捕获变量对应一个public字段
    • 原方法内new该对象,把局部变量拷贝到字段
    • 匿名方法被编译成实例方法(非static),Target字段指向该对象,形成闭包;
  4. Unity中若把该委托存成类字段或传入StartCoroutine,隐藏类生命周期会被拉长→GC延迟
  5. ILSpy/ Rider IL Viewer 查看时,重点关注ldfldstfld指令,可快速定位捕获变量是否被意外装箱(如捕获int?枚举)。

答案

// 面试时直接写在白板,命名风格贴近Unity
public class EnemySpawner : MonoBehaviour
{
    // 需要热更的刷怪间隔,会被Lua侧随时调整
    private float m_Interval;

    void Start()
    {
        m_Interval = 2f;

        // 匿名方法实现的闭包,捕获列表:m_Interval、this
        System.Action spawnLoop = delegate
        {
            // 访问成员变量+局部参数
            InvokeRepeating("Spawn", 0f, m_Interval);
        };

        // 把闭包传到热更层,Lua侧可择机调用
        HotfixBridge.Register("SpawnLoop", spawnLoop);
    }

    void Spawn() { /* 刷怪逻辑 */ }
}

IL核心片段(Unity 2021.3/.NET Standard 2.1,Release模式):

  1. 编译器生成隐藏类
.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass1_0'
       extends [mscorlib]System.Object
{
  .field public float32 interval
  .field public class EnemySpawner '<>4__this'
  
  // 匿名方法被编译成实例方法
  .method assembly hidebysig instance void 
          '<Start>b__0'() cil managed
  {
    IL_0000:  ldarg.0      // this of display class
    IL_0001:  ldfld        float32 EnemySpawner/'<>c__DisplayClass1_0'::interval
    IL_0006:  stloc.0      // 将interval存为局部变量,供InvokeRepeating使用
    ...
  }
}
  1. Start方法内
IL_0012:  newobj       instance void EnemySpawner/'<>c__DisplayClass1_0'::.ctor()
IL_0017:  stloc.0
IL_0018:  ldloc.0
IL_0019:  ldarg.0      // this
IL_001a:  stfld        class EnemySpawner EnemySpawner/'<>c__DisplayClass1_0'::'<>4__this'
IL_001f:  ldloc.0
IL_0020:  ldarg.0
IL_0021:  ldfld        float32 EnemySpawner::m_Interval
IL_0026:  stfld        float32 EnemySpawner/'<>c__DisplayClass1_0'::interval

结论

  • 每次new隐藏类,堆分配48 B(32-bit)/56 B(64-bit),在Unity真机Profiler中能看到一次GC.Alloc
  • 若把该委托缓存到静态字典,隐藏类随EnemySpawner实例一起释放,可避免重复alloc
  • 如果捕获的是值类型且后续无修改,可手动拆成显式传参+lambda来消灭闭包,降低il2cpp后的代码体积。

拓展思考

  1. il2cpp下,隐藏类会被翻译成C++类,字段布局与C#完全一致,但虚方法表消失,调用委托会走il2cpp::vm::RuntimeDelegate::Invoke,比mono下慢约15%;
  2. HybridCLR热更场景,闭包隐藏类必须放在AOT主包,否则热更层无法反序列化已捕获的字段;
  3. Unity 2022+的Burst 1.8仍不支持含闭包的委托,若JobSystem需要捕获数据,必须手动拆成struct+NativeArray
  4. 面试加分项:现场用Profiler.GetRuntimeMemorySizeLong对比“匿名方法闭包”与“lambda+局部函数”两种写法,实测内存差异并给出帧率报告,可直接打动主考官。