做 Unity 性能优化这些年我最大的感受不是“卡顿好难查”而是“卡顿查出来之后更难修”。尤其是那种帧率图看上去像锯齿一样一上一下、一开某个功能就瞬间掉帧的情况十次里有七八次都和 GC 有关。GC 之所以讨厌是因为它不像 Draw Call、不像 Overdraw 那样有明确的指标你甚至能在 Profiler 里看到一帧的耗时突然飙到 40ms、60ms但就是不知道是谁捅的娄子。这篇是《Unity 卡顿·帧率保卫战》系列的第 4 篇专门把 GC 这件事从头到尾拆一遍它从哪来、怎么定位、怎么消除、哪些引擎选项能帮你兜底。适合正在做移动端优化、或者被线上版本突然掉帧折磨得睡不着觉的 Unity 开发者照着做至少能解决 80% 的 GC 卡顿问题。1. GC 到底是什么它为什么能卡掉你的帧1.1 托管堆与垃圾回收的基本原理GC 的全称是 Garbage Collection也就是垃圾回收。Unity 的脚本层C#跑在 Mono 或 IL2CPP 上时所有new出来的对象都会分配在托管堆Managed Heap里。比如你写Listint list new Listint();这个 List 对象本身、它的内部数组都是在托管堆上分配的。堆是有限的当里面的对象不再被引用这块内存就成了垃圾但 C# 不会像 C 那样立刻把它释放掉而是等 GC 机制在某个时机把垃圾统一回收腾出空间继续用。Unity 的垃圾回收器是分代的一般分为三代第 0 代、第 1 代、第 2 代。新分配的对象先进第 0 代存活过一轮回收就晋升到第 1 代再存活就进第 2 代。GC 扫描的时候优先收集第 0 代因为第 0 代对象大多活不长回收效率最高。只有第 0 代内存不够了才会触发更高代际的回收。这个设计本身是好的但问题在于Unity 默认的 GC 是“全暂停”式的也就是说一旦触发回收整个游戏线程都会被冻结直到回收完成。哪怕只是回收第 0 代也会在某一帧里造成一个明显的耗时尖峰。用一句大白话说你辛辛苦把一帧的成本压到 8ms结果 GC 在某帧里突然跳出来用 30ms 打扫卫生那这帧就铁定卡了。而且 GC 调度的时机不完全由你掌控它可能出现在任何一次分配导致堆容量不足的时候。这就是 GC 卡顿最恶心的地方——它不是每帧都出现而是“不定期发作”排查起来特别像灵异事件。1.2 为什么 GC 会引发掉帧Stop The World移动端游戏的 GC 卡顿本质上就是 Stop The World 造成的。GC 回收时需要扫描对象引用关系、标记存活对象、清理死亡对象这个过程为了保证一致性必须暂停业务逻辑线程。Unity 是主线程驱动的主线程一暂停整个渲染管线、物理、动画、UI 全都得等着。暂停时间取决于堆里对象的数量和存活对象复杂度可能只有 1ms也可能是几十毫秒。这里有个容易忽略的点GC 暂停时间短不等于不卡。帧率目标如果在 60 FPS那每帧预算只有 16.7ms一个 5ms 的 GC 尖峰就占掉三分之一预算叠加这一帧本来就有的渲染耗时很容易突破 33ms 甚至更糟。所以在移动端尤其是低端 Android 机上GC 卡顿比在 iOS 上更明显因为 CPU 频率低、内存带宽小、同一套 GC 逻辑跑得更慢。更麻烦的是GC 的回收和分配是联动的。如果你每帧都在堆上分配大量临时对象堆容量会持续增长GC 触发频率越来越高代际也越来越深。等到第 2 代回收触发那可不是几十毫秒能解决的低端机上瞬间飙到几百毫秒都有可能。我见过一个项目上线后一局游戏十几分钟越到后面越卡最后查出来就是每帧都在 new 各种字符串和浮点数组把托管堆养到了几百 MB隔一会儿就触发一次全量回收。1.3 从 Unity Profiler 里认出 GC 卡顿识别 GC 卡顿最直接的方式是看 Profiler 的 CPU 数据。打开 Profiler 连接到真机抓一段游戏中的帧数据按耗时排序如果发现某个 GC 相关方法在 Mono 下常见的是GarbageCollect、GC.Collect在 IL2CPP 下可能叫il2cpp::gc::GarbageCollector::Collect出现在金标题里那基本就是 GC 卡顿没跑了。不过光看到GC.Collect还不够你还要判断这次回收是哪种代际。Unity Profiler 的 CPU Timeline 视图里可以展开 GC 调用的子节点看到 collect 的代数信息。另外Profiler 的 Memory 模块里也有 Managed Heap 的 Used 和 Reserved 曲线如果 Used 曲线呈阶梯状持续上升说明分配速度大于回收速度。这里我给一个实战经验在真机 Profiler 里看 GC 卡顿一定不要只抓 10 秒钟 30 帧的数据太短抓不到问题建议抓 60 秒以上把曲线整体截下来再回头分析哪些帧 GC 压力大、和哪些玩法事件相关。这样定位范围会小很多。2. 定位 GC 压力不用瞎猜先拿数据说话2.1 Profiler 的真机抓取姿势Android/iOS很多新手犯的错是只在编辑器里看 Profiler。编辑器里的 GC 行为和真机差异其实非常大因为 Editor 本身会吞掉一部分分配CPU 频率、内存带宽也和手机完全不同。正确的做法是接真机。Android 上推荐用 Unity Profiler 通过 ADB Over WiFi 连接或者直接把 Development Build 包打出来用 Profiler 的Attach to Player。iOS 上则需要插 USB在 Xcode 里跑起来再用 Profiler 连接。连接的时候记得勾选 Deep Profile不然很多方法内部的小分配你是看不出来的。但 Deep Profile 会显著拖慢帧率所以抓 GC 和抓常规性能数据要分开抓不要一边开 Deep Profile 一边看帧率曲线。我习惯的做法是第一次不开 Deep Profile先确认帧率尖峰出现的时间和 GC 调用是否吻合第二次开 Deep Profile专门抓那几个尖峰帧展开完整调用树找出分配源头。2.2 理解 GC Allocation、Reserved、UsedProfiler 的 Memory 模块里有几个概念很多人分不清这里统一讲清楚。Managed Heap Used 是当前托管堆里实际被对象占用的内存Reserved 是堆从操作系统申请到的总内存。Reserved 可能远大于 Used因为堆不会频繁还给系统而是留着复用。当 Used 逼近 Reserved 时GC 会尝试扩展 Reserved 大小也就是向操作系统申请更多内存这个过程本身也会卡一下。GC Allocation 表示单位时间内托管堆上的分配总量。如果你看到某帧的 GC Allocation 突然涨到几 MB说明这一帧里有大量临时对象被创建。用 Profiler 的 Hierarchy 视图按 GC Alloc 排序就能定位到具体是哪个函数创建了这些对象。这里有个细节GC.Alloc这一列只有在开启 Deep Profile 时才有准确数据所以前面说的两次抓取流程是必要的。2.3 通过 Memory Profiler 排查托管堆问题除了内置 Profiler我强烈建议给项目接入 Unity 官方的 Memory Profiler 包。它在 Package Manager 里搜com.unity.memoryprofiler就能装。Memory Profiler 最大的价值是能抓一个完整的堆快照Snapshot然后在专业视图里看到所有托管对象的新旧对比还能看出哪些对象是“泄漏型”的——比如从未被释放的、持续增长的。实操技巧在游戏里操作一个典型流程后连续抓两份快照用 Memory Profiler 的 Diff 模式对比就能快速看到两次操作之间新增了哪些对象、谁在持有它们。常见的字符串缓存、事件订阅导致的委托链增长、静态集合里只增不减的对象在这个 Diff 视图下都无所遁形。注意抓快照的瞬间也会触发 GC所以想把快照当证据就别在抓取前干那种会改变堆状态的操作。3. 代码层优化把“隐式分配”全部揪出来3.1 字符串、装箱、LINQ 与闭包陷阱代码层的 GC 优化核心就一句话消灭每一帧里“看不见”的堆分配。最常见的三大元凶是字符串拼接、装箱和 LINQ/闭包。字符串拼接是重灾区。string a b c d;在 C# 里会生成多个中间字符串对象每个都是独立分配。哪怕你只在 UI 上显示一个 “Score: 100”每秒更新一次如果每帧都在拼字符串那就是每帧都在分配垃圾。优化方案很简单能用StringBuilder的地方用StringBuilder并且把 StringBuilder 实例缓存在字段里用完了调Clear()继续用不要频繁new。另一种更狠的做法是预拼接所有可能的字符串运行时直接查表适合状态枚举、固定文案这类场景。装箱就是值类型被隐式转换成object。比如Debug.Log(score)如果 score 是 int这里就会发生一次装箱在堆上生成一个对象。你在代码里写得爽GC 在后面哭。排查时重点看box关键字在 Profiler 调用树里它是明确显示的。消灭装箱的办法是重载匹配的方法或者用string.Format的重载、用值类型的.ToString()显式转换、用泛型约束避免object类型参数。还有一个隐藏点枚举类型经常拿来和字符串拼接或者作为字典 key也容易产生装箱建议改用int或HashCode。LINQ 就不用说了Where、Select、OrderBy这些扩展方法背后全是迭代器对象和闭包。在 UI 逻辑里偶尔用一次问题不大但在 Update 里每帧跑 LINQ 就是灾难。闭包也类似lambda 表达式只要捕获了外部变量就会在堆上生成闭包对象。优化方式是把循环体改成普通 for/foreach把临时变量提出来或者用struct实现IComparerT传进去避免产生 GC 分配。这些优化虽然琐碎但在一个大型项目里代码量上去之后净收益非常可观。3.2 常用 API 的隐藏分配物理、音效、UI除了 C# 自身的语法陷阱Unity 引擎 API 也藏着一堆隐式分配。最典型的是Physics.Raycast返回的RaycastHit数组不是单个RaycastHit是 struct 不会分配但Physics.RaycastAll返回的就是RaycastHit[]数组每次调用都会分配。解决方案是使用Physics.RaycastNonAlloc系列接口传入预分配的数组然后根据返回值判断命中数量。音效方面AudioSource.PlayOneShot(Clip)如果你在 Update 里反复调用也会导致某些内部对象分配务必将 Clip 引用缓存而不是每次都从 Resources 加载。UI 是最容易被忽略的重灾区。UGUI 的Text.text每次改值都会触发文本网格重建如果字符串本身是拼接出来的GC 压力和重建开销会叠加。LayoutGroup的自动布局、ContentSizeFitter每帧计算也会产生分配。所以 UI 优化的原则是尽量只在数据变化时更新 UI不要在 Update 里轮询刷新用对象池管理面板、列表项用TMP_Text而不是老的Text因为 TMP 的内部重建性能远好于 UGUI Text。3.3 对象池与缓存策略实战对象池几乎是应对 GC 的标配手段。它解决的痛点是频繁创建和销毁对象会让堆产生大量碎片并且每次new都会产生分配。对象池的思路是把不再用的对象回收到池里需要时再取出复用。Unity 的ObjectPoolT类在较新版本里已经很成熟不用自己写轮子。但要注意几个坑出池时一定要把对象身上的状态重置尤其是 Transform、Rigidbody 的物理状态回池时要禁用 GameObject、清空子物体、取消事件监听池的初始容量要预估好避免运行中反复扩容。除了 GameObject 对象池数据结构对象的复用也值得重视。比如你有个ListVector3用来存放路径点每一帧new一个再填充这显然不合理。正确做法是把 List 声明成字段调用list.Clear()后重新填充。字典、队列、栈同理。内存分配少一次是一次这个积累效应在长时间运行的游戏里会被放大十倍百倍。3.4 协程、事件、委托中的分配优化协程是另一个容易藏分配的地方。StartCoroutine(MyCoroutine())会创建一个协程对象IEnumerator状态机内部还会分配对象。如果你在一个频繁触发的地方比如点击按钮、击中目标启动协程那每触发一次就会多一堆堆分配。优化思路能用 Update/固定轮询替代的就别用协程必须用协程时尽量缓存 IEnumerator 实例而不是每次CreateCoroutine新建。不过要注意当你停止协程再重新启动时缓存实例的状态可能没重置需要加个标志位处理。事件和委托同样有坑。进行事件订阅是堆分配-取消订阅也是。在怪物死亡、玩家拾取物品这类高频逻辑里如果频繁加退事件垃圾量会很大。更隐蔽的是Unity 的某些接口如Button.onClick.AddListener(() {})每次传 lambda 都会分配一个委托对象如果这个按钮在运行时经常被创建销毁垃圾就不断产生。建议办法是遵循对称原则在 AddListener 的地方一定要对应的 RemoveListener 或 RemoveAllListeners对复用型对象把监听方法包装成带缓存委托的字段避免重复分配能用UnityEvent的序列化绑定就尽量用编辑器绑定不要在运行时动态挂。4. 引擎层选型与进阶手段4.1 Incremental GC 与 Burst/ECS 的取舍Unity 从若干版本开始支持 Incremental GC增量式垃圾回收。它的原理是把原本一次完成的回收任务切分成多个小块分散到多帧执行从而避免单帧的长时间暂停。听起来很美好但它不是银弹首先增量 GC 会把本应一帧完成的工作摊薄到后续帧里所以整体 GC 次数可能变多单帧方差变小但总 CPU 开销可能增加。其次对于一帧内分配极其巨大的项目增量 GC 仍然会卡因为回收能力跟不上分配速度。所以在移动端我的建议是先做代码层优化把分配压下去再开增量 GC 兜底不要反过来。Burst 和 ECS 就比较激进了Burst 编译器把 C# 代码编译成高度优化的原生代码配合 DOTS 的 ECS 架构可以把大量逻辑搬到非托管内存中完全绕开托管堆从根源上消灭 GC。但 DOTS 有学习成本工程改造量也大不是说切就能切。我的判断是如果你正在做一个新项目且目标就是低端机流畅运行那 DOTS 值得投入如果是存量项目优先做代码层优化、对象池、增量 GC 这三板斧。4.2 自定义堆管理allocator 与 NativeArray在要保留 Mono/IL2CPP 代码架构的前提下我们还有半托管路线把关键数据放到 NativeArray、NativeList 这类 Unity 的 Native 容器里。它们分配在非托管内存不受 GC 控制用完你需要手动Dispose()。很多团队在项目里把高频数据传输、点云、路径数据全部改用 NativeArray这样托管堆上的活动对象数量大幅下降GC 触发频率自然就降下来了。使用 NativeArray 要小心生命周期管理。如果你在一个需要长期存活的地方分配 NativeArray又在另一个地方使用跨越了多个帧很容易踩中Dispose在错误时机调用的坑。实战里我建议用NativeArrayT搭配using语句或者在类的OnDestroy里统一释放。同时要注意NativeArray 默认分配的是堆内存频繁创建销毁也会有非托管内存的碎片和分配开销所以同样要有对象池思路缓存一批 NativeArray 按需取用。4.3 什么时候该用 IL2CPP、什么时候用 Mono这个问题经常有人问。IL2CPP 相比 Mono最大的优势是 AOT 编译后运行效率更高、代码更安全、兼容性一致性更好而且 IL2CPP 的 GC 实现比 Mono 在某些场景下更稳定。但是名字里的 “2CPP” 不代表没有 GC它只是把 IL 转成 CGC 还是在的只是实现方式换成 IL2CPP 自带的 Boehm GC 增强实现。所以不要以为切了 IL2CPP GC 就消失了。从优化角度来看发布移动端游戏都应该用 IL2CPP。AOT 之后 JIT 版本的运行时开销减少一些隐式装箱优化反而更友好。真机测试时如果发现同样的代码在真机上 GC 尖峰比编辑器高一大截很可能就是 IL2CPP 的 GC 实现更积极。遇到这种情况除了代码排查也可以尝试开启 Player Settings 里的 “Low Memory Mode” 之类的选项有些平台的 IL2CPP 会启用更保守的内存策略。5. 常见问题与排查技巧实录5.1 典型问题速查表症状典型原因快速验证方法修复方向帧率每隔几十秒骤降一次频繁大对象分配触发第 1/2 代回收Profiler 里抓 GC 调用节点确认代际用对象池、缓存、避免大数组频繁创建Update 里每次调用都分配小对象字符串、装箱、LINQ、闭包开 Deep Profile 按 GC Alloc 排序改成 StringBuilder、for 循环、预分配某 UI 界面打开/关闭卡顿文本重建、LayoutGroup 重排、动态对象创建Memory Profiler 前后对比快照UI 对象池、Layout 改手动触发、文本缓存长时间运行后越来越卡静态集合持有对象、事件订阅泄漏抓两次快照按对象计数变化排序清理静态引用、RemoveListener、定时巡检低端机卡成 PPT高端机没事分配量太大GC 暂停时间被放大在低端真机上抓 Profiler严格限制每帧分配量使用 NativeArray协程启动频繁导致卡顿每次 StartCoroutine 产生协程对象和状态机Profiler 看 IEnumerator 分配缓存协程实例或改用 Update 状态机5.2 我踩过的几个坑第一个坑是盲目给 UI 列表加对象池。一开始我把整个竖向列表的所有行全部池化结果因为列表项内部的文本尺寸不同、布局不同回池再出池时产生了意想不到的 Layout 重排反而比直接创建更卡。后来我改成只池化行模板内容变化时复用同一行不再整页销毁重建问题就解决了。从这里学到的经验是对象池不一定适合所有对象确认 “创建销毁成本高于复用重置成本” 再池化。第二个坑是协程缓存。我曾经为了消除协程分配写了个协程启动管理器把 IEnumerator 实例缓存起来重复 Start。结果忘记在StopCoroutine后重置其中的循环变量导致同一个协程第二次执行时直接从最后状态开始角色行为直接错乱。后来我在缓存前强制给 IEnumerator 包一层包装器让每次启动都用一个新实例但这个新实例内部复用一个真正的对象才不会产生分配最终算是改对了。第三个坑和 Profiler 有关我一度在图里看到 GC.Alloc 很高就拼命调代码结果调完 GC.Alloc 确实降了但帧率没有任何改善。后来才发现原来那一帧的 GC.Alloc 是 UI 的图集上传开了一个新 RenderTexture 引起的真正的掉帧不是 GC 而是 GPU 上传。这就提醒我们不要看见一个指标就埋头调要结合帧耗时、渲染耗时、线程耗时一起看。5.3 一套可落地的日常优化清单检查项具体操作验收标准分配量控制不开 Deep Profile 抓一次真机 Profiler记录 GC Allocation 峰值移动端峰值 1 MB/帧中大型游戏主循环无 Linq/闭包搜索 Update/FixedUpdate/LateUpdate 内的 LINQ 和 lambda全部改为 for/foreach 或缓存委托UI 刷新策略用脏标记判断数据是否变化再刷新 TMP 文本无 Update 里持续改文本的代码对象池覆盖高频对象子弹、敌人、UI 弹窗、受击飘字运行中对象创建数量维持波动小协程使用规范化梳理启动协程的点位能不用则不用高频触发点无 StartCoroutine事件生命周期所有 AddListener 都有对应 RemoveListener反复进出场景后内存曲线平稳NativeContainer 生命周期检索 NativeArray、NativeList 的分配和 Dispose长时间运行无 Non-Allocated 泄漏在项目里推行这套 GC 优化流程时我的经验是不要一上来想着“彻底消灭分配”这不现实也没必要。更合理的策略是先把峰值降下来让 GC 尖峰不出现在关键帧然后把 GC 触发频率控制在让运行期堆大小稳定在一个合理范围内。只要这两点做到玩家基本就感受不到 GC 的存在了。我用这套打法优化过好几个项目从最初随便一帧分配 3MB、每秒卡顿 5 次到后面峰值控制在 200KB 以内、连续作战一局不掉一点帧中间靠的就是一句一句代码抠、一次一次抓 Profile 比对。最后再分享一个小技巧每次优化完记得在项目的 CI 流程里加一个 GC Allocation 上限的判断超过阈值就标红这样性能回归才能在发版之前被拦住而不是等上线了再被玩家骂。
企业数字化 ERP 产品动态
相关推荐
AI伴侣游戏实战:LLM角色设计、记忆系统与安全策略全解析 一个 AI 连续聊了二十轮之后,角色设定早就崩成路人甲,更别提记得你昨天随口提过的流浪猫——这是 AI 伴侣游戏和普通 AI 聊天最本质的差别。做“邻信”这个 LLM 驱动的 AI 伴侣游戏时,我给自己定的目标一直不是“做一个更聪明的聊天机器人”&… · 2026/9/24 21:44:58
基于Matlab的PCB一致性检测:从图像配准到缺陷分类的完整实现 1. 项目概述与整体思路拆解1.1 为什么要做“PCB一致性检测”,这个项目解决什么问题PCB电子板卡在出厂之前,最让人头疼的问题不是“这块板子是坏的”,而是“这批板子长得不一样”。比如同一批次的板卡,焊盘位置偏差了0.1毫米、丝印… · 2026/9/24 21:44:51
Minimax H3本地部署指南:ONNX+ComfyUI视频生成实战 1. 项目概述:这不是又一个“一键启动”的幻觉,而是真正能跑起来的本地视频生成闭环最近在几个AI创作群和本地部署论坛里,几乎每天都能看到类似这样的提问:“Minimax H3到底能不能在自己电脑上跑?秋叶包里没找到&#x… · 2026/9/24 21:44:51
提示词框架实战:五模块结构化设计提升AI输出稳定性 1. 先搞清楚“战无不胜”的提示词到底赢在哪很多人第一次接触提示词,脑子里想的都是“有没有一句万能咒语,复制粘贴就能让AI听话”。我刚开始也这么想,后来踩了足够多的坑才明白:真正稳定的提示词,从来不是一句话&… · 2026/9/24 22:19:06
景区游客流量数据分析系统:Python+SQLite+Tkinter实战 说实话,景区游客流量数据分析这种题目,在课程设计和毕业设计里的出镜率相当高。它正好踩中了Python、数据分析、数据库、GUI这四个关键词,表面看要做的东西很多,但项目结构一旦理清楚,实现起来其实很快。这篇文章把我之… · 2026/9/24 22:19:06
Grok Bot三天从零推进公司Day1复盘:AI原生创业工作流全拆解 最近我把官方直播《如何使用Grok Bot 在三天内从零推进一家公司》Day1完整看了一遍,这个版本配了双语字幕,看起来不费劲,边看边记,笔记断断续续写了十几页。说实话,我去看之前对Grok Bot的预期只是“实时联网的聊天机器… · 2026/9/24 22:19:06
AI提示词工程实战:从设计思路到防瞎编约束的完整指南 1. 为什么“会写提示词”正在变成一项硬技能我大概是从两年前开始系统性地整理自己的提示词库的。起因很简单:同一个模型,同一个问题,我写出来的答案跟别人写出来的答案,质量差距大到像是两个不同的产品。后来我花了不少时间复盘&… · 2026/9/24 22:19:06
Egg.js定时任务与扩展机制实战:从缓存刷新到框架深度定制 1. 第11天为什么把定时任务和扩展机制放在一起学1.1 15天学习计划的前10天都干了什么先交代一下我这15天计划的节奏。今天是第11天,计划已经过去三分之二。前10天我没有按文档目录一章一章啃,而是按一条请求从进来到返回的链路去推进:第1天把… · 2026/9/24 22:18:59
用Grok Bot三天从零推进公司:Day1实操拆解与避坑指南 直接和大家聊聊这场直播里第一天最值得看的东西。看到“三天内从零推进一家公司”这个标题,我第一反应是:现在的AI工具链到底能不能支撑一个毫无基础的创始人,在72小时里跑通公司从0到1最关键的一环?老实说,在完整看完… · 2026/9/24 22:18:59
基于YOLOv8的渔船作业监控系统:从环境搭建到边缘部署全流程 简介:这是一套面向计算机、人工智能、自动化等专业学生与教师的毕业设计级项目资源,围绕YOLOv8实现渔船作业监控系统,可用于毕设、课程设计、大作业或项目立项演示。压缩包共97个文件,约24.21MB,以70个Python源码文件为… · 2026/9/24 0:00:13
1D-CNN时间序列建模实战:从Conv1d原理到工业落地 简介:面向时间序列数据建模的一维卷积神经网络完整实现,适合深度学习入门者及需要快速验证时序模型的研究者,能够从音频、文本、传感器或股价等序列中挖掘局部特征与时间依赖。压缩包体积很小,只有3KB,内含3个Python脚… · 2026/9/24 0:00:26
柔软的L:汉语语流中被忽视的舌肌张力控制 1. 这个“L”不是字母表里的L,而是舌尖上的L最近在几个方言群和语音教学社群里,反复看到有人发一句:“也说字母L:柔软的长舌”。初看以为是英语发音课笔记,点开才发现全是方言爱好者、播音系学生、语言康复师甚至戏曲演… · 2026/9/24 0:00:44