
1. 发烫优化系列第5篇先把烫手山芋从GPU头上挪开做Unity性能优化的朋友应该都有过这种经历测试机上游戏跑起来手机背面热得能煎鸡蛋帧率忽高忽低玩家在评论区疯狂反馈“一玩就烫”。大多数人的第一反应是“GPU又爆了”——马上降画质、砍分辨率、关阴影结果发现温度没降多少该掉的帧还是照掉。这一篇系列文章我想把矛头从GPU那边转回来认真聊聊CPU在发热问题里扮演的角色。标题起得很直接——“CPU不是无辜的”这不是标题党。在大量中低端安卓机尤其是发热敏感机型上GCGarbage Collection垃圾回收、Draw Call、Canvas重建这三个CAD方向的CPU开销才是发烫和卡顿的真正来源。GPU在多数时候反而挺闲的是CPU在背后疯狂加班导致整机功耗居高不下发热自然跟着飙升。这篇内容适合正在做Unity手游性能优化、被发热和卡顿问题困扰、尤其是项目里UGUI用得比较重的开发者。我会把三个“CPU杀手”的原理拆开揉碎讲清楚它们为什么会让手机发烫再给出可落地的定位方法和修法。读完不敢说你立刻就能把温度压下来但至少能让你下次用Profiler定位问题时不再只是在GPU那一栏干瞪眼。2. GC不是背锅侠它是真的在烧CPU2.1 托管堆分配和发热之间的隐秘联系先说一个反常识的事实GC之所以会导致发热根源不在垃圾回收本身而在垃圾回收之前的内存分配。Unity的Mono和IL2CPP都跑在托管环境里C#脚本中每new一个对象、每拼接一次字符串、每用一次LINQ都会在托管堆上分配内存。托管堆被填到一定程度就会触发GCGC需要暂停游戏线程去扫描对象引用、标记存活对象、压缩堆内存——这个过程是纯CPU操作而且极其耗时。发热的逻辑链是这样形成的频繁分配内存 → 堆很快变满 → GC频繁被触发 → GC期间CPU占用率瞬间飙高 → CPU高负载产生更多热量 → 手机降频 → 游戏变卡。很多团队只看到“游戏卡了”去查GPU发现渲染压力并不大转头又去优化Shader温度依然压不住。其实问题的根子是代码里隔几帧就new一个List、一个字节数组或者Update里做字符串拼接。我之前接手过一个卡顿项目UI上的金币数字每秒刷新代码写的是coinText.text 金币 coinCount.ToString()。这里面每次赋值都会产生两个临时字符串对象每秒刷新一次就产生两个垃圾对象。单看不多但配合其他地方的分配几分钟就能把托管堆填满然后GC就会卡掉好几帧。这类问题用Profiler抓出来以后改成string.Format或者预分配StringBuilder温度立刻就能看到改善。2.2 怎么判断GC是不是烫源Profiler的正确打开方式很多人用Unity Profiler只会看CPU Usage那一栏的横向条看到“垃圾回收”四个字就完事了。但真正要定位GC问题我的习惯是分三步走。第一步看GC Allocation曲线。在Profiler窗口里打开Memory分类下的“GC Allocation”图表如果这条曲线在持续上升且没有明显回落说明托管堆在持续分配这就是GC会被频繁触发的直接证据。第二步点开CPU Usage里的某一个高帧找到耗时的调用栈。如果看到GC.Collect或者GarbageCollect相关的条目就顺着调用关系往上查看哪段业务代码触发了GC。注意触发GC的那个帧不一定就是分配最多的帧因为GC是延迟触发的要看分配峰值帧的调用栈找的是“谁在疯狂new对象”。第三步用Memory ProfilerUnity官方包Package Manager里可以装抓一次内存快照看托管堆里到底什么类型的对象占了大头。这一步能看到byte[]、string、class实例各自占了多少比猜靠谱得多。注意经验上如果一帧内GC Allocation超过2MB且每秒发生一次以上基本就是灾难级别。中低端机上的合理目标是一帧分配控制在几百KB以内能压到100KB以下更理想。2.3 几个立竿见影的GC削减手段削减GC分配的思路说白了就一句话让托管堆上尽量少地诞生新对象或者干脆提前租好对象反复用。最基础也最容易被忽略的几招先列出来字符串拼接禁用号改用StringBuilder或$插值注意插值在部分版本里也会产生装箱讲究的话直接拼StringBuilder。Update、协程、UI回调这种高频路径里禁止出现new关键字包括匿名委托、Lambda捕获局部变量会生成闭包类实例。避开LINQ的Where/Select/OrderBy这些语法糖背后是迭代器和委托对象分配GC压力很大。换成for循环性能一样零分配。装箱要命把int塞进object、把struct当interface调用都会产生装箱。用泛型或者直接重载来规避。协程的WaitForSeconds每次都会new一个对象应该缓存成静态字段复用。同一道理yield return null本身没分配但如果你在循环里new WaitForSeconds(0.5f)那就是持续在造垃圾。举一个实际案例我优化过一个战斗飘字系统每次飘字都创建新的GameObject和Text组件数字变化时还会重新new一个字符串数组来格式化。改成对象池以后Text组件复用了字符串格式化也统一走StringBuilder战斗场景里的GC Allocation从每帧1.5MB直接掉到每帧50KB。画质没动、分辨率没动手机温度肉眼可见地降了。2.4 分代GC、增量GC和IL2CPP下的差异Unity的GC在不同后端下表现有细微差异但原理一致。Mono运行时用的是分代GC把托管堆分成0代、1代、2代新对象分配在0代GC优先回收0代回收成本低。IL2CPP后端在Android和iOS上使用的是Boehm GC的变体它不分代至少历史上不分代每次GC都是全量扫描——这就意味着同样频率的分配IL2CPP下的GC停顿可能更久。Unity 2020以后引入了Incremental GC增量式垃圾回收在Player Settings里可以勾选“Use Incremental GC”。它的做法是把一次完整的GC工作拆成多帧来做每帧只做一小部分从而把单次GC的卡顿摊薄。代价是每帧都会多消耗一点CPU时间用于GC的增量处理如果项目本身CPU已经吃紧这个选项不见得是好事。但如果是“偶尔卡一大下”的问题增量GC确实能把卡顿感抹平不少。实操建议是中低端机目标机型上开增量GC同时继续削减分配频率两手抓。增量GC只是把卡顿从“一次大卡”变成“每帧小卡”如果分配量还是那么大CPU照样持续发热治标不治本。3. Draw Call不是渲染问题是CPU的物理课3.1 SetPass Call和Draw Call别搞混但都是CPU的活关于Draw Call行业内有个很常见的误区觉得Draw Call是GPU的负担。实际上Draw Call的瓶颈在CPU不在GPU。每次Draw CallCPU都要做这样几件事检查渲染状态变更、绑定Shader和贴图、更新常量缓冲区、向GPU提交命令。每件事都有指令开销。GPU反而无所谓——它就是等命令来了照着画。Unity的Stats面板里有两列数据一个是Draw Call一个是SetPass Call。很多人只看Draw Call去了其实SetPass Call才是更值得盯的指标。Draw Call只是“画一次”的命令数SetPass Call是“切换一次渲染状态Shader Pass”的次数。切换Pass这个动作在CPU端极其昂贵因为要重新验证状态、重新绑定一堆资源。一个4万个顶点的大模型只要1个Draw Call但十几个小模型如果各用各的ShaderSetPass Call就上去了CPU照样崩。从发热层面看Draw Call高意味着CPU每帧要执行大量渲染状态相关指令指令流水线被塞满CPU功耗居高不下手机自然烫。所以“降低Draw Call”本质上不是图形优化是CPU优化。3.2 静态批处理和动态批处理的边界Unity提供了两种内置批处理方式理解它们的边界条件比背API重要得多。静态批处理Static Batching只要标记了Batching Static的物体且Shader、贴图、材质相同或兼容Unity在构建时会把它们的网格合并成一个大网格运行时只需要一次Draw Call。代价是内存暴涨——合并不等于压缩每个物体原始网格数据还在合并后的网格是额外内存。我见过一个项目为了把所有场景物件都标成Static内存直接从1.2GB飙到1.8GB中低端机直接闪退。静态批处理适合场景里的静态装饰物不适合需要动态变化的物体更不是“越多越好”。动态批处理Dynamic BatchingUnity会在运行时自动把满足条件的小物体合并成一波Draw Call。条件很苛刻顶点数要少Unity文档说是小于900个顶点实际不同管线有差异、材质要相同、不能有镜像变换scale为负数会失效、不同光照法线信息也会阻断合批。动态批处理CPU也是有开销的它会每帧合并顶点数据物体越多开销越大顶点总量大到一定程度反而比直接分批Draw Call更耗CPU。实操里我的经验是能合批的尽量通过图集和共用材质实现不要把希望完全寄托在动态批处理上。与其让Unity每帧帮你做合批运算不如直接把材质贴图统一、减少材质种类这是最稳妥的方向。3.3 SRP Batcher现代Unity的CPU减负神器如果你的项目用的是URP或HDRPSRP Batcher是绕不开的重点。这套机制的理念和传统批处理完全不同——它不合并网格而是复用渲染状态。具体来说它把每个材质的属性数据预先打包成GPU可读的缓冲区CBUFFER当切换物体时如果Shader变体不变、材质属性缓存的布局不变CPU就不需要重新绑定一堆渲染状态只需要更新缓冲区的数据偏移量就行。关键点在于SRP Batcher只对SRP Batcher兼容的Shader生效。如果你还在用内置管线的旧Shader比如自己写的Surface Shader没做改造SRP Batcher是不起作用的会静默退回传统Draw Call路径。用URP项目的时候打开Frame Debugger看一眼Draw Call后面有没有显示“SRP Batch”标记没有就说明Shader不兼容赶紧改。我的实测经验同样是30个物体各带独立材质颜色传统方式要30个Draw Call、30次SetPass Call改用SRP Batcher后SetPass Call可以压到1Draw Call也会因为材质属性不必重新上传而大幅减少CPU时间。中低端机上这个改动省下来的CPU时间能明显缓解发热。3.4 工具链Frame Debugger才是Draw Call问题的显微镜定位Draw Call问题不要只盯着Stats面板的数字发呆。Frame Debugger是Unity自带的逐Draw Call回放工具打开后每一帧的每一个Draw Call都能单独点开看能直观看到合批为什么失败。我排查Draw Call问题时通常这样用Frame Debugger逐帧翻阅找到未被合批的Draw Call条目看它的“原因”列Unity会直接写明为什么不合批比如“材质不同”“网格顶点超过动态批处理限制”“没有标记静态批处理”等。同一材质下的多个物体如果每个都单独出现优先怀疑是不是复制了材质而非共用同一个材质实例。项目里很多物体喜欢用GetComponentRenderer().material去改颜色这会导致每次调用都实例化一个新材质瞬间把合批拆散。注意Canvas的Draw Call。UGUI的Canvas渲染其实也是通过网格提交Draw Call的如果Canvas下面挂了多个材质不同的Graphic元素每个元素都会破坏合批导致Draw Call爆炸。顺带一提RenderDoc这种外部工具也能抓帧分析但对Unity项目来说Frame Debugger给的信息已经足够解决90%的合批问题。别一上来就整重型工具先用自带的把问题看清。4. Canvas重建UGUI发热里最容易被忽视的那个坑4.1 什么是Canvas重建它为什么那么贵如果说GC和Draw Call还算“名声在外”那Canvas重建Canvas Rebuild绝对属于“隐形的CPU杀手”。它发生在UGUI的层级树更新时主要包含两个阶段Layout Rebuild布局重建和Graphic Rebuild图形重建。每次你修改UI元素的位置、尺寸、文本内容、颜色或者层级结构UGUI都会把对应的Canvas标记为脏dirty然后在当前帧的更新阶段重新计算网格。这个重建过程完全在CPU上进行。文本要重新生成MeshImage要重新根据Sprite计算四个顶点和UV复杂的UI界面一套重建下来CPU开销轻松超过一整个场景的渲染开销。更要命的是Unity有一个“脏”标记的传播机制——子Canvas元素变了如果你没有正确隔离层级可能整个Canvas下所有元素都跟着重建一遍。发热逻辑正值UI动态变化频繁比如飘字、伤害数字、聊天滚动、金币刷新→ Canvas持续被标记脏 → 每帧都执行网格重建 → CPU狂转 → 热量飙升。4.2 嵌套Canvas隔离重建范围的利器这里最有价值的优化技巧不是告诉你怎么精简UI元素而是要用嵌套Canvas把重建隔离在最小范围。原理很简单UGUI的重建是以Canvas为单位的。如果一个Canvas下有100个UI元素你只改了其中一个小图标的颜色理论上是只重建那个图标但如果其他元素之间存在脏标记传播或者Text有auto-size之类的属性重启布局会导致大量节点一起重建。一个有效的做法是把动态元素成组放进独立的子Canvas里这样动态元素的任何变化只会触发该子Canvas自己的重建不会污染整个界面。举个实际案例一个头像面板包含头像框、等级数字、血条、聊天气泡、按钮。血条每帧都在变长度聊天气泡时不时弹出来。如果这四样全挂在同一个根Canvas下血条一变化整个UI可能都要参与布局重建。把血条单独放进一个子Canvas聊天气泡放进另一个子Canvas头像和等级数字放进静态的根Canvas——动态变化被物理隔离CPU的每帧重建量从“全部UI”缩减到“一个子Canvas”效果极其明显。4.3 Text和Font的隐藏陷阱Canvas重建里有个特别容易被低估的东西——Text组件的字体纹理上传。当你修改Text的字符串内容时UGUI不仅要重新生成文本Mesh如果新文本里包含了字体纹理图集Font Texture Atlas里没有的字符还会触发字体纹理的动态更新把新字符的位图上传到GPU。这个操作在CPU和GPU之间产生同步等待造成的卡顿比单纯重建Mesh更严重。回避方案很直接优先使用动态字体时要控制字符集范围。Unity的Font有个“Dynamic”选项勾选后就是动态字库字库纹理是运行时生成的。对于中文字体几千个常用汉字会导致图集非常大建议做字库裁剪——把不常用的生僻字从字体资产里移除图集小了上传压力小了重建成本也低了。另一个常见问题是Text的Best Fit自动适应尺寸选项。这个选项听着方便但它会让Text在每次内容变化后都重新测量尺寸测量过程是逐字符进行的非常吃CPU。UI上能不开Best Fit就别开手动定好字号和RectTransform大小换行就用Horizontal Overflow和Vertical Overflow控制或者直接用ContentSizeFitter代替注意ContentSizeFitter本身也会触发Layout重建别滥用。4.4 实战定位Canvas重建热点定位Canvas重建问题Unity Profiler的CPU Usage窗口里搜索关键字“Canvas”或“Rebuild”能看到Canvas.SendWillRenderCanvases、CanvasUpdateRegistry.PerformUpdate、CanvasRenderer等条目。点开看调用栈如果看到Text.OnPopulateMesh、Image.OnPopulateMesh、LayoutGroup之类的函数基本就知道是哪个节点在拖后腿。还有一种更细的做法用Profiler的“Hierarchy”视图按Self CPU排序那一列会显示每个函数的自身耗时能直接看到是哪个UI组件把CPU时间吃掉了。对于频繁重建的Text直接看它的OnPopulateMesh耗时通常就是重灾区。经验上一个中等复杂度UI界面50个左右元素的Canvas重建耗时如果超过1ms在中低端机上是需要警惕的。压到0.2ms以下是理想状态。怎么压回到4.2的思路隔离动态区、裁剪字库、减少Text的刷新频率比如不要每帧刷新血条数字改成每隔几帧或数值变化时才刷新都是有效手段。5. CPU发烫的调优排查顺序从现象到根因5.1 先定框架不要一上来就闷头改代码很多开发者做性能优化都习惯“想到什么改什么”——今天觉得是GC明天觉得是Draw Call后天又觉得是阴影。没有一套系统的排查顺序改了一堆发热还是照旧。我个人常用的方法是先定场景、再定帧、最后定函数。先定场景是在说发热问题只在战斗场景出现还是主界面也烫战斗场景和主界面的渲染压力差别很大如果主界面也烫可以先怀疑UI和后台逻辑比如网络轮询、AI更新别急着怀疑特效和粒子。定帧是第二步用Profiler选一个发热期间的平均帧不要选最低帧也不要选最高帧选CPU占用率最高的典型帧看这个帧的CPU时间都花在哪块。Unity的Profiler分好几个模块Rendering、Scripts、Physics、Animation、UI、GC等每个模块占的毫秒数一目了然。哪块大先动哪块。最后才落到具体函数上。用Deep Profile发布包不要开Deep Profile开销太大用Profiler Context Menu手动标记性能关键区或者用ProfileMarker把业务代码划分成块定位具体函数。5.2 发热问题专属的排查清单我自己的经验是发热问题往往不是单一原因而是一个“综合症”。以下清单是我在优化发热问题时必查的几项按优先级排序GC Allocation是否持续增长这是头号怀疑对象因为它导致的发热最隐蔽、最难在普通性能测试里暴露。UI是否有持续重建打开Profiler观察Canvas重建频率动态UI越是高频刷新越要处理。是否在Update里做了太多循环和查询比如每帧FindObjectOfType、GetComponent、transform.position的频繁访问——这些API看着无害内部却有引擎级开销和缓存穿透。是否有每帧执行的协程协程每帧MoveNext也有开销数量多了一样吃CPU。物理和动画Physics每帧步进、Animator的蒙皮更新Skin Mesh也在CPU上跑刚体和骨骼多的场景CPU占用会相当可观。粒子系统虽然粒子渲染在GPU但粒子系统的更新模拟、碰撞检测在CPU。大量并行粒子会明显抬高CPU占用。阴影和实时灯光这点容易被误解成GPU压力其实阴影的阴影映射Shadow Map渲染时需要CPU提交额外的渲染Pass指令Draw Call翻倍SetPass Call更是成倍增长CPU端开销巨大。5.3 “先Castle后工人”分级优化策略优化发热问题不要追求一步到位。我的建议是分两轮进行第一轮做“止血”第二轮做“治理”。止血轮的目标是快速消除最严重的CPU尖峰。用Profiler定位到当前最耗CPU的前三个热点比如GC分配、Canvas重建、粒子更新针对这三个点做最小改动比如把金币刷新从每帧改为每0.1秒关闭超出屏幕的粒子发射器把动态UI隔离到子Canvas。这些改动不需要重写架构一两天就能完成但温度下降会很明显。治理轮的目标是架构级优化对象池全面落地、UI框架从即时刷新改成事件驱动刷新、把频繁的字符串操作改成缓存复用、合批与SRP Batcher全面启用、制定代码规范禁止高频路径上的GC分配。这个阶段可能需要一两周要铺开做但收益是稳定和可持续的。注意优化发热问题最怕“眉毛胡子一把抓”。一轮改动里同时改了十个地方最后温度降了但你根本不知道是哪个改动起了作用下次遇到类似问题依然要靠猜。建议每次改动只动一个变量然后用Profile对比改动前后的CPU占比和温度数据形成可复盘的记录。6. 实测案例一个“低画质但发烫”的MMO主城修复记录6.1 问题描述和初步定位有一次我接手一个MMO项目的主城场景发热优化。场景本身并不复杂美术风格偏卡通面数也不高理论上画质压力不大。但测试机上运行5分钟后手机背部温度稳定超过42度帧率从60掉到30以下。第一轮用Profiler定位先说结论GPU的耗时只在6ms左右不是热点。CPU那个条状图里占比最高的分别是“UI.Rebuild”3.8ms、“GC.Alloc”2.1ms和“Animator”1.7ms。我当时就明白这是个“CPU发热”的典型样本——渲染压力不大但CPU在疯狂加班。顺着调用栈往下挖UI.Rebuild集中在主城界面左上角的一个活动入口图标上。这个图标每隔几秒就有一个闪烁动画且它的Text子物体每帧都在用协程更新颜色数值——最致命的是这段话写在了整个主UI Canvas下导致这个小小的闪烁动画触发整个主界面的Canvas重建。6.2 修复动作和结果针对上面三个热点我做了三个层面的改动第一把活动入口图标从主Canvas下提出来放进独立的子Canvas并关闭它的“Raycast Target”属性UI射线检测也会参与重建判定。这一个小改动主界面的Canvas重建耗时从3.8ms降到了0.4ms。第二把协程刷颜色的写法改成Tweener比如DOTween的DOColor来做并且把更新频率从每帧改为每隔0.1秒更新一次。这避免了持续产生的新对象和字符串操作GC Allocation从2.1ms降到了0.3ms。第三排查Animator时发现有几个NPC身上的Animator开启了“Update Mode Always”即使玩家不靠近也每帧更新骨骼动画。改成“Culling Mode Based On Renderers”后远离镜头的NPC直接跳过动画更新Animator耗时降到0.2ms。最终主城场景的CPU耗时从11.7ms降到了5.8ms帧率稳定在60。测试机连续运行30分钟后背部温度从42度降到37.5度。整个优化过程没有调一档画质、没砍一个特效完全是在CPU端做文章——这正好印证了标题的观点CPU不是无辜的。7. 关于CPU占用的最后几点心得做Unity优化做多了我越来越觉得性能优化不是“调参数”的手艺活而是“建立数据直觉”的能力。每一次改动前记录CPU各模块占比改动后再记录一次时间久了看一眼Profiler就能大概判断出问题出在GC、渲染、UI还是动画上。分享一个我常用的简单模型中低端机主场景CPU帧耗时预算可以这样分——渲染相关3ms以内UI重建1ms以内脚本逻辑2ms以内GC分配0.5ms以内物理动画1ms以内余量留2ms。超过预算的部分就是发热的可疑源头。按这个预算表去卡每一项发热问题通常能定位到具体模块。最后如果你真的想让手机不烫本质上是降低CPU在单位时间内的有效工作量——不是让CPU“变快”而是让它“少干没用的事”。代码里减少一次new、UI里减少一次Canvas重建、渲染上少设置一个Pass都是在给CPU减负也是在给手机降温。“发烫优化系列”到现在正好五篇这个主题其实还没聊完。脚本侧的优化思路和资源侧的内存分配策略后面有空了我会继续写。如果你在项目里遇到CPU占用高但GPU看起来不忙的情况不妨用这篇里的方法先查一遍GC和Canvas大概率会有发现。