用匿名方法实现一个带捕获列表的闭包并说明其IL生成
解读
国内Unity面试中,**“闭包+IL”**是区分“只会写业务”与“能深入引擎”的试金石。
主考官想确认三件事:
- 你是否真的用匿名方法写过带捕获列表的闭包(不是lambda语法糖);
- 能否把C#编译器生成的隐藏类与字段映射到Unity生命周期(如Update、协程、热更新);
- 能否通过IL预判GC。Alloc与装箱,从而指导AssetBundle或Lua热更框架的内存策略。
答得越贴近Unity真机 profiling 场景,分数越高。
知识点
-
匿名方法(delegate {})在C# 2.0引入,编译器始终生成私有嵌套类+实例字段来保存捕获变量;
-
捕获列表=所有被匿名方法读取或写入的外部局部变量+this引用;
-
IL层面:
- 生成类,每个捕获变量对应一个public字段;
<>c__DisplayClassX - 原方法内new该对象,把局部变量拷贝到字段;
- 匿名方法被编译成实例方法(非静态),Target字段指向该对象,形成闭包;
- 生成类,每个捕获变量对应一个public字段;
-
Unity中若把该委托存成类字段或传入StartCoroutine,隐藏类生命周期会被拉长→GC延迟;
-
ILSpy/ Rider IL Viewer 查看时,重点关注ldfld与stfld指令,可快速定位捕获变量是否被意外装箱(如捕获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模式):
- 编译器生成隐藏类
.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使用
...
}
}
- 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后的代码体积。
拓展思考
- il2cpp下,隐藏类会被翻译成C++类,字段布局与C#完全一致,但虚方法表消失,调用委托会走il2cpp::vm::RuntimeDelegate::Invoke,比mono下慢约15%;
- 在HybridCLR热更场景,闭包隐藏类必须放在AOT主包,否则热更层无法反序列化已捕获的字段;
- Unity 2022+的Burst 1.8仍不支持含闭包的委托,若JobSystem需要捕获数据,必须手动拆成struct+NativeArray;
- 面试加分项:现场用对比“匿名方法闭包”与“lambda+局部函数”两种写法,实测内存差异并给出帧率报告,可直接打动主考官。
Profiler.GetRuntimeMemorySizeLong
事件与多播委托在IL层面有何区别
解读
国内Unity面试里,面试官抛出“IL层面”四个字,通常不是让你背IL指令表,而是验证两件事:
- 你是否真的用ILDasm/ILSpy看过编译产物;
- 你是否理解事件语法糖如何被C#编译器翻译成私有委托字段+add_/remove_访问器,从而在多播委托的基础上再套一层封装。
答不到“私有字段+访问器”这一层,基本会被判定为“只用过+=,没看过底层”。
知识点
-
多播委托(multicast delegate)
- 编译后就是class类型,继承自System.MulticastDelegate,内部维护_invocationList数组。
- 支持+=、-=,本质是Delegate.Combine/Remove静态方法。
-
事件(event)
- 只是C#语法标记;编译器在包含类型里生成:
a) 一个私有委托字段(名字与事件相同,但前缀);
b) 一对public add_xxx/remove_xxx方法,标记为specialname;
c) 外部代码的+=/-=被重定向到这对方法,无法从外部直接赋值或调用Invoke,从而做到“订阅隔离”。
- 只是C#语法标记;编译器在包含类型里生成:
-
IL差异速记
- 多播委托:ldfld直接拿到字段,callvirt Invoke。
- 事件:ldarg.0 → call add_xxx/remove_xxx,字段对外不可见;Invoke只能在类内部call。
答案
打开ILDasm可以看到:
-
声明一个public多播委托
public Action MyDelegate;
IL只生成字段.field public class [mscorlib]System.Action MyDelegate,外部可ldfld直接访问并invoke。 -
声明一个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. 能把框架层做成“无闭包”规范。
知识点
-
闭包本质:编译器生成
<>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 ,项目启动时一次性
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>();
-
-
框架固化:
- 在 热更层 提供
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 出包前做 自动化回归测试。