ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity GC卡顿优化:从托管堆到代码层面的内存分配实战

Unity GC卡顿优化:从托管堆到代码层面的内存分配实战 1. 先把 GC 这件事看明白托管堆、垃圾回收与那一哆嗦的卡顿在《Unity 卡顿·帧率保卫战》系列里我们已经聊过帧率波动的直接原因、渲染瓶颈、以及各种工具定位的思路。今天这篇要聊的是一个你盯着 Profiler 看了半小时也未必能一眼盯出来的卡顿来源GC。GCGarbage Collection垃圾回收。在 Unity 的 Mono 或者 IL2CPP 环境下C# 脚本分配出来的托管对象最终都活在托管堆Managed Heap上。你写的每一个new、每一次字符串拼接、每一次 LINQ 查询都在往这块堆上塞东西。而 Unity 的 GC 触发机制决定了它会在某个时刻——非常不凑巧的时刻——把整条游戏线程停下来然后从头到尾扫一遍堆看看哪些对象已经没人引用了再把它们标记、清除、压缩。这个“停下来”的过程就叫 Stop The World。GC 跑的时候你的游戏逻辑、渲染管线、物理模拟全部原地冻结。一次几十毫秒的停顿在帧率曲线图上就是一记突兀的尖峰在玩家手感上就是“画面抖了一下”“摇杆没反应”“技能放出去卡了一拍”。这种卡顿你很难通过调画质、减顶点、合批去解决因为问题根本不在渲染而在内存分配。我最早真正被 GC 打疼是在做一个偏重 UI 的模拟经营项目时。当时编辑器 Profiler 看着还好一上中端安卓机每过几秒就掉一波帧掉帧时机毫无规律跟场景里有没有爆炸特效、有没有大量 NPC 完全没有对应关系。最离谱的是我特意把 Update 里的逻辑全部清空只留一个空的界面依然周期性卡顿。后来才反应过来只要 UI 上不停有文字刷新、有列表更新、有动画回调GC 就不会消停。所以在开始动手优化之前你我得先建立一个基本认知GC 卡顿不是“内存不够用”的问题而是“分配太频繁”的问题。内存总量通常够但每次 GC 全堆扫描的时间成本是实打实的。理解了这一点后面所有优化手段本质上都是同一个思路——减少分配而不是增大内存。1.1 GC 为什么会“停顿”托管堆与原生堆的两张皮Unity 引擎底层是 C 写的原生堆Native Heap上住着引擎的核心数据网格、贴图、物理刚体、动画骨骼。这一块内存由引擎自己管理对象生命周期由引擎代码显式控制不需要 GC 操心。但你的 C# 脚本跑在 Mono 虚拟机或 IL2CPP 生成的 C 代码 内置 GC上C# 对象住的是另一块地——托管堆Managed Heap。托管堆最大的特点是它不需要你手动释放。你用new ListEnemy()创建一个容器用完就扔GC 会替你打扫。听起来很美好代价就是GC 打扫的时候你得停下来等着。更麻烦的是Unity 的默认 GC 是非分代、非并发的 Boehm GC意思是它不区分“存活久的对象”和“刚出生的对象”整个堆从头到尾全是它的扫描范围。对象越多扫描越慢堆越碎压缩越耗时。这里有个非常反直觉的坑你给 Unity 的“Heap Size”设置得越大反而可能更卡。因为 GC 回收完一批对象之后堆并不会立刻缩小而是把空出来的内存留着备用。如果这时候你又不断分配新对象GC 会推迟触发看起来挺好但一旦某个更高分配峰值到来堆不够用GC 就不得不一口气做一次完整的全堆扫描压缩这一下就是几十毫秒。中端手机上一次重型 GC 卡掉两帧三帧非常常见。注意在 Unity 里你可以在 Player Settings 里把Use Incremental GC勾上试试分帧回收。但增量 GC 只是把一次大停顿拆成多次小停顿它消灭不了 GC 成本只是在“感知卡顿”和“CPU 总开销”之间做了个取舍。项目中如果条件允许我后面会聊更根治的做法。1.2 一次 GC 的完整生命周期从分配侧到回收侧我们来走一遍一次典型 GC 的完整流程你就明白为什么我强调“罪魁祸首是分配频率”。假设你有一个怪物波次系统每帧都检测玩家与怪物的距离然后通过字符串拼接拼出一句$距离 {distance:F1} 米显示在 UI 上。这一句代码在每秒 60 帧下会产生每秒 60 次字符串分配。每次分配在托管堆上占几十到几百字节。当整个项目的分配速度超过一定水位GC 就被触发了。触发之后GC 要做三件事标记Mark从根对象静态字段、栈上的局部变量、寄存器引用、C# 与 C 交互的 GC Handle出发把所有仍然可达的对象在内部标记位里打上记号。这一步要遍历所有存活对象堆越大、存活对象越多越慢。清除Sweep把没被标记的对象认成垃圾把它们的空间回收进空闲链表。这一步通常比较快但会让内存产生碎片。压缩Compact可选把存活对象挪到一起腾出连续大块空间。这一步要搬运大量数据最耗时。Boehm GC 默认不压缩但 Unity 在部分平台上会做某种程度的整理或者因为碎片化导致分配失败而引发新的 GC。整个流程里你的游戏线程是完全停住的。Profiler 里那个尖峰标记出现的位置一般在 GC 或 GarbageCollect 项下面。如果你在真机上看到Gfx.WaitForPresent或PlayerLoop里嵌着一段 GC 耗时恭喜你你找到了“看不见的卡顿”的元凶。1.3 理解“Allocation”和“GC Alloc”的区别打开 Unity 的 Profiler你会看到一列叫GC Alloc的数据。很多新手会把它跟“总内存分配”搞混。其实它统计的是在这一帧里所有 C# 脚本和引擎回调往托管堆上新分配了多少字节。有个特别常见的误区GC Alloc 不为 0 并不一定代表这帧会卡。可能只是分配了几个小对象摊到一帧里开销很小。但 GC 卡顿的特性是“非线性”的——平时每帧几百字节没有感觉但当堆水位涨到阈值GC 一次回收几百 MB 堆里所有垃圾的时候停顿时长跟“已分配但未释放”的总量成正比。所以我们的目标是让 GC Alloc 尽量趋近于 0而不是“看起来不大就行”。我在项目里一般定一个硬指标核心战斗逻辑Update、FixedUpdate、频繁回调的 GC Alloc 必须压到每帧 0~2KB 以内UI 刷新逻辑可适当放宽到 10KB但前提是不要每帧都做。超过这个水位就把它当成 Bug 修。2. 常见 GC 触发源哪些写法正在悄悄偷你的帧率很多人一提到 GC 优化第一反应是“降低对象池大小”或者“少用 Instantiate”。这个思路没错但只覆盖了冰山一角。实际项目里GC 分配的大头往往不在显眼的new上面而在一些你根本不会注意到的“隐形分配”。我按踩坑频率从高到低列一下最常见也最典型的 GC 触发源。2.1 字符串拼接吃内存的第一大户字符串是不可变对象。你再怎么拼接实际上都是一次次创建新字符串把老字符串扔给 GC。典型场景// 反例每帧都在拼接字符串 string text 当前HP: hp / maxHp 波次: wave; uiText.text text; // 又触发一层内部字符串分配这段代码放在 Update 里每帧至少产生 4~5 个字符串对象再加上一次uiText.text的 setter 内部转换GC Alloc 轻松超过 500 字节。60 帧跑一分钟就是 1.8MB 分配量看起来不大但这样写的人多了总量就很可观。正确的做法有两个用StringBuilder复用同一个实例在需要改变内容时Clear()再Append()。Unity 的TextMeshPro其实也提供textInfo和SetText(string, float, float, ...)这类带格式化参数的重载专门避免字符串临时对象。// 正例复用 StringBuilder StringBuilder sb new StringBuilder(64); // 只分配一次 void UpdateHUD(int hp, int maxHp, int wave) { sb.Clear(); sb.Append(当前HP: ).Append(hp).Append(/).Append(maxHp); sb.Append( 波次: ).Append(wave); uiText.text sb.ToString(); // 这里仍然会分配一次但至少比上面少很多 }如果ToString()本身还在意就只能考虑基于字符数组的 UI 系统或者接受这“一次分配”的成本。高频显示文本这个需求本身在设计上就要尽量低频。2.2 LINQ 与 lambda方便是真的分配也是一样的多Where、Select、OrderBy、ToList这些 LINQ 扩展方法内部会生成迭代器对象、委托对象某些重载还会生成闭包。一行list.Where(x x.hp 50).FirstOrDefault()在 IL 层面往往意味着一个 lambda 表达式对象、一个迭代器状态机对象、一个WhereEnumerableIterator。三个托管对象起步每调用一次就分配一次。这在编辑器里用没什么问题但在每帧调用的战斗逻辑里速度会非常可感。我的一般建议是在 Update、协程内层循环、物理回调这条“热路径”上告别 LINQ。用for循环手写过滤、排序或者干脆提前维护一个“低血量敌人列表”增量更新。冷路径比如按钮点击、界面打开、配置加载用 LINQ 无所谓频率低一次分配摊销不掉什么成本。顺带说一句List.Find/List.Exists这些方法内部大多数不会分配但如果你传入的是 lambda那 lambda 是否需要闭包捕获外部变量就决定了会不会分配。闭包捕获时编译器会生成一个闭包类DisplayClass每次调用都要new一次这个类这是隐性分配的大户。2.3 装箱与拆箱被低估的分配装箱就是把值类型int、float、struct打包成 object 放到堆上。拆箱是逆操作。装箱意味着必分配没有任何例外。常见装箱场景object obj 42; // 装箱 string s string.Format(玩家数量: {0}, playerCount); // 参数是 object传入 int 就装箱字符串拼接/格式化是最容易触发装箱的地方因为string.Format、$...里嵌值类型时为了拼进字符串编译器会把它们转成 object装箱一次然后 ToString 又是一次字符串分配。所以字符串优化和装箱优化永远是联动的。另外还有一个特别容易中招的点DictionaryTKey, TValue 的键和值在部分 API 里被当成 object 处理时。比如Debug.Log(分数 score)这种因为Debug.Log(object)接受 object 类型参数score 装箱。调试代码无所谓但如果在高频逻辑里打这样的日志也会成为 GC 来源。2.4 协程与迭代器每次yield都是分配协程本质上是一个状态机对象。当你调用StartCoroutine(SomeCoroutine())时SomeCoroutine()实际上返回一个IEnumerator这个枚举器对象本身就是一次堆分配。协程内部每个yield return语句也会把当前状态和等待对象WaitForSeconds、WaitForEndOfFrame等塞进状态机里需要时还会再分配等待对象。我们可以做个快速统计一个持续 5 秒、每秒 yield 10 次的协程总共会创建约 50 个迭代器状态机和若干等待对象一次性累计几百字节到几 KB 不等。如果项目里大量使用协程处理动画、伤害飘字、倒计时这些分配加起来很可观。解除方式高频逻辑尽量不用协程改用普通Update 计时器变量或者用UniTask这类零分配的异步库。如果要复用等待对象可以把WaitForSeconds实例缓存在字段里。同一个WaitForSeconds(1f)对象可以被多个协程共享Unity 还内置了WaitForSecondsRealtime的类似缓存玩法。// 复用等待对象 private WaitForSeconds wait new WaitForSeconds(0.1f); IEnumerator Tick() { while (true) { yield return wait; // 不再重复分配 } }2.5 Unity 引擎 API 的隐形分配除了你自己的代码Unity 引擎的一部分 API 也会在调用后产生托管分配。最典型的就是各种Find、GetComponent、SendMessage系列尤其是GameObject.Find和Object.FindObjectOfType它们要遍历场景中所有对象返回前还会分配字符串比较用的临时对象。放在每帧调用里是灾难。更隐蔽的是GetComponent。老版本 Unity 的GetComponent通常会分配临时的 Type 对象或内部查询结构在 IL2CPP 下也不一定完全消除。虽然新版改善了不少但最佳实践始终是只在初始化时获取一次缓存到字段里不要在 Update 里反复 GetComponent。另外还有Camera.main、transform.position的 getter/setter 本身不分配但如果你每次调用transform.position new Vector3(...)这个新的Vector3是栈上的值类型没有问题。真正要小心的是跟Coroutine、Thread、WWW/UnityWebRequest、Physics.RaycastAll这些返回数组或对象的接口配套使用时往往有明显的 GC 成本。Physics.RaycastAll每次都会分配一个新的RaycastHit[]数组如果调用频率高这就是个隐形分配大户。换成Physics.RaycastNonAlloc直接复用数组是常见优化手段。2.6 集合扩容List、Dictionary 悄悄干的好事ListT.Add在内部容量不足时会分配一个更大的新数组把旧元素搬迁过去。Dictionary 类似甚至还会因为哈希碰撞分配额外节点对象。这些扩容分配在一次次 Add 之后会出现周期性尖峰平时每帧分配 0 字节突然有一帧分配了 1MB比如一万个元素的列表扩容GC 压力肉眼可见。对策很简单在可以预估容量的时候直接给构造函数传 initialCapacity。new ListEnemy(128)、new Dictionarystring, int(64)把容量一步到位之后 Add 就不再触发扩容。另外频繁清空再填充的容器可以考虑改用对象池缓存原始 List 实例不要让它在每帧重建。3. 定位 GC 问题Profiler 的正确打开方式知道哪些写法会分配不等于你能找到项目里“哪些代码在分配”。无论你对代码库多熟悉最终定位都要靠 Profiler。这一节我讲一下我自己的排查套路。3.1 先用 CPU Profiler 抓全局分配打开 Window Analysis Profiler切到 CPU Usage 模式在左上角选目标设备编辑器或真机。跑一段你怀疑卡顿的场景让它录制 10~30 秒然后观察 Hierarchy 窗口。顶部有一列 GC Alloc按从大到小排序你会看到这一帧里各个函数分配的字节数。关键是要勾上Hierarchy 里“Show Related Objects”吗其实不用直接看即可。但要注意编辑器里的 GC Alloc 数据跟真机 IL2CPP 下有一定差异。我在编辑器里经常看到一些编辑器相关的调用混进来所以判断标准通常是跑一段固定的“目标玩法”让 Profiler 录制真机数据再回放反复确认。如果某帧 GC Alloc 突然飙升直接在这帧上停住往下展开调用栈。你会看到类似于这样的链路Update - PlayerController.Update() - HUD.UpdateText() - string.Concat(string, int, string)这就是需要优化的点了。3.2 Memory Profiler 看堆水位GC 卡顿有时候不是因为某个单帧分配多而是因为整个堆水位长期偏高GC 触发后要扫描大量对象。这种情况 CPU Profiler 反而不太好抓需要另一个工具Window Analysis Memory ProfilerUnity 2019 以后内置旧版本可以从 Package Manager 装。Memory Profiler 能给你一张托管堆内存快照托管堆总大小Managed Heap Size已用大小Used Size未用但已保留的大小Reserved Size所有托管对象类型分布我拿到快照后第一件事就是看“哪个类型占了最多字节”。如果System.String占掉托管堆一大半说明字符串分配失控。如果System.Object[]很大往往是被 List 或数组扩容拖累。还有可能看到一些 Unity 内部对象比如UnityEngine.UI.Text的底层文本缓存。这个快照也可以用来做“前后对比”优化前跑一段场景拍一张快照优化后跑同样场景拍一张快照。两张对比优化效果一目了然。3.3 Deep Profile 与真机数据的取舍Deep Profile 会把所有代码插桩连引擎内部函数的进度都能看到定位 GC Alloc 来源时极其好用。但它的代价非常大性能会下降好几倍Editor 可能卡到 5 FPS大部分时候没法跑真机。我的做法是先用 Deep Profile 在编辑器里把问题点找出来改成可复现的小场景然后再到真机上用非 Deep 模式验证优化效果。真机测试有个容易踩的坑很多中端手机的 CPU 频率抖动、发热降频都会导致帧率波动跟 GC 无关。所以真机测试时我一般会同时关注几个指标CPU 主线程耗时、GC 标记耗时、以及Gfx.WaitForPresent。如果在某一个尖峰上GC 时间确实占了 20ms 以上那才能坐实是 GC 卡顿。4. 优化实战把代码里的 GC 一点一点掐死给出一些代码优化指导注意这只是常用的套路具体项目还需结合业务做取舍。4.1 对象池不只用于 Instantiate也用于容器和临时对象对象池的思路很简单把需要重复创建和销毁的对象提前准备好放到池子里用的时候取用完还回去。很多人第一反应是 GameObject 的池化但实际上装箱对象、字符串、数组、List、甚至委托和闭包都可以做类似处理。拿ListEnemy来说如果某个系统每帧要临时收集一批敌人做检测你可以写个静态工具类// 简单的 List 池减少反复分配 public static class ListPoolT { private static StackListT pool new StackListT(); public static ListT Get() { return pool.Count 0 ? pool.Pop() : new ListT(); } public static void Release(ListT list) { list.Clear(); pool.Push(list); } }这样在热路径里var enemies ListPoolEnemy.Get(); GetEnemiesInRange(pos, radius, enemies); // 使用 enemies... ListPoolEnemy.Release(enemies);对于 GameObject 对象池也可以用类似的思路。需要注意的一点是对象池本身也会诞生分配比如 Stack 容器在池容量增长时扩容但这些是一次性成本不会出现在高频循环里。我通常把池容量设置成业务并发峰值避免它反复扩容。4.2 字符串优化从拼接范式到缓存范式字符串的优化可以从三个层面做第一层少拼。能显示静态文本就显示静态文本不要每帧生成新文本刷新。比如血量条用 Image 的 fillAmount而不是显示“100/100”这种文本。第二层复用。用 StringBuilder 或TextMeshPro的SetText重载避免临时字符串。TMP 的SetText(string, float, int)这类重载是专门为此设计的内部用的是字符缓冲区不产生字符串分配。第三层缓存。如果文本内容确实需要变化但变化频率不高比如只在事件触发时更新那把最终 string 缓存在字段里只在需要的时候text.text cachedString。注意Text.text的 setter 在 TextMeshPro 里会做一次内部解析如果你用的是 UGUI 原生 Textsetter 内部也会跑Font.RequestCharactersInTexture和重建顶点这些不一定是 GC 问题但同样影响性能。我没法给“最优”方案因为最终方案取决于你的 UI 系统。但从经验看先把“每帧拼接字符串显示到 UI”这个行为消灭掉能解决一半以上的字符串 GC 压力。4.3 用 Profile 驱动的闭包与 Lambda 清理闭包的问题比较难缠因为很多写法你根本没意识到它会分配。一个比较常用的检测方法是右键点击 lambda 变量Go To Implementation看看编译器是否生成了一个c__DisplayClass或c类型的类。如果生成了说明有捕捉或者委托缓存的问题。常见解决办法不捕获外部变量的 lambdaUnity 编译器会缓存成一个静态委托不产生分配。所以尽量让 lambda 内部不要引用方法外的局部变量、字段尤其是 this 的字段。需要捕获变量时把捕获的变量改成参数传递比如用UnityActionint然后myEvent.Invoke(i)或者手工提取一个私有方法避免闭包类。如果闭包实在避免不了那就将委托实例缓存到字段里只靠RemoveListener/AddListener复用同一个委托。// 反例每次订阅都生成闭包 button.onClick.AddListener(() HandleClick(id)); // 正例缓存委托 private UnityAction cachedAction; void Subscribe(int id) { if (cachedAction null) { cachedAction () HandleClick(this.id); } button.onClick.AddListener(cachedAction); }注意如果闭包确实改变了 id那这个方法就不适用需要换思路。总之闭包优化没有银弹核心判断点是这个回调会不会在热路径里反复注册/注销会就必须处理。4.4 数组与 Native 侧分配考虑 NonAlloc 与值类型对物理和部分渲染相关的 API尽量使用NonAlloc版本。比如// 反例每次调用都会分配新数组 RaycastHit[] hits Physics.RaycastAll(ray, 100f); // 正例复用同一个数组缓冲区 RaycastHit[] hitsBuffer new RaycastHit[32]; int hitCount Physics.RaycastNonAlloc(ray, hitsBuffer, 100f);hitsBuffer要尽量大到你预期的最多碰撞数否则返回 -1 就尴尬了。物理学上判断命中数超过缓冲区长度需要额外处理但一般来说32~64 个足够大多数场景。块注意在 IL2CPP 上部分NonAllocAPI 仍可能因为内部封箱产生小分配但无论如何比Alloc版本少几个量级。另一个思路是使用值类型而不是引用类型。比如一个“伤害飘字”系统如果用ListFloatingTextDataFloatingTextData是类的话每次添加一个元素都分配一个小对象。改成struct FloatingTextData配合固定数组或NativeListGC 压力直接归零。4.5 缓存组件引用与函数调用链设计组件缓存的道理很简单不要在 Update 里 GetComponent。但我想说的是一个更深层的点有些组件引用连缓存都没用因为它确实会动态变化。比如角色身上动态挂载的 BuffUI 需要监听 Buff 变化。这种场景下我用事件驱动代替轮询。事件本身基于委托或UnityEvent只要委托实例是缓存的单次事件触发分配很小。轮询则意味着每帧都要访问、遍历、比较哪怕不分配CPU 时间也在消耗。所以GC 优化和 CPU 优化永远是链在一起的。一个设计得好的系统内存分配频率低CPU 循环次数也少。反过来如果你发现一个系统 GC 不断通常它也意味着 CPU 周期在空转。4.6 增量 GC 的“兜底”思路如果代码层面已经做到了相对极致但项目里仍然有无法避免的分配比如第三方库、甚至引擎自身可以考虑启用Use Incremental GC作为兜底。开启后GC 会分摊到多个帧里避免单帧大尖峰。代价是总回收时长变长可能在低端机上表现为“持续的微小抖动”而不是“明显大卡”。在 Player Settings Player Other Settings Configuration 里勾选Use Incremental GC需要 IL2CPP 或 Mono 都支持大部分现代版本都可以。它适合以下场景项目里存在不可控的高峰分配比如动画播放、UI 大列表刷新优化成本极低游戏帧率要求是“稳定 30 FPS”而不是“60 FPS 无尖峰”你不能大范围重构代码需要短平快地缓解卡顿。但请记住增量 GC 不是免死金牌。它本质上是把“一次暂停 30ms”变成“连续 30 帧每帧暂停 1ms”如果你的帧预算只有 16ms加 1ms 也算不得大麻烦但如果本来 CPU 就紧这 1ms 也可能造成掉帧。所以最佳实践永远是先清理分配再决定要不要开增量。5. 一个真实案例UI 卡顿与“幽灵 GC”的完整排查过程前面原理和套路讲了很多可能还是有点抽象。我拿一个实际排查过的项目片段来做完整演示。这个项目是一个即时制战斗 UI表现是战斗过程中右上角每秒刷新一次“击杀数/总敌人数”文本同时左下角有个持续滚动的战斗日志ScrollRect每 200ms 追加一条新文本。玩家反馈“时不时卡一下尤其敌人多的时候明显”。我最开始怀疑对象是渲染因为战斗日志和击杀数据都涉及大量文本绘制。于是先看 Rendering Profiler发现 Batches 不高顶点数也没爆渲染线程耗时平稳。切到 CPU Profiler 抓 GC Alloc没有特别大的单帧峰值但也没有完全归零。于是我启用了 Memory Profiler 快照发现在托管堆里System.String占了大概 15MB而项目托管堆总量是 32MB。GC 每次扫描 32MB 堆、标记 15MB 字符串加上碎片整理的搬运一次回收就是 15~25ms。再看具体来源击杀文本用了string.Format每分钟 60 次调用每次产生 2~3 个临时字符串战斗日志的追加逻辑用了logsText.text newMsg \n因为Text.textsetter 本身会产生新的字符串对象所以每追加一次等于把整段历史日志重新拼接了一遍。日志越多单次开销越大最终导致每 200ms 就是一个越来越大的 GC 尖峰。排查到这一步解法就很清晰了击杀文本改成TextMeshPro的SetText(string, int, int)重载避免临时字符串战斗日志改为缓存历史字符串每次Clear()后只拼接“最后 50 条”防止无限增长如果仍要无限日志就改成数组 使用 StringBuilder 构建显示内容再设一个最大行数限制给 ScrollRect 里的文字项做池化避免频繁Instantiate新的 Text 对象。改完对比同一段战斗流程GC Alloc 总量下降了大概 90%GC 尖峰直接消失真机帧率曲线的锯齿也明显平滑了。那个项目后续还在战斗中启用了增量 GC 作为兜底双保险后基本听不到玩家反馈“卡顿”了。提示Debug.Log、UnityEngine.UI.Text 等高频 UI 文本操作尤其是“追加式”文本很容易成为隐藏堆分配大户。遇到持续累积的 UI 列表/日志时优先考虑“限制显示条数 复用字符缓冲区”不要让它无限生长。6. 常见问题速查表与实战避坑技巧最后整理一份长期踩坑之后总结的速查表方便你排查时对照。真机上做 GC 排查时看这些数据基本不会白看。症状可能原因快速验证方法解决方向周期性的“卡一拍”时间间隔均匀字符串拼接 UI 刷新GC 按固定频率触发Profiler 里看 GC 项停顿点与 Update 里的字符串代码对齐用 StringBuilder缓存文本减少刷新频率场景内敌人变多后开始掉帧每帧 Find/GetComponent或容器扩容GC Alloc 随逻辑更新变大参照第 2.5 / 2.6 节缓存引用预分配容量进入某个子场景/UI 面板时大卡对象池没有预热一次性批量创建大量对象Memory Profiler 看那一帧“Managed Heap Size”跳变对常用对象做池化预热分摊创建开销Lambda 回调注册/反注册频繁后卡闭包捕获导致 DisplayClass 分配检查生成代码里是否有c__DisplayClass或new c缓存委托实例或改方法组长列表内容无限增加后越来越卡文本拼接 UI 重建顶点数累积观察 GC 消耗与 ScrollRect content 子物体数量限制子物体数量虚拟化列表使用了大量协程后不定时抖动协程状态机/等待对象分配用yield return null换成WaitForFixedUpdate试试或看生成状态机的类型用 UniTask 或改 UpdateProfiler 真机数据与编辑器差异大编辑器会用不同 GC 模式分配数据不一致在真机用 Development Build Autoconnect Profiler 复测以真机数据为准6.1 关于 “release of invalid gc handle” 的处理既然当前热门搜索里出现了release of invalid gc handle. the handle is from a previous domain这类报错我简单提一句。这个问题我遇到过几次通常发生在做“域重载”Domain Reload / Script Reload 后或热重载插件时底层 GC 句柄还没有被正确清理。它本身不一定是性能问题但它会导致内存泄漏或后续分配异常。目前的处理建议是关闭“Domain Reload”在 Edit Project Settings Editor Enter Play Mode Settings 里把Reload Domain取消勾选或者确保热重载插件的版本与当前 Unity 版本匹配。这通常不是普通代码问题而是引擎生命周期管理的问题。遇到时先隔离到最小复现场景再去查插件的 GC Handle 释放逻辑。6.2 对引擎与工具链的版本心态Unity 每年都在改进 GC 实现。2021 LTS、2022 LTS 的 Mono/IL2CPP 上一些老版本中的 GC 隐式分配已经优化了不少。但依赖引擎优化不如自己改掉分配习惯这是我写过多年代码后的真实感受。真正靠谱的项目是把“GC 纪律”当成代码规范的一部分热路径禁止 LINQ、禁止字符串拼接、禁止装箱禁止每帧 GetComponent/Find允许的分配要集中在初始化、事件回调、低频刷新中上线前必须做真机 Memory Profiler 对比记录优化前后的 GC Alloc 与 GC 尖峰数据。做到这些你会发现卡顿排查难度一下子下降了一个级别因为你根本不给 GC 留子弹。6.3 帧率目标与 GC 预算的联动设计如果你正在定项目的性能预算建议把 GC 当成一项独立预算来管理而不是“其他 CPU 开销”的一部分。我给一个简单指引帧预算 16ms60 FPS时单帧 GC 时间控制在 2ms 以内帧预算 33ms30 FPS时单帧 GC 时间控制在 4ms 以内允许少量低频大 GC但单次不要超过两帧的预算32ms / 66ms而且频率要降到“每 10 秒最多一次”。这些数值放到具体项目里可以再调但至少说明GC 开销不是“有没有”而是“有多少、多久一次、峰多高”。用这个思路去做优化决策你就不会把时间浪费在“消灭所有分配”这种不现实的目标上而是会优先处理那些高分配 高频触发的热点逐步把 GC 尖峰压到可接受的范围。我个人的体会是GC 优化做久了其实是在培养一种“分配嗅觉”——看到一段代码下意识会去想这里会产生哪些临时对象、会不会进热路径、能不能惰性化处理。这种能力一旦建立写出来的代码连 Profiler 都不用开你自己就知道哪里会藏雷。希望这篇《Unity 卡顿·帧率保卫战》的第 4 篇能帮你把这种嗅觉也建立起来。
返回列表