1. 这个系列要解决什么问题
做 Unity 性能优化的朋友,多半都有过这种经历:游戏跑起来帧率看着还行,帧时间曲线也不算离谱,但就是在某些时刻——切技能、开背包、刷怪、甚至只是播了个动画——画面突然肉眼可见地"钝"了一下。你盯着 Profiler 看半天,发现 CPU 占用不高,Draw Call 也正常,GPU 更是闲得发慌,那这个卡顿是从哪来的?
大概率,是 GC。
GC(Garbage Collection,垃圾回收)是 Unity 里最典型的"看不见的卡顿来源"。它不像 Draw Call 和 Overdraw 那样可以在 Frame Debugger 里直观看到,也不像复杂的 Shader 那样有明显的 GPU 开销标识。它藏在托管堆(Managed Heap)的分配与回收里,平时你感知不到,但当它触发时,一卡就是几十毫秒,直接把你的帧时间打穿。
这个系列前几篇聊了渲染管线的优化、资源的加载与卸载、CPU 侧的代码热点排查,今天这篇专门把 GC 单独拎出来,讲清楚三件事:GC 为什么会卡、GC 到底是怎么工作的、以及在 Unity 项目里我们真正应该怎么去治理它。
如果你是个刚接触优化不久的新手,这篇文章可以帮你建立一套"分配意识"——从写代码的第一天就知道哪些写法会埋下 GC 隐患。如果你已经做了几年优化,那这篇文章里的排查方法和避坑清单,应该也能让你在项目里少走不少弯路。
提示:本文讨论的 GC 机制基于 Mono 运行时下的 Unity 项目,IL2CPP 与 Burst 相关内容会单独提及,但不会展开得太深。
2. GC 到底是怎么把帧率搞崩的
2.1 托管堆分配与回收的基本逻辑
先补一个基础概念。Unity 里的 C# 脚本跑在 Mono 或 IL2CPP 运行时上,它们在底层维护着一块托管堆(Managed Heap)。你在代码里 new 一个对象、拼接一个字符串、用一次List.Add导致内部数组扩容——这些操作都会在托管堆上分配内存。
托管堆的内存不是用完就立刻还给操作系统的,而是留在堆里等下一次分配复用。当堆里的空闲空间不足、无法满足新的分配请求时,运行时就会触发一次 GC。GC 会遍历所有托管对象,找出那些已经没有任何引用的"垃圾"对象,把它们占用的内存回收回来,必要时还会对堆进行压缩整理。
这个过程本身没什么问题,问题在于它发生得太"突然"。
我在之前的项目里遇到过一种非常典型的场景:一个刷怪玩法,每波怪死亡时都会掉落大量掉落物,每个掉落物对应一个包含多个字段的类实例。玩家在波次结束时连续拾取,每拾取一个都会创建拾取提示、飘字、特效预制体引用等一堆临时对象。平时一切正常,但当拾取数量累积到某个临界点、托管堆被填满时,GC 一次触发就是 30 到 50 毫秒的停顿。在 60 FPS 的项目里,一帧的预算只有 16.7 毫秒,这一下直接掉到 20 帧以下。
这个"临界点"就是你堆的高水位线。GC 的触发频率和单次耗时高度相关:堆越小、分配越频繁,GC 就越频繁;堆越大、存活对象越多,单次 GC 扫描和整理的时间就越长。而最糟糕的,是在堆快满的时候才触发——那时可回收的对象很多,但存活对象也不少,GC 既要回收又要压缩,耗时自然高。
2.2 为什么 GC 会在游戏运行时"间歇性发作"
游戏逻辑不像服务器程序那样持续稳定地分配内存,它的分配模式是"脉冲式"的。玩家点击按钮、切换场景、释放技能、加载资源——这些时刻的分配量会瞬间激增。
问题在于,GC 的触发并不是均匀分布的。它只在分配请求无法被满足时才发生,所以表现就是:一段时间内堆内存一直够用,你完全感知不到 GC 存在;突然某个操作加大了分配量,堆不够了,GC 立刻触发,你的帧时间曲线就会出现一个孤零零的尖峰。
这个"堆够用"的阶段里,托管堆其实已经被垃圾对象占了大半,只是还没到触发线而已。换句话说,GC 卡顿不是你某一行代码"写错了"导致的,而是你整个项目的分配节奏和 GC 的触发机制天然不匹配。
我做过一个简单的测试示例来理解这个现象:在一个 Update 循环里每帧创建一个 1 KB 左右的临时字节数组,然后用完就丢。看起来每一帧的开销都是"毫秒级以下"的,但帧率曲线在 60 FPS 附近稳定几秒后,就会规律性地出现一次 20 毫秒以上的尖峰。原因很简单:每帧 1 KB 的分配量很小,但连续 60 帧就是 60 KB,几秒钟后堆空间耗尽,GC 启动,所有未引用的数组被回收——然后下一个循环周期又开始了。
这就是 GC 卡顿最迷惑人的地方:它不像 GPU 瓶颈那样持续稳定地拉低你的帧率,而是"间歇性发疯"。你抓帧的时候很可能恰好错过了那一帧,等你想复现的时候又怎么也复现不出来。
2.3 分配量与 GC 耗时的量化关系
在 Unity Profiler 里,GC 的耗时通常标在 CPU 的GC Alloc和GarbageCollectAssets这类条目上。但这里有个比较坑的点:Profiler 里显示的GC Alloc表示的是"分配了多少字节",而不是"GC 停顿了多少毫秒"。
我之前见过不少团队,看到某帧 GC Alloc 只有 2 KB,就觉得这帧没问题。但真相是,2 KB 的分配本身确实没什么成本,但它可能刚好是压垮骆驼的最后一根稻草——这 2 KB 分配出去之后,堆满了,GC 被触发,然后一卡就是几十毫秒。分配给 GC 的负债,GC 可能晚几十帧才让你还。
所以量化和排查 GC 问题时,一定要关注两个维度:一是分配量本身(GC Alloc),二是 GC 实际触发的次数和单次耗时。前者可以用 Profiler 的 CPU 模块直接看,后者需要你结合帧时间曲线和日志里的GC.Collect来推算。
实操建议:在开发期打开 Unity 的
Log Assert或者手动在关键帧节点记录GC.GetTotalMemory(false)和GC.CollectionCount(0),配合帧时间一起输出,能很快定位哪些玩法操作会触发 GC 尖峰。
3. Unity 项目里最常见的 GC 分配来源
3.1 字符串拼接:看似无害的隐形大户
在所有 GC 分配的来源里,字符串拼接几乎可以排到前三,但大多数开发者对它完全没有警惕。
一个很典型的例子:
void Update() { uiText.text = "Score: " + score.ToString(); }这行代码看起来人畜无害,但实际上它执行了至少两次托管堆分配:一次是score.ToString()创建了一个新的字符串对象,另一次是"Score: " +拼接又创建了一个更大的字符串对象。如果这行代码放在 Update 里,每帧都会产生两个字符串对象,60 秒就是 7200 个垃圾字符串,这些最终都要靠 GC 来回收。
我在一个 UI 项目里做过一次彻底排查,仅仅是把一个 HUD 上的飘字更新从字符串拼接改成预先提取数字前缀、再用StringBuilder复用,GC Alloc 就从每帧约 8 KB 直接降到了近乎为 0。修改量不大,但帧时间的毛刺肉眼可见地变少了。
字符串问题在日志、调试信息和热更新系统里更严重。很多团队为了方便排查问题,在战斗中频繁Debug.Log,日志内容里又带大量拼接。开发期没问题,但如果你不小心在发布版本里忘了关掉这些日志,它们每一行都在制造垃圾对象,GC 负担直接拉满。
实操建议:发布版本务必关闭不必要的
Debug.Log,或者用日志开关统一控制。一个简单的#if UNITY_EDITOR || DEVELOPMENT_BUILD宏就能把正式包的日志分配降到接近于零。
字符串问题的完整治理方案包括:
- UI 上频繁更新的文本,用
StringBuilder或预格式化的字符串模板替换拼接。 - 数字转字符串时,尽量避免每帧
ToString(),缓存常量或使用自定义的快速转换方法。 - 日志系统里用
string.Format也尽量少用,它内部会产生装箱和临时数组。可以改用占位符拼接或使用专门的日志格式化器。 - 热更框架里的配置表读取,如果频繁取字符串字段,考虑做好缓存。
3.2 装箱(Boxing):每次调用都在交"隐形税"
装箱是另一类高频分配。当一个值类型(int、float、struct)被赋给 object 类型的变量、作为 object 参数传给方法、或者被插值进字符串时,CLR 会在堆上创建一个包装对象,把值类型的数据拷贝进去。这个包装对象就是垃圾,等你下次 GC 时被回收。
最简单的例子:
object boxed = 42; // 装箱 Debug.Log("Value: " + someInt); // 装箱 + 字符串拼接Debug.Log的重载里没有直接接收 int 的版本,所以你会发现传int进去时它实际上走了object参数,触发装箱。这种开销在每帧都执行的地方特别致命。
装箱在 Unity 项目里几乎无法完全避免,因为某些框架层 API 内部就会装箱,但我们可以做到在热点代码里避免主动触发:
- 不要用
object类型接收值类型变量。 - 使用泛型方法(
T)替代object参数,避免隐式装箱。 - 字符串拼接时避免拼接值类型,先转成字符串再拼(当然最好直接用 StringBuilder)。
- 事件系统和委托中避免传递值类型裸数据,实在要传就包一层 struct 并复用。
- 字典的 key 如果是 int、float 等值类型,要注意部分 API 实现内部会装箱比较。尽量用自定义的
Dictionary<int, T>而非Dictionary<object, T>。
注意:装箱分配极其微小,单次装箱可能只有几十字节,但它单个 GC 周期内可能发生数万次,积少成多,GC 压力就是这么来的。
3.3 LINQ 与闭包:写法酷炫但对 GC 不友好
LINQ 和 Lambda 闭包是现代 C# 里非常常见的写法,但它们在 Unity 的 Mono 运行时下往往会产生大量隐形分配。
拿Where举例:
var validTargets = allTargets.Where(t => t.IsAlive).ToArray();这段代码在运行时会发生什么?Where返回的是迭代器,迭代器本身是一个状态机对象,需要分配;t => t.IsAlive这个 Lambda 如果捕获了外部变量,还会生成一个闭包对象;ToArray()又分配了一个数组。三处分配,三个垃圾对象。
在列表只有十几个元素的逻辑里这没什么,但在每帧都要做的事里,这种写法直接让你的 GC Alloc 飙升。我见过一个单位的 AI 系统,每帧对单位列表做一次OrderBy排序取最近的敌人,结果每帧分配了 4 到 5 个数组和迭代器对象,直接导致 GC 频繁触发。
需要注意的是,并非所有 LINQ 都有相同的分配行为。比如List<T>.ForEach在某些运行时下可以通过内部优化避免分配迭代器,但Where.Select.OrderBy的链式调用几乎必然产生迭代器链。IL2CPP 下部分 LINQ 会做内联优化,但也别指望它完全消除分配。
实操建议:如果你不想彻底放弃 LINQ,至少确保它在低频调用(如按钮点击)中使用,而不是放在 Update 或 OnGUI 等高频路径里。热点循环里建议自己写 for 循环 + 手动筛选。
Lambda 闭包的问题类似。局部变量被捕获后,编译器会生成一个闭包对象,闭包对象本身在堆上分配。如果把 Lambda 传给一个不会立即执行、而是存下来之后调用的方法(比如事件、协程、协程回调),闭包对象的生命周期会拉长,垃圾和内存占用都会上升。
避免方法:
- 用普通的私有方法替代 Lambda 回调。
- 如果必须用 Lambda,确保它不捕获局部变量(无捕获的 Lambda 可以被静态方法替代)。
- 热点循环内不要用带捕获的 Lambda。
- 协程和事件注册的 Lambda 要注意注销时机,避免闭包对象长期存活。
3.4 协程与数组:藏在异步逻辑里的持续分配
协程(Coroutine)在 Unity 里的实现本身就会分配额外对象。每一个StartCoroutine调用都会创建一个协程对象,如果协程体内有yield return,每个yield也会触发一次状态机的对象分配。
更麻烦的是,许多常见的yield写法本身就会分配对象:
yield return new WaitForSeconds(1f);new WaitForSeconds每执行一次就创建一个新对象。一个每 5 秒触发一次的协程,运行一分钟就产生了 12 个临时对象。如果项目里有几十个协程在跑,这个分配量相当可观。
我以前在项目里做过一个优化:把全局常用的WaitForSeconds缓存成静态字段复用,GC Alloc 立刻降了不少。
另一种高频分配来源是数组和List<T>。很多人习惯用new List<int>()然后在循环里Add,但List<T>在容量不足时会自动扩容,扩容操作会分配一个新数组并把旧数据拷贝过去。如果在 Update 里反复创建和扩容 List,垃圾对象会越来越多。
数组分配本身也不可避免,但可以优化:
- 热点路径里用对象池(Object Pool)复用数组和 List。
- 预先指定容量(
new List<T>(capacity)),减少扩容次数。 - 避免在多帧循环中反复创建临时数组。
- 用
ArrayPool<T>(.NET Standard 2.1 或 Unity 的NativeArray)应对高频临时数组需求。
3.5 UI 与动画:Text 和 IMGUI 的隐性分配
Unity 的 UI(UGUI)在更新大量文本和布局时,会产生比我们想象中更多的 GC 分配。很多新手以为 UI 卡顿是渲染问题,但实际上Text.text的赋值、LayoutRebuilder的刷新、以及IMGUI的 OnGUI 都会持续分配。
Text.text赋值会触发一次文本布局计算,这个过程会分配临时的TextGenerationSettings、List<UIVertex>等对象。如果你的 HUD 上有很多每帧都在变动的文本,每秒分配的临时对象数量会非常可观。
OnGUI的问题更严重。IMGUI 系统本身是即时模式,GUILayout、GUIStyle的调用会在每帧都创建大量的临时对象,尤其是在编辑器里。如果在正式包里还保留着 OnGUI 的调试面板,那基本等于给 GC 加了持续压力。
实操建议:正式包中彻底清理 OnGUI 调用,UI 文本更新频率尽可能降到"仅在数值变化时更新",而不是每帧都赋一次相同的字符串。
优化 UI 分配的几个方向:
- 预生成文本对象,只在内容变化时更新。
- 使用
TextMeshPro替代旧版 UGUI Text,它的文本生成开销更低,且支持SetText避免不必要的字符串分配。 - 频繁刷新的列表,考虑使用虚拟化列表(如
VirtualizingScrollRect或LoopListView)。 - UI 动画(如透明度、位移)使用 DOTween 的回调尽量传入预缓存参数,避免每帧 Lambda 闭包分配。
4. 从 Profiler 到代码:找到 GC 卡顿的真凶
4.1 Profiler 的正确打开方式:过滤掉干扰项
GC 问题的排查,第一步就是看 Profiler,但新手很容易被海量信息带偏。
具体操作:打开 Window > Analysis > Profiler,切到 CPU Usage Profiler 模块,在左上角的Target Selection里选玩家设备或编辑器。然后在Hierarchy视图下,可以看到每一帧的各种系统调用耗时和 GC Alloc 字节数。
关键的一步:在Hierarchy视图的右上角,有一个"排序方式"下拉菜单,切换到GC Alloc,它会按每帧每个函数分配的字节数降序排列。这样你就能一眼看到谁在真正制造垃圾。
我通常在优化前一帧画好一个"基线快照":记录帧时间、GC Alloc、GC 触发次数,然后针对最大分配源逐项优化,每优化一项就再看一次基线,对比是否下降。不要让 Profiler 只当"体检报告",要让它当"跟踪定位器"。
4.2 用 Deep Profile 精准定位分配点
普通的 Profiler 在 Release 模式下抓到的分配信息是"按函数聚合"的,它只能告诉你这个函数一共分配了 100 KB,但具体是哪一行代码产生的,得靠Deep Profile才能看到——但这个功能有坑,开启后会让所有脚本方法都被插桩,性能开销翻倍,帧率会明显下降。
所以我的习惯是:
- 先用普通 Profiler 定位到分配量大的顶层函数。
- 然后只针对那个函数,开启 Deep Profile 抓一次帧,或者直接在该函数入口和关键分支处手动加计时和
GC.GetTotalMemory打点。 - 找到具体的分配代码行后,关闭 Deep Profile,改完代码后再用普通 Profiler 确认。
注意:
Deep Profile抓到的 GC Alloc 数字会被插桩本身放大,不要拿它当精确值,只看相对趋势。
编辑器下还有一个小技巧:在 Console 窗口把Logging里的Exception打开,跑一遍目标玩法,如果看到大量Allocation报警(某些版本会输出"Allocation"相关错误日志),那基本可以确定这些分配源头都在日志里被你一网打尽了。
4.3 帧时间曲线的"毛刺"怎么看
GC 卡顿最典型的 Profiler 表现,是帧时间曲线上出现孤立尖峰,周围帧的耗时都正常。这时候不要只盯着尖峰那一帧看,要看它前后几十帧的分配趋势。
我之前处理过一个"每隔几秒掉一次帧"的诡异问题:帧时间曲线每 6 秒左右就出现一个 35 毫秒左右的尖峰,但 Profiler 的 CPU 耗时里并没有任何单帧函数超过 10 毫秒。排查了很久才发现是某个异步加载回调里每 6 秒执行一次,每次会创建一张 2 MB 左右的临时 RenderTexture,然后立刻丢弃。虽然没有在那一帧触发 GC,但堆空间被持续挤占,GC 的触发时机被推后到了另一帧,于是尖峰就"随机"出现在了一个看起来完全无辜的帧上。
所以排 GC 问题时,记住一句话:GC 尖峰出现的那一帧,往往不是分配量最大的那一帧,而是堆满的那一帧。真正的大户,要去尖峰之前的那些帧里找。
4.4 用 Unity Memory Profiler 看堆内部分布
这里推荐一下 Unity 自带的 Memory Profiler 包(com.unity.memoryprofiler),它可以抓取托管堆的整体快照,区分出每个对象类型占用的内存大小。虽然它不像 CPU Profiler 那样能定位到代码行,但能帮你确认哪些类型的对象占用了大量托管堆空间。
举个例子:排查时发现托管堆总占用 200 MB,但代码里找来找去只看到几 KB 的临时分配。这时候用 Memory Profiler 抓快照,可能会发现某个缓存容器里存了大量永不清理的Texture2D或Material引用,它们不是 GC 垃圾,而是真正的内存占用大头。
GC 卡顿优化的目标,不只是减少分配量,还要降低托管堆的峰值占用。堆越大,GC 单次扫描的时间越长。Memory Profiler 能帮你找出那些"看起来合理但长期占用堆内存"的对象,比如没有及时清理的列表、缓存了过多永久对象的容器、以及过度预加载的资源。
5. 我能跑多快就多快:Unity 项目里的 GC 优化实操
5.1 对象池:让高频临时对象"起死回生"
对象池可以说是 Unity GC 优化的第一板斧。它解决的核心问题是:让某些频繁创建销毁的对象不再被反复 new 出来。
对象池的思路很简单:创建一个容器,预先生产一批对象,用的时候从池里取,用完了还回去,而不是直接丢弃让 GC 回收。
public class ObjectPool<T> where T : class, new() { private readonly Stack<T> _pool = new Stack<T>(); public T Get() { return _pool.Count > 0 ? _pool.Pop() : new T(); } public void Release(T instance) { _pool.Push(instance); } }这只是个最朴素的实现。实际项目里,对象池还需要考虑初始化、重置状态、容量上限、线程安全等问题。
用对象池能直观减少分配量的经典场景:
- 子弹、敌人、掉落物等 GameEntity 的实例化与销毁。
- 飘字、特效、伤害数字等生命周期极短的 UI 元素。
- 频繁创建和释放的 List、数组等容器。
- 频繁调用的协程对象(把协程实例池化复用)。
我见过一个极端案例:某射击游戏里子弹对象是纯 C# 类,每次开火都 new 一个实例,玩家火力全开时每秒创建几百个对象。加了对象池之后,GC Alloc 直接降了一个数量级,GC 触发频率肉眼可见地下降了。
关于对象池的注意事项:
- 池里的对象必须彻底清理状态,否则可能复用到脏数据。
- 池容量要动态调整,避免空闲时池占内存过大。
- 如果对象持有 Unity 原生资源引用(如
GameObject、Texture),池化时要特别注意引用泄漏。建议配合GameObject.Instantiate的预制体池使用,而不是纯 C# 对象池。
5.2 缓存与复用:能拿到就不重造
缓存和对象池的差别在于:对象池管的是"生命周期短、频繁创建销毁"的对象,缓存管的是"内容昂贵、读取频繁"的数据。两者经常配合使用。
一个最典型的缓存场景是WaitForSeconds:
public static class Yielders { private static readonly Dictionary<float, WaitForSeconds> Cache = new Dictionary<float, WaitForSeconds>(); public static WaitForSeconds Wait(float seconds) { WaitForSeconds wfs; if (!Cache.TryGetValue(seconds, out wfs)) { wfs = new WaitForSeconds(seconds); Cache.Add(seconds, wfs); } return wfs; } }这样协程里yield return Yielders.Wait(1f)就只会为每个不同的时长分配一次对象,而不是每次协程执行时都 new。
类似的缓存思路还能用在这些地方:
- 数字转字符串:高频使用的整数(0-100)用静态数组预先生成字符串,避免
ToString()。 - UI 文本:常用的格式化模板提前生成,不要在每帧重新拼。
- 反射结果:如果项目用了大量反射(比如配置表映射),缓存 FieldInfo、MethodInfo、Type,避免每次调用都查一遍。
- 常用列表和数组:容量固定的临时列表可以缓存复用,比如每帧收集敌人目标时用同一个 List 清空再填。
5.3 结构体替代类:用值类型把分配"抄近道"
在 C# 里,struct(值类型)和class(引用类型)的关键区别在于:结构体实例通常分配在栈上或内嵌到其他对象里,不会在托管堆上单独分配;类实例则一定在堆上分配,是 GC 的直接管理对象。
如果一段代码里创建了很多小对象,但又不修改它们的内容,可以考虑用struct替换class:
一个典型的例子是坐标数据结构:
// 堆分配版本 public class GridCell { public int X; public int Y; public int Type; } // 栈/值类型版本 public struct GridCellStruct { public int X; public int Y; public int Type; }new List<GridCell>(1000)时,用GridCell类会产生 1000 个堆对象,而用GridCellStruct只产生一个数组,里面内嵌 1000 个连续的结构体。GC 压力天差地别。
不过结构体不是银弹。如果结构体被装箱(比如放进object类型容器),它同样会在堆上分配包装对象。结构体过大(超过几个字段),拷贝成本会超过引用传递的成本,反而不划算。
实操建议:小于 16 字节的小数据、不需要继承多态的、生命周期短的数据结构,优先考虑用 struct;频繁修改内容且体积大的,保留 class 更合适。
5.4 慎用协程,必要时改用异步/状态机
协程虽然写起来方便,但正如前面所说,它本身就是 GC 分配大户。一个项目里如果协程满天飞,GC 压力会非常明显。
拿一个战斗系统的技能流程举例:技能释放本身是个非常高频的操作,如果每个技能都用协程去控制播放、等待、回调,那么每放一个技能就产生至少 3 到 5 个协程对象和yield对象。如果一个玩家一秒钟放三个技能,那就是每秒钟 10 到 15 个垃圾对象。
协程的优化方向有几个:
- 缓存 WaitForSeconds / WaitForEndOfFrame / WaitForFixedUpdate 等 yield 对象实例,这个效果立竿见影。
- 用非协程方案替代简单延时逻辑:比如用
Invoke或自己维护的计时器系统。 - 复杂异步流程用状态机或异步方法(async/await)重写,如果 Unity 版本支持的话,
UniTask是一个不错的替代方案,它把协程的分配降到了最低。
举个简单的状态机替代协程的示例:
class SkillSequence { private float _timer; private int _state; public void Update(float dt) { switch (_state) { case 0: PlayAnimation(); _timer = 0.5f; _state = 1; break; case 1: _timer -= dt; if (_timer <= 0) { ApplyDamage(); _state = 2; } break; case 2: // 结束 break; } } }这个写法没有任何堆分配,逻辑清晰度不亚于协程,性能却好得多。
5.5 字符串和日志的"零分配"改造
字符串是 GC 大户,但也不是完全没法做到几乎零分配。核心思路是:把字符串拼接从"每次生成新对象"改成"复用同一个可变字符缓冲区"。
StringBuilder是可以复用的工具对象。可以把它静态缓存起来,每次使用前Clear():
private static readonly StringBuilder _builder = new StringBuilder(256); public static string BuildHudString(int score) { _builder.Clear(); _builder.Append("Score: "); _builder.Append(score); return _builder.ToString(); }注意ToString()仍然会产生一个字符串对象,但至少拼接过程零分配。如果 UI 文本赋值支持直接给StringBuilder(如 TextMeshPro 的SetText),那连最后的ToString都能省掉。
日志改造同理。写一个自己的Log类,内部用 StringBuilder 和日志等级控制,发布版本直接跳过所有调用:
public static class Log { [System.Diagnostics.Conditional("ENABLE_LOG")] public static void Info(string format, params object[] args) { // 内部用 StringBuilder 格式化,避免 string.Format 装箱 } }用Conditional特性比#if宏更优雅:Conditional会让编译器在未定义预处理符号时直接移除调用代码,但要求方法的参数类型不能被省略。实际上如果你追求严格零分配,最好的方式还是发布版直接不调用任何日志方法。
5.6 用 ArrayPool 和 NativeArray 降低临时数组开销
临时数组在 Unity 里也很常见。每帧计算一些临时的Vector3数组、RaycastHit数组,用完就丢。这种需求可以用System.Buffers.ArrayPool<T>来做,它把一个池化的内存块借给你,用完还回去,不产生堆分配。
using System.Buffers; var hits = ArrayPool<RaycastHit>.Shared.Rent(64); try { int count = Physics.RaycastNonAlloc(ray, hits, 100f); // 使用 hits[0..count] } finally { ArrayPool<RaycastHit>.Shared.Return(hits); }这段代码比RaycastAll(返回新数组)高效得多,因为RaycastNonAlloc + ArrayPool的组合不会分配新数组,而是复用池里的内存。
在 Unity 的 DOTS / Job System 里,NativeArray<T>也有类似作用。它分配的是非托管内存,不受 GC 管理,但必须手动Dispose()。用得好能彻底把临时数据搬出托管堆,用得不好会造成内存泄漏。
注意:
ArrayPool返回的数组长度可能大于请求的长度,使用时必须基于count而不是array.Length遍历。
6. GC 优化的常见坑与排查技巧
6.1 别迷信 "GC Alloc = 0"
有些开发者把 GC Alloc 当成 fps 的替代指标,看到某帧 GC Alloc 是 0 就放心了。但 GC 卡顿的根本原因是"堆满后触发 GC",而不是"分配量本身"。所以:
- GC Alloc 是 0 不代表没有 GC,可能只是分配发生在上一帧,这帧恰好没新的分配。
- GC Alloc 是几十 KB 也不代表一定会卡,关键要看它是否导致了 GC 触发。
- 如果你把一帧的 GC Alloc 降到 0,但上一帧分配了大量对象且堆已满,那么下一帧仍然会触发一次大 GC。
我在项目里见过最典型的例子:团队花了一周把所有 Update 里的临时分配都清理干净了,但帧时间还是每隔一段时间掉一次。最后发现是资源加载系统在后台持续加载 AssetBundle,每加载一个 Bundle 都会创建一堆临时对象。那些对象不在 Update 路径里,所以普通 Profiler 的"逐帧分配"列表里根本没有它们。
排查经验:GC 卡顿不止要看 Update 路径,还要关注异步回调、协程、资源加载回调、物理回调(OnCollisionEnter、OnTriggerStay)等所有可能在帧间"突发"执行的代码路径。
6.2 小心 "过度优化":牺牲可读性换性能
GC 优化最大的陷阱之一,就是过度追求零分配,把代码写得像天书一样难维护。
举个例子:有些团队为了不产生字符串分配,把每个 UI 文本的显示逻辑都改成手动拼接字符数组,代码可读性瞬间下降,改一处要动七八个地方。优化确实有效,但对团队协作和后续维护是灾难。
我的建议是建立"分配预算":
- 高频路径(Update、FixedUpdate、LateUpdate、OnGUI):严格要求 GC Alloc 接近于零。
- 中频路径(按钮点击、技能释放、每 1 秒一次的逻辑):允许少量分配,但要有意识控制。
- 低频路径(场景加载、配置初始化):分配量大一点问题不大,因为 GC 可以被你主动安排在加载时的空闲帧。
这种分级处理后,你就不用在每一行代码上都战战兢兢,把宝贵的优化精力集中在真正影响帧率的地方。
6.3 你改了代码,为什么卡顿还在
我排查过很多"改了但没效果"的案例,总结下来,大多数原因是:
你优化的不是真正触发 GC 的那个分配点。GC 的触发在时间上是滞后的,你清掉了一个 Update 里的分配源,但 GC 尖峰可能在半秒后由其他分配源触发。所以优化时必须同时看"分配总量"和"GC 触发次数",改完之后再观察几分钟的帧时间曲线,确认尖峰是否消失,而不是只盯着一帧数据。
隐藏的第三方库代码在分配合并。比如 DOTween、TextMeshPro、Unity UI 内部实现都有自己的缓存池和临时对象,它们的分配行为在你自己的代码之外。Profiler 里看着像"某个 UI 系统函数分配了很多",但实际触发点可能在它的内部。这种时候不要贸然改库源码,优先调整自己的调用方式(比如减少 DOTween 的创建频率、避免 TMP 的 SetText 传入新字符串)。
编辑器自身的分配干扰。在 Editor 中跑游戏,编辑器自身(比如程序化导入、Asset Database 刷新、Inspector 刷新)会产生大量托管分配,和游戏本身的分配混合在一起。如果你在 Editor 里看到一个莫名其妙的 GC 尖峰,先确认它是否是编辑器进程自身的开销。用真机测试能避免这个干扰。
6.4 为什么真机表现和编辑器差很多
这是 Unity 优化里非常经典的问题:编辑器下的 GC 行为和真机差距极大。
原因是编辑器的执行环境和 Mono/IL2CPP 运行时不完全相同:
- Editor 有自己的编辑器代码和资源管理进程,分配模式与独立 App 不一样。
- Editor 下的 GC 策略通常比真机宽松,堆大小和触发阈值不同。
- IL2CPP 后托管堆的布局和 GC 策略(增量式 GC、并发 GC)也会发生变化。
我遇到过的实际情况是:同样一段代码,在编辑器里每帧 GC Alloc 显示 5 KB,帧率稳定 60;但打包到 Android 真机上,帧率只有 40 出头,GC 尖峰非常明显。这是因为真机上的 Mono 运行时堆更小、GC 触发更频繁,同样 5 KB 的分配在编辑器里根本不够触发一次 GC,在真机上可能每两三秒就压垮一次堆。
所以:
- 优化效果最好以真机 Profiler 为准,编辑器数据只做趋势参考。
- 在做发布包优化时,务必确认 Build Target 是 IL2CPP 还是 Mono,二者 GC 行为差异会直接影响你的优化结论。
- 有条件的话,在真机上用 Unity Profiler 的
Autoconnect Profiler抓取数据,别只看编辑器内的模拟结果。
7. 给不同阶段开发者的 GC 优化清单
7.1 还在学习期的开发者:先养成分配意识
如果你刚接触 Unity,我的建议不是先背一堆优化技巧,而是先养成"写代码时思考分配"的习惯。具体来说:
- 每写一个可能会被频繁调用的函数,先想一下它是否会产生临时对象。
- 打开 Profiler 时,先看 GC Alloc 而不是只看帧率。
- 多读优秀开源项目的源码,留意他们是怎么处理字符串、列表、协程和回调的。
- 不要等性能出现问题才去优化,而是在写第一行代码时就有意识地选择低分配方案。
我见过很多新手在项目初期写了大量 LINQ 和 Lambda 友好的代码,等开发后期 Profiler 一上,整个团队都在为这些代码买单。如果从一开始就控制得比较好,后面几乎不需要大改。
7.2 中期项目:建立 GC Alloc 基线,针对性优化
如果你的项目已经进入中期,功能很多,代码已经成型,那么别想着"一次把所有 GC 问题解决",那不可能也不现实。正确做法是:
- 用 Profiler 抓一次"典型一局游戏"的完整 GC Alloc 和 GC 触发记录,建立基线。
- 找出 TOP 5 的 GC 分配源,逐项优化。
- 每优化完一项,跑一次同样的场景,记录新的基线,确认是否下降。
- 反复迭代,直到 GC Alloc 控制在每帧 1 KB 以内,GC 触发频率降到几十秒一次甚至更低。
这个阶段的优化清单,我按优先级排序:
| 优先级 | 优化项 | 预期效果 |
|---|---|---|
| P0 | 清理 Update 里所有字符串拼接、LINQ、装箱 | GC Alloc 直接下降 30%-50% |
| P0 | 缓存 WaitForSeconds 等 yield 对象 | 减少协程产生的垃圾对象 |
| P1 | 对象池化子弹、特效、飘字 | 大幅减少高频临时对象分配 |
| P1 | 减少 UI Text 的每帧刷新 | 减少 UI 系统内部临时对象 |
| P2 | 用 struct 替换部分小 class | 降低托管堆压力 |
| P2 | 关闭发布版日志 | 消除日志产生的隐性分配 |
| P3 | 引入 ArrayPool / NativeArray | 消除临时数组分配 |
7.3 大型项目:从"救火"转向"体系化治理"
项目大到一定规模,单独的优化点已经解决不了问题了,你需要一套可持续的治理机制。
我在几个大型项目里采用的方案大致如下:
- 建立性能回归流水线:在 CI 里跑一个固定的性能测试场景,自动采集 GC Alloc、GC 触发次数、帧时间,超过阈值就报警。
- 制定代码规范与 Review 检查项:要求热点路径禁止使用 LINQ、禁止无脑字符串拼接、禁止用
new WaitForSeconds等。Code Review 时把 GC 分配作为必查项。 - 建设共享的缓存与对象池库:提供统一的
ObjectPool<T>、Yielders、日志封装、StringBuilder管理模块,让团队不会各自造轮子。 - 定期做 Profiler 专项巡检:每个大版本上线前,专门跑一遍各玩法的 GC 专项测试,把 GC 尖峰消灭在发布之前。
这样做了之后,GC 问题就不会再是"隔三差五冒出来一个,每次都要花好几周去查"的救火任务,而是进入了一个可量化、可追踪、可持续的轨道。
8. 最后分享一点实际体会
GC 优化这件事,做了几年之后最大的体会倒不是某个具体技巧,而是"看待卡顿的方式"。
以前我遇到卡顿,第一反应是"哪个系统太耗了,去查它的 CPU 占用"。后来才慢慢明白,很多卡顿不是"某个系统太耗",而是"某个系统的分配节奏刚好撞上了 GC 的触发机制"。你把它改成零分配之后,帧率不一定立刻变好,但 GC 尖峰一定会减少——那种"时不时卡一下"的体验,比单纯的帧率低要可怕得多。
另外想说的一点是:GC 优化永远不是一次性的工作。项目每加一个玩法、每引入一个新的第三方库,都可能带回新的分配问题。保持分配意识,建立定期巡检的习惯,比憋一个大招一次性清干净更重要。
如果你正在被"看不见的卡顿"折磨,我的建议是:先开 Profiler,按 GC Alloc 排序,找到最大的几个分配源,一个一个改掉。目标不是追求绝对的 GC Alloc = 0,而是让 GC 的触发频率和单次耗时降到玩家感知不到的水平。
这篇聊的是 GC 的机制、分配来源和优化手段。下一部分如果大家有兴趣,我可以接着聊聊 IL2CPP 下的内存行为差异,以及 Burst + Job System 里如何彻底绕开托管堆。那部分玩起来,才叫真正的"性能自由"。