做Unity卡顿排查这些年,我手机里始终存着一句话:卡顿不需要理由,但一定有源头。帧率下降会直接劝退玩家,而GC则是帧率下降里最会伪装的那个。很多团队优化到后期,发现卡顿不是来自渲染重、也不是来自加载慢,而是每一帧里那些不起眼的临时对象在悄悄填满托管堆。今天这篇《GC:看不见的卡顿来源》,就是想把这一整个排查链路和大家聊透。
先说一个我常挂在嘴边的判断:在Unity项目里,GC分配本身不一定会让你掉帧,真正让你掉帧的是“分配产生的时机和数量”。一次分配几KB对象,垃圾回收几次可能都没事;但如果你的Update循环里每一帧都在分配,托管堆就会在几秒内膨胀,GC被逼着频繁工作,于是帧率曲线开始出现锯齿状尖刺。这种卡顿不发生在你的业务逻辑代码里,你盯着自己的Update函数盯到天黑也找不到问题,必须把视线拉到“托管堆”这个维度上来。
1. 为什么说GC是帧率表上最典型的“隐形刺客”
先讲清楚一个基础概念。GC是Garbage Collection(垃圾回收)的缩写,Unity里的C#脚本跑在Mono或IL2CPP运行时上,你用
new
关键字创建出来的所有引用类型对象(类、数组、字符串、委托、List这些)都分配在托管堆里。运行时不定期的扫描整个堆,找到那些“没有任何引用指向”的对象,把它们的空间回收掉,这就是一次GC。
听起来好像很安全、很智能,对吧?问题在于,GC执行回收的那一刻,托管环境对整个游戏来说是“暂停”的,英文圈叫Stop the World。它停止所有线程,把当前的对象图从头到尾遍历一遍,找出哪些对象还能到达、哪些已经是死对象,然后进行压缩或清理。这个停顿的时长由托管堆里的存活对象数量决定,而不是由你刚刚分配了多少来决定。换句话说,即使你这一帧只分配了10KB垃圾,但如果前面几秒累计了一大堆未被回收的对象,一次GC就可能扫出上百MB的遍历量,在移动设备上产生几十毫秒甚至更长的停顿。
为什么我说它是“隐形刺客”?因为一个标准卡顿尖峰反映在Profiler上,你默认会去看哪个函数耗时高。但GC停顿不会在脚本耗时里显示,它是一个独立的“回收阶段”,往往出现在一帧的末尾或某个特定点,你只看Average Time或者Scripts耗时根本看不出端倪。我见过不止一次这样的场景:某个性能负责人拿着帧率曲线说“平均47帧”,结果打开曲线图一看,每隔几帧有个深红色尖峰,整个曲线像狼牙棒。排查了三天场景、骨骼、粒子,最后在GC Alloc列里发现一个每帧都执行的字符串拼接,改完立竿见影。这个经历在我团队里被重复了至少五次,所以我决定写这篇专门讲GC的文章。
另外,移动端和PC端的体感差异很大。PC上有足够的内存带宽和高速CPU,几十毫秒的GC停顿可能只是让帧率从120掉到100,人眼不一定看得出。但在中端Android机上,本身CPU频率低、内存带宽有限,一次30ms的GC停顿就是两帧白屏,玩家立刻会觉得“卡了”。这也是为什么我觉得做Unity优化的人必须把GC当成一级警备对象——你不主动控制它,它就会在你最关键的交战帧里给你一记闷棍。
2. 用Unity Profiler找出每一笔GC Alloc的来龙去脉
既然GC这么隐蔽,那就得把“看见它”变成固定操作。第一步,打开Unity自带的Profiler,路径是Window > Analysis > Profiler,切换到CPU Usage模块。不要急着点Play,先把工具栏里的Deep Profile(深度剖析)勾上。Deep Profile会把所有脚本函数插入剖析钩子,这样你能看到每一层C#函数调用里分配的字节数,这是最直接的定位方式。
光勾Deep Profile还不够,你要知道在哪一列看分配。在CPU Usage面板的表格区域,右侧滚动条下方有若干个列,其中两个最关键的列分别叫“GC Alloc”和“Time ms”。GC Alloc的单位默认是字节(B),它表示该函数在这一帧内分配的托管内存总量。点击GC Alloc列头可以排序,让它从高到低排列,这样一帧里哪个函数分配最多最靠前,问题点一眼就能揪出来。
不过我要提醒一个新手经常犯的错:只看单帧。因为游戏逻辑里很多分配是按条件触发的,比如每5秒刷一次敌人、掉血才生成伤害飘字、打开背包才实例化UI,如果你恰好录了一帧“平静期”,它可能GC Alloc只有几十字节,你得出结论“没问题”。正确做法是持续录制5到10秒,然后拖动时间轴逐帧观察,尤其注意战斗、切场景、操作界面等高频时期。我会把Profiler的录制时长调到10秒以上,再用侧边的“录制”按钮保存一份Play Mode数据,回到编辑器里慢慢看。
Deep Profile有个致命缺点:它会让项目运行慢好几倍,直接放进真机可能低端机就变成PPT了。所以我的推荐组合是:编辑器里用Deep Profile的逻辑,真机上用Development Build + Autoconnect Profiler的方式,先跑正常逻辑,再通过Remote框架远程连线录数据。近年来Unity还出了个Hot Reload Deep Profiling(2021.2+,只对Development Build生效),可以不用在构建前插桩,而是在运行时选择性地对某个脚本模块开启深度剖析,对定位线上问题非常友好。
如果单纯盯GC Alloc还不够,建议再装一个官方Memory Profiler包(Package Manager里搜com.unity.memoryprofiler)。这个工具的本职工作是分析内存分布,但连续抓两张堆快照,对比Managed Heap对象的数量变化,你就能看到GC都“造”了什么类型的对象。有一次我遇到Profiler显示某帧GC Alloc高达1MB,但函数树里全是一些底层调用,看不出业务逻辑。后来用Memory Profiler抓快照才发现,瞬间多了上千个System.Object数组——罪魁祸首是某个通用工具方法里用了
params object[]
,把一堆结构体参数装箱塞进了数组。这种场景,纯靠GC Alloc列反而不容易定位,两条路径交叉验证才是正解。
还有一个细节,很多人不知道:Profiler的GC Alloc统计的是“托管分配”,并不包含Native内存。比如纹理、Mesh、RenderBuffer等由引擎分配的资源不走托管堆,它们的峰值和泄漏要用Memory Profiler的Native部分来查。所以别把GC Alloc当成内存优化的一切,它只负责托管那一亩三分地。
3. 藏在日常代码里最容易触发GC的五个习惯
这一节我总结五类最常见、也最容易被忽略的GC来源。每一类都是我在真实项目里反复遇到的,不是教科书上干巴巴的理论。
3.1 字符串拼接:第一嫌疑犯
在Update里写
myText.text = "Score: " + score;
大概是Unity项目里最烂漫的写法规避。
+
看起来轻巧,但它不是一个原子操作。C#中string是不可变类型,每次拼接都会在堆上创建新的string对象,并且把旧字符串的内容复制过去。如果这行代码每帧执行一次,意味着每帧都分配一个或几个新字符串,旧字符串成为垃圾,攒一会儿就触发一次GC。
我见过一个实际案例:一个排行榜界面,每帧刷新10个玩家名字和分数,每个都用“名字 + “:” + score + “分””拼一段,一帧下来GC Alloc 6KB左右。听起来不多,但那是持续新增的,半分钟后托管堆堆了几百KB,GC频率从每10秒一次变成每2秒一次,帧率就开始跳。改法很简单:能用StringBuilder的就用StringBuilder,并且把它做成类字段或静态缓存,
sb.Clear()
后追加新数据。如果是简单的“前缀+数值+后缀”,其实也可以直接把数字
score.ToString()
转成一个缓存好的string,再拼接加法——但ToString本身也可能分配,更稳妥的做法是预先算好可能的数值范围,用查表法,或者干脆用Unity的
TextMeshPro
里面那个
SetText(string, int)
重载,它避免了临时字符串。
风险提示:如果只是偶尔触发一次,比如打开设置面板时才拼接一次UI字符串,这种量级的分配通常无所谓,别过度优化。但凡是跑到Update、协程循环、事件频繁触发里的字符串操作,一律默认使用StringBuilder或预定义完整格式。
3.2 LINQ:优雅的代价
C#的LINQ写起来是真的爽:
list.Where(x => x.id == 1).FirstOrDefault();
一行顶十几行循环。但爽的背后是实打实的分配。
Where
会创建一个迭代器对象,
FirstOrDefault
要用一行
enumerator.MoveNext()
去遍历,整个链式调用还要生成委托、可能创建闭包容器。在老Mono运行时上这几乎每一层都是new。IL2CPP下情况好一些,但依然会产生额外的托管对象。
我并不是说完全禁用LINQ。在编辑工具、非高频逻辑、启动加载这些对性能不敏感的地方,用LINQ可读性的收益远大于分配损失。但如果在战斗逻辑的碰撞检测、每帧的射线命中判空数组、每帧的敌人列表筛选里用LINQ,那就是在给帧率埋雷。替换方案也很朴素:手写
for
循环,自己维护结果List或数组。看起来代码长了点,但0 GC Alloc,而且对缓存友好。
特别注意一类陷阱:
OrderBy
这类排序操作会把整个集合复制到新数组里做排序,GC Alloc和集合大小成正比。一个200个元素的列表做一次排序可能分配几KB,如果每帧都做,那真是灾难。能优化的思路是:改用可复用数组的
List.Sort
,或者维护一个“是否需要重新排序”的脏标记,只在数据变化时排序。
3.3 装箱与拆箱:藏在值类型里的分配
每次把一个值类型(int、float、Vector3、枚举、struct)转换成object或接口类型,都会发生装箱(Boxing),也就是在堆上创建一个新对象,把值复制进去。这个过程的GC Alloc是隐式的,代码里往往看不到
new
。典型场景:把int塞进
ArrayList
或
List<object>
,在Debug.Log里用“+”拼接数字,往
string.Format
里传数字,以及把一个值类型变量赋值给
object
类型的参数。
我见过最夸张的一段代码,是把玩家的等级、金币、攻击力等十几个整数值全部放在一个
Hashtable
里做配置,每次读取都要
(int)table["level"]
,读取过程拆箱一次。这还不算,如果往表里写入临时值,装箱又分配一个对象。整个战斗流程每帧读几十次这种Hashtable,GC Alloc直接把Profiler刷红。
拆解的改法有两个层面:第一,坚决用泛型容器替代非泛型容器,比如
Dictionary<string, int>
而不是
Hashtable
,
List<MyStruct>
而不是
ArrayList
。第二,对频繁拼接的字符串,别依赖“+”的隐式装箱,直接用StringBuilder追加数字的方法,或者先调用
ToString()
(注意ToString不一定无分配,但至少它不经过中间对象)。顺带提一个冷知识:枚举转字符串
myEnum.ToString()
也是装箱+反射的分配大户,频繁输出调试信息时尽量用预缓存的字符串数组,或者改用整数ID。
3.4 闭包与匿名函数:每次Lambda都在“new”一个对象
C#的Lamba表达式是个语法糖,它背后会生成一个编译器闭包类。如果一个Lambda捕获了外部变量(比如循环里的计数器、局部的敌人对象),编译器会为它创建一个新的闭包对象,并把捕获的变量保存在对象字段里。于是,执行到这一行Lambda时,就在堆上多了一个闭包实例。哪怕Lambda本身很短,分配也照发生不误。
实际操作中,最常见的是在Update里写
someButton.onClick.AddListener(() => { DoSomething(); });
,虽然这个AddListener写一次之后按钮就不会再触发,但如果你是在一个动态创建的UI上,每次生成这个UI都AddListener一次,闭包就等于UI数量。改进方案:优先使用“绑定接收者实例”的委托
void OnClick(){ DoSomething(); }
,把方法名直接传给AddListener,这样每次创建的是同一个委托实例,不会为闭包单独分配。如果确实需要捕获参数,建议在UI初始化时新建一个UI事件数据类,通过统一回调方法传递数据,避免每次点击重建闭包。
协程是另一个隐蔽分配点。
StartCoroutine(MyCoroutine())
本身会分配一个协程对象,而
yield return new WaitForSeconds(1f);
这行每次执行都会new一个WaitForSeconds实例。一个5秒的连招动画如果每帧yield一次新WaitForSeconds,几秒内也能堆不少垃圾。改成在协程开始前把WaitForSeconds实例缓存成字段:
private readonly WaitForSeconds delay = new WaitForSeconds(1f);
再
yield return delay;
,就能把每次yield分配降为0。同样的思路也适用于
WaitForEndOfFrame
、
WaitForFixedUpdate
这些。
3.5 看似无害的API和组件访问
有些Unity API本身就会产生GC分配。
GetComponents<T>()
(带s的复数版本)每次调用都会返回一个新数组,哪怕数组里只有一个元素,也会重新分配。如果你在Update里频繁调用
GetComponents<Collider>()
,那差不多等于每帧new一个数组,属于比较隐蔽的罪魁祸首。替代方案是把这个获取动作放到Awake或Start里缓存,或者用
TryGetComponent
(较新的Unity版本已提供,无分配)。
另外,
GetComponent<T>()
在旧版本Unity里其实也会有一些内部查找开销,但不一定分配托管对象。为了保险,我始终建议高频引用(Transform、Renderer、Animator、Text等)在初始化时缓存成私有字段,不要在Update里反复GetComponent。这条看似基础,但我真在项目里抓到过一个写了一年Gameplay的团队,他们移动代码里每帧GetComponent
(),全项目到处是这种写法。
再有一类是渲染和UI的API。比如
Camera.main
每次调用对主摄像机做查找,内部会缓存但第一次还需对象查找;
Camera.main
本身无GC,但涉及类似消息系统的方法可能分配。真正高频分配大户之一是UGUI的
Text.text
赋值:它会检查文本改变,但如果传入的字符串是每帧新拼的,本身就分配了;而在某些Unity版本中,设置TextMeshPro的文本如果使用简单字符串变量赋值通常也不分配,只是内部字符缓冲需要容纳新文本,会触发缓冲扩容才分配。因此UI刷新时,尽量复用同一个TextMeshPro对象,并用
SetText
的重载或预格式化的BigInteger/字符串,避免文本内容长度变化过大导致内部缓冲反复扩容。
4. 从对象池到零GC分配:一套可以直接抄走的优化方案
排查出分配点之后,下一步就是动手把高频路径的GC Alloc降到合理范围。我的总原则很简单:低频分配不必刻意消灭,高频分配必须用一个模式去解决。这里分享几个我已经在多个项目里验证过、可以直接套用的方案。
4.1 一个基础对象池模板
对象池几乎是所有Unity项目必备的组件,适用于子弹、敌人、伤害飘字、技能特效、拾取物等频繁创建和销毁的对象。实现逻辑不复杂:准备一个栈或队列,存着空闲实例;需要时从栈里弹一个,没有就new一个;用完后再把实例放回栈里,而不是Destory掉。直接贴一个精简版模板:
public class ObjectPool<T> where T : Component
{
private readonly Stack<T> pool = new Stack<T>();
private readonly T prefab;
private readonly Transform parent;
public ObjectPool(T prefab, Transform parent, int preloadCount = 0)
{
this.prefab = prefab;
this.parent = parent;
for (int i = 0; i < preloadCount; i++)
{
var obj = CreateNew();
obj.gameObject.SetActive(false);
pool.Push(obj);
}
}
public T Get(Vector3 position, Quaternion rotation)
{
T item = pool.Count > 0 ? pool.Pop() : CreateNew();
item.transform.SetParent(parent); // 可选重新挂载
item.transform.SetPositionAndRotation(position, rotation);
item.gameObject.SetActive(true);
return item;
}
public void Release(T item)
{
item.gameObject.SetActive(false);
pool.Push(item);
}
private T CreateNew()
{
var obj = Object.Instantiate(prefab, parent);
obj.gameObject.SetActive(false);
return obj;
}
}
实战中要注意两点:第一,对象池不是万能的,如果你的对象数量峰值很大(比如同时存在200个)而平均只有20个,池子会一直持有200个实例,反而挤占内存。所以要给池子设上限,超过上限的实例直接销毁,这个上限可以通过模拟峰值来设置。第二,用完的对象再放回池子时,一定要把它的状态重置干净,包括位置、旋转、Scale、颜色、Enabled标志等。很多诡异Bug就是对象从池里拿出来时还带着上一次的残影,处理这些状态重置的回调,可以让对象实现一个
IResetable
接口,在Release里统一调用。
4.2 缓存组件引用和预分配集合容量
这条可能不需要写太多,但我还是要强调:所有频繁使用的组件引用,都放到Awake或Start里存起来。比如一个Character类,在Awake里缓存
Transform
、
Animator
、
Rigidbody
、
Collider
,需要时直接用缓存字段,而不是到处GetComponent。这不仅是减少GC,也是减少查找开销。
集合容量预分配也特别容易被忽略。
List<T>
在Add时如果内部数组容量不够,会创建一个更大的数组,把旧元素全部拷贝过去,这个数组是引用类型,分配在堆上。如果你知道这个列表最多存30个元素,构造时就写成
new List<Enemy>(30)
,就避免了中途扩容的多次分配。同理,
Dictionary<TKey,TValue>
也可以预估容量构造。如果某个列表每帧都要清空再填充,千万别用
new List
,而是维护一个类字段,每次
list.Clear()
后继续用。Clear不会释放内部数组,只会把Count归零,整体分配就会小很多。
4.3 手写循环代替LINQ的实践
给个最简单也最常用的漂亮例子。原来的写法:
// 找出第一个活动的敌人
Enemy target = enemies.FirstOrDefault(e => e.IsAlive && e.Distance < 10);
改成手写循环:
Enemy target = null;
for (int i = 0; i < enemies.Count; i++)
{
Enemy e = enemies[i];
if (e.IsAlive && e.Distance < 10)
{
target = e;
break;
}
}
这段代码的GC Alloc从原来的一次迭代器+委托+闭包变为0。如果这个敌人查找逻辑每秒执行几十次,节省出来的分配量非常可观。类似地,
Where
过滤后取前几个需求,用手写循环也能轻松做到。说白了,增强型for和普通for本身都不产生分配,分配的来源主要是LINQ内部的迭代器与委托。
4.4 更隐蔽的“伪优化”:小心缓存的坑
有些情况下,你的“优化”反而会增加GC。我见过一个项目,为了减少字符串拼接,在启动时新了一个巨大的StringBuilder静态字段,然后在每个协程、多个线程里都去调用它,完全忘了StringBuilder是线程不安全的,同时高频率Clear和Append导致逻辑错乱。这种改法哪怕GC降了,也会引入更致命的稳定性问题。
还有一次,团队把每帧生成的一次性数组改成了
ArrayPool<byte>
,但调用方忘记归还,或者归还后还持有引用继续读写,导致数据被其他模块覆盖,出现了比卡顿更可怕的闪退。使用
System.Buffers.ArrayPool<T>
时一定要遵守“借用期内不能释放,释放后不能再碰”的规则。在游戏逻辑里,我一般不用这种底层池,而是用Unity的
NativeArray<T>
加Allocator.TempJob,更可控。
4.5 增量GC与时间片:必要时才用
Unity提供了Incremental GC(增量式垃圾回收),核心思路是把一次完整的GC分成多个小片,分散到不同帧执行,降低单帧的“Stop the World”时长。你可以通过Player Settings里的“Incremental GC”选项打开,或者运行时设置
System.GC.Collect()
的频率。它带来的收益是尖峰降低,但总CPU开销会增加,因为分片需要更多次调度。如果项目代码里本身每帧分配很少,开不开增量GC差距不大;如果代码里GC Alloc很高且你短时间没法全部消除,开增量GC确实能把40ms的尖峰摊平到每帧2~3ms,但综合帧率未必提高,可能还会更低。
我个人的经验是:增量GC适合作为“最后一道防线”,而不是第一天就开启。先做代码层面的分配消除,等把主力业务逻辑的GC Alloc压到每帧几十字节,再把增量GC关掉,用真正的性能数字做决策。别指望它救一个疯狂分配的项目——那样只是把灾难拉长了,摊薄了,但总CPU开销依然是瓶颈。
5. 优化之后怎么验证?别让“看似优雅”的判断骗了你
每次优化完GC,很多人一看GC Alloc从8KB降到0,就觉得万事大吉。但真机上卡不卡,还得看帧率曲线和玩家体验。我有一套固定的验证流程,这里分享出来。
第一步,同一台设备、同一场景、同一操作路径,用Profiler录优化前后的各10秒数据。对比指标除了GC Alloc总量,还要看“GC Invoke Count”(GC调用次数)和帧时间曲线上的尖峰频率。GC Alloc降了,但如果堆内存里残留了大量被池子缓存的实例,GC扫描时间未必降下来。所以真正要看的还有每个采样帧的Managed Heap Size,看它是否保持稳定。
第二步,真机操作阶段,打开Profiler的Timeline视图,把GC事件作为一个独立层显示。有些平台(如iOS的IL2CPP+Incremental GC)在Timeline中会以橙色块显示回收时间,你能非常直观地看到卡顿尖峰和GC块的对应关系。如果优化后GC块几乎不出现,帧率曲线就平滑了。
第三步,做一个最接近实际体验的测试:让玩家快速连招、频繁打开关闭界面、在UI里持续滚动列表,测试10分钟。很多GC问题是偶发的,短时间录制看不到,长测才能暴露。我这里说的偶发,是指某个缓存池扩容、某个对象首次创建时的预分配、或者是别人调用的某段低级库代码。
另外我有一个反过来的提醒:别被“0 GC Alloc”绑架。零分配是个很好的目标,但不应该成为唯一目标。如果为了消灭每帧几KB的分配,你把代码写得像天书,维护成本暴涨,反而得不偿失。优先消灭高频路径上的分配,低频路径上偶尔一次几十KB分配,只要不是频繁触发,对帧率的影响微乎其微。把精力放到真正让玩家感到卡的“每帧尖峰”上,才是性价比最高的优化方式。
我还分享一个自己踩过的坑。有次为了消除一个武器技能的GC,我把一个自定义的
BuffData
类改成struct,试图避免从堆上分配。结构体确实通常不分配堆内存,但一旦这个struct被塞进
List<BuffData>
,在整个列表扩容或返回时还是可能产生装箱或拷贝。更麻烦的是,struct的装箱在多次调用后反而产生了更多GC,而且代码里到处是用引用方式传struct才安全,简直是从一个坑跳进另一个更深的坑。所以优化时要看具体上下文,不能光盯着“引用类型换成值类型”这一个点子。
最后聊一个小技巧,我排查GC时经常用脚本在关键帧打印当前的GC总内存:
long before = System.GC.GetTotalMemory(false);
// 执行一段业务逻辑
long after = System.GC.GetTotalMemory(false);
Debug.Log($"Alloc: {after - before} bytes");
这在编辑器里快速验证某段函数是否产生异常分配很管用,尤其是Profiler不方便打开的时候。但别把它放到线上代码里,GetTotalMemory本身也有开销,而且它触发GC的次数不固定,对比结果仅供参考。
回到开头那句话:GC是帧率保卫战里最经典的隐形敌人。只要你能在Profiler的GC Alloc列里看见它,在Memory Profiler的堆快照里看清它,再用对象池和非分配写法压住它,卡顿基本就少了一大半。这个系列还有几篇会讲渲染开销、资源加载和其他隐藏瓶颈,但如果你连GC这道坎都迈不过去,后面的坑大概率会因为分配问题一个接一个地冒出来。先把这个最看不见的源头治住,你会忽然发现很多莫名其妙的掉帧,其实早就有答案了。

203

被折叠的 条评论
为什么被折叠?



