做 Unity 性能调优这几年我见过太多“渲染没问题、Draw Call 不高、帧率却像心电图一样”的案子。项目在编辑器里跑得飞起一上真机就开始每隔几秒顿一下CPU 主线程上偶尔冒出一个大尖峰翻遍整个渲染管线也找不到原因。这种卡十有八九是 GCGarbage Collection垃圾回收在背后偷帧——它不像渲染开销那样一眼能看见却总在最不该卡的时候给你来一记停顿。《Unity 卡顿·帧率保卫战》系列写到第四篇这次专门把 GC 这件事从原理到实战掰开揉碎讲一遍。GC 是什么C# 这门语言自带托管内存机制你用new创建出来的对象不用手动释放内存回收交给垃圾回收器处理。托管堆上那些不再被引用的对象回收器会在某个时刻统一清扫。问题就出在这个“某个时刻”回收器一启动通常会暂停整个程序的执行扫描所有存活对象、释放垃圾、整理内存这个暂停在手机上可能是几毫秒到几十毫秒。你的逻辑和渲染全被按了暂停键玩家感受到的就是一顿一顿的卡。这篇内容适合所有用 Unity 做游戏或应用的开发者不管 C# 熟练度如何学会找出并消灭 GC 分配帧率稳定性能立刻上一个台阶。1. GC 在 Unity 里到底怎么“卡帧”的很多人以为 GC 卡顿是“内存不够用”导致的其实根源要更微妙。先弄清楚托管堆的工作方式后面所有优化手段才有依据。1.1 托管堆的运作方式分配、存活与回收C# 代码每次执行new都要从托管堆上划一块内存出来。堆不会无限膨胀回收器会在某个阈值或分配失败时介入它先从根集合静态字段、方法栈、寄存器等出发把所有仍然存活的对象标记出来然后把没被标记的内存视为垃圾并回收。整个过程最要命的一点是——在 Unity 的 Mono 和 IL2CPP 后端里回收阶段基本是“停世界”的也就是所有托管线程都得停下来等着。这就解释了为什么“分配本身”不是罪罪在“分配速率 × 回收成本”。你每帧只分配几十 KB看似不多但堆会不断增长每隔几秒就要做一次全量标记扫描。堆里存活对象越多、堆越大单次回收耗时就越长。玩家感知到的是战斗最激烈时突然卡一下或者角色跑动中每 3 秒稳定顿一次。用大白话打个比方托管堆就像一个办公室的垃圾桶回收器是保洁员。如果每个人每分钟都往桶里丢纸团保洁员就得一趟趟来如果他每次来都撞上你正在开重要会议整个会议室都得停下等他打扫完。GC 就是那个“保洁员”你没法不让他来只能尽量减少制造纸团的频率。1.2 停顿、碎片化与“卡顿连锁反应”GC 导致的卡顿有三种典型形态表现各不相同排查方向也完全不同。第一种是单次大停顿。某个系统一次性分配了大量对象比如战斗中瞬间生成 50 个粒子特效和 20 个伤害飘字下一帧 GC 立刻触发一卡就是几十毫秒。这种情况通常伴随明显的帧率尖峰。第二种是频繁小停顿。每帧都分配了少量对象堆的阈值被反复触碰GC 每 1~2 秒跑一次每次几毫秒。看起来单次影响不大但如果你在 60 FPS 下跑5 毫秒的停顿就意味着丢失 1/3 帧玩家感受到的就是“不跟手”“轻微掉帧”。第三种是托管堆碎片化引发的连锁反应。Unity 的 GC 很多时候只做标记清扫不做压缩压缩会进一步增加停顿。当对象大小不一、生命周期乱成一团时即使整体使用量不高空闲内存也被拆成碎片。这时候一个 64 KB 的大分配可能找不到连续空间被迫再触发一次 GC——明明不是真正的内存不足却要反复付出回收代价。严重时堆被撑大后不会马上缩回去低端手机可能在 GC 之外再吃到一次来自操作系统的“内存警告”甚至被杀进程。1.3 编辑器不卡、真机卡硬件差异与测量误区“编辑器里好好的真机一卡一卡”是 GC 问题最常见的报案方式。原因有三个层面桌面 CPU 主频高、缓存大同样的 10 毫秒 GC 停顿在编辑器的 8 核台式机上只占一个零头在手机 1.8 GHz 的小核上可能就是半帧的损失编辑器的堆和真机的堆大小、回收策略不完全一致编辑器默认走 Mono 后端而真机发布通常用 IL2CPP两者 GC 行为有差异。所以别拿编辑器里的 Profiler 数据去判断真机流畅度GC 问题尤其如此。正确的做法是出真机包Build Settings 里勾选 Development Build同时开 Autoconnect Profiler用 USB 连接设备后把 Profile 跑起来。Development Build 本身会比正式包慢一些但定位分配热点足够用了。建议至少加测一次正式包不带 Development 标志的帧率两者对比才能看清真实损耗。2. 第一步把 GC 分配点从代码里揪出来优化 GC 和优化渲染一样先测量再动手不要靠猜。我踩过最大的坑就是凭感觉“觉得”某段代码有分配结果优化了三天Profiler 一测GC Alloc 一点没降。2.1 Profiler 的 GC Alloc 列怎么看Unity 自带的 Profiler 是找 GC 分配最快的手段。打开 Window Analysis Profiler选 CPU Usage 模块在 Hierarchy 模式下找到 “GC Alloc” 列。这一列的单位是字节新版还支持 KB/MB它表示某个方法调用过程中产生的托管内存分配总量。按这一列降序排序分配大户立刻浮出水面。这里有个细节容易看走眼Hierarchy 模式的 GC Alloc 是“含子调用”的累计值。也就是说一个UpdateUI()的 3 KB 分配可能包含它内部调用的string.Format的 2.5 KB 和GetComponent的 0.5 KB。想看清到底哪一行是元凶就点开这个方法展开调用子树或者切到 Raw Hierarchy 模式看 Self GC Alloc方法自身产生的分配。我自己习惯是先按 Total 列锁定大方法再点进去逐层展开找 self 分配高的那一行然后回代码里看那行的写法。CPU Usage Timeline 模块也值得配合看。切换到 Timeline 模式后你能看到 CPU 时间轴上出现“GarbageCollect”之类的标记这些标记的宽度就是 GC 暂停的耗时。如果你发现某个标记反复出现且宽度可观再回到 Hierarchy 模式定位是谁逼出来的分配定位链条就完整了。2.2 用运行时脚本快速判断 GC 频率有时候不方便接 Profiler比如在已经发到渠道的包上排查或者想给测试团队一个快速判断指标。这时候可以写个简单的运行时监测脚本用Profiler.GetMonoUsedSizeLong()采样托管堆使用量观察曲线是否出现“突然掉下去”的回收特征。using UnityEngine; using UnityEngine.Profiling; public class GcMonitor : MonoBehaviour { public float interval 1f; private float timer; private long lastUsed; private void Start() { lastUsed Profiler.GetMonoUsedSizeLong(); timer interval; } private void Update() { timer - Time.deltaTime; if (timer 0f) return; timer interval; long used Profiler.GetMonoUsedSizeLong(); long heap Profiler.GetMonoHeapSizeLong(); if (used lastUsed - 4096) Debug.Log($[GC] 发生回收托管堆使用量从 {lastUsed / 1024}KB 降到 {used / 1024}KB); else Debug.Log($[GC] 持续分配较上次 {(used - lastUsed) / 1024}KB当前 {used / 1024}KB / {heap / 1024}KB); lastUsed used; } }这个脚本的原理很简单堆使用量在两次采样之间突然显著下降说明回收器刚跑过持续上涨说明分配速率非常快离下一次强制回收不远了。注意它只能做粗略估算不能替代 Profiler 定位具体分配点但在冒烟测试和线上问题快速判断上很实用。跑完记得删掉毕竟Debug.Log自己也是消耗品。2.3 内存快照Memory Profiler 的对比分析如果问题不是“频繁小分配”而是“堆持续涨、回收效果差”建议用 Unity 官方的 Memory Profiler 包。Package Manager 里搜索com.unity.memoryprofiler安装它能抓取托管堆和原生对象的多份快照并支持逐项对比。实操流程游戏进入稳定场景后抓一份快照作为基线让游戏“正常游玩”60 秒再抓一份然后在 Compare 视图里看两份快照的差异。重点关注 Managed Heap 部分按“大小增长”排序那些新增的大对象就是推动堆膨胀的元凶。配合GC Allocation归因使用你能同时回答“谁在分配”和“分配完还留着不放”两个问题。对于查找对象池该池什么、缓存该缓存什么这个思路非常高效。2.4 排查的正确顺序与定位技巧落实到执行层面我给 GC 排查定了一条固定顺序先用 Profiler 的 Hierarchy 模式记录 30 秒典型玩法按 GC Alloc 降序排列。处理前五名“每帧都会执行”的方法比如 Update、LateUpdate、FixedUpdate、OnGUI 里的分配。处理高频触发的一次性分配点比如碰撞瞬间的特效生成、伤害飘字。最后处理协程、事件、UI 回调这类容易被忽略的隐式分配。每一轮改完后重新抓 Profiler对比 GC Alloc 总量和 Timeline 里 GarbageCollect 出现的频率。记住一个原则先把“每帧都发生”的分配清零收益永远最大。偶尔一次 100 KB 的分配和每帧 1 KB 的分配后者对帧率的破坏力往往更大因为它把 GC 变成了常态访客。3. 常见的分配大户一行代码一个坑这一节是我在项目里最常抓到的“案犯”每一个都在线上真实咬过人。我把它们连代码带原理列出来你对照自查一遍往往比任何高级工具都管用。3.1 字符串拼接、插值与日志输出的隐形成本字符串是 C# 里最容易被忽略的分配源因为号看起来太无辜了。字符串是不可变对象每次生命: hp都会生成一个新的字符串对象如果拼接的内容包含数字还会额外产生数字转字符串的临时对象。string.Format({0}:{1}, name, level)同样如此内部还要解析格式模板分配更多临时字符串。${money}这种插值写法要特别小心。在目前 Unity 支持的 C# 版本下插值本质上是走string.Format那一套值类型参数传进去会装箱boxing产生托管堆分配。HUD 里每帧刷新金币、血量、坐标数值是移动游戏 GC 分配的大头我在后面第 4 章会专门讲怎么处理。Debug.Log也是个隐形大户。你以为发布版里没有日志就没事错了——Debug.Log(玩家被击中剩余血量 hp)这行代码字符串拼接发生在调用 Log 之前不管日志最终打不打出来分配已经发生。而且 Log 自身的参数处理也有开销。正确的姿势是用条件编译包一层[System.Diagnostics.Conditional(UNITY_EDITOR)] public static void Log(object message) Debug.Log(message);把项目中所有直接调用Debug.Log的上千处都换成这个自定义方法发布版里连调用都不会编译进去分配自然为零。这个改动成本极低收益却是实打实的。3.2 LINQ、闭包与装箱优雅背后的代价LINQ 确实写起来舒服但它是 GC 分配的重灾区。list.Where(x x.hp 0).First()每次执行都会创建迭代器对象和委托对象如果 lambda 捕获了外部变量还会多一个闭包对象。一次调用几十字节看起来不痛不痒但放在每帧遍历 100 个敌人的战斗逻辑里一帧就是几千字节几秒钟就能喂饱一次 GC。闭包的分配逻辑值得单独记一下lambda 表达式如果没有捕获外部变量编译器会把它缓存成静态委托不会每次分配一旦捕获了count、id这类局部变量编译器就得为这组变量创建一个闭包类实例每次执行到 lambda 创建点都会 new 一个出来。所以我在 Review 代码时会特意看循环体里有没有“捕获变量的 lambda 创建”有就建议改成普通循环。装箱boxing则是另一个维度。把值类型塞进object、ArrayList、非泛型接口或string.Format的参数都会产生装箱对象。list.Add(1)这种写法数字 1 会被包成一个堆上的对象用完后变成垃圾。泛型能解决大部分问题但项目里残留的ArrayList、Hashtable、Dictionaryobject, object这类老 API 是重灾区迁移到泛型版本通常能直接消掉几成分配。3.3 foreach 的隐藏开销与普通循环的胜利“foreach 一定分配”这个说法在现在 Unity 的 C# 编译器下并不完全准确。直接 foreach 一个T[]或ListT编译器会使用结构体枚举器不产生堆分配。真正的问题是下面这几种写法把集合声明成接口类型比如IEnumerableT、IListTforeach 时枚举器会装箱。遍历非泛型集合比如ArrayList、Hashtable。foreachDictionaryK,V时把循环变量定义为接口或基类类型。在泛型方法里对泛型参数T做 foreach编译器无法确定枚举器是结构体只能走接口路径。所以在热循环里我的习惯是能写 for 就写 for能用数组就用数组。数组的随机访问零开销、零分配这永远是性能基准。如果确实需要字典遍历就保持foreach (var kv in dict)的直接写法不要升级成接口再遍历。3.4 协程、WaitForSeconds 与 UnityEvent协程是 Unity 里另一个“看不见分配”的坑。StartCoroutine(WaitAndDo)用字符串启动协程内部要做字符串哈希、反射查找方法比直接传方法引用慢一个量级还会分配额外的字符串和反射对象。正确的写法永远是StartCoroutine(WaitAndDo())直接传方法引用。协程循环体里的yield return new WaitForSeconds(0.5f)也是高频分配点。每个WaitForSeconds都是一个堆对象循环每转一圈就 new 一个。解决办法是缓存成静态字段反复用同一个实例private static readonly WaitForSeconds WaitHalfSec new WaitForSeconds(0.5f); private IEnumerator FireLoop() { while (true) { Fire(); yield return WaitHalfSec; } }注意如果一个协程要等待多种不同的时长就给每种时长单独建一个缓存字段。这个改动不需要改逻辑却能把高频协程的分配直接清零。事件系统同样有雷。UnityEventint这类带参数的 UnityEvent 在 Invoke 时为了构造参数列表会产生分配在 UI 点击这种低频场景可以忽略但如果某个战斗事件每帧触发多次建议换成 C# 原生 event 或者自定义的零分配消息系统。C# 原生 event 在触发时只做委托调用不产生托管分配代价是你得自己处理生命周期管理取消订阅。这个取舍我觉得值。3.5 高频 API 调用里的“隐形 new”有些 Unity API 名字听起来不像会分配实际却暗藏分配。GetComponent(string)这种按字符串查找的版本内部要维护字符串表比泛型版本慢又费泛型GetComponentT()本身不分配可以放心用。FindObjectOfTypeT()在热路径里几乎每次都有可观开销——它要扫描整个场景内部数组、列表分配一大堆能用缓存引用的地方就别反复调用。物理相关接口里Physics.Raycast的普通版本不分配但Physics.OverlapSphere、Physics.SphereCastAll这类“一次性返回数组”的接口每次调用都会 new 一个新数组返回。正确姿势是用 NonAlloc 版本配合预先分配好的缓存数组private readonly Collider[] overlapBuffer new Collider[16]; private int CollectTargets(Vector3 center, float radius) { return Physics.OverlapSphereNonAlloc(center, radius, overlapBuffer); }Instantiate和Destroy也值得点名。它们不只是托管堆分配还会触发原生对象的创建销毁、组件序列化开销很大。战斗场景里的子弹、敌人、飘字、粒子特效如果每一发都是 new 一下再销毁GC 和原生内存双重压力都会爆表。这个问题的标准解法就是第 4 章要讲的对象池。4. 实战优化把每帧 GC 压到可以忽略定位到分配点之后剩下的就是动手改。这一章给的是可以直接抄进项目里的方案我只挑“投入产出比最高”的几种讲。4.1 对象池高频创建销毁问题的终极解对象池的思路很朴素对象用完不销毁放回池子里下次要用直接从池里取。它消灭的不只是Instantiate/Destroy的托管分配还把原生对象的创建开销也一起省了对帧率稳定性的提升是双份的。Unity 2021 之后自带UnityEngine.Pool.ObjectPoolT不用再手搓底层栈但用的时候有几个参数容易踩坑using UnityEngine; using UnityEngine.Pool; public sealed class BulletPool : MonoBehaviour { [SerializeField] private Bullet bulletPrefab; private IObjectPoolBullet pool; private void Awake() { pool new ObjectPoolBullet( createFunc: () Instantiate(bulletPrefab), actionOnGet: b b.gameObject.SetActive(true), actionOnRelease: b b.gameObject.SetActive(false), actionOnDestroy: b Destroy(b.gameObject), collectionCheck: false, defaultCapacity: 32, maxSize: 128); } public Bullet Spawn(Vector3 pos, Quaternion rot) { Bullet bullet pool.Get(); bullet.transform.SetPositionAndRotation(pos, rot); bullet.gameObject.SetActive(true); return bullet; } public void Release(Bullet bullet) pool.Release(bullet); }对应的 Bullet 脚本里不再用Destroy(gameObject)收尾而是把自身还给池private void OnTriggerEnter(Collider other) { // 处理碰撞逻辑 pool.Release(this); }注意几个细节。collectionCheck: false表示 Release 时不检查对象是否重复入池能省一次遍历代价是你不能手滑把同一个对象 Release 两次否则会破坏池的状态。maxSize是池的上限超出上限的 Release 会直接销毁对象这个值要根据峰值并发量估算太小起不到池化效果太大会白白占着内存。我习惯在场景初始化时先“预热”池子——预生成一半容量的对象放进去避免战斗第一波才临时 Instantiate 导致的峰值卡顿。枪械类游戏我实测过用池之前一梭子子弹打出去 GC 尖峰 12 ms池化后稳定在 2 ms 以内这个收益非常直观。4.2 UI 文本与字符串复用HUD 卡顿的头号解药HUD 数值刷新是字符串分配的最大聚集地也是最容易被忽视的。每一帧都执行goldText.text 金币 gold等于每帧都在送垃圾给回收器。优化方向有三个层次从低到高第一层只在数值变化时更新文本。配合缓存把上一次显示的值记下来private int lastGold -1; private readonly StringBuilder sb new StringBuilder(32); private void UpdateGoldText(int gold) { if (gold lastGold) return; lastGold gold; sb.Clear(); sb.Append(金币).Append(gold); goldText.text sb.ToString(); }第二层把固定的“前缀字符串”单独缓存用string.Concat或StringBuilder拼接减少临时字符串数量。第三层彻底零分配自己写一个IntToString(int)函数把数字转成 char 数组再赋给 Text。这个方案代码量稍多但如果你的系统里 HUD 支持“每秒跳动几十次”的实时数据比如同时显示速度、坐标、伤害数字值得投入。我见过一个实时吞吐监控界面原本每帧分配 1.5 KB改造后 GC Alloc 归零帧率从 45 直接拉回 60。核心原则就一句字符串拼接的成本不是 CPU而是它产生的托管垃圾。只要垃圾不再每帧产生GC 的自然频率就能大幅下降。4.3 组件缓存、结构体与 TryGetComponent每帧调用GetComponentT()虽然不分配但每次都做组件查找仍然有 CPU 开销在低端机上会放大成帧率问题。常规做法是在Awake里把引用缓存成字段private Text hpText; private Rigidbody rb; private void Awake() { hpText GetComponentText(); rb GetComponentRigidbody(); }Unity 2019 之后新增的TryGetComponent也很实用。相比先判空再 GetComponent 的两段式写法它一次调用同时完成查找和判断语义清晰在高频逻辑里值得全面替换老写法。结构体struct与类class的选择要理性。结构体在作为局部变量、参数传递时通常不产生托管分配但一旦被装箱塞进 object、接口或非泛型容器反而比类更糟。性能敏感的临时数据Raycast 结果、坐标向量、小配置块用结构体是合理的涉及 UI 引用、组件引用、业务对象这类堆上生命周期明确的强行改成结构体只会让代码难写收益却很有限。优化不是炫技热点在哪优化就落在哪。4.4 协程改造Wait 缓存与 UniTask 的零分配方案协程本身的问题不只是WaitForSeconds而是“协程状态机实例”也会在 StartCoroutine 时分配。如果一个协程被反复启动/停止比如玩家频繁按攻击键打断再重新发起连招状态机的分配积少成多。两个改造方向第一个能用普通方法驱动的循环就别用协程。比如最简单的“每隔 0.5 秒开一炮”直接写在 Update 里private float nextFireTime; private void Update() { if (Time.time nextFireTime) return; Fire(); nextFireTime Time.time 0.5f; }这个写法零分配零状态机逻辑还更好懂。第二个需要异步顺序执行的复杂流程考虑用 UniTaskCysharp 开源库。UniTask 本质上是结构体任务把大部分协程场景的托管分配降到了接近零而且支持异常处理、取消、与await语法无缝配合。它已经是不少商业项目标配了这里不多展开但如果你还在用传统协程写每帧高频流程替换成 UniTask 通常能立刻看出 Profiler 里 GC Alloc 的变化。4.5 增量式 GC 与平台设置最后一道防线优化代码之外Unity 还提供了一个“物理层面”的开关增量式 GCIncremental GC。在 IL2CPP 后端下Project Settings Player Other Settings Configuration 里能找到 “Enable Incremental GC” 选项。勾选后回收器会把一次完整的标记清扫拆成很多小块分摊到每一帧去执行避免单帧长停顿。听起来很完美但要清醒增量式 GC 只是把“痛感”分散了总 CPU 开销并没有减少。低端机上你可能看到的不再是每隔几秒卡一下而是 CPU 占用长期偏高、帧率轻微下降。它对“偶尔一次大分配”的场景效果明显对“持续小分配”的场景可能帮倒忙。而且并非所有平台都支持WebGL 等平台是不生效的。我的建议是先做代码层面的分配优化最后再开这个开关做 A/B 测试用真机帧率数据决定去留别把它当默认选项。5. 排查实战那些让我印象深刻的坑理论讲完落地时还会遇到各种奇奇怪怪的现象。这一章记录我从真机项目里踩出来的排查经验都是常规文档里不会写的细节。5.1 真机上“间歇性停顿”的定位全过程去年做一个挂机 RPG 项目测试反馈“每隔两三秒顿一下战斗时尤其明显”。我接上 Android 真机抓 Profiler先用 CPU Usage Timeline 找到了 GarbageCollect 标记停顿时间 8 到 15 毫秒不等频率正好和玩家描述对上了。再切到 Hierarchy 模式看 GC Alloc前三名分别是HUD 的文本更新方法每帧 3.2 KB、敌人 AI 的协程每帧创建 40 个 WaitForSeconds、单位死亡时 Instantiate 的粒子特效每次 6 KB。修复方案按优先级逐个上文本更新加了“数值变化才刷新”的缓存协程里的 WaitForSeconds 改成静态缓存粒子特效和单位本体一起接进了对象池。改动完成后重新抓数据GC Alloc 从每帧约 15 KB 降到不足 100 字节GarbageCollect 标记几乎消失真机帧率从 28~45 波动稳定到 60。这个过程没有任何高深技巧就是先用量化工具找到证据再针对性地改前后两天搞定。我用一张表总结优化前后的对比作为量化标准参考优化项优化前每帧/每次分配优化后HUD 文本刷新3.2 KB每帧0仅在数值变化时分配协程 WaitForSeconds40 个对象每帧0静态缓存复用单位死亡粒子6 KB每次0对象池复用单帧 GC Alloc 总量约 15 KB小于 100 B5.2 手动 GC.Collect 是解药还是毒药项目里经常能看到这样的代码切换场景后调用System.GC.Collect()初衷是“把不用的内存清掉”。这个思路对了一半但代价往往被忽略。GC.Collect 是同步的全量回收调用点在哪停顿就在哪。切场景时本来就有加载开销玩家眼里这是黑屏/加载转圈多停顿几十毫秒感知不强可如果代码写在游戏过程中某个按钮的回调里那就是一次深刻的卡顿体验。更麻烦的是Unity 的托管堆在回收后不一定会把内存归还给操作系统它可能只是标记为空闲。你手动 GC.Collect 看着 Used 降下来了实际 Heap Size 还是那么大下次分配照样从堆里划回收频率并不会下降太多。我的建议让回收器自己按阈值工作只在两种场景下手动干预——一是切场景后配合Resources.UnloadUnusedAssets()做一次收尾二是检测到内存告警需要立刻释放时。日常玩法中绝不主动调。5.3 明明优化过却更卡过度优化的代价优化过头这事我见过太多次。有个同事为了消灭字符串分配写了一个复杂的缓存字符串表把几十个 UI 文本的每一种数值组合都预生成好字符串存着内存占用爆增代码逻辑绕得没人敢动而实际 Profiler 显示这些文本每秒只变两三次本来不做任何优化也毫无影响。这就是典型的投入产出比失衡。优化前一定要问自己三个问题这段代码在每帧执行吗它的分配量真的对 GC 频率有实质影响吗改完之后代码是否还能维护如果三个答案分别是“否”“否”“会变差”那就别动。性能优化的本质是预算管理不是把所有代码都改成零分配。GC Alloc 从 15 KB 降到 100 字节是有意义的从 100 字节降到 0 字节在绝大多数项目里不值得。5.4 团队协作中的 GC 预算与检查清单GC 问题最容易在项目后期集中爆发因为它是“写代码时攒出来的债”。我在团队里推行过一套简单的 GC 预算制度效果很直接任何新系统上线前用 Profiler 跑一遍典型玩法GC Alloc 必须低于每帧 1 KB超过就拦下来返工日常 Review 代码时对照下面这个清单快速过一遍检查项规则Update/LateUpdate/FixedUpdate 内字符串拼接、插值、Format禁止Update 内 LINQ 与 lambda 闭包禁止Update 内 FindObjectOfType、Resources.Load禁止高频创建/销毁的游戏对象必须使用对象池协程中的 WaitForSeconds、WaitUntil必须缓存复用StartCoroutine 使用字符串启动禁止高频触发带参数 UnityEvent换成 C# 原生 eventDebug.Log 直接调用统一走条件编译包装方法这套清单不复杂但对新人的约束力很强能防止最常见的分配习惯性问题在项目早期就埋下来。GC 优化的最大成本从来不是改代码而是“问题上线之后才被发现”的返工成本。服务器主机评测GC 调优这件事说到底是一套“分配预算”思维——写每一行代码时脑子里多根弦这个方法会被调用几次每一次调用会产生多少托管垃圾垃圾多久会被回收一次这种意识一旦建立很多坑根本不会踩进去。我在实际项目里最深的体会是GC 问题几乎不会单独出现它总在战斗、加载、UI 更新这些压力点一起爆发所以把它和对象池、缓存、异步方案放到同一个优化体系里去考虑效果远好于头痛医头。如果你顺着这篇文章的思路把常见分配陷阱梳理一遍再配合 Profiler 测一轮自家项目你会发现很多“莫名其妙的卡顿”其实早就有了答案。系列下一篇我准备聊聊渲染层面的帧率保卫战——光照、后处理和 Overdraw 的方向。不过在进入渲染之前先把 GC 这一课过好因为逻辑层的每一次无谓分配都会让渲染层帧时间瞬间爆表。