如何避免闭包在Unity热更新中产生的GC Alloc
解读
国内项目普遍使用 HybridCLR(huatuo)、ILRuntime 或 xLua 做热更,这些方案把热更代码跑在 托管层(虚拟机) 上。闭包在 C# 编译后会生成 匿名类实例,每次 new 都会带来 堆分配,而虚拟机无法像 Mono/IL2CPP 那样做 栈上逃逸分析,于是 GC Alloc 会被放大;一旦每帧触发,** Profiler 里立刻飙红**,在低端安卓机上直接掉帧。面试时,考官想看你是否 1. 能定位闭包来源 2. 能用零 GC 写法替换 3. 能把框架层做成“无闭包”规范。
知识点
- 闭包本质:编译器生成
<>c__DisplayClass类,把捕获变量变成字段,每次委托 += 都 new 一次。 - 热更虚拟机差异:ILRuntime 使用 C# 反射 + 包装器,HybridCLR 使用 AOT + 解释器,两者都无法 内联 匿名对象,导致 堆分配必然发生。
- Unity Profiler 红帧判定:Deep Profile 下看到
System.Func/System.Action带newobj,且调用栈停在 热更层(il2cpp_vm_xxx或ilruntime_xxx),即可确认是闭包 GC。 - 零 GC 委托三板斧:
- 静态委托缓存:把 lambda 写成静态函数,变量通过参数透传。
- 对象池 + 接口:把回调写成实现
IEvent接口的类,从池中取出复用。 - ref struct + Burst(仅限 Editor 或 SBP 编译期):用
FunctionPointer把委托转成无 GC 函数指针,但热更层无法直接调用,需 提前注册 到原生层。
- 框架级规范:
- 禁止在 Update/Timer/Network 回调里写 lambda;
- 提供全局 DelegateCache<T>,项目启动时一次性
Delegate.CreateDelegate并缓存; - 强制 CodeReview 规则:PR 里只要出现
+= () =>直接打回。
答案
分三步落地:
- 定位:Profiler 里勾选 Deep Profile + Call Stacks,过滤
GC.Alloc,若栈顶在热更 DLL 内且分配对象是<>c__DisplayClass,即可确认闭包。 - 改写:
- 把捕获变量拆成参数,用 静态函数 替换 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>();
- 把捕获变量拆成参数,用 静态函数 替换 lambda:
- 框架固化:
- 在 热更层 提供
DelegatePool.Get<T>,启动时一次性创建 泛型静态委托字典; - 在 Timer、Event、Network、UI 消息 四个高频系统里 强制使用 DelegatePool,并在 Editor 下加断言:若检测到
newobj <>c__DisplayClass,直接抛异常,保证 出包前 0 警告 0 红帧。
- 在 热更层 提供
拓展思考
- HybridCLR 0 分配委托的未来:官方已支持 AOT 泛型委托共享,可把常用
Action<T>提前在 AOT 层注册,热更里直接Delegate.CreateDelegate零分配;但 捕获变量 仍需走解释器,所以 框架层必须完全无捕获。 - ILRuntime 的 “DelegateAdapter” 池:ILRuntime 在 v2.0 提供了 DelegateAdapter 复用机制,但默认只缓存 无参无返回 委托,项目需在 启动阶段 把高频签名一次性注册进去,否则第一次调用仍会 new。
- 与 Burst 结合做战斗层:战斗逻辑若放在 Assembly-CSharp.dll 里,可用
FunctionPointer<Delegate>+ Burst 编译成无 GC 函数,热更层通过 接口 ID + 参数结构体 调用,既享受 零 GC 又保留 热更能力,但调试困难,需 Scriptable Build Pipeline 出包前做 自动化回归测试。