C#委托、事件与闭包

3 阅读8分钟

用匿名方法实现一个带捕获列表的闭包并说明其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层面:

    • 生成类,每个捕获变量对应一个public字段;<>c__DisplayClassX
    • 原方法内new该对象,把局部变量拷贝到字段;
    • 匿名方法被编译成实例方法(非静态),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. 面试加分项:现场用对比“匿名方法闭包”与“lambda+局部函数”两种写法,实测内存差异并给出帧率报告,可直接打动主考官。Profiler.GetRuntimeMemorySizeLong

事件与多播委托在IL层面有何区别

解读

国内Unity面试里,面试官抛出“IL层面”四个字,通常不是让你背IL指令表,而是验证两件事:

  1. 你是否真的用ILDasm/ILSpy看过编译产物;
  2. 你是否理解事件语法糖如何被C#编译器翻译成私有委托字段+add_/remove_访问器,从而在多播委托的基础上再套一层封装。
    答不到“私有字段+访问器”这一层,基本会被判定为“只用过+=,没看过底层”。

知识点

  1. 多播委托(multicast delegate)

    • 编译后就是class类型,继承自System.MulticastDelegate,内部维护_invocationList数组。
    • 支持+=、-=,本质是Delegate.Combine/Remove静态方法。
  2. 事件(event)

    • 只是C#语法标记;编译器在包含类型里生成:
      a) 一个私有委托字段(名字与事件相同,但前缀);
      b) 一对public add_xxx/remove_xxx方法,标记为specialname;
      c) 外部代码的+=/-=被重定向到这对方法,无法从外部直接赋值或调用Invoke,从而做到“订阅隔离”。
  3. IL差异速记

    • 多播委托:ldfld直接拿到字段,callvirt Invoke。
    • 事件:ldarg.0 → call add_xxx/remove_xxx,字段对外不可见;Invoke只能在类内部call。

答案

打开ILDasm可以看到:

  1. 声明一个public多播委托public Action MyDelegate;
    IL只生成字段.field public class [mscorlib]System.Action MyDelegate,外部可ldfld直接访问并invoke。

  2. 声明一个public事件public event Action MyEvent;
    编译器额外生成:

    • .field private class [mscorlib]System.Action '<MyEvent>k__BackingField'
    • .method public specialname instance void add_MyEvent(class [mscorlib]System.Action value)
    • .method public specialname instance void remove_MyEvent(class [mscorlib]System.Action value)
      外部IL代码写obj.MyEvent += Handler;时,编译器自动翻译成callvirt instance void add_MyEvent无法直接触碰底层委托字段,也就无法Invoke或清空,这就是二者在IL层面的核心区别:事件在委托之上加了封装与方法级访问屏障

拓展思考

Unity热更场景(xlua/ILRuntime)经常利用这一差异做“安全事件”:

  • 在基类里把真实委托字段设成private,只暴露add/remove,防止热更脚本意外清空主工程事件。
  • 反射取事件时,若用GetField会返回null,必须用GetAddMethod()才能挂接,面试时可顺带提到,既展示IL细节,也体现代码隔离意识,容易加分。

    如何避免闭包在Unity热更新中产生的GC Alloc

解读

国内项目普遍使用 HybridCLR(huatuo)ILRuntime 或 xLua 做热更,这些方案把热更代码跑在 托管层(虚拟机)  上。闭包在 C# 编译后会生成 匿名类实例,每次 new 都会带来 堆分配,而虚拟机无法像 Mono/IL2CPP 那样做 栈上逃逸分析,于是 GC Alloc 会被放大;一旦每帧触发,** Profiler 里立刻飙红**,在低端安卓机上直接掉帧。面试时,考官想看你是否 1. 能定位闭包来源 2. 能用零 GC 写法替换 3. 能把框架层做成“无闭包”规范

知识点

  1. 闭包本质:编译器生成 <>c__DisplayClass 类,把捕获变量变成字段,每次委托 += 都 new 一次。

  2. 热更虚拟机差异:ILRuntime 使用 C# 反射 + 包装器,HybridCLR 使用 AOT + 解释器,两者都无法 内联 匿名对象,导致 堆分配必然发生

  3. Unity Profiler 红帧判定:Deep Profile 下看到 System.Func/System.Action 带 newobj,且调用栈停在 热更层il2cpp_vm_xxx 或 ilruntime_xxx),即可确认是闭包 GC。

  4. 零 GC 委托三板斧

    • 静态委托缓存:把 lambda 写成静态函数,变量通过参数透传。
    • 对象池 + 接口:把回调写成实现 IEvent 接口的类,从池中取出复用。
    • ref struct + Burst(仅限 Editor 或 SBP 编译期):用 FunctionPointer 把委托转成无 GC 函数指针,但热更层无法直接调用,需 提前注册 到原生层。
  5. 框架级规范

    • 禁止在 Update/Timer/Network 回调里写 lambda
    • 提供全局 DelegateCache ,项目启动时一次性 Delegate.CreateDelegate 并缓存;
    • 强制 CodeReview 规则:PR 里只要出现 += () => 直接打回。

答案

分三步落地:

  1. 定位:Profiler 里勾选 Deep Profile + Call Stacks,过滤 GC.Alloc,若栈顶在热更 DLL 内且分配对象是 <>c__DisplayClass,即可确认闭包。

  2. 改写

    • 把捕获变量拆成参数,用 静态函数 替换 lambda:

      // 原闭包
      int id = player.id;
      timer.Register(1f, () => Send(id));   // 每帧 new 一次
      
      // 零 GC 写法
      private static readonly UnityAction<int> s_Send = Send;   // 缓存委托
      timer.Register(1f, s_Send, player.id);                  // 无捕获,零分配
      
    • 若回调需多参,定义 struct EventArg 并复用:

      public struct DamageArg{ public int attacker; public int damage; }
      private static readonly ObjectPool<DamageArg> s_argPool = new ObjectPool<DamageArg>();
      
  3. 框架固化

    • 在 热更层 提供 DelegatePool.Get<T>,启动时一次性创建 泛型静态委托字典
    • 在 Timer、Event、Network、UI 消息 四个高频系统里 强制使用 DelegatePool,并在 Editor 下加断言:若检测到 newobj <>c__DisplayClass,直接抛异常,保证 出包前 0 警告 0 红帧

拓展思考

  1. HybridCLR 0 分配委托的未来:官方已支持 AOT 泛型委托共享,可把常用 Action<T> 提前在 AOT 层注册,热更里直接 Delegate.CreateDelegate 零分配;但 捕获变量 仍需走解释器,所以 框架层必须完全无捕获
  2. ILRuntime 的 “DelegateAdapter” 池:ILRuntime 在 v2.0 提供了 DelegateAdapter 复用机制,但默认只缓存 无参无返回 委托,项目需在 启动阶段 把高频签名一次性注册进去,否则第一次调用仍会 new。
  3. 与 Burst 结合做战斗层:战斗逻辑若放在 Assembly-CSharp.dll 里,可用 FunctionPointer<Delegate> + Burst 编译成无 GC 函数,热更层通过 接口 ID + 参数结构体 调用,既享受 零 GC 又保留 热更能力,但调试困难,需 Scriptable Build Pipeline 出包前做 自动化回归测试