ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Unity手游CPU优化:GC、Draw Call与Canvas重建的发热治理实践

Unity手游CPU优化:GC、Draw Call与Canvas重建的发热治理实践 1. 手机发烫这笔账CPU 至少要背一半做移动端 Unity 优化的这几年我最常被问到的问题是游戏一跑起来手机就发烫是不是 GPU 太弱了十次里有八次我掏出的 Profiler 截图都指向同一个结论——GPU 负载确实高但 CPU 侧才是那个被忽视的隐形火源。尤其是当你把 60fps 作为目标时一帧只有 16.7 毫秒的预算CPU 上随便一个 GC 峰值或者 UI 重建都能把这 16 毫秒吃穿掉帧、降频、发热一条龙服务。这篇文章是发烫优化系列的第 5 篇前面几篇聊的是渲染分辨率和 GPU 侧的账这次我们把火力集中到 CPU。具体拆三块托管堆的 GC 压力、Draw Call 在 CPU 端的花销、以及 Canvas 重建对 UI 线程的消耗。如果你在做 Unity 手游、小游戏、数字孪生这类需要长期跑在真机上的项目这篇会非常对口。文章里的每个结论基本都来自我在中低端 Android 机上的实测以及几个已上线项目的复盘可以直接拿去用不用再自己踩一遍。1.1 为什么排查发热时应该先盯 CPU很多人有个惯性思维画面越好看发热越严重那一定是 GPU 在做重活。但发热本质上是芯片高速运转产生的功耗CPU 和 GPU 都会贡献热量。移动端 CPU 为了保证帧率被系统调度器频繁拉到高频这个状态的发热量非常可观。更麻烦的是CPU 侧的负载通常是脉冲式的——大部分时间很空闲突然来一个 GC 峰值或者一次 Canvas 重建瞬间拉高功耗然后又把频率降下来。这种高频抖动对发热的影响比 GPU 稳定高负载还要大。1.2 这篇文章能帮你定位什么我想让你读完以后能带着自己的工程去回答三个问题当前版本每帧分配了多少托管内存、是谁在分配场景里到底有多少个有效 Draw Call、合批为什么失败UI 的 Canvas 是否在每帧做无意义重建这三个问题一旦有了明确答案发热和帧率波动的问题基本就解决了一半。剩下的一半就是把对应的优化手段落进代码里。2. GC 压力帧循环里每一次 new 都在给发热添柴2.1 托管分配为什么会变成 CPU 的额外负担很多 Unity 开发者对 GC 有个误解以为 GC 只在内存不够的时候才跑一下平时没什么影响。实际在移动端完全不是这样。Unity 默认使用的 Boehm GC 是一个非分代、非压缩的标记-清除收集器。它的特点是所有对象都混在一起管理每次收集都要从头到尾扫描一遍托管堆把活着的对象全部检查一遍。在旧版本上一次完整收集造成的毛刺可以达到几十毫秒甚至上百毫秒这在 16.7 毫秒的帧预算面前就是一场灾难。就算你开了 Unity 2019 之后提供的 Incremental GC增量式垃圾收集把一次收集拆到多帧里去执行问题也没有消失。分配内存这个动作本身就会带来 CPU 开销要找到一个空闲块、要触碰内存页、要把新对象初始化、还要把引用关系写进 GC 的跟踪结构。当你一秒 60 帧、每帧分配几 KB 甚至几百 KB 的时候这些开销会被持续放大。再加上移动端 CPU 的频率调度是看负载的高分配率会让 CPU 频繁请求跑在高频段频率一高发热量自然就上来了。2.2 高频代码里最常见的分配来源在真机 Profiler 的 GC Alloc 列里我见到的高频分配基本就这么几类字符串拼接。string.Format、字符串相加、数字转字符串。一个伤害数字每秒刷新 10 次看似没什么但在 RPG 里可能有几十个飘字同时在变。LINQ 表达式。Where、OrderBy、FirstOrDefault这类操作大多数情况下都会分配枚举器对象。具体分配多少要看 Unity 绑定的 C# 运行时版本但别赌它不分配。闭包与匿名方法。把 lambda 注册给事件或协程时如果编译器不能缓存委托实例每次执行都会创建新对象。捕获了外部变量的 lambda 尤其容易二次分配。协程里的yield return new WaitForSeconds(...)。这一行写在循环里就等于每帧给 GC 送一份长期垃圾。正确的是把 WaitForSeconds 实例缓存起来复用。装箱。把 int、float 这类值类型塞进 object 类型的集合、或者拼进字符串时会产生装箱分配。字典的TryGetValue在值为结构体时也要留意。高频的 GetComponent、Camera.main。这两个接口本身不一定分配大量内存但会把调用链拖慢间接放大其他分配的影响。你可以做个简单实验在 Update 里写一行string s score: score;然后连真机跑 Profiler 看 GC Alloc那一帧的分配会从几 KB 跳到几十 KB。就这么一行在低端机上就足以制造肉眼可见的掉帧。2.3 止血手段不是让你不 new而是别在热路径里 new解决 GC 压力我的核心原则就一句话把分配从热路径挪到冷路径或者干脆复用。对象池不是只给子弹和特效用的。伤害飘字、掉落物 Icon、消息通知凡是频繁创建销毁的一律进池。池子用 Stack 实现就够了但要注意池内对象取用时做 Reset否则你只是把分配问题换成了状态污染问题。字符串这块要分开处理。频繁拼接用 StringBuilder固定格式的关键数据显示直接在组件上缓存上次的文本只在数值真正变化时才调用Text.text value。这里有个细节UGUI 的 Text setter 只有在值不同时才会触发图形重建所以在赋值前先做一次比较能省掉大量无意义的 UI 重建。缓存一切高频引用的东西。GetComponent、transform、Camera.main 这种尽量在 Awake/Start 里存好。Camera.main 底层涉及按 Tag 查找主相机场景复杂时第一次调用就有明显开销运行时频繁调用更是得不偿失。委托和事件要小心用。OnClick.AddListener(() DoSomething())这种写法如果闭包捕获了外部变量每次 AddListener 都会产生委托对象。可以把方法引用直接传进去或者用不捕获外部变量的静态 lambda。结构体和类怎么选也值得单独说。以前很多教程说能用 struct 就别用 class但真实情况是把大结构体塞进 List、或者在热路径里反复搬运复制开销可能比引用类型的追踪开销更大。我现在的判断标准是先看分配再看复制优先保证零分配再考虑值类型还是引用类型带来的局部性差异。3. Draw CallCPU 向 GPU 递话的隐藏成本3.1 一次 Draw Call 在 CPU 侧经历了什么Draw Call 这个词大家都不陌生但它的成本到底花在哪很多人理解偏了。一个 Draw Call 不只是叫 GPU 画一下。在 CPU 侧它要经历状态校验检查当前渲染状态是否需要切换、资源绑定网格、材质、纹理、Shader 参数、命令缓冲写入把绘制指令翻译成驱动能识别的数据等一连串操作。最关键的一点是如果每帧都有几百个 Draw CallCPU 在渲染管线前段就要消耗大把时间。这部分时间在 Profiler 里通常被划进 Rendering 相关模块很多人看到 Rendering 就以为是 GPU 慢其实是 CPU 在准备绘制指令。移动端普遍是 Tile-Based GPU 架构CPU 提交过来的命令会先进命令缓冲器状态切换过于频繁时驱动还得做额外合并和重排CPU 的负担会进一步放大。3.2 三种合批方式的适用边界Unity 的静态合批、动态合批、GPU Instancing很多项目只用了静态合批然后抱怨合批没用。它们的边界和代价得说清楚方式适用场景主要代价常见失败原因静态合批完全不动的场景物体内存显著上升运行时无法更新物体未标记 Static、材质不一致动态合批运行时移动的小物体每帧合并网格的 CPU 开销顶点数超限、Shader 属性不一致GPU Instancing大量相同网格、相同材质需要 Shader 支持 Instancing材质属性不同、Shader 不带 Instancing 变体静态合批把标记为 Static 的物体网格在场景加载时合并成一个大网格运行时不再单独提交。代价是内存明显上升因为要保留合并后的顶点副本所以适合地形、建筑、装饰物这类静态元素。动态合批对运行时移动的小物体生效Unity 会在每帧用 CPU 把网格合并后再提交这个动作本身不便宜而且顶点数超过约 900部分 Shader 属性下上限更低就合不了。GPU Instancing 则适合同一棵树、同一个石头、一群敌人这种大量重复对象它让一次绘制里塞下几十上百个实例每个实例通过MaterialPropertyBlock传递不同位置和颜色。3.3 从 Frame Debugger 倒推合批失败原因排查合批问题我习惯直接打开 Frame Debugger逐条看每个 Draw Call 前面的状态设置。合批失败时Frame Debugger 基本都会给出原因材质实例不一致、纹理图集没合好、光照模式不同都有明确标记。这里有个特别容易踩的坑代码里给某个物体设置了 MaterialPropertyBlock但另一个物体的材质是直接new出来的实例Unity 会把它们认成两个不同材质合批直接失败。正确做法是尽量用共享材质sharedMaterial不要直接改renderer.material——这个 setter 内部会实例化材质你每改一次就多一份材质副本。只是想改个别参数而不破坏合批就用 MaterialPropertyBlock 设置对应属性。还有一个容易被忽略的方向减少无效提交。场景里看不见的、距离很远的、被遮挡的物体如果能在 CPU 提交前被剔除渲染准备时间会大幅下降。移动端一定要关注 Camera 的 Far Clip 距离、LOD 切换阈值和自定义剔除逻辑。很多项目的 Draw Call 数字看起来不大真正让 CPU 受累的是那些无效提交。4. Canvas 重建UI 卡顿和发热共同的真凶4.1 Canvas 的脏标记机制这一块是我认为 CPU 优化里信息量最大、但最少人真正搞懂的环节。UGUI 并不是每帧把所有 UI 网格重新生成一遍它用的是脏标记机制只有被标记为 dirty 的 Canvas才会在渲染前执行一次重建。这个重建分两个阶段。先是布局重建Layout Rebuild处理 RectTransform、LayoutGroup、ContentSizeFitter 这类布局计算然后是图形重建Graphic Rebuild重新生成所有 Text、Image 的顶点网格并重新合批。你可以这样理解只要一个 Canvas 下任何一个元素发生变化它所在的整个 Canvas 都要做一次完整的合批计算。范围越大单次重建越贵。4.2 谁在悄悄触发重建以下是我在项目里反复遇到的几个典型案发现场每帧刷新的文本。倒计时、伤害数字、日志输出一个 Text 的 text 属性每帧都在变它所在的 Canvas 每帧都在重建。最坑的是这个重建代价会拖累整个 Canvas 里的其他 UI哪怕那些 UI 完全没动。ScrollRect 里的大量子项。滚动位置变化会触发子项的位置更新如果子项里还挂了 LayoutGroup每帧的布局计算量会成倍上升。动态增删 UI 元素。很多项目喜欢用代码拼 UI频繁实例化和 Destroy每次增删都会让 Canvas 标记 dirty。动画驱动 RectTransform 或 Color。调用 SetAsLastSibling、修改 Image.color都会让 Canvas 进入重建流程。UI 弹窗动画里用 DoTween 同时 Tween 几十个 Image 的 Color结果就是每帧整个 Canvas 重建一次。4.3 工程上如何压低重建频率静态 UI 和动态 UI 分开 Canvas。把只会变化一次或很少变化的内容放进独立 Canvas动态部分的重建就不会拖累整个 HUD。注意一个误解Canvas 套 Canvas 并不会增加多少额外开销真正的开销在 rebuild 的范围拆开反而划算。数值显示优先用 Sprite 切换而不是 Text。比如金币数量用若干位数字图片拼成计数器改某一位时只需要重建对应的 Image这比刷新整个 Text 网格便宜得多。很多卡牌游戏的血量、金币就是这么做的。淡入淡出用 CanvasGroup别逐个子物体改 Color。单个 Color 变化会触发顶点颜色重建CanvasGroup 的 Alpha 变化走的是合批通道大多数情况下代价小一个量级同时还能用 blocksRaycasts 一起处理输入事件。关掉不需要交互的 Graphic 的 Raycast Target。默认创建的 Image 和 TextRaycast Target 都是开着的UI 事件系统每次点击都要对所有开启选项的元素做射线检测。纯展示元素一律关掉这个习惯能省下不少事件处理的 CPU 时间。Canvas 重建比较隐蔽的点在于它不像 Draw Call 能在 Stats 窗口里看到一个明晃晃的数字。你必须在 Profiler 的 UI 模块里找Canvas.SendWillRenderCanvases、Canvas.BuildBatch这些标记才会意识到它到底吃了多少时间。这也是我把这一节单独拎出来的原因——不少项目的发热问题最后定位到源头就是 UI 重建。5. 定位 CPU 瓶颈的完整排查链路5.1 先给设备设立测试基线优化最忌讳上来就改你得先知道当前是什么水平。我的做法是固定一台中低端测试机固定场景选择 UI 最密集、逻辑最重的代表场景关闭动态分辨率和其他动态负载用 Profiler 连真机跑 5 到 10 分钟的同一段流程录下 CPU 模块的时间线。这里有个客观事实必须尊重手机发热存在热积累和降频效应。连续跑几分钟后的帧率和你一开始跑那 10 秒完全不同。如果你只截开头几秒的数据一定会被后续的降频打脸而且你根本分不清是优化前就卡还是设备热了才卡。5.2 按模块拆解 CPU 耗时拿到 Profiler 数据后先看 Main Thread 和 Render Thread 的耗时分布。我有一套固定的排查顺序看 PlayerLoop 总耗时判断是脚本逻辑差还是渲染准备差。展开 ScriptRunBehaviourUpdate按 GC Alloc 排序找到每帧分配量最大的方法。看 Rendering 模块如果有持续几十毫秒的耗时用 Frame Debugger 查 Draw Call 数量和合批情况。看 UI 模块找到Canvas.SendWillRenderCanvases和Canvas.BuildBatch的耗时再结合操作时序判断是哪个 Canvas 在重建。用 Deep Profile 模式确认具体方法的耗时但注意 Deep Profile 在真机上会给所有方法带上额外开销只适合短期采样不适合长时间挂机。如果是 Android 设备我一般用带采样频率的 Profile 模式避免 Deep Profile 的开销把数据带偏。很多新手上了真机就开 Deep Profile结果发现时间线里全是 Profiler 自己的开销那就不是数据失真是数据根本没法用。5.3 三个容易误判的场景排查过程中有几个坑值得单独说第一个是把渲染线程耗时全归到 GPU。Render Thread 的耗时和 GPU 的实际耗时不是一回事移动端的 GPU 耗时要用厂商工具或 Unity 的 GPU 计时来看。Render Thread 时间很高可能恰恰说明 CPU 在往 GPU 提交数据时吃紧这跟显卡性能关系不大。第二个是只盯平均帧率不盯帧时间曲线。60fps 目标下一帧 15 毫秒、下一帧 22 毫秒平均值可能还挺好看但肉眼已经能感受到卡顿。每次 22 毫秒的峰值都是一次短时高负载反复出现就是在给发热添柴。判断优化效果一定要看时间线的毛刺密度。第三个是忽略热降频。优化前跑 10 分钟采样后半段的数据优化后跑同样时长再采样这样对比才有意义。否则你优化完测出来的提升可能只是因为你换了台没跑热的手机或者两次测试间隔太久设备凉下来了。6. 我这几轮优化下来的实测数据与习惯6.1 一个上线项目的真实数据在一个上线过的模拟经营类项目里我们做过一轮 CPU 侧专项优化这里放一组我记忆里比较典型的数据测试条件是中端 Android 机、60fps 目标、同一场景同一段流程指标优化前优化后平均帧耗时28ms16.5msGC Alloc战斗峰值1.8MB/帧18KB/帧Draw Call主城场景约 400约 120Canvas 重建耗时5.2ms/帧0.6ms/帧烤机 10 分钟后机身温度46°C41°C温度和帧时间的变化很直观但还有两个间接收益。一是耗电速度明显下降玩家的体感反馈变好。二是 CPU 峰值减少了低端机上的降频次数大幅减少帧率曲线更平滑这对评分和留存都有实际影响。6.2 之后我写进开发规范的几条规矩优化不是一次性的。项目迭代很快新功能一加老问题会以新的形式回来。我现在要求团队每个里程碑都做一次真机 Profiler 采样固定同一台设备、同一个场景把 GC Alloc、Draw Call、Canvas 重建这三个指标写进发版检查清单。代码评审上凡是 Update/LateUpdate 里出现字符串拼接、LINQ、new 对象、动态改 UI 的一律打回重写。这些规则写进项目开发规范文档之后新同学也基本不会再犯低级错误。如果你现在正在为手机发烫、帧率波动发愁我的建议是别急着加特效、降分辨率先花一个下午把 Profiler 连上真机按这篇文章的顺序把 CPU 侧的三块查一遍。GC、Draw Call、Canvas 重建这三座大山压在身上GPU 分辨率调得再低也只是治标。CPU 这头治好了你会发现 GPU 那边的压力也跟着松了——因为它们本来就是同一笔帧预算里的两个账户CPU 省下来的每一毫秒最终都会变成手机上实打实降下来的温度。
RELATED READING

延伸阅读

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