说实话,每次看到项目群里发烫、掉帧的问题,大家第一反应都是 GPU 不行、Shader 太费、后处理太多……但我做过不少 Unity 项目的性能优化后,越来越确定一件事:CPU 往往才是那个“闷声干大事”的发热源。尤其当你把 GPU 侧压得差不多之后,Profiler 里剩下的三个大块就是 GC、Draw Call 和 Canvas 重建。这一篇就围绕这三件事,讲讲它们在 CPU 上到底干了什么、怎么定位、怎么治。
先说结论:显卡发热通常来自像素填充率和显存带宽压力,而 CPU 发热则来自线程空转、内存分配、渲染状态切换和 UI 网格重建。如果你在真机上摸过后盖,会发现很多时候 CPU 的功耗占比并不比 GPU 低多少。这个系列前面几篇主要聊了 GPU 侧的渲染开销,今天把火力集中到 CPU,把这三个“背锅侠”的老底掀开看看。
1. CPU 在“发烫”这件事上扮演的角色
1.1 为什么 CPU 常常被冤枉
很多团队的优化流程是:手机发烫 → 打开 Profiler → 看到 Rendering 耗时偏高 → 开始砍 Shader、砍后处理、砍分辨率。这一套做完,帧率有时候确实上来一点,但后盖照样烫。再仔细看 Profiler,发现主线程里真正的大头是脚本里的内存分配、渲染线程的 Draw Call 提交、还有 UI 的网格重建。
CPU 发热的本质不是“计算量大”这么简单,而是功耗。手机 SoC 上 CPU 和 GPU 通常是同一个封装里的不同模块,CPU 满频工作一样会推高整机功耗和温度。而且 CPU 一旦长时间高负载,系统调度器会主动降频,降频后帧率掉到 30 以下,体感就是“越来越卡”。所以优化 CPU 侧的这三个点,不只是为了帧率数字好看,更是为了让机器在 30 分钟、60 分钟之后还能保持稳定性能。
拿一个形象点的类比:GPU 是流水线上的加工设备,CPU 是调度员、打包工和原料工。你让加工设备优化到飞起,但调度员每秒钟要喊几百次“下一批”、打包工不停把货物拆了重新装、原料工反复把布料剪了重新织,最后产能一样上不去,而且这三个人的体力消耗(功耗)一点不少。
1.2 三个问题之间的优先级
GC、Draw Call、Canvas 重建这三件事看似互不相干,实际上经常一起出现。举个例子:一个 MMO 大厅,主城场景里大量角色、大量 UI 飘字、排行榜列表频繁刷新,这时候 GC 分配爆炸、Draw Call 分分钟破百、Canvas 每帧重建,三种问题同时压在主线程上,帧间隔直接拉成心电图。
我的经验是,优先级按影响面排:GC 最先处理,因为它影响的是所有逻辑的稳定性,哪怕你 Draw Call 很低,只要每帧都在分配对象,GC 一触发就是几十毫秒的停顿;Draw Call 次之,它决定渲染线程和主线程之间的提交压力,影响整体帧率上界;Canvas 重建最后处理,因为它的爆发通常集中在 UI 交互的瞬间,属于“点状卡顿”,需要单独压。
在动手之前先定位。不要凭感觉猜,Unity Profiler 的 CPU Usage 模块能直接告诉你主线程时间都花在哪。后面第 3 章会详细讲怎么用 Profiler 给这三件事做体检。
2. 核心细节解析与实操要点
2.1 GC:别再让它随意抄底
GC 在 Unity 里是 Mono 或 IL2CPP 托管堆的垃圾回收机制。老版本 Unity(以及不少 IL2CPP 项目的默认配置)使用的 Boehm GC 是非分代、非压缩的回收器,它的特点是:只要触发回收,就会把整个托管堆扫一遍,扫描期间所有托管线程全部暂停。这个“暂停”在移动端上非常致命,常见表现就是帧率突然掉一下,然后恢复正常,过几秒又掉一下。
为什么移动端对 GC 这么敏感?因为移动端 CPU 主频低、核数少,一帧预算只有 16.6ms(60fps)或 33.3ms(30fps),GC 一次扫堆只要超过 5ms,这帧就废了。而且更麻烦的是,GC 的触发时机不可控,它往往在你刚分配一批对象、堆内存达到阈值的时候来一下,正好卡在战斗最激烈的时刻。
最常见的隐形分配源,我列个清单,你们对着查:
- 字符串拼接:
Debug.Log("HP: " + hp + "/" + maxHp)这种代码,每执行一次就产生至少两个临时字符串。 - LINQ:
list.Where(...).Select(...)看起来简洁,但 Where 会创建迭代器结构体,Select 还会再包一层,遇到 lambda 更是每次都分配。 - 闭包和匿名委托:循环里给按钮
onClick.AddListener(() => DoSomething(i)),这个i会被闭包捕获,编译器会创建一个包装类对象。 - 装箱:值类型转 object,比如把 int 传给一个接收 object 参数的函数,或者调用非泛型接口方法,都会装箱。
- 协程里的
yield return new WaitForSeconds(...):每次执行都 new 一个对象,循环等待更是频繁分配。 - GetComponent 本身不分配,但如果你在 Update 里每帧调用它,虽然不产生 GC,也会增加 CPU 指令开销,应该缓存引用。
用 Profiler 的 GC Alloc 列抓分配点时,我的阈值是每帧 2KB 以上就要警惕了,超过 10KB 基本意味着你在热路径上做了不该做的事。比如某个循环里每帧创建一个临时 List,那 GC 曲线肯定是一根直线往上窜。
实操修正的方向很明确:字符串拼接改成 StringBuilder 或字符串插值(注意插值也会分配,但比多个+好);LINQ 改成普通 for 循环;闭包改成显式传参;装箱用泛型接口或直接 ToString;协程的等待对象手动缓存;GameObject 和临时列表一律走对象池。
2.2 Draw Call:合批不是万能的
Draw Call 是 CPU 向 GPU 提交渲染命令的开销,但很多开发者把“Draw Call 数量”和“性能”直接画等号,这是误解。严格来说,CPU 更怕的是 SetPass Call,也就是渲染状态切换。每次切换材质、Shader Pass、贴图,CPU 都要重新打包一批渲染状态给 GPU,这个过程的成本远高于单纯增加一个 Draw Call。
Unity 提供了静态合批、动态合批、GPU Instancing、SRP Batcher 四种合批手段,各有各的适用场景:
- 静态合批:把场景里标记为 Static 且共享相同材质的网格合并成一个大网格。代价是内存增长,因为合批会复制顶点数据,打包时间和包体也会变大。适合地形、建筑、摆放件这类完全静止的物体。
- 动态合批:对小网格自动合并,限制很死,顶点数超过 900(某些平台 300)就不生效,而且很多材质属性差异会打断合批。移动端项目基本可以放弃依赖它。
- GPU Instancing:适合大量相同网格、相同材质、只有 transform 或颜色不同的物体,比如几百棵一样的树、成千上万个小兵。它把每个实例的变换数据打包成缓冲区,一次提交绘制全部实例。注意它和动态合批互斥,一种物体只能走其中一条路径。
- SRP Batcher:URP 和 HDRP 下的主力方案,专门解决材质属性绑定和状态切换的开销。前提是 Shader 必须兼容 SRP Batcher,尽量用 URP 内置的 Lit/Unlit 或者遵循 SRP Batcher 规范的自定义 Shader。
你以为开启合批就万事大吉,实际问题往往是合批没有按预期生效。最常见的原因包括:物体使用了材质实例而不是共享材质,两个物体虽然看起来一样,但各自有独立材质实例就会打断合批;UI 元素没有打图集,每个小图都是独立纹理;阴影投射和额外 Pass 会把批次拆开。
用 Frame Debugger 可以逐帧查看每个 Draw Call 为什么合批/为什么没合批。它会把合批失败的物体标出来,点击后能看到这个 Draw Call 的材质、网格、光照信息,对照原因慢慢排查。
2.3 Canvas 重建:UI 卡顿的隐形元凶
UGUI 的 Canvas 是一个网格缓存和批处理单元,它把子 UI 元素的顶点数据合并成网格提交给 GPU。问题是:只要 Canvas 范围内的任何一个 UI 元素发生变化,整个 Canvas 的所有元素都要重新生成网格、重新合批、重新提交。这就是“Canvas 重建”,在 Profiler 里通常表现为Canvas.SendWillRenderCanvases。
触发重建的操作比你想的更常见:修改 RectTransform 的尺寸或位置、修改文本内容、更改图片 Sprite、启用或禁用子物体、修改材质、改变颜色和 alpha(部分版本)。哪怕是 ScrollRect 滚动一下,整个 content 下的所有元素都会重新计算,如果你把所有 UI 都放在同一个 Canvas 下,那么每次弹窗、每次飘字、每次进度条变化,所有 UI 全部重建一遍。
这个成本有多高?一个包含上百个元素的 Canvas,重建一次消耗 2~3ms 是很正常的事,如果还有大量 Text 在里面,Text 的字体图集查找和顶点生成会更慢。几个弹窗动画同时在跑,每帧重建几十上百个元素,帧率直接被按在地上摩擦。
优化 Canvas 的核心原则是“分割”。把静态 UI(背景、边框、常驻图标)放在一个 Canvas,把动态 UI(血量数字、冷却时间、飘字)放在另一个 Canvas,两张 Canvas 互不干扰,动态部分重建时静态部分不用跟着倒霉。值得注意,Canvas 不能无脑切碎,每个 Canvas 都有自己的合批链路和网格,切得太碎会导致 Draw Call 上涨和内存浪费,一般控制在 3~5 个以内。
另外几个高频坑:Text 的 Best Fit 会在字体变化时重新采样,非常贵,尽量固定字号;Shadow 和 Outline 组件会让文本顶点翻倍,能不用就不用;Image 的 Raycast Target 默认开启,每帧 UI 射线检测会遍历所有带这个选项的组件,能关就关。
3. 实操过程与核心环节实现
3.1 用 Profiler 给 CPU 三个痛点做“体检”
连接真机,打开 Window > Analysis > Profiler,切到 CPU Usage 模块。这里我强烈建议用 Development Build + Autoconnect Profiler 方式跑真机,Editor 里的 Profiler 数据参考价值极低,因为编辑器自身开销、 vsync 模拟和资源加载路径都跟真机差很多。
录制一段至少 5 分钟的真实玩流程,包括战斗、UI 操作、切场景。然后看两个东西:一个是主线程 Timeline 的耗时分布,一个是 GC Alloc 曲线。Timeline 里主要关注这几项:
| 项 | 代表什么 | 合理范围 |
|---|---|---|
| Scripts | 脚本逻辑总耗时 | 目标 < 4ms @60fps |
| Rendering | 渲染提交耗时 | 依场景复杂度,稳定不抖动 |
| Canvas.SendWillRenderCanvases | UI 网格重建 | 目标 < 1ms |
| VSync | 垂直同步等待 | 如果经常是 0,说明帧率被其他东西拖住 |
GC Alloc 曲线可以用 Hierachy 模式查看。关闭 Deep Profile,Deep Profile 会注入大量插桩代码,性能和分配数据都会失真。默认 Profiler 已经能抓 GC Alloc 的调用栈,只是粒度可能不如 Deep Profile 细,但对定位热分配点够用了。
真机测试时还有个小技巧:在 Profiler 窗口的 Frame 面板里点击某一帧,右侧 Hierarchy 会按耗时排序,重点看 Scripts 和 GC Alloc 两列交叉最高的函数,那基本就是元凶。
3.2 GC 定位与清零:从 Profiler 抓分配到代码整改
先说一个我自己的标准:热路径(Update、协程、UI 回调、战斗逻辑)上每帧 GC Alloc 清零,非热路径的单次分配控制在 KB 级以下。这不是强迫症,是真的能做到,关键在于把“每帧分配”的习惯改成“预分配 + 复用”。
用一个最常见的滑动条、数值飘字场景举例。优化前代码可能是这样的:
void Update() { // 很常见的写法:每帧拼接字符串 hpText.text = "HP: " + currentHp + "/" + maxHp; // 协程等待,每帧都 new 一个 WaitForSeconds StartCoroutine(WaitAndDoSomething(1f)); }这段代码每帧做了什么?字符串拼接创建了至少 3 个临时对象,StartCoroutine本身也可能分配,WaitForSeconds又是一个新对象。如果这个脚本同时挂在 20 个角色身上,一帧就是 20 份分配,GC 曲线直接起飞。
优化后的做法分两步。第一步,把文本拼接改成手动缓存数值或使用 StringBuilder:
StringBuilder sb = new StringBuilder(32); void Update() { sb.Clear(); sb.Append("HP: "); sb.Append(currentHp); sb.Append("/"); sb.Append(maxHp); hpText.text = sb.ToString(); }注意 StringBuilder 的ToString()仍然会分配字符串,这是绕不过去的,因为 Text 组件必须接收字符串。但相比之前 3 个临时对象,至少少了一半。如果数字变化不频繁,更好的做法是只在数值真正变化时才更新文本,加一个脏标记判断。
第二步,协程等待对象手动缓存:
static readonly WaitForSeconds waitOneSecond = new WaitForSeconds(1f); IEnumerator WaitAndDoSomething() { yield return waitOneSecond; // do something }WaitForSeconds只要参数不变,完全可以做成静态只读对象,整个游戏生命周期只分配一次。类似的还有WaitForEndOfFrame、WaitForFixedUpdate。
对象池方面,我的建议是不要只盯着 GameObject,临时 List、数组、结构体容器同样值得池化。比如一个频繁生成的飘字系统,与其每次new一个飘字对象和它的位置 List,不如提前开一个固定容量的池子,用完归还。配合List.Clear()复用内部数组,GC 分配能压到接近零。
3.3 Draw Call 落地:合批方案选型和验证
接到一个优化任务,不要上来就到处勾 Static,先分析场景构成。我的流程是:
首先把场景物体按“是否经常移动、是否共享材质、是否是同网格”分成三类:不动且共享材质的进静态合批;大量重复且可能移动的进 GPU Instancing;其余交给 SRP Batcher。如果是 URP 工程,先把 SRP Batcher 打开,在 Project Settings > Graphics 里确保勾选,然后检查自定义 Shader 是否兼容。兼容的标准是 Shader 里所有属性都走 CBUFFER 声明,而不是用_Color、_MainTex_ST这类不在 CBUFFER 里的变量。Unity 官方 Lit/Unlit 没问题,第三方 Shader 要逐个验证。
静态合批的操作看起来简单,勾上 Static 就行,但注意几个坑:静态合批只对完全静止的物体生效,运行时任何 transform 变化都会打断合批并产生警告;合批后的网格顶点数不能超过单个 Mesh 的索引上限(64K 索引),超出会分成多个批次;内存会增加,所以静态合批不是越多越好,而是“该合的合,不该合的不合”。
验证合批效果用 Frame Debugger。打开 Window > Analysis > Frame Debugger,逐帧看 Draw Call 列表,如果两个物体理论上能合批但实际分开了,点击对应的 Draw Call,右侧会显示它的 mesh、material、pass,以及为什么没有合批。常见提示包括“Material differs”“Mesh differs”“Shadow caster pass 导致额外 draw”。你可以针对性地改材质实例、合并贴图图集、调整光照设置。
实际项目中我遇到最多的问题是:美术给同一个模型的不同部位贴了独立材质,导致静态合批直接失效。解决办法是把多张贴图合到一张图集里,用 UV 偏移区分部位,一套材质搞定。另一个问题是粒子系统,每个粒子发射器都是一个独立 Draw Call,优化方向是粒子贴图图集化和限制发射器数量,而不是去合批它们,因为粒子本身是动态顶点流,静态合批对它无效。
3.4 Canvas 重建优化实操:拆分与降频
前面说了 Canvas 重建的原理,实战里的第一步永远是定位。打开 Profiler 的 UI 模块,可以看到每个 Canvas 的重建耗时和重建原因。如果你的 Unity 版本没有 UI 模块,就主线程里抓Canvas.SendWillRenderCanvases的调用栈,它能告诉你哪个 Canvas 触发了重建。
定位到问题 Canvas 后,按静态/动态拆分成两个或更多 Canvas。静态 Canvas 放背景、边框、常驻按钮、图标,动态 Canvas 放血量数字、冷却遮罩、飘字、滚动列表。两个 Canvas 的层级关系不影响视觉表现,因为 UI 渲染顺序由 Canvas 的 sortingOrder 决定,和父子关系不是强绑定。
拆分之后,我在动态 Canvas 内部还会做更细的降频处理。比如每帧都在变化的数字文本,改成数值变化时才更新文字的脏标记模式:
int _displayedHp; public int hp { set { if (_displayedHp == value) return; _displayedHp = value; hpText.text = value.ToString(); } }这段代码的意义是避免每帧调用ToString()和 Text 的 setter,因为后者内部会标记脏并触发重建。只有真正数值变化才更新,重建次数从每帧一次降到实际变化次数。
ScrollRect 滚动列表是 Canvas 重建的重灾区。推荐的做法是对象池 + 只渲染可见项,不要整个 content 全量生成。就算不列表化,至少要把列表所在的 content 独立成一个 Canvas,避免列表滚动拖累全屏 UI 重建。
另外,少用 Shadow、Outline 这类会产生额外顶点和重建的组件。一个带 Outline 的 Text,其顶点数会翻倍,重建成本随之上升。如果在列表项里大量使用,滑动时卡顿非常明显。可以改用预先做好的九宫格背景图,或者直接去掉阴影效果。
4. 常见问题与排查技巧实录
4.1 Profiler 数据波动大,如何判断是不是 GC
很多人打开 Profiler 看到帧时间忽高忽低,却说不清问题在哪。我的判断顺序是:先看 GC Alloc 曲线是否持续上涨或周期性出现尖峰;再看主线程 Timeline 是否有那种“突然一条长条”的阻塞;最后用 Memory Profiler 抓堆快照,对比两个时间点的 Object 数量和类型。
如果你看到一个函数的 GC Alloc 是 0,说明它没有额外分配,但这不代表它实时性就好。有些函数本身不分配对象,但做了大量计算导致 CPU 占用高,这类问题用 GC 视角是抓不到的,得看 Self 耗时。GC 定位专门看分配,CPU 耗时定位专门看 Self,两套指标要分开用。
还有一个很容易忽略的点:IL2CPP 的 GC 行为和 Mono 不一致,同一个项目两种后端跑出来的 GC 曲线可能完全不同。优化的时候我一般兼顾两个平台,但以真机目标平台为准,比如目标是 Android 就用 IL2CPP + ARM64 跑 Profiler。
4.2 我已经开启合批但 Draw Call 还是高
合批没有生效的原因很多,我把平时排查的清单整理成表,方便大家按图索骥:
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 静态合批后 Draw Call 没降 | 物体没正确标记 Static | 检查 GameObject 的 Static 复选框,确认不是运行时才设置的 |
| 两个物体同网格同材质却分两个批 | 各自持有材质实例 | 检查 Material 引用是否是同一个,不要用 Renderer.material(会实例化) |
| UI Draw Call 很高 | 图片没用图集,或用了独立材质 | 查看 AtlasProvider 是否生效,确认所有 UI 元素引用图集里的 Sprite |
| URP 下 SRP Batcher 没生效 | Shader 不兼容 SRP Batcher | 在 Frame Debugger 里看 Draw Call 是否为 Batch 类型,控查 Shader 是否走 CBUFFER |
| 阴影导致批次翻倍 | 多光源投影产生额外 Pass | 阴影质量调低或改用烘焙光影 |
调试技巧:在 Frame Debugger 里搜关键词 “Why did the batch break”,Unity 新版会给出相对明确的拆分原因,照着提示改比瞎猜效率高得多。
4.3 Canvas 重建优化的经典误区和注意点
先说一个最常见的误区:不少人以为 UI 元素少就没事。实际上 Canvas 重建的开销不仅取决于元素数量,还取决于网格生成的复杂度。一个只有 5 个 Text 的 Canvas,如果这 5 个 Text 都开了 Best Fit 并且频繁改内容,重建成本可能超过 50 个静态 Image 的 Canvas。
另一个误区是“拆分越细越好”。每个 Canvas 都有独立的批次和网格,拆出十几个 Canvas,Draw Call 会涨,内存也会涨。一般控制在 3~5 个以内,并且每个 Canvas 的作用域要清晰。我见过把每个按钮单独包一个 Canvas 的项目,Draw Call 直接翻倍,得不偿失。
注意点方面,我特别强调:真机测试时不要只看 Editor。Editor 的 UI 重建路径和真机有差异,尤其是字体渲染和网格上传部分,Editor 里看不出来的问题,到了真机全暴露。做 UI 优化必须用 Development Build 在真机上跑,用 Profiler 的 UI 模块和真机帧率双确认。
列表优化还有一个容易被忽略的小点:当 ScrollRect 停下来之后,如果 content 的尺寸和位置还在变化,比如惯性回弹动画,它依然会持续触发 Canvas 重建。可以在回弹动画结束后把 content 的anchoredPosition冻结,或者干脆在不需要动画时禁用 ScrollRect 的 inertia。
结尾
调 UI 和 GC 这种事,表面上是在抠代码细节,实际上是在跟项目里的“每帧惯性”作斗争。我自己的体会是:性能优化不靠神来之笔,靠的是把每帧干了什么、为什么这么干、有没有更省的方式这三个问题问到底。如果你能把 GC Alloc、SetPass Call、Canvas 重建这三个指标都量化进项目的性能预算里,并且每次提交代码都看一眼变化,发热问题基本不会积累到上线前才爆发。
最后分享一个我常用的“性能预算表”模板,适合贴到项目 Wiki 或者团队文档里:主线程脚本耗时不超过 4ms、GC Alloc 每帧不超过 2KB、SetPass Call 移动端不超过 50 个、Canvas 重建不超过 1ms。这四个数守住,CPU 侧的发烫黑锅基本就可以摘掉了。