Unity手机发烫?先查CPU:GC、Draw Call与Canvas重建优化指南
手机发烫这件事很多人第一反应是 GPU 在跑渲染压力大。做 Unity 项目久了会发现最烫的场景往往是 CPU 先撑不住的。尤其是主线程里的 GC、Draw Call 和 Canvas 重建这三个东西看着不起眼加起来能把 60fps 的帧耗拉到 30ms 以上手机温度直接上来型号都会降频。这篇是发烫优化系列的第 5 篇专门聊 Unity CPU 侧的几个大头也会分享一套我自己一直在用的定位和优化流程。适合做 Unity 游戏、AR/VR、微信小游戏这类移动端项目的开发者参考不管你的 UI 是用 UGUI 还是正在评估别的方案这套思路都通用。1. 先别急着骂GPU发烫项目的CPU账单从哪开始算1.1 一个误区发烫不等于GPU满载很多做移动端 Unity 的同事一遇到手机发烫就联想到建模面数高、后处理重、粒子多觉得是 GPU 在“全力渲染”。实际上在真机上手机发烫的本质是 SoC 整体功耗高而 CPU 和 GPU 是同一块散热区域。如果你的 CPU 每帧要把 40ms 花在逻辑、UI 重建和渲染命令提交上帧率已经掉到 20 帧就算 GPU 只用了 3ms设备依然会因为持续高负载而发烫。Unity 的渲染流程里CPU 至少承担了两大类工作一是主线程的 Game Logic、动画、物理、UI 布局和重建二是渲染线程有时是主线程把 Mesh、材质、Transform 打包成渲染命令再通过图形 API 提交给 GPU。GPU 只负责执行这些指令底层的“排兵布阵”都是 CPU 在干。所以当你在 Profiler 里看到主线程/渲染线程长时间忙碌时锅并不在 GPU而是 CPU 被这些看不到的工作拖住了。这里还要提醒一个新人不常注意的点CPU 发热还有一个“隐性放大器”——功耗叠加。设备为了维持目标帧率会把 CPU 频率拉到高档于是功耗上升、温度升高温度一高又触发降频帧率也跟着崩。于是你会看到“越玩越卡、越卡越热”的死循环。所以做发烫优化第一步不是盲目的减面而是先搞清楚 CPU 的时间都花在哪、会不会存在为了维持帧率而高频运行的情况。1.2 在Profiler里怎么看CPU开销Unity 自带的 Profiler 是排查这类问题的第一优先工具。Windows 下打开 Window Analysis Profiler切到 CPU Usage 模块先看主线程通常叫 Player Loop的时间线。把视图从 Timeline 切到 Hierarchy 模式按 Self Ms 或 GC Alloc 排序能很快看到当前帧哪块占了最多时间。Profiler 里的常见条目含义我一般怎么用Player Update各脚本的 Update 调用包含 MonoBehaviour 脚本逻辑按 Self Ms 排序列出脚本热点UI.Layout / UI.ProcessingUGUI 布局计算与重建数值高说明 UI 驱动有问题Canvas.SendWillRenderCanvasesUI 网格重建和 Canvas 脏标记回刷数值高基本可判定 Canvas Rebuild 严重BatchRenderer.SetDynamicArray / RenderForwardOpaque渲染对象收集与 Draw Call 提交配合 Frame Debugger 看清批处理情况Gfx.WaitForPresentCPU 在等待 GPU 完成上一帧这种情况下真正瓶颈是 GPU方向要对Physics.Processing物理碰撞计算物理层高优先看场景刚体数量实践经验是不要在编辑器里直接拿当前 Game 视图做性能判断这个状态受编辑器窗口、GPU 同步、还有你自己电脑驱动的影响数据基本不可信。正解是 Build 一个 Development Build勾选 Autoconnect Profiler用真机连上来抓帧。手机上操作几轮把每个功能模块都点一遍再拉回到 Profiler 里回放观察哪一段主线程耗时最高、发热最明显。数据抓回来之后我会先区分“主线程瓶颈”和“渲染线程瓶颈”。主线程的 Player Update、UI 等条目占用高问题在逻辑侧Renderer 工具相关耗在高问题在视觉侧。两者对应完全不同的优化路线。2. GC、Draw Call 与 Canvas 重建Unity CPU 的三个大头拆开讲2.1 GC 分配与回收为什么它是隐蔽的性能杀手GCGarbage Collection本身不是 bug但移动端 Unity 项目里它往往是 CPU 卡顿的重要来源。原理可以简化成脚本层每创建一个引用类型对象——比如字符串、数组、Lambda、装箱后的结构体——都要从托管堆申请内存。堆里的对象越积越多GC 就会在某个时机暂停执行线程去扫描哪些对象已经没人引用了然后回收空间甚至做堆压缩。听起来是不是像一场“大扫除”问题是这种扫除来得毫无预兆。你在 Update 里写一行scoreText.text score: score;看起来人畜无害实际上每帧都在堆上分配一个新的字符串。等托管堆超过阈值GC 触发二三十毫秒的暂停时间就出现了表现在玩家端就是高频卡顿。卡顿之后系统为了追帧率会把 CPU 频率拉高于是温度也上去了。定位 GC 问题我会优先看 Profiler 里的 GC Alloc 这个字段。Hierarchy 视图下按 GC Alloc 排序能找出每帧产生分配的函数。很多团队只看“总内存”不看“每帧分配量”这其实不到位。总内存高不代表每帧都在抖动但 GC Alloc 高则说明你的代码每帧都在往堆上制造垃圾哪怕最后内存被回收CPU 也已经付出了代价。造成 GC Alloc 的常见操作我基本可以背出来了字符串拼接HP: hp、string.Format之类Unity 2022 上甚至有些 API 内部也做字符串拼接。LINQlist.Where(x x.enabled)这种写法爽是爽但会在堆上创建迭代状态机、委托闭包。闭包在 Update 里直接someEvent () { ... }每次新建一个委托对象。foreach对非泛型集合如 ArrayList、Dictionary 的枚举器用 foreach 可能产生装箱。频繁 Instantiate / Destroy场景物体的创建销毁远不止原生内存还伴随对象生命周期维护。规避手段也很常规能缓存就缓存能复用就复用能用 StringBuilder 就不做字符串拼接能用 struct 就不要装箱能用对象池就避免反复 Instantiate。后面第 3 章我会放一段实际对比代码告诉你从 2MB 到 200KB 是怎么做到的。2.2 Draw Call 的真相CPU 在渲染请求上花的时间Draw Call 这个词在优化圈里已经被念叨了十几年但很少有人把“为什么它是 CPU 压力”解释清楚。渲染一帧时CPU 需要为每一个可见物体准备顶点、索引、材质、Transform、Shader 状态然后通知 GPU“你可以画这个物体了”。在传统渲染管线下每次切换材质、贴图、Shader 都是一次状态重建非常耗时。这里必须区分两个容易混淆的指标Draw Call 总数和 SetPass Call。Profiler 里的 Batches 通常是合批后的真实 Draw Call 数SetPass Call 则是切换渲染状态的次数。一个让你卡顿的帧往往是 SetPass Call 很高而不是单纯的 Batches 多。举个例子你有 50 个 Draw Call但每个都用了不同材质那可能比 150 个 Draw Call 但都共用一张图集还慢。Unity 提供的合批手段大致思路就是让 CPU 少跟 GPU 啰嗦几句Static Batching对静态物体勾选 Static做预处理把多个小网格合并成大网格。代价是运行时内存和包体会变大但 Draw Call 明显减少。Dynamic Batching对小物体顶点数满足限制自动合批。听着省事但 CPU 在渲染线程每帧还要动态拼接顶点数据有些案子反而更慢要实测。SRP BatcherURP/HDRP 项目里可以开启它能复用材质的 Shader 属性绑定降低 SetPass Call 的 CPU 开销。但要求 Shader 和材质兼容不是所有材质都吃这碗饭。GPU Instancing所有物体共享同一个 Mesh、Material、Shader只是位置、颜色等属性不同。大量重复物体用这个效果极好拿来做草丛、树木、子弹非常合适。另外一个很容易踩的坑是“材质实例化”。在代码里给 Material 设置_ColorUnity 常常会创建一个新的 Material 实例这个实例如果不主动销毁不仅打断合批DG 和内存都会涨。所以我一般建议动态修改颜色尽量用 MaterialPropertyBlock而不是直接碰 material。想亲眼看到 Draw Call 在哪被打断就用 Frame Debugger。打开 Window Analysis Frame Debugger选一帧逐个 Draw Call 查看每个格子用了什么 Mesh、Material、Shader以及为什么批处理在中间断开。很多时候一合批就发现原来是某个物体用了带独立纹理的变体或者不小心有个 GameOject 的 Scale 不一致批处理直接被拆开。2.3 Canvas 重建UGUI 每帧都在替你还的债UGUI 的渲染模型和传统 GameObject 渲染不一样。一个 Canvas 下的所有 UI 元素会被 UGUI 收集起来生成 Mesh再作为一个整体批量画出来。问题是一旦 UI 属性发生变化Text 内容、颜色、尺寸、位置、锚点Unity 就会把 Canvas 标脏然后触发 Rebuild。Rebuild 分为 Layout 重建和 Graphic 重建简单理解Layout 重建当 UI 元素的 RectTransform 受布局组件影响需要重新计算位置和尺寸。Graphic 重建当 Image、Text 等图形的顶点和 Mesh 需要重新生成时发生。这些重建并不像 GPU 那样由独立线程承担很多就在主线程的 Canvas.SendWillRenderCanvases 阶段同步执行。所以当你的页面里塞满了几十个元素又全部挂在同一个顶级 Canvas 下只要一个血条在变化整个 Canvas 的所有 UI 元素都可能被重新 build meshCPU 一下就爆了。我见过最典型的热点场景是排行榜列表。列表滚动时每个 Item 的 RectTransform 都在动每个Text、Image如果变化都会让整个 Canvas 重新生成 Mesh。如果列表所在的 Canvas 和底部背景、顶部标题放在一起那这个重建代价就会被连带放大。屏幕上看起来只是滑了一下列表Profiler 里 Canvas.SendWillRenderCanvases 却可能占了 10ms 甚至更多。从优化角度讲最基本的策略就是“动静分离”把几乎不会变化的东西背景、标题、边框放在一个 Canvas把频繁变化的内容血条、飘字、列表单独拆到另一个 Canvas。拆开之后视觉上合批可能受损但能大幅缩小 Rebuild 范围整体收益往往是正的。除此之外还可以不要在 Update 里每帧改 Text 内容改成只在值真正变化时再 set。对列表 Item 做对象池并且不要对不可见 Item 继续更新 RectTransform。尽量少嵌 LayoutGroup尤其是深层嵌套一次 Layout 重建能拖垮整帧。如果用 TextMeshPro注意 TMP 的字体缓存和 Rebuild 逻辑但它的网格重建有时比旧版 Text 更可控。随后第 3 章会讲具体怎么落地这套拆分逻辑。3. 真机优化实操流程从数据定位到逐个击破3.1 抓数据开发包 Profiler 真机连接拿一个已经出现发烫的项目举例。我先不用任何外部工具纯靠 Unity Profiler 跑一轮。第一步Build Settings 里打开 Development Build勾选 Autoconnect Profiler。这个选项会保证手机上运行的 Development 包能自动连接到编辑器 Profiler。然后在 Player Settings 的 Optimization 下面把 Managed Stripping Level 保持默认不要为了测试开激进裁剪不然看不出栈。第二步Build 到 Android 真机。连接方式最好用 USB不稳定的话采样会断。实在不行用 adb 网络转发也能连但要先确保网络稳定。连接后点击 Profiler 顶部的连接目标选择你的设备。第三步让测试同学在手机上按正常游玩路径操作不用刻意卡把所有关键 UI 都点一遍。整个过程录个 5 分钟左右的剖面数据。回去用 Profiler 的 Playback 功能逐帧回放直接把性能问题对应到具体操作上。这里有个细节很多人会直接开启 Deep Profile。Deep Profile 能拿到所有函数的调用栈但会严重改变性能甚至让原本 30ms 的帧变成 100ms只能用来定位某个特定函数是否异常不建议作为整体抓性能的手段。正常定位就用普通 Profiler已经能看到函数出现的热度。回放数据时我会用两套顺序来读先看 Timeline找到掉帧最严重的时间点记下那个时间段做了什么操作再切到 Hierarchy按 Self Ms 排序看那一帧最重的条目是什么。如果 Main Thread 一直很高就继续往下追如果高的全是 Gfx.WaitForPresent那说明 CPU 都在等 GPU你还去拼命降 Draw Call 没意义反而该看 GPU 侧。3.2 GC 专项从 2MB 分配到 200KB我拿一个真实存在的场景来演示。某项目主界面有动态飘字、伤害数字和背包列表Profiler 里 GC Alloc 平均一帧 2MBMain Thread 峰值 40ms。按 GC Alloc 排序Top 1 是“Update 中拼接字符串”Top 2 是“获取组件/遍历列表”。修复第一处把所有 UI 组件引用在 Awake 里集中缓存不要在 Update 里反复 GetComponent。这个改动看着简单但热点函数从“GetComponent 查找 字符串拼接”变成了“访问缓存字段”。public class HUDTextManager : MonoBehaviour { private Text scoreText; private Text comboText; private StringBuilder sb new StringBuilder(32); private void Awake() { scoreText UIRoot.Instance.scoreText; comboText UIRoot.Instance.comboText; } private void Update() { int score GameData.GetScore(); if (score ! lastScore) { sb.Clear(); sb.Append(得分: ); sb.Append(score); scoreText.text sb.ToString(); lastScore score; } } }第二处修复是频繁生成临时ListDropItem的逻辑。原来每帧都 new 一个 List 用来收集掉落物改成预先申请一个固定容量 List用完立刻 Clear。只要没有并发问题这种复用就很安全。private ListDropItem visibleItems new ListDropItem(16); private void Update() { visibleItems.Clear(); for (int i 0; i allDropItems.Count; i) { if (allDropItems[i].isVisible) { visibleItems.Add(allDropItems[i]); } } // 之后遍历 visibleItems 处理即可 }第三处把频繁生成的小对象改为对象池。这东西不难实现关键是各类频繁出现的特效、飘字、弹道提示不要 Instantiate/Destroy而是从池里取、用完归还。做完这三步再抓一轮数据GC Alloc 能从平均 2MB 掉到 150KB 左右。注意有一些 GC Alloc 来自第三方插件或者 Unity 内部的 API比如 Unity 的某些 UI 网格更新、Physics 查询优化到“完全零分配”在复杂商业项目里很难但把每帧分配压到可控范围工作已经完成大半。切记不要在优化中途把 Profiler 关掉只凭手感判断这样你会被拖入玄学调参。3.3 Draw Call 专项合并、合批、删材质处理完 GC下一个 CPU 大头是 Draw Call。这里先在 Profiler 的 CPU 模块里找到 Rendering 相关条目看 Batches 数字是不是特别多再打开 Frame Debugger 看一下状态切换。通常我给自己定的移动端目标线是这样场景参考目标普通战斗界面Batches 150SetPass 30大厅/主城Batches 200SetPass 40复杂列表 UIBatches 120SetPass 25如果数字远超目标就开始做减法。第一步把整个场景里没有交互、不会旋转移动的物件全部标记为 Static。然后在 Player Settings 里勾选 Static Batching。这一步最简单但注意 Static Batching 会增加内存大场景里要平衡。移动端我一般不会把大树、大建筑这种高面数模型也塞进 Static Batching那会白吞内存效果却一般。第二步找到大量重复的物体。类似采集物、草丛、小怪尽量走 GPU Instancing。这需要在 Shader 里勾选 Instancing 选项然后代码里用MaterialPropertyBlock传 per-instance 属性比如颜色、大小。之后在 Profiler 里看 Batches 数能直观看到单个 Draw Call 覆盖几十个实例。第三步对 UGUI 做图集管理。把 UI 元素按页面、功能拆成图集同一 Canvas 下优先共用贴图。不要让美术把十几个按钮做成十几张独立小图否则 UI 的 Draw Call 会像雪崩一样涨。另外UGUI 里如果某个 Image 标记了 Raycast Target并且经常出现在动态层它也会给 UI 绘制带来额外压力能关就关。这里我要特别提醒动态合批在移动端并不总是“神器”。Dynamic Batching 虽然能自动把小物体合到一起但 CPU 每帧都要为它们重新计算顶点组合这对低端机来说是一次性的额外负担。某些项目里关闭 Dynamic Batching 后主线程耗时反而下降。所以遇到 Draw Call 高先别一股脑把 Dynamic Batching 打开先用 Frame Debugger 看看到底是哪些物体在打断合批。3.4 Canvas 专项把 UI 拆成“动”和“静”UI 优化没有一劳永逸的按钮Canvas Rebuild 的问题尤其依赖层级设计。实操步骤很简单打开 UI 面板的根节点先肉眼判断哪些元素是永远不动的哪些是每帧或频繁变化的。比如一个主城界面底部任务是静态的按钮也是静态的角色头像框是静态的但顶部的金币、钻石是动态的右下角的红点提示列表也是动态的。我的做法是把动态部分从主 Canvas 里剥出去。操作上就是建一个新的子 Canvas把动态元素放到下面去并且去掉这个子 Canvas 的 Image 的 Raycast Target。这里插一个细节在界面层级中Canvas 是可以互相嵌套的。子 Canvas 的加入会中断父 Canvas 的 UI 合批代价是增加一点 Draw Call。但换来的收益是当金币数值每次跳动时只有那个子 Canvas 需要 Rebuild父 Canvas 里一堆静态 UI 完全不用跟着重画。这个取舍在移动端通常是值得的。另一点列表优化别死磕“每帧更新所有 Item”。做滚动列表时我一般会做一个可视范围控制只对进入视口的 Item 做布局和更新离开视口的立即回收。如果用的是 UGUI 的 ScrollRect可以开启movementType Clamped然后在滚动事件里根据 content 位置判断当前哪些 Item 可见、哪些不可见。这样列表滚动时的 Canvas.SendWillRenderCanvases 消耗会小很多。写代码时还有个很容易忽略的地方Text的raycastTarget。如果这个 Text 根本不需要点击请把它的 raycastTarget 关掉。否则每一个 Text 改动Unity 还会顺带做事件系统的 raycast 检查在现代 UI 的 Canvas 重建上可是实打实的 CPU 时间。完成 Canvas 拆分后再用 Profiler 复测一次看 Canvas.SendWillRenderCanvases 和 UI.Layout 两条。通常能做到单帧 UI 重建压到 1.5ms 以下。如果你看到这两项数字还很高那基本可以断定 UI 里还有潜伏的“每帧变更”继续追。4. 排查中绕过我踩过的坑常见问题速查4.1 几个典型性能问题的表现与解法表现可能原因排查方向GC Alloc 很高但代码里没看到 newUnity API 或第三方插件内部有分配用 Memory Profiler 查分配栈检查 Physics、Animator、Particle 的临时对象Draw Call 不高但 CPU 主线程依然卡可能瓶颈在脚本、物理、动画不在渲染看 Main Thread 自耗找 Scripts/Layout 等条目别只盯 BatchesSetPass Call 很高怎么合批都没用材质变体太多、Shader 频繁切换Frame Debugger 查看每个 draw call 的 Shader 状态变化Canvas.SendWillRenderCanvases 高但 UI 看起来没动有不可见 UI 或 Text 每帧被赋值相同内容脚本里打断点排查 Text 的 setter隐藏的元素是否还处于 Active 状态优化后帧率没变化手机还是烫真正的瓶颈是 GPU 或电源管理CPU 没占主导看 Gfx.WaitForPresent、RenderThread 是否跑满打包到低端机后 UI 更卡高端机上 Canvas 重建不快低端机就暴露真机分级测试针对低端机降低 UI 刷新频率带红点提示的 UI 是我印象最深的“伪装者”。某个界面红点由请求结果触发每次数值变化都会导致父节点 Canvas 标脏但由于红点很小几乎没人注意到它一直在闪烁。后来用 Profiler 一帧帧找发现红点所在模块的 Text 组件每帧重新 set 同一个字符串。修复方案也很粗暴在 setter 里先比较新旧值相同就 return不要触发 UI 更新。4.2 调试工具与量化目标Unity 自带的工具组合我目前最常用的是这三件套Profiler看整体 CPU、GC Alloc、Batches定位热点。Frame Debugger看每个 Draw Call 的详细内容、判断合批断裂点。Memory Profiler看托管堆、原生资源、纹理占用排查内存引起的 GC 问题。另外Unity 2020 以后很多版本对新版 Input System、ECS 的支持已经成熟如果你的项目是纯逻辑层 CPU 开销高可以考虑把核心状态机迁移到 Job System / Burst但这些都是重工程改造建议先把 GC、UI、Draw Call 清干净再考虑不要一上来就搞大架构。量化目标上我自己的参考标准是中端 Android 设备比如骁龙 7 系 / 天玑 8000 系至少保证Main Thread 平均低于 16msGC Alloc 每帧不超 200KBBatches 低于 150SetPass 低于 30Canvas SendWillRenderCanvases 低于 2ms。如果设备再低一级这些目标还会继续缩水。没有目标就优化很容易陷入“调完一版但不知道算不算成功”的状态。5. 写在最后一些优化节奏的私人建议做发烫优化最重要的不是“会不会用工具”而是你能不能忍得住不凭直觉乱改。我的习惯是每次只改一个变量比如这周只清 GC 分配下周再拆分 Canvas改完立刻打包真机测和之前的 Profiler 数据做对比。连续改三四个变量再去测出了问题根本不知道是谁引起的。另外一个高阶技巧优化到后期如果 Profiler 的 Playback 数据来回看已经分不清差异可以在关键帧上加 Script 标记用UnityEngine.Profiling.Profiler.BeginSample把自定义逻辑包起来。这样在 Profiler Timeline 上就能看到某个操作模块的精确起始时间。尤其是在排查 UI 操作触发的问题时能帮你把鼠标操作和 UI 事件精确对上。最后再分享一个我自己的小习惯做移动端优化之前先去跑一遍该设备的系统评测别只看 CPU 主频。同样是“骁龙”老机型小核调校、散热能力完全不同帧率差距很大。测发热优化一定要在同一台常测机上复测不然你都不知道到底是代码优化了还是设备今天刚好降温了。这个系列前几篇聊的都是 GPU 和渲染资源但到了真正的项目现场CPU 侧的坑往往比 GPU 还多。希望这篇里的定位思路、实操流程和避坑记录能帮你在下一次“手机发烫”的联合会诊里少一点帮 CPU 喊冤的时间。