背景:16.6ms 里没有 GC 的位置
AAA 手游和端游的帧预算是死线,不是建议。60fps 意味着每帧 16.6ms,其中渲染、物理、逻辑、音频都在争这块蛋糕。托管 GC(哪怕是 Unity 的增量式 GC)一旦触发,轻则 0.5ms,重则几十毫秒,帧率曲线直接出现刺峰。
问题是,GC 压力往往不来自"大对象",而来自主循环里一行看起来无害的代码:
// 战斗系统 Update(),每帧调用voidUpdate(){_enemies.RemoveAll(e=>e.HP<=0);// ← 每帧分配闭包}这一行,在手机端每秒产生 60 个短命对象。量不大,但不停地戳 Gen0,配合 Unity 的分代 GC,迟早在战斗高峰触发一次你不想要的回收。
为什么匿名委托会分配:编译器的真相
把上面那行代码扔进 SharpLab,你会看到:
// 原始代码_enemies.RemoveAll(e=>e.HP<=0);// 编译器实际生成(没有捕获外部变量时)// ✅ 无捕获 = 编译器生成静态缓存字段,只分配一次privatestaticPredicate<Enemy><>9__1_0;if(<>9__1_0==null)<>9__1_0=newPredicate<Enemy>(<Update>b__1_0);_enemies.RemoveAll(<>9__1_0);纯无捕获的 lambda,编译器会帮你缓存,只分配一次。真正的麻烦在捕获了外部变量的场景:
floatdamageThreshold=_player.AttackPower*0.5f;// 局部变量_enemies.RemoveAll(e=>e.HP<damageThreshold);// ← 捕获了!// 编译器生成:varclosure=new<>c__DisplayClass3_0();// 每帧 new,堆分配closure.damageThreshold=damageThreshold;_enemies.RemoveAll(newPredicate<Enemy>(closure.<Update>b__0));只要捕获了局部变量,编译器就必须在堆上分配闭包对象。没有任何绕过方式,除非你改变代码结构。
定量:用 Unity Profiler 看见它
先建基线:
// 测试场景:1000 个敌人,60fps,战斗持续 10 秒[MemoryDiagnoser]publicclassEnemySystemBenchmark{privateList<Enemy>_enemies;privatefloat_threshold;// 方案 A:捕获局部变量[Benchmark(Baseline=true)]publicvoidCapturingLambda(){floatt=_threshold;_enemies.RemoveAll(e=>e.HP<t);// 每次调用分配闭包}// 方案 B:无捕获 static lambda(C# 9)[Benchmark]publicvoidStaticLambda(){_enemies.RemoveAll(statice=>e.HP<=0);// 0 分配}// 方案 C:缓存委托到字段privatestaticreadonlyPredicate<Enemy>_isDead=statice=>e.HP<=0;[Benchmark]publicvoidCachedDelegate(){_enemies.RemoveAll(_isDead);// 0 分配}}BenchmarkDotNet 结果(Mono on Android,Pixel 7 Pro):
| 方案 | Mean | Alloc/帧 | 10s 总分配 |
|---|---|---|---|
| CapturingLambda | 312 ns | 32 B | ~19 KB |
| StaticLambda | 278 ns | 0 | 0 |
| CachedDelegate | 275 ns | 0 | 0 |
19KB 看着不多,但这只是一个系统的一个调用点。战斗系统里有十几处类似模式,加起来轻松超过 1MB/s 的 Gen0 压力。
C# 9staticlambda:最低成本的意图锁定
// static 修饰符在编译期禁止捕获// 捕获任何外部状态直接报编译错误,CI 就能拦截_enemies.RemoveAll(statice=>e.HP<=0);// ❌ 编译错误:CS8821 — 静态匿名函数中不能使用局部变量floatthreshold=0;_enemies.RemoveAll(statice=>e.HP<threshold);static lambda不改变运行时行为,但把"我不想引入捕获"变成了编译期约束。对于大型团队来说,这是代码审查的替代品——接手代码的初级工程师加一行捕获,CI 直接打回,不需要等 Profiler 告警。
需要传入外部状态时:让状态流动,而不是被捕获
游戏逻辑里状态无处不在,大多数情况不可能用无捕获 lambda。正确的做法是把状态变成显式参数,而不是隐式捕获。
模式一:委托缓存到实例字段
publicsealedclassEnemySystem:ISystem{// 实例级委托:构造时分配一次,之后每帧复用privatereadonlyPredicate<Enemy>_isDeadPredicate;// 状态通过 this 访问,不是通过闭包捕获局部变量privatefloat_damageThreshold;publicEnemySystem(){_isDeadPredicate=IsEnemyDead;// 方法组,绑定到 this,分配一次}// 实例方法:通过字段读状态,不是通过闭包privateboolIsEnemyDead(Enemye)=>e.HP<=0;publicvoidUpdate(floatthreshold){_damageThreshold=threshold;_enemies.RemoveAll(_isDeadPredicate);// 0 分配,复用字段}}关键点:委托生命周期和状态生命周期一致。_isDeadPredicate活得和EnemySystem一样长,只在系统创建时分配一次。
模式二:带 state 参数的 API(.NET 6+ / Unity 2022+)
很多高性能 API 提供带 state 参数的重载,专门为了避免闭包而设计:
// 自定义高性能遍历接口:state 参数模式publicstaticclassSpanExtensions{publicstaticintRemoveAll<T,TState>(thisSpan<T>span,TStatestate,Func<T,TState,bool>predicate)// state 作为参数传入,不是捕获{intwriteIdx=0;for(inti=0;i<span.Length;i++){if(!predicate(span[i],state))span[writeIdx++]=span[i];}returnwriteIdx;}}// 调用:static lambda + state,完全零分配floatthreshold=player.AttackPower*0.5f;intremaining=enemySpan.RemoveAll(threshold,static(e,t)=>e.HP>=t);Unity 自带的很多 API(Array.FindAll、List.FindAll)没有 state 参数,这是历史包袱。但如果你在写内部工具库,设计新接口时应该默认带 state 参数。
进阶:DOTS / Burst Compiler 场景
如果你们在用 Unity DOTS,委托本身就是个反模式——Burst Compiler 不支持托管委托,需要用函数指针:
// Burst 兼容:非托管函数指针[BurstCompile]publicunsafestructEnemyFilterJob:IJobParallelFor{[ReadOnly]publicNativeArray<EnemyData>Enemies;publicNativeArray<bool>Results;// 函数指针:完全绕开委托机制,无堆分配,无虚表查找,Burst 可内联[NativeDisableUnsafePtrRestriction]publicFunctionPointer<EnemyFilterDelegate>FilterFn;publicvoidExecute(intindex){Results[index]=FilterFn.Invoke(Enemies[index]);}}// 调用侧unsafevoidScheduleFilter(){varfn=BurstCompiler.CompileFunctionPointer<EnemyFilterDelegate>(FilterByHP);varjob=newEnemyFilterJob{Enemies=_enemyData,Results=_results,FilterFn=fn// 编译时绑定,运行时零开销};job.Schedule(_enemyData.Length,64);}[BurstCompile]staticboolFilterByHP(EnemyDatae)=>e.HP>0;DOTS 场景下,这不只是"减少 GC",而是"让 Burst 真正介入"——只有静态方法 + 函数指针,Burst 才能 SIMD 化你的逻辑。
事件系统:最容易被忽视的分配源
游戏事件系统是匿名委托重灾区,因为订阅通常写得很随意:
// ❌ 常见写法:每次都分配新委托对象voidOnEnable(){EventBus.OnEnemyDied+=(enemy)=>UpdateKillCount(enemy);// 新对象!EventBus.OnEnemyDied+=(enemy)=>{/* 另一个闭包 */};}两个问题:一是每次OnEnable都在堆上新建委托对象;二是无法正确Unsubscribe(+=新 lambda 和-=新 lambda 是不同对象,取消订阅无效,内存泄漏)。
正确写法:
publicsealedclassUIKillCounter:MonoBehaviour{// 委托作为字段,明确生命周期,可以正确取消订阅privateAction<Enemy>_onEnemyDiedHandler;privatevoidAwake(){// 只分配一次_onEnemyDiedHandler=OnEnemyDied;}privatevoidOnEnable()=>EventBus.OnEnemyDied+=_onEnemyDiedHandler;privatevoidOnDisable()=>EventBus.OnEnemyDied-=_onEnemyDiedHandler;// 正确取消privatevoidOnEnemyDied(Enemyenemy){_killCount++;RefreshUI();}}这个模式同时解决了三个问题:零重复分配、正确取消订阅、可测试(方法可以被单独单测)。
全局决策树
这个委托/lambda 会在高频路径(Update/FixedUpdate/热循环)里调用吗? │ ├─ 否(一次性初始化、低频事件) │ └─ 随便写,可读性优先 │ └─ 是 │ ├─ 需要捕获外部状态吗? │ │ │ ├─ 否 → static lambda 或静态方法组 │ │ 编译器缓存,0 分配 │ │ │ └─ 是 → 状态是否与对象生命周期一致? │ │ │ ├─ 是 → 委托缓存为实例字段(构造时一次分配) │ │ │ └─ 否(每帧不同值) │ │ │ ├─ API 支持 state 参数?→ static lambda + state │ │ │ └─ 否 → 重构 API 加 state 参数 │ 极限性能(DOTS/Burst)→ 函数指针 │ └─ 是 Unity DOTS/Burst 路径? └─ FunctionPointer<T>,BurstCompile 静态方法落地检查清单
生产项目里推行这套模式,可以从以下几点入手:
开启 Roslyn Analyzer 检测捕获:可以用社区的
Meziantou.Analyzer或自写规则,在 CI 阶段对高频路径的捕获 lambda 报警。Unity Memory Profiler 建基线:在战斗开始后 30 秒截一次托管堆快照,找
<>c__DisplayClass开头的类型,排序 by count,前五名优先处理。新接口规范:内部工具库的所有接受委托参数的 API,一律提供带
TState state的重载版本,这是架构层面的零成本预防。事件系统规范:订阅写入
Awake/构造函数,取消写入OnDisable/Dispose,委托字段化,代码审查 checklist 里加这一条。
GC 优化不是非常规手段,是大厂手游的标准工程实践。帧率平滑不靠运气,靠的是每一个 allocation site 都有人盯。