用匿名方法实现一个带捕获列表的闭包并说明其IL生成
解读
国内Unity面试中,**“闭包+IL”**是区分“只会写业务”与“能深入引擎”的试金石。
主考官想确认三件事:
- 你是否真的用匿名方法写过带捕获列表的闭包(不是lambda语法糖);
- 能否把C#编译器生成的隐藏类与字段映射到Unity生命周期(如Update、协程、热更新);
- 能否通过IL预判GC.Alloc与装箱,从而指导AssetBundle或Lua热更框架的内存策略。
答得越贴近Unity真机 profiling 场景,分数越高。
知识点
- 匿名方法(delegate {})在C# 2.0引入,编译器始终生成私有嵌套类+实例字段来保存捕获变量;
- 捕获列表=所有被匿名方法读取或写入的外部局部变量+this引用;
- IL层面:
- 生成
<>c__DisplayClassX类,每个捕获变量对应一个public字段; - 原方法内new该对象,把局部变量拷贝到字段;
- 匿名方法被编译成实例方法(非static),Target字段指向该对象,形成闭包;
- 生成
- 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;
- 面试加分项:现场用
Profiler.GetRuntimeMemorySizeLong对比“匿名方法闭包”与“lambda+局部函数”两种写法,实测内存差异并给出帧率报告,可直接打动主考官。