
做Unity项目这么久你肯定遇过这种灵异事件帧率曲线平时稳如老狗但每隔十几二十秒就突然掉一帧掉完立刻恢复时间完全无规律有时候你盯着Profiler看半天也抓不到它代码逻辑里翻来覆去找不到一个可疑的循环。这时候大概率是GC在背刺你。这一篇是《Unity 卡顿·帧率保卫战》系列的第四篇前面聊过帧率指标的建立、CPU侧的逻辑热点、渲染侧的耗时大户今天专门收拾GC——垃圾回收。GC在不少开发者耳朵里几乎是玄学代名词但说实话它本质上就是一个会随机打断你游戏的内存保洁阿姨平时不声不响一干活就把所有线程按住不让动等它收拾完你的玩家已经觉得画面跳了一下。这篇文章适合那些正在做中大型Unity项目、被卡顿折磨又找不到头绪的朋友也适合想系统建立性能红线的新手团队。我会把GC导致卡顿的原理、定位方法、实操优化方案和团队落地经验一次说透。1. 先定位GC是怎么把帧率拽下水的1.1 帧率曲线上的随机掉坑到底长什么样GC导致的卡顿和普通逻辑卡顿有明显区别。如果你在Profiler里看到的是某个函数每帧都稳定占用好几毫秒那是逻辑热点问题很好修。但GC卡顿的特征是帧时间突然冲高然后立刻回落到正常水平像心电图上偶尔的早搏。我给这类问题起的名字叫周期型瞬卡。原因是托管堆上的垃圾不会在产生的那一刻就被回收而是像垃圾桶一样攒着攒到阈值才触发一次完整清理。所以你会看到帧率每隔一段时间就规律性掉一次。这个一段时间可能是十几秒也可能是半分钟完全取决于你每帧分配了多少托管内存、堆涨到了多大。在移动端真机上一次完整GC停顿经常在20毫秒到200毫秒之间。60帧的目标帧预算只有16.67毫秒200毫秒意味着这一帧直接废掉玩家体验到的是肉眼可见的卡一下。如果刚好发生在技能释放、镜头切换这种操作敏感期感受会加倍放大。更麻烦的是这种掉帧无法靠优化单个函数来消除因为卡顿点根本不在你的业务逻辑里而在垃圾回收器本身。1.2 托管堆、分配与STWGC卡顿的本质要理解GC为什么能按停你的游戏得先看它在做什么。C#里凡是class对象、字符串、数组、委托这类引用类型都要在托管堆上分配空间。托管堆不像栈那么随意它由GC统一管理GC需要知道每个对象是否还被引用着。当堆内存使用量超过一定阈值或者分配请求无法满足时垃圾回收器就开始工作。第一步是标记从根对象静态字段、方法栈上的局部变量、寄存器引用等出发遍历所有引用关系把还在使用的对象标记为存活第二步是清理或压缩把死掉对象的内存回收甚至移动存活对象的位置来整理堆空间。关键在于标记和整理的过程是停止世界Stop The World的。GC工作期间所有托管线程都要暂停你的Update、FixedUpdate、协程、物理回调全部冻结。这个停顿对玩家来说就是一次卡帧。而且堆越大、存活对象越多GC一次要扫描的东西就越多停顿时间越长。这里有个很多人忽视的点大对象一次性分配往往没有想象中那么可怕真正危险的是小对象高频分配。因为GC触发频率取决于分配速率和堆阈值每帧几百个小分配会持续推高堆使用量让GC在不知不觉中频繁触发。就像一个房间每天往里堆一堆小纸团虽然每个都不大但隔一阵子你就不得不停下来收拾一次。如果你每天只往房间里搬一个大箱子可能一周才需要收拾一次。1.3 Mono与IL2CPP的GC实现差异为什么排查方案要不同Unity到现在仍然有两套C#后端Mono运行时和IL2CPP。很多人只知道IL2CPP编译出来是C、体积大、性能好但很少关心它们背后的GC实现完全不一样这直接决定了你排查卡顿的方式。Mono在较新版本中使用的是分代式精确GC它把托管堆分成年轻代和老年代新分配的对象优先在年轻代GC次数多但每次扫描范围小老年代对象很少被扫描。这种设计让Mono下的GC在小堆积场景下表现还不错。IL2CPP的情况要复杂一些。IL2CPP把C#转换成C后再编译成原生代码它自带的GC更偏向保守式实现。保守式垃圾回收器扫描栈和寄存器时只要看到一个数值看起来像指针就会当成指向堆对象的引用因此它没法精确知道哪些对象真正没人用。后果是某些对象可能比你预期存活更久堆上的假垃圾也可能累积内存压力更大。这也是为什么很多项目在编辑器里用Mono跑得好好的一打包到Android/iOS就出现间歇性卡顿。到了真机上你应该以IL2CPP行为为准不能再拿编辑器下的表现作为判断依据。实际操作中我强烈建议团队从立项起就统一在IL2CPP 真机环境下做性能基准测试别等上架前才仓促面对这一堆差异。2. 抓真凶用Profiler和代码审查锁定GC分配2.1 三个工具定位GC热点Profiler、Memory Profiler、代码打点先说最基础的Unity Profiler。打开Window Analysis Profiler切到CPU Usage模块运行时在卡顿发生前后立刻点击Record或选中卡顿的那一帧。重点看Hierarchy视图里的GC Alloc列单位是字节。选中卡顿帧后按GC Alloc排序分配量最高的那几个方法就是嫌疑犯。但这里有个技巧不要只看单帧单帧分配高不代表GC压力大要连续观察几十帧的GC Alloc曲线。如果几乎每帧都在几千字节以上堆会稳定增长GC早晚被触发。如果只有个别帧分配很高那更多是瞬时压力比如列表扩容、创建对象重点看是不是发生在玩家操作节点。第二个工具是Memory Profiler需要在Package Manager里安装。它比自带Profiler更进一步可以拍摄托管堆快照看到堆里到底游荡着哪些类型的对象以及它们的引用关系。这个工具特别适合排查内存泄漏和堆持续增长。第三个方式比较土但有效在代码里打点。用System.GC.GetTotalMemory(false)可以取当前托管堆大小你在每帧结尾调用一次连续记录数值就能画出堆内存的增长曲线。通过曲线斜率能估算出GC大概多久会触发一次。配合System.GC.CollectCount可以了解GC频率。这个方法不需要装任何插件适合临时在真机上跑一轮。2.2 一眼识破的高GC代码清单在实际项目中绝大多数GC Alloc来自这几种写法我整理了一份一眼识破清单字符串拼接string msg HP: hp MP: mp;这种看起来人畜无害的写法每执行一次就在堆上创建新的字符串对象。循环里执行100次就是100个字符串对象。Debug.Log滥用调试日志本身就是个陷阱。Debug.Log(Player name took damage damage)这行代码在编辑器里会分配较多内存在真机上长期保留的高频Log也会拖慢速度。LINQ全家桶Where、Select、OrderBy、ToArray、First、Any这些扩展方法全是分配大户。OrderBy尤其夸张内部会创建一堆中间容器和迭代器。foreach遍历非泛型集合遍历ArrayList、Hashtable这类非泛型集合时枚举器会装箱、产生分配。即便遍历字符串foreach(char c in str)也会因为内部CharEnumerator产生少量分配。协程里反复newyield return new WaitForSeconds(1f)这种写法每次执行都创建一个新对象。高频率协程累积起来数字很可观。装箱值类型被当成object使用时就会装箱。经典场景包括Debug.Log(score)分数是int、把int存进Listobject、调用需要object参数的接口、自己写的object参数工具方法。这些写法单独看都不起眼但组合起来就是一场GC灾难。而且它们隐蔽性强代码评审很难一眼全部抓到所以我建议用Profiler数据说话而不是靠人肉review。2.3 悄悄藏着的分配源闭包、装箱和协程比上面那些更隐蔽的是闭包。Lambda表达式捕获外部变量时编译器会生成一个闭包类每次执行lambda时如果都需要捕获新变量就会创建新的闭包实例。典型例子for (int i 0; i 100; i) { buttons[i].onClick.AddListener(() Debug.Log(i)); }这串代码不只是创建了100个委托还创建了100个闭包对象因为它们各自捕获了不同的i值。就算i不变化只要lambda内引用了外部变量每次生成委托也可能伴随一次分配。装箱问题在自定义struct上特别容易踩坑。你定义了一个值类型struct DamageInfo把它传给一个接收object参数的函数或者调用一个定义在接口里的方法而该接口被显式实现时就会装箱。我在一个项目里看到有人用ListDamageInfo.Contains()因为DamageInfo没有重写Equals内部走的是object.Equals每次比较都装箱战斗结算一帧几万个比较GC Alloc直接爆表。协程也有不少隐性分配。协程状态机本身在启动时会分配一次这不可避免但如果你在一个长时间运行的协程里反复yield return new WaitForSeconds或者yield return new WaitUntil那每次循环都会创建新对象。正确的做法是把WaitForSeconds实例缓存起来复用。3. 实操改造把每帧分配降下来的具体方案3.1 字符串与日志的第一轮清剿字符串问题必须从两个层面同时下手。第一个层面是拼接方式。高频路径上不要用拼接也不要盲目用string.Format它虽然优雅但每次也会创建参数数组和新字符串。最稳的做法是复用一个StringBuilderprivate StringBuilder _sb new StringBuilder(128); private string FormatDamage(int damage, int hp) { _sb.Clear(); _sb.Append(Damage:).Append(damage); _sb.Append( HP:).Append(hp); return _sb.ToString(); }注意Append一个int时底层走的是数字的ToString也会有分配但好消息是StringBuilder会自己处理临时缓冲区整体分配量比字符串直接拼接小得多。如果调用频率极高甚至可以自己写一个定制的数组/字符栈方案但那属于进阶玩法大多数项目不需要走到这一步。第二个层面是日志治理。在高频循环里写Debug.Log基本等于给GC上供。我见过一个项目战斗系统的每帧伤害计算里留了十几个Log出包后帧率稳定在15到20帧删掉日志后直接回到60帧。你的代码应该用条件编译或日志分级封装起来public static class LogHelper { [System.Diagnostics.Conditional(ENABLE_VERBOSE_LOG)] public static void Verbose(string message) { Debug.Log(message); } }只在需要排查问题时才开ENABLE_VERBOSE_LOG发布版本默认关闭这样既保留了调试能力又不会让日志拖垮线上版本。3.2 数据结构与API选型从数组、非Alloc到struct在删除LINQ的问题上我态度很明确游戏核心循环里禁止使用LINQ不是为了编程风格而是因为它把分配藏得太深。你要用它做的事情用手写循环都能做而且更可控。拿排序举例OrderBy看似一行搞定但底层要分配好几个对象换成ListT.Sort配合比较器分配量小得多如果比较器是struct实现IComparerT甚至可以做到零分配。数组和列表相关API也常被忽视。Physics.RaycastAll这类返回数组的API每次调用都分配新的结果数组mesh.vertices同理。Unity其实给每个返回数组的接口都提供了对应的非Alloc版本比如RaycastNonAlloc、GetVertices(ListVector3)。新项目里可以直接规定所有物理检测必须用NonAlloc版本所有Mesh数据读取必须用List版本这条规则能从根上消灭一大半数组分配。接着是struct的设计。自定义值类型必须重写Equals和GetHashCode否则任何集合操作都可能装箱。Dictionary查值时如果自定义struct作为key且没重写GetHashCode每次查找会走默认的反射式哈希既慢又可能分配。重写之后性能是碾压级别的public readonly struct ItemId : System.IEquatableItemId { public readonly int Id; public ItemId(int id) Id id; public bool Equals(ItemId other) Id other.Id; public override bool Equals(object obj) obj is ItemId other Equals(other); public override int GetHashCode() Id; }在DOTS或ECS项目里struct用得越多、class用得越少GC压力自然越小。传统MonoBehaviour项目里虽然不可能彻底不用class但设计数据载体时优先考虑struct长期收益非常大。3.3 对象池落地从GameObject到UI元素对象池是打击GC分配的经典武器但很多人做得太粗糙。单纯把GameObject反复Instantiate和Destroy对GC的伤害来自两方面一是实例化过程要创建大量组件和对象二是Destroy并不会立即回收内存对象在托管堆上变成垃圾等待GC清理。对象池就是为了消灭这种反复生灭的模式。一个工业级的对象池至少要做这几件事预创建一定数量的实例、提供Get和Release接口、释放时把对象挂到隐藏父节点下、必要时自动扩容。我以前在一个弹幕射击项目里写过一个简化版public class GameObjectPool { private readonly StackGameObject _pool new StackGameObject(); private readonly GameObject _prefab; private readonly Transform _parent; public GameObjectPool(GameObject prefab, Transform parent, int preload) { _prefab prefab; _parent parent; for (int i 0; i preload; i) { var go Object.Instantiate(prefab, parent); go.SetActive(false); _pool.Push(go); } } public GameObject Get() { var go _pool.Count 0 ? _pool.Pop() : Object.Instantiate(_prefab, _parent); go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); go.transform.SetParent(_parent, false); _pool.Push(go); } }对象池不仅适用于怪物、子弹、技能特效UI元素同样适用。动态列表滚动时反复创建Text、ImageGC压力非常大。我在项目里把提示文字、伤害数字、技能指示器全部池化之后UI相关帧时间直接砍掉一半。3.4 改造前后对比一个真实战斗系统的数字这里分享一次真实的改造记录。一个ARPG手游项目战斗场景里每帧有若干敌人在攻击玩家飘字、伤害计算、技能冷却倒计时、战斗日志一起跑。优化前Profiler数据惨不忍睹每帧GC Alloc大约20KB到30KB大约每8秒触发一次GC真机上卡顿频繁。优化手段就三件事删除高频Log、缓存WaitForSeconds、把所有伤害飘字和敌人对象池化。另外把战斗结算里一个ListDamageInfo的排序从LINQ换成了手写循环加List.Sort。优化后的数据每帧GC Alloc降到2KB到4KBGC触发间隔延长到半分钟以上最直观的变化是帧率曲线几乎变成一条直线。这个案例里没有用任何高深技巧只是把候选清单里的问题一个个消灭。对了改造过程中最容易反复出现的是同事又随手写了Debug.Log和字符串拼接。所以我把GC Alloc的红线标准写进了代码评审清单谁提交的代码让Profiler单帧分配超过阈值谁就要回去优化。4. 进阶武器增量式GC与长线内存治理4.1 增量式GC把一次大卡顿拆成无数次小卡顿Unity在2021.2版本带来了增量式垃圾回收Incremental GC位置在Player Settings的Other Settings下方有个Incremental Garbage Collection的勾选项。它的原理是把原本一次性完成的标记工作拆成很多小份分摊到多个帧里执行。形象点说原来清洁工每周末来一次把整个房间大扫除三小时期间谁都别想进房间现在改成每天晚上顺手打扫十分钟房间随时能用。这个方案的价值在于把GC停顿降到很短短到玩家基本感知不到。但它不是白给的增量GC的总CPU开销通常比一次完整GC更高因为分帧标记需要维护状态、切换上下文。如果你的项目帧率本来就很紧张平均帧时间可能会略微上升换来的是不再随机掉帧。适合的取舍场景是平均帧时间有余裕但GC峰值造成的卡顿很烦人。需要重点注意的是平台兼容性。增量式GC在部分平台、部分Unity版本上的支持情况不一样尤其是WebGL微信小游戏这类环境可能压根不生效或者表现不稳定。所以最优做法是在真机上分别开着和关着跑一小时对比帧率曲线的峰谷表现不要想当然。4.2 主动GC什么时候需要手动调System.GC.Collect很多人一听到System.GC.Collect()就觉得是性能毒药其实分场景。频繁调用确实是灾难因为每次GC都有固定开销你等于是自己制造卡顿。但在明确的舒适区调用它效果却很好。最佳时机是加载场景、切换关卡、弹出大段剧情这些玩家不操作的时间点。做法是在加载画面显示期间调用一次private IEnumerator LoadLevel() { yield return StartCoroutine(LoadAssets()); System.GC.Collect(); yield return null; }这会让堆内存回落把原本可能发生在游戏中途的GC卡顿提前到加载阶段。还有一点提醒在WebGL和微信小游戏平台上内存压力极其敏感手动GC不能太频繁否则会引发短促掉帧或性能波动。我通常只在场景切换时调用一次游戏运行过程中绝不手动触发。另一个隐蔽但有用的手段是监控System.GC.GetTotalMemory在内存超过预设阈值时主动切换性能模式比如降特效、降分辨率和关闭体积雾从根源上减少后续的分配压力。这比等GC自己爆发更可控。4.3 长线内存治理从防分配到防泄漏GC卡顿往往是内存管理不善的果要治本就得防泄漏。最常见的泄漏途径是事件订阅没有反注册。A对象订阅了B对象的事件A销毁了但忘了-B只要还活着A就被事件引用着永远活不到GC回收。这种泄漏会让堆只涨不降最终把GC触发频率越推越高。另一个泄漏温床是静态容器。全局ListGameObject、Dictionarystring, object缓存了一些对象后续不再使用但没有移除项。它们会被静态引用锁死。排查手段就是用Memory Profiler拍快照对比两个时间点找出持续增长的引用类型。我建议团队建立长期的内存治理机制每轮版本都拍内存快照对比对无明显引用但仍有存活对象的类目保持警惕。同时给所有自定义的可销毁对象统一提供反注册接口在生命周期末尾强制清理事件订阅。这些制度性的工作比一次性优化对GC卡顿的贡献更大。5. 常见问题与排查技巧实录5.1 五种典型卡顿症状速查表症状可能原因排查方法解决方案隔一段时间规律性掉帧堆分配持续增长触发周期GCProfiler观察GC Alloc曲线与Mono堆曲线清剿高频分配控制每帧GC Alloc打开界面/弹窗瞬间卡顿UI元素大量创建、Canvas重建、字符串处理Memory Profiler看瞬时分配UI对象池化界面预加载越跑越卡帧率持续下降内存泄漏导致堆疯狂增长快照对比找出泄漏引用反注册事件、清理静态引用玩家操作技能/攻击瞬间卡一下技能特效大量实例化、伤害计算箱装、闭包Deep Profile定位逻辑帧池化特效、重写struct、缓存委托WebGL/小游戏平台上画面周期性冻结增量GC不生效完整GC停顿长真机日志观察GC时间改为手动GC加载界面、压缩堆分配这个表是我这几年的实战总结基本能覆盖九成以上的GC类卡顿场景。定位问题最快路径永远是先确认是不是GC导致的再确认分配来自哪里最后按优先级逐个击破。5.2 真机实战低端机与微信小游戏的GC目标不同平台的GC承受能力完全不一样。PC上每帧分配50KB可能毫无影响因为桌面CPU强、GC快但低端Android机上每帧分配超过几KB都可能是压垮帧率的稻草。我建议按照平台设定硬性红线移动端每帧GC Alloc控制在2KB以内PC可以放宽到8KB到16KB微信小游戏则要惨烈一些最好接近0。真机调试时要注意Profiler连接真机会有额外性能损耗但它还是很关键。拿到卡顿样本后观察的指标不要只看GC Alloc还要看GC的时间消耗也就是Profiler里Mono或GC相关线程的耗时。有时候分配量不算离谱但GC每次耗时很长说明堆里存活对象太多这时候优化方向不是减少分配而是减少长期存活的垃圾对象和过大的静态缓存。微信小游戏是比较特殊的场景它的内存上限低、GC机制受限、加载包体大小敏感。我的建议是上线前专门做一次极限内存压力测试让角色持续战斗20分钟观察堆内存曲线和GC卡顿频率。如果曲线一路上扬先解决泄漏如果GC间隔越来越短就加大对高频路径的分配治理。另外Unity的Application.targetFrameRate在小游戏上别设太高稳定大于求快。5.3 团队协作把GC红线写进日常开发GC优化最怕的是一个人管得辛苦团队随手又制造新坑。所以我把治理动作制度化了三条一是把Profiler的GC Alloc数据作为版本性能准入标准。每次提测前跑一遍标准性能场景记录每帧平均GC Alloc和GC触发间隔不达标不合并。二是代码评审重点盯高频路径凡是Update、协程、事件回调里出现字符串拼接、LINQ、new关键字评审人都要问一句这里真的需要每次分配吗。三是建立一个性能陷阱文档把项目里踩过的分配坑按代码模式记录下来新同事入职先读一遍。这些工作看起来不像技术攻关那么爽但效果极好。一个好的团队应该让性能问题在写代码阶段就被拦住而不是等到帧率崩了再回头查。另外建议在UPR等线上监控工具里也配置GC相关指标这样用户端反馈的卡顿问题都能追踪到具体版本、具体设备和具体生命周期而不是靠用户主观描述有点卡来猜。最后说一点我个人的体会。做了几年Unity性能优化我最深的感受是GC问题不是一个修一次就好的技术点而是一种工程文化。它藏在每一行字符串拼接、每一个lambda、每一次偷懒用的LINQ里。靠Profiler可以帮你抓到这一轮的犯人但只有团队真正理解了托管分配是有代价的并且愿意在每一天的开发里遵守纪律帧率曲线才能长期保持稳定。希望这篇能帮你少走点弯路下次看到帧率上那种莫名其妙的掉坑你应该知道从哪里下手了。