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

解读

国内项目普遍使用 HybridCLR(huatuo)ILRuntimexLua 做热更,这些方案把热更代码跑在 托管层(虚拟机) 上。闭包在 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.Actionnewobj,且调用栈停在 热更层il2cpp_vm_xxxilruntime_xxx),即可确认是闭包 GC。
  4. 零 GC 委托三板斧
    • 静态委托缓存:把 lambda 写成静态函数,变量通过参数透传。
    • 对象池 + 接口:把回调写成实现 IEvent 接口的类,从池中取出复用。
    • ref struct + Burst(仅限 Editor 或 SBP 编译期):用 FunctionPointer 把委托转成无 GC 函数指针,但热更层无法直接调用,需 提前注册 到原生层。
  5. 框架级规范
    • 禁止在 Update/Timer/Network 回调里写 lambda
    • 提供全局 DelegateCache<T>,项目启动时一次性 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 出包前做 自动化回归测试