1. 发烫优化系列第五篇:为什么 CPU 不是无辜的
做 Unity 移动端项目这些年,我越来越怕听到一句话:“发热是 GPU 的事,CPU 只是发发指令。”这话放在 PC 上勉强能糊弄过去,放在手机上就是灾难。手机 SoC 里 CPU 和 GPU 共享同一块散热板、同一套电源管理,CPU 一旦长时间高负载,整机温度照样往上窜,然后 GPU 被迫降频,帧率跟着掉,玩家骂的还是你。
这个系列写到第五篇,前四篇聊了渲染管线、纹理、Shader 和阴影,这次把镜头对准 CPU 侧。具体来说,是三个最容易被忽视、又最常把 CPU 拖进泥潭的东西:GC(垃圾回收)、Draw Call、Canvas 重建。它们有个共同特点——单看每一帧的开销都不大,但会以极高的频率反复触发,累积起来就是持续的中等负载,而持续中等负载恰恰是手机发烫最典型的诱因。
这篇文章适合谁看?如果你做过 Unity 项目,发现真机上玩十分钟就开始温热、二十分钟后帧率明显下滑,Profiler 里 GPU 时间看着还行但 CPU 时间居高不下,那这篇就是写给你的。我会把每个问题的成因、定位方法、优化手段和踩过的坑都摊开讲,代码和参数都给到能直接抄的程度。不追求理论完备,只追求你照着做能降温。
2. GC:那个每帧都在偷偷吃 CPU 的家伙
2.1 为什么 GC 会让手机发烫
先说清楚 GC 到底在干什么。Unity 用的是自动内存管理,你 new 出来的托管对象、装箱的值类型、字符串拼接产生的临时对象,都堆在托管堆上。堆满了或者达到某个阈值,GC 就得跑一趟,把没引用的对象清掉。问题在于,GC 运行时主线程会被挂起(Stop-The-World),而且这个挂起时间跟堆上存活对象的数量成正比。
移动端的托管堆通常不大,但对象数量可能很多。一次 GC 可能只花几毫秒,听起来无所谓。可如果你的代码每帧都产生垃圾,GC 就会频繁触发,比如每秒好几次。每次几毫秒,一秒就是十几毫秒的额外 CPU 开销,而且这些开销是突发的、不可预测的,会造成帧率抖动。更麻烦的是,GC 触发时 CPU 会瞬间拉高频率去处理,这种高频短时的负载对电池和散热的压力比平稳负载更大。
我实测过一个中低端机项目,战斗场景里每帧产生约 40KB 垃圾,GC 大约每 1.5 秒触发一次,每次 8 到 12 毫秒。听起来不多,但战斗持续时 CPU 温度在 15 分钟内上升了 6 到 8 摄氏度,帧率从 60 掉到 45 左右。把垃圾降到每帧 2KB 以下后,GC 间隔拉长到十几秒,温度上升明显放缓。
2.2 用 Profiler 把垃圾源头揪出来
定位 GC 不要靠猜,靠 Profiler。打开 Unity Profiler,切到 CPU Usage 或者 Memory 模块,重点看两个东西:GC Alloc这一列的每帧数值,以及GC Collect的调用频率。
具体操作:在 Profiler 窗口里选中一帧,展开 Hierarchy 视图,按 GC Alloc 排序。你会看到每个函数在这一帧分配了多少字节。通常排在前面的就是元凶。常见的垃圾大户有这么几类:
- 字符串操作:
string + string、string.Format、ToString()在循环里调用。字符串是不可变的,每次拼接都产生新对象。 - 装箱:把值类型当 object 用,比如
Debug.Log(someInt)、往List<object>里塞 int、用非泛型集合。 - 闭包和委托:lambda 捕获外部变量时会生成闭包类实例,每次执行都 new 一个。
- LINQ:
Where、Select、OrderBy这些会产生迭代器和中间集合,在 Update 里用就是灾难。 - 数组和 List 的临时创建:
new Vector3[]、GetComponents<T>()返回新数组、foreach遍历某些集合时的迭代器分配。
提示:Profiler 的 Deep Profile 模式能精确到每一行,但开销极大,只适合在编辑器里定位问题,别在真机上开。
2.3 把每帧垃圾压到接近零的实操手段
知道源头之后,优化思路就清晰了:能缓存就缓存,能复用就复用,能避免分配就避免分配。下面是我在项目里反复用的几招。
字符串这块,循环里绝对不要拼接。需要动态文本用StringBuilder,并且把它做成成员变量复用,每次Clear()而不是 new。数字转字符串如果频率高,考虑自己写个简单的整数转字符缓存的工具,或者用ZString这类零分配库。UI 上的文本更新,能只在数值变化时刷新就别每帧刷。
装箱这块,Debug.Log在正式包里应该被条件编译干掉,或者至少别在 Update 里调用。集合一律用泛型版本,List<int>而不是ArrayList。字典的 key 如果是枚举,注意枚举做 key 时的比较可能涉及装箱,用EqualityComparer或者干脆用 int 做 key。
闭包这块,Update 里注册的回调尽量用方法引用而不是 lambda,或者把 lambda 提到循环外。事件订阅在对象销毁时记得取消,否则不仅泄漏还持续产生引用。
容器复用,所有临时 List、数组都做成池。Unity 自带的ListPool、ArrayPool或者自己写个简单的对象池都行。GetComponents这类 API 有带 List 参数的重载,传一个复用的 List 进去,就不会每次分配新数组。
// 反例:每帧分配 void Update() { var enemies = GetComponentsInChildren<Enemy>(); // 每次新数组 foreach (var e in enemies) { /* ... */ } } // 正例:复用 List private readonly List<Enemy> _enemyCache = new List<Enemy>(); void Update() { _enemyCache.Clear(); GetComponentsInChildren(_enemyCache); // 填充已有 List,零分配 for (int i = 0; i < _enemyCache.Count; i++) { /* ... */ } }实测下来,一个中等复杂度的战斗场景,把这些手段用上之后,每帧 GC Alloc 能从 30 到 50KB 降到 1KB 以内,GC 触发间隔从秒级拉到十几秒甚至更长。CPU 的突发负载没了,温度曲线会明显平缓。
2.4 关于 GC 的几个常见误区
第一个误区是“托管堆设大一点就不会频繁 GC”。堆大了确实触发频率降低,但每次 GC 的耗时变长,因为要扫描更多对象。而且移动端内存本来就紧张,堆设太大容易触发系统级内存压力。我的经验是,与其调堆大小,不如从源头减少分配。
第二个误区是“值类型不产生垃圾”。值类型本身在栈上没问题,但一旦被装箱、被放进 object 数组、被闭包捕获,照样上堆。struct里如果包含引用类型字段,复制时也只是复制引用,不会产生新对象,但要注意语义。
第三个误区是“GC 只在堆满时才跑”。Unity 的增量 GC 和分代机制会让它在不同时机触发,而且GC.Collect()手动调用会强制全量回收,千万别在运行时随便调,那是一次完整的 Stop-The-World。
3. Draw Call:CPU 提交指令的隐形税
3.1 Draw Call 到底贵在哪
很多人以为 Draw Call 的开销在 GPU,其实 GPU 执行绘制本身很快,真正贵的是 CPU 侧的准备和提交。每个 Draw Call 之前,CPU 要做一堆事:检查渲染状态、设置材质和 Shader 参数、绑定纹理和缓冲区、做视锥剔除和排序、最后把命令塞进命令缓冲区。这一套流程在移动端 API(比如 OpenGL ES)上尤其重,因为状态切换的验证成本高。
一个 Draw Call 的 CPU 开销大概在几十微秒量级,听起来很小。但如果你有 500 个 Draw Call,一帧就是十几毫秒,60 帧的预算只有 16.6 毫秒,光提交指令就吃掉了大半。而且这些开销是每帧重复的,CPU 持续处于中高负载,温度自然下不来。
我见过一个项目,场景里几百个独立的小物件,每个都是单独的 MeshRenderer,Draw Call 常年 600 以上。GPU 时间只有 4 毫秒,CPU 时间却有 20 毫秒,帧率卡在 40 上不去,手机烫得能煎蛋。这就是典型的 CPU 瓶颈导致的发烫。
3.2 用 Frame Debugger 看清 Draw Call 的构成
优化 Draw Call 第一步是搞清楚它们从哪来。Unity 的 Frame Debugger 是最好的工具,它能逐条列出这一帧所有的绘制命令,包括每个 Draw Call 用的 Shader、材质、网格、以及为什么没有合批。
打开 Frame Debugger,点 Enable,然后逐条往下看。重点观察:
- 哪些 Draw Call 用了相同的材质和 Shader,却没有被合批。
- 哪些是因为渲染状态不同(比如不同的纹理、不同的渲染队列)被打断。
- UI 部分的 Draw Call 有多少,是不是每个 Text 都单独一个。
常见的合批失败原因有:材质实例不同(renderer.material会创建实例,应该用sharedMaterial)、顶点数超过合批上限、使用了不同的光照贴图、物体之间有遮挡关系导致排序打断、以及动态合批的顶点属性限制(比如带法线和 UV 的网格顶点数上限是 300 左右)。
3.3 静态合批、动态合批与 GPU Instancing 的取舍
Unity 提供三种主要的合批手段,各有适用场景,选错了反而更糟。
静态合批适合场景里不动的物体,比如建筑、地形装饰。它在构建时把多个网格合并成一个大网格,运行时就是一个 Draw Call。代价是内存占用增加,因为合并后的网格数据要常驻。而且合并后的物体不能单独移动,否则合批失效。对于大量重复的小物件,静态合批效果显著。
动态合批适合移动的小物体,但限制很多:顶点数不能太多、不能用不同的缩放、Shader 不能太复杂。它在运行时由 CPU 把顶点变换到世界空间再合并,本身也有 CPU 开销。所以动态合批不是越多越好,超过一定数量反而增加 CPU 负担。
GPU Instancing适合大量相同网格和材质的物体,比如草地、子弹、粒子。它把每个实例的变换矩阵传给 GPU,一次 Draw Call 画一堆。前提是 Shader 支持 instancing,材质要开启Enable GPU Instancing。对于移动端,instancing 的收益通常比动态合批大,因为 CPU 侧几乎不增加负担。
我的选型经验是:静态物体优先静态合批,大量重复的动态物体优先 Instancing,剩下的小批量动态物体再看动态合批。三者可以混用,但要注意别让它们互相干扰。
3.4 从 600 降到 80:一次真实的 Draw Call 优化记录
回到前面那个 600 Draw Call 的项目。我的优化步骤是这样的:
第一步,用 Frame Debugger 分类。发现其中约 350 个是场景装饰物,都是独立的小网格,材质相同但因为是不同 prefab 实例,用了material而不是sharedMaterial,导致每个都创建了材质实例,合批全断。改成sharedMaterial后,动态合批生效,直接降到 200 左右。
第二步,把不动的装饰物标记为 Static,开启静态合批。这部分又合并掉约 100 个 Draw Call。
第三步,剩下的动态小物件改用 GPU Instancing。Shader 换成支持 instancing 的版本,材质开启 instancing,Draw Call 降到 80 左右。
第四步,UI 部分单独处理(下一节细说),又省掉几十个。
最终 CPU 时间从 20 毫秒降到 8 毫秒左右,帧率稳定在 60,手机温度在同样游戏时长下低了 5 摄氏度以上。整个过程没有动美术资源,纯粹是渲染配置和代码层面的调整。
注意:静态合批会增加包体和内存,如果场景很大,要权衡。我一般只对重复度高、数量大的静态物体开,零散的大物件手动合并网格更划算。
4. Canvas 重建:UI 发烫的隐藏推手
4.1 Canvas 重建的触发机制
Unity 的 UGUI 系统里,Canvas 是合批的基本单位。同一个 Canvas 下的 UI 元素,如果材质和纹理相同,会被合并成较少的 Draw Call。但代价是,只要 Canvas 下任何一个元素发生变化(位置、大小、颜色、文本内容、显隐),整个 Canvas 就会被标记为 dirty,下一帧要重新计算所有元素的网格和合批,这就是Canvas 重建。
重建的开销跟 Canvas 下的元素数量成正比。一个 Canvas 下有 200 个 UI 元素,每次重建都要重新生成这 200 个元素的顶点数据、重新排序、重新合批。如果这个重建每帧都发生,CPU 就被持续占用。更糟的是,重建还会产生托管堆垃圾,进一步加重 GC 负担,两个问题叠加,发烫效果翻倍。
最常见的触发源是:每帧更新的文本(比如倒计时、分数、血条数值)、每帧移动的 UI 元素(比如飘字、进度条)、以及频繁 SetActive 的按钮或面板。
4.2 动静分离:把 Canvas 拆成多个
解决 Canvas 重建的核心思路是动静分离。把频繁变化的 UI 元素和静态的 UI 元素放在不同的 Canvas 下,这样动态部分重建时,静态部分不受影响。
具体做法:给每个需要频繁更新的 UI 模块单独建一个 Canvas,比如血条一个、倒计时一个、飘字一个。静态的背景、边框、固定按钮放在主 Canvas 下。这样每次只有小的动态 Canvas 重建,元素数量少,开销可控。
拆 Canvas 也有代价,每个 Canvas 至少一个 Draw Call,拆太多会增加 Draw Call。所以要平衡:变化频率高的才拆,变化频率低的合并。我的经验是,一个界面拆成 3 到 5 个 Canvas 比较合理,动态元素总数控制在每个 Canvas 50 个以内。
另外,Canvas 的嵌套要注意。子 Canvas 会继承父 Canvas 的渲染设置,但重建是独立的。如果父 Canvas 重建,子 Canvas 不一定重建,反之亦然。利用这个特性可以做更细的隔离。
4.3 文本更新的正确姿势
文本是 Canvas 重建的头号元凶。Text组件每次text属性被赋值,哪怕内容没变,都会触发重建。所以第一条铁律:赋值前先判断内容是否真的变了。
// 反例:每帧赋值,即使内容相同也触发重建 void Update() { scoreText.text = score.ToString(); } // 正例:只在变化时更新 private int _lastScore = -1; void Update() { if (score != _lastScore) { _lastScore = score; scoreText.text = score.ToString(); } }第二条,数字文本尽量用等宽字体或者固定位数,避免因为字符宽度变化导致布局重算。第三条,如果文本更新频率很高(比如每帧变的计时器),考虑用TextMeshPro,它的重建开销比传统Text小,而且支持更多优化选项。TMP 的SetText系列方法可以避免一些不必要的分配。
还有个小技巧:对于纯数字的频繁更新,可以用图集数字(把 0 到 9 做成图片,用 Image 拼),完全绕开文本重建。这在血条、分数这类场景很有效,代价是灵活性差一点。
4.4 SetActive、LocalScale 还是移出相机:显隐方案怎么选
UI 元素的显隐是另一个高频操作,不同方案对 Canvas 重建的影响差别很大。
SetActive(false)会把元素从 Canvas 的渲染列表中移除,触发一次重建。再次SetActive(true)又触发一次。如果频繁切换,重建次数就上去了。但它的好处是彻底不参与布局和渲染,静态时零开销。
改LocalScale为 0 或者极小值,元素还在 Canvas 里,仍然参与布局计算,但视觉上不可见。它也会触发重建(因为变换变了),而且因为元素还在,合批和布局的开销还在。一般不推荐。
移出相机视野(改位置到屏幕外),元素还在渲染列表里,只是被剔除。它同样触发重建,而且如果没被正确剔除还会浪费 Draw Call。也不推荐。
我的选择是:低频切换用 SetActive,高频切换用 CanvasGroup 的 alpha。CanvasGroup.alpha = 0配合interactable = false和blocksRaycasts = false,可以让元素不可见且不响应交互,但元素还在渲染列表里。关键是,改 alpha 是否触发重建取决于具体版本和设置,实测在多数情况下比 SetActive 温和。如果元素数量多,还是拆 Canvas 最稳。
提示:如果一定要频繁 SetActive,把这个元素单独放一个 Canvas,这样重建范围最小。
5. 三者叠加时的排查顺序与实战心得
5.1 先定位瓶颈在谁身上
GC、Draw Call、Canvas 重建经常同时存在,盲目优化容易做无用功。我的排查顺序是固定的:
先看 Profiler 的 CPU 时间分布。如果GC.Collect占比高,先治 GC。如果Camera.Render或Renderer相关占比高,先治 Draw Call。如果Canvas.SendWillRenderCanvases或Canvas.BuildBatch占比高,先治 Canvas 重建。
这三个指标在 Profiler 里都能直接看到。Canvas.BuildBatch是 Canvas 重建的主要开销函数,Canvas.SendWillRenderCanvases是重建的触发入口。看到它们频繁出现,就说明 UI 有问题。
定位清楚之后,一次只改一个变量,改完再测。同时改多个,出了问题都不知道是哪个引起的。
5.2 一份可以直接抄的检查清单
下面这张表是我每次做发烫优化都会过一遍的清单,按优先级排列:
| 检查项 | 判断标准 | 优化手段 |
|---|---|---|
| 每帧 GC Alloc | 大于 1KB 就要查 | 缓存、池化、去装箱、去闭包 |
| GC 触发间隔 | 小于 5 秒就要查 | 同上,重点查 Update 里的分配 |
| Draw Call 数量 | 移动端超过 150 要查 | 合批、Instancing、图集合并 |
| 材质实例 | 有.material调用 | 改sharedMaterial |
| Canvas 数量 | 单个 Canvas 元素超 100 | 动静分离拆 Canvas |
| 文本更新 | 每帧赋值 | 加变化判断,或换 TMP |
| UI 显隐 | 频繁 SetActive | 拆 Canvas 或用 CanvasGroup |
这张表覆盖了 80% 的 CPU 侧发烫问题。剩下的 20% 通常是脚本逻辑本身的问题,比如每帧的复杂计算、频繁的物理查询、大量的协程切换,那些需要单独分析。
5.3 几个我踩过的坑
第一个坑:过度合批导致内存暴涨。有次为了降 Draw Call,把整个场景的静态物体全开了静态合批,结果包体大了 30MB,低端机加载时直接内存不足闪退。后来改成只合批重复度高的小物件,问题解决。合批不是越多越好,要看内存预算。
第二个坑:TMP 文本的材质实例。TextMeshPro 每个文本如果用了不同的材质参数(比如描边、阴影),会创建材质实例,导致合批失败。解决办法是用 Material Preset 统一参数,或者用 TMP 的图集和 Shader 变体控制。
第三个坑:Canvas 重建的连锁反应。有次把一个频繁更新的元素放在主 Canvas 下,它每帧重建,导致整个主 Canvas 的所有元素都重算,Draw Call 也跟着涨。拆出去之后,主 Canvas 稳定,Draw Call 也降了。这个坑很隐蔽,因为你看 Draw Call 数量没变,但重建开销全在主 Canvas 上。
第四个坑:GC 的“假优化”。有次把一个大数组改成对象池,GC Alloc 确实降了,但 CPU 时间没降,因为池的查找和归还逻辑本身有开销,而且池太大导致缓存不友好。后来把池大小限制在合理范围,才真正见效。优化要看整体 CPU 时间,不能只盯 GC Alloc 一个指标。
5.4 优化之后怎么验证效果
优化完不能只看 Profiler 数字,要上真机测温度。我的验证流程是:同一台设备,同一个场景,连续跑 20 分钟,用系统自带的性能监控或者第三方工具记录 CPU 占用、GPU 占用、电池温度和帧率。
对比优化前后的曲线,重点看三个东西:CPU 占用是否从持续高位变成有波动的中低位、温度上升斜率是否变缓、帧率是否更稳定。如果 CPU 占用降了但温度没降,可能是 GPU 成了新瓶颈,或者散热本身有问题。如果温度降了但帧率没升,说明之前是 CPU 瓶颈,现在瓶颈转移了,可以继续找下一个。
我一般会把优化前后的数据做成表格存档,方便后续版本对比。这个习惯帮我避免了很多“感觉优化了但实际没效果”的自我欺骗。
6. 写在最后的一点个人体会
做发烫优化这些年,最大的感受是:CPU 从来不是无辜的旁观者,它是那个默默干活、干多了就发火的角色。GC、Draw Call、Canvas 重建这三件事,单独看都是“正常开销”,但它们的共同点是高频、持续、可累积。手机发烫往往不是某一帧的峰值造成的,而是长时间的中等负载把热量一点点堆上去的。
我现在的习惯是,项目早期就把 GC Alloc 的监控加进 CI,每帧超过阈值就报警。Draw Call 和 Canvas 数量也做成运行时统计,在开发机上实时显示。这样问题在萌芽阶段就被发现,不用等到真机发烫了再回头查。
如果你正在被发烫问题困扰,建议先从 Profiler 的 CPU 时间分布入手,找到占比最高的那一项,按这篇文章的顺序逐个击破。别想着一次全改完,改一个测一个,数据会告诉你有没有效果。这套方法我在好几个项目上验证过,从 600 Draw Call 降到 80、从每帧 40KB 垃圾降到 1KB 以内、从每帧 Canvas 重建到只在必要时重建,每一步都有明确的收益。