ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity移动端CPU发烫优化:GC、Draw Call与Canvas重建实战

Unity移动端CPU发烫优化:GC、Draw Call与Canvas重建实战 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)、往Listobject里塞 int、用非泛型集合。闭包和委托lambda 捕获外部变量时会生成闭包类实例每次执行都 new 一个。LINQWhere、Select、OrderBy这些会产生迭代器和中间集合在 Update 里用就是灾难。数组和 List 的临时创建new Vector3[]、GetComponentsT()返回新数组、foreach遍历某些集合时的迭代器分配。提示Profiler 的 Deep Profile 模式能精确到每一行但开销极大只适合在编辑器里定位问题别在真机上开。2.3 把每帧垃圾压到接近零的实操手段知道源头之后优化思路就清晰了能缓存就缓存能复用就复用能避免分配就避免分配。下面是我在项目里反复用的几招。字符串这块循环里绝对不要拼接。需要动态文本用StringBuilder并且把它做成成员变量复用每次Clear()而不是 new。数字转字符串如果频率高考虑自己写个简单的整数转字符缓存的工具或者用ZString这类零分配库。UI 上的文本更新能只在数值变化时刷新就别每帧刷。装箱这块Debug.Log在正式包里应该被条件编译干掉或者至少别在 Update 里调用。集合一律用泛型版本Listint而不是ArrayList。字典的 key 如果是枚举注意枚举做 key 时的比较可能涉及装箱用EqualityComparer或者干脆用 int 做 key。闭包这块Update 里注册的回调尽量用方法引用而不是 lambda或者把 lambda 提到循环外。事件订阅在对象销毁时记得取消否则不仅泄漏还持续产生引用。容器复用所有临时 List、数组都做成池。Unity 自带的ListPool、ArrayPool或者自己写个简单的对象池都行。GetComponents这类 API 有带 List 参数的重载传一个复用的 List 进去就不会每次分配新数组。// 反例每帧分配 void Update() { var enemies GetComponentsInChildrenEnemy(); // 每次新数组 foreach (var e in enemies) { /* ... */ } } // 正例复用 List private readonly ListEnemy _enemyCache new ListEnemy(); 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 CallCPU 提交指令的隐形税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 持续处于中高负载温度自然下不来。我见过一个项目场景里几百个独立的小物件每个都是单独的 MeshRendererDraw 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 的版本材质开启 instancingDraw 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调用改sharedMaterialCanvas 数量单个 Canvas 元素超 100动静分离拆 Canvas文本更新每帧赋值加变化判断或换 TMPUI 显隐频繁 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 重建到只在必要时重建每一步都有明确的收益。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进