资讯详情

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

📅 2026/9/18 17:44:59 | 华诺云谱 👁 阅读
Unity移动端发热优化:GC、Draw Call与Canvas重建实战
做移动端性能优化这些年“发烫”永远是玩家反馈里最扎心的一个词。前四篇我集中火力把GPU侧的纹理、Shader、后处理全收拾了一遍帧率确实提上来了可低端机一跑温度计还是往上涨。后来把Profiler的CPU模块摊开一帧一帧看才意识到问题一直藏在CPU侧GC Alloc每帧几MB地在跳Canvas.SendWillRenderCanvases时不时顶出峰值SetPass Call更是一副没人管的样子。这期就聊GC、Draw Call、Canvas重建这三件“CPU背锅三兄弟”它们不直接拉低帧率但会持续累积CPU负载让手机越来越烫。文章适合刚给项目做完GPU优化、正准备往CPU侧挖的Unity开发者也适合手里有UI复杂、战斗飘字多、列表频繁刷新的项目却找不到发热根源的人。1. 先算清发热的账CPU在移动端渲染里到底干了多少活1.1 为什么GPU优化完了手机还是烫很多人有一个下意识判断画面烫一定是GPU在拼命渲染。于是照着网上攻略压纹理、砍后处理、换Shader变体一顿操作后帧率确实从20帧提到了28帧但手机背面还是热。原因其实很简单你的瓶颈不在GPU而在CPU。GPU优化做完之后渲染负载下降了原本被GPU拖住的画面开始快速产出帧这时候CPU反而成了流水线上最忙的那个工位。移动端SoC里CPU和GPU共用一片芯片CPU长时间高负载同样会让芯片整体温度上来。更要命的是现在的手机系统都有动态调频机制CPU一旦持续忙大核直接拉高频率而频率和功耗不是线性关系——频率提20%功耗可能涨40%。功耗一高温度就压不住。所以要判断发烫是不是CPU的问题不能靠猜得看Profiler里两个核心数值主线程耗时和渲染线程耗时。如果主线程的ScriptUpdate、Canvas.SendWillRenderCanvases、Camera.Render这些模块排在最前面GPU那边反而空闲那CPU就是发热主谋。1.2 每一帧CPU的固定工作清单了解一下CPU在移动端每帧都要干什么你才会理解为什么它可能这么辛苦。Unity的Player Loop里主线程要依次处理脚本Update、动画Animator、物理Physics、粒子系统更新、骨骼蒙皮、粒子/碰撞的同步、UI的布局与网格重建、可见性剔除最后把所有渲染命令提交给渲染线程。渲染线程再做剔除、合批、生成Draw Call命令交给底层图形API。很多团队只盯着脚本Update忽略了渲染提交这点特别容易踩坑。脚本Update里就算啥都没干只要场景里有几千个物体被剔除、几百个UI元素在动态变化、几十个材质需要切换CPU就已经在负重前行了。更麻烦的是移动端CPU的单核性能远不如PC很多逻辑根本没机会跑多线程结果就是主线程一帧耗尽。我见过一个项目逻辑很干净但打开Profiler发现Camera.Render占了整个帧时间的40%。为什么会这样因为场景里挂了几十个实时光源和阴影虽然画面看起来还行但CPU每次渲染提交都要为每个光源、每个阴影执行一堆状态切换和计算。所以排查发热时永远要按主线程模块列表从上往下看而不是只看代码。1.3 从Profiler看CPU与GPU谁在等谁Profiler里“谁在等谁”这个问题直接决定优化方向。常见的情况是主线程在等待GPU表现在Gfx.WaitForPresent或WaitForTargetFPS特别高说明GPU才是瓶颈这时候拼命砍CPU代码没用。反过来如果GPU线程大部分时间空闲主线程的渲染提交、脚本、UI却堆得满满当当那么问题是CPU侧你开了垂直同步也一样会掉帧因为上游就没能按时供水。还有一个容易混淆的点移动端显示器刷新率固定即使CPU和GPU加起来能在8ms内跑完一帧但垂直同步会强制每16.6ms才交换一次画面这时Profiler里会看到很多空闲等待。这个等待不代表性能好只说明负载还没超线。真正判断性能的硬指标是去掉等待后的实际工作时间是否接近或超过帧预算。如果实际工作时间已经16ms了新闻发布会还是开垂直同步帧率稳30看不出问题一旦场景更复杂就立刻掉帧、发热。2. GC Alloc是张看不见的账单每帧几MB分配的发热逻辑2.1 GC为什么会拖累发热从分配到停顿的完整链路GC之所以是发热优化的大头得从托管堆的工作原理说起。Unity的Mono和IL2CPP都带着托管运行时C#代码里new一个对象或调用会返回字符串的API时内存会从托管堆里分配。托管堆有个特点它不像栈上分配那样用完就完而是会积累一堆没人引用的垃圾等堆内存达到阈值运行时就会触发垃圾回收把所有存活对象扫一遍把垃圾内存清出来。这个回收动作的代价非常大。Boehm GC是保守式回收器回收时会挂起所有执行线程整个游戏世界在那段时间是停住的。玩家看到的表现就是十几帧掉帧、操作卡顿、动画跳变。更关键的是频繁GC会让CPU在回收上反复烧电这跟游戏逻辑没关系纯粹是内存管理在给你“偷偷升温”。我常打一个比方GC就是每帧丢一点纸屑等到地板上全是纸屑时保洁阿姨冲进来把所有人都叫停然后蹲在地上扫半小时才能继续工作。扫完一次下一帧又开始丢如此循环。发热的逻辑就是这样串联的频繁分配→频繁GC→CPU忙→功耗高→温度高。2.2 用Profiler锁定每一帧的分配热点定位GC分配我建议分两步走。第一步打开Profiler的CPU Usage模块选中Player主线程在模块列表里看GC Alloc那一列。如果一帧的GC Alloc常年超过1MB基本就是灾难了。第二步如果要精确到是哪个函数在分配我会勾上Deep Profile重新抓一帧。注意Deep Profile会显著放大函数耗时它会把每个函数都插桩所以只看分配相对大小别拿Deep Profile的耗时去评估真实帧时间。常见的热点长得都很有规律。我总结了这几年遇到最多的几类string.Format、字符串拼接尤其是Text.text 金币: count.ToString()这种每帧都在建新字符串LINQ表达式Where、OrderBy、ToList这些都会生成迭代器和委托对象Lambda和闭包每帧作为参数传入事件或协程时分配yield return new WaitForSeconds(1f)协程里每帧new一个对象装箱把int、float等值类型塞进object、ArrayList或非泛型List时发生。这里面最冤的是数字转字符串。一个金币数字表面上就几毫秒但如果你在Update里让它跟随动画变化每秒60帧每帧两个字符串分配几分钟后GC就受不了了。2.3 压GC的常规武器缓存、池化和避免分配型API压GC没有玄学就是三板斧缓存、池化、换API。缓存是最容易上手的。场景里所有组件引用、Transform、常用字符串在Awake或Start里取一次Uniform存在字段里。我见过太多项目在Update里Find(Text/Level)然后GetComponentText()这不仅是耗时问题如果调用内部有分配就是持续的GC压力。池化的对象主要是两种一是预制体实例二是临时字符串。预制体池化最标准用StackT存空闲实例取用时弹出归还时压回。字符串池化稍微麻烦点但可以用一个静态的StringBuilder池或者针对纯数字场景直接封装一个不产生额外临时字符串的格式化器。下面这个例子是我项目里用了很久的数字格式化写法public static class HudNumber { private static readonly char[] Digits new char[16]; public static string ToString(int value) { int index 15; bool negative value 0; uint v negative ? (uint)(-value) : (uint)value; do { Digits[index--] (char)(0 v % 10); v / 10; } while (v ! 0); if (negative) Digits[index--] -; return new string(Digits, index 1, 15 - index); } }这个方法只分配最终的那个字符串不会有中间临时对象。相比string.Format和int.ToString的隐式分配一帧省下来非常可观。换API的核心是避免分配型写法用StringBuilder代替频繁拼接用for循环代替LINQ事件用弱引用或缓存委托协程里的WaitForSeconds缓存成静态字段。这里我要提一个容易忽略的点foreach在旧版本Unity里对数组和ListT的迭代都可能有分配虽然现代版本已经优化了但如果项目用的还是Unity 2018、2019我建议在热点循环里直接用for。还有一个取舍要聊Unity的增量式GC。它在Player Settings里可以开启原理是把一次大GC拆成每帧一小步减少单帧卡顿但总耗时和总CPU占用反而会上升。如果你做的是发热优化我建议先不要开增量GC而是先把每帧分配砍到几十KB让GC根本不需要频繁触发。分配少了GC频率自然降低温度才能真正压下来。3. Draw Call不是洪水猛兽但SetPass Call和无效合批是3.1 Draw Call和SetPass Call的成本到底差在哪Draw Call这个词大家都懂但很多人不知道它跟SetPass Call的区别。Draw Call是CPU向GPU发出的“把这个网格画出来”的指令而SetPass Call是CPU在切换材质、Shader Pass、渲染状态时的操作。真正贵的是SetPass Call因为每次切换驱动层都要重新校验一大堆渲染状态GPU流水线也要跟着重排。我用快递来类比Draw Call是发一件快递SetPass Call是换一种快递车辆规则。如果一批快递都用同样的车辆规则发快递员只需要写一个清单一次性出发如果每个快递都要换一种规则快递员每件都要重新规划路线、重新填表时间自然就上去了。所以在Profiler里我优先看的永远不是Batch总数而是SetPass Call这个数字。减少SetPass Call的手段首推把场景里用到的材质数量压下来。同一个预制体复制100个只要它们引用同一个材质资产理论上是能合批的一旦你为了改某个颜色调用了material.colorUnity会实例化出一个新材质原来能合批的100个物体立刻变成了2个批次。这种因为“改一个颜色”导致的隐性材质膨胀在UI和特效里尤其常见。3.2 四种合批手段的适用边界Unity的合批方案按适用场景来分大致有四种静态合批、动态合批、GPU Instancing、SRP Batcher。它们各有自己的边界选错了反而更热。静态合批适合场景里不动的物体。它在构建或运行时把静态物体网格合并成一个大网格换来的是减少CPU提交次数代价是内存上涨因为合并前每个物体有自己的网格合并后还会生成合批网格。移动端内存紧张不能无脑把整个场景都静态合批。动态合批适合小物体且频繁移动的场景例如小石块、碎屑。但动态合批是有代价的引擎每帧都要在CPU上把网格数据合并到缓冲区顶点越多、物体越多CPU开销越大。它确实能减少Draw Call但如果你有一堆高模怪物动态合批的每帧计算反而会让CPU更烫。GPU Instancing适合大量相同物体、相同材质、只改变位置大小的场景比如一大片草、一大群敌人。它一次Draw Call绘制多个实例而且CPU不用每帧合批数据是发热优化里的好东西。前提是Shader必须支持Instancing美术用的自定义Shader经常忘开这个关键词。SRP Batcher是URP/HDRP时代的重点。它的思路不是合并几何体而是通过持久化材质属性缓存大幅减少SetPass Call。开启它之后同一个材质变体下的物体切换批次时会便宜很多。这个方案对低端机特别友好因为它降低的是CPU的渲染提交成本。如果你项目已经切了URP我建议优先确认SRP Batcher是否正常工作。3.3 合批优化前的三个检查动作很多团队做合批优化改动前不记录数据改完看帧率没涨就回退了非常可惜。我总结了一个固定流程每次优化前先做三件事。第一打开Profiler的Rendering模块记录当前帧的Draw Call、SetPass Call、Triangles、Vertices以及Batching统计里的Static Batched/Dynamic Batched数据。第二用Window菜单下的Frame Debugger逐帧查看批次划分哪些物体被拆成了独立批次一眼就能看到。拆批最常见的原因是材质不同、贴图不同、Mesh不同、光照/shadow设置不同。第三确认当前项目的渲染管线如果是URP优先开SRP Batcher如果还是内置管线再讨论静态合批或动态合批。做完这三件事你才能知道哪些批次该合、哪些不该合。有些物体虽然不能合批但不代表性能差例如大场景里的地形和角色本来Draw Call就不多强行合批反而增加静态内存。合批的目标永远是降低CPU渲染提交时间而不是为了Profiler面板上那个数字好看。4. Canvas重建UI层最容易被忽略的CPU加班现场4.1 Canvas的重建机制一个元素变动同Canvas陪跑UGUI的底层机制很多老手都说不清UI里的每个文本、图片、按钮最终都会生成网格交给CanvasRenderer再由一个Canvas统一打包并提交GPU。问题是这个“打包”动作是有触发条件的。只要某个UI元素的层级、位置、尺寸、颜色、文本内容发生变化它就会把自己标记为脏然后Unity会在渲染前调用Canvas.SendWillRenderCanvases把脏元素的网格重新生成一遍再把整个Canvas的批次重新整理。关键点在这里同一个Canvas下的其他静态元素也会因为这次变化被重新走一遍批次整理。如果一个界面里背景、标题、按钮都是静态的只有右下角一个经验条在动结果整个界面每次都要陪它一起重建。这在Profiler里的表现就是Canvas.SendWillRenderCanvases耗时高而且帧帧出现。发热层面的影响是这样的UI重建本身是CPU上的多边形计算、顶点变换、批次优化持续高频触发时CPU负载就上去了。所以UI越花哨、动效越多、重建越频繁手机就越容易发热。4.2 动态Text、RaycastTarget和描边UI发热的三大坑UI层常见的性能杀手我带过的项目里几乎每回都能踩中这三个。第一是动态Text。Text.text一旦变化整个文本的顶点都要重新生成。如果这个文本每帧都跟着战斗数值飘每一帧都在做字符排版、图集采样、网格生成。字符越多重建成本越高。更麻烦的是中文字体动态字体模式下每个新字符还可能要往字体图集里塞新字形图集满了一样触发重建。所以动态显示的数字能用Sprite数字就用Sprite数字不能用就别每帧改。第二是RaycastTarget。Unity里每个Graphic组件默认都开启了RaycastTarget这意味着即使没有鼠标去点它EventSystem每帧也会检查它是否被指针命中。一个复杂界面里几十上百个Text、Image全开着RaycastTarget事件系统的射线检测成本会叠加。更妙的是很多UI元素根本不需要响应点击比如背景图、装饰线、纯文字。关闭RaycastTarget只对不需要交互的Graphic做效果立竿见影。**第三是Outline和Shadow组件。**这两个效果组件会给每个顶点复制出好几份生成额外的三角面。一段长文本加上Outline顶点数量直接翻倍甚至翻几倍CPU重建成本和GPU渲染成本同时上升。我建议优先让美术把描边做在贴图里或者用Shader控制在需要时才启用。4.3 Canvas分层与列表虚拟化重建隔离的实战做法既然“一个元素动整块Canvas陪跑”那解法自然就是隔离把动态内容和静态内容拆到不同的Canvas下。一个界面里静态背景、标题、底部按钮放一个Canvas动效区域、飘字、进度条放另一个Canvas。这样动效区域重建时静态Canvas完全不受影响。拆Canvas之后要注意合批问题。同一个Canvas下不同材质的元素不会合批拆成多个Canvas也可能让批次变多。但综合来看重建成本的降低通常比批次数量增加更值尤其移动端UI动效频繁时。我通常会在拆Canvas后用Frame Debugger再看一遍确认没有因为拆分产生明显过量的批次。列表滚动是另一个重灾区。一个背包列表几十个Item每个Item都有Icon、Text、Button滚动时所有Item的位置都在变化所有子Graphic都会标记脏。我的做法是配合RectMask2D做显示裁剪同时做虚拟列表只生成可视区域数量的Item滚动时循环复用Item把不可见的Item移到末尾缓存。配合对象池列表滚动的GC分配和Canvas重建都能降到原来的十分之一。还有一个容易被忽略的点UI元素如果只是在屏幕外没被相机看到只要它还在Canvas下且仍是激活状态它依然会参与Canvas的重建和批次计算。列表和飘字离开可视区域后立刻把整个GameObject设为不激活能省下大量无效重建。5. 一次发烫优化实录从Profiler数据到改代码的完整链路5.1 复现现场的基线数据一台会从常温升到39℃的机器今年年初接到一个项目表现很典型中端安卓机跑一个包含背包、战斗飘字、任务引导的玩法5分钟手机背面从温到烫体感温度大概39℃往上帧率在20到30帧之间抖动。我拿到测试机后不急着改代码先做了20分钟的稳定复现然后抓了一帧Profiler作为基线。当时同一帧的数据是这样的模块耗时GC AllocScriptUpdate6.2ms4.8MB/帧Canvas.SendWillRenderCanvases7.8ms0BCamera.Render4.1ms0BSetPass Call210-Draw Call640-总帧时间31.8ms4.8MB/帧看到这个数据不用猜都知道问题在哪。脚本Update里堆了海量分配Canvas重建比一帧预算的一半还多SetPass Call高得离谱。最讽刺的是GPU侧没多少活帧率就是起不来CPU忙成一个高速涡轮。5.2 第一步先压GC把每帧5MB的清零我处理GC的套路是先在Profiler里展开ScriptUpdate按GC Alloc排序找到分配最大的几个函数。这个项目里的排名前三分别是伤害飘字用Instantiate实时创建、金币数字每帧string.Format、任务面板用LINQ筛选数据。伤害飘字直接改成对象池最简单但也最有效的改造public class SimplePoolT where T : Component { private readonly StackT _pool new StackT(); private readonly T _prefab; private readonly Transform _parent; public SimplePool(T prefab, int preload, Transform parent) { _prefab prefab; _parent parent; for (int i 0; i preload; i) { T instance Object.Instantiate(prefab, parent); instance.gameObject.SetActive(false); _pool.Push(instance); } } public T Get() { T item _pool.Count 0 ? _pool.Pop() : Object.Instantiate(_prefab, _parent); item.gameObject.SetActive(true); return item; } public void Recycle(T item) { item.gameObject.SetActive(false); _pool.Push(item); } }金币数字换成了前面写的HudNumber.ToString任务面板的LINQ过滤改成了for循环。做完这三件事再抓一帧GC Alloc从4.8MB/帧降到了60KB/帧ScriptUpdate也从6.2ms降到了2.1ms。这个改动没有动一根渲染管线发热体感已经下来了。5.3 第二步拆Canvas把7.8ms的重建打散接着处理Canvas.SendWillRenderCanvases的7.8ms。我在Frame Debugger里观察发现背包界面里有一个飘在左上角的战斗数字动画每帧都在动。但整个背包界面和它挂在同一个Canvas下背景、Item、按钮全被带着做重建。改造方案很简单把战斗数字、伤害飘字、经验条动效这些所有动态元素统一挪到一个叫DynamicCanvas的子节点下原本的静态背包界面放在StaticCanvas下。每次动效变化时只有DynamicCanvas需要重建StaticCanvas完全清闲。顺带把所有不需要点击的Graphic的RaycastTarget全部关掉背包列表切成虚拟列表屏幕外的Item直接SetActive(false)。这个步骤做完Canvas.SendWillRenderCanvases降到了0.4ms几乎可以忽略。5.4 第三步收拾Draw Call和SetPass Call最后处理渲染提交。这个项目的背包界面有大量小图标全是散图每张散图一个材质Item一多SetPass Call就爆炸。我让美术把图标全部打进了图集同时确认所有UI材质用的是同一个默认材质没有因为改颜色产生材质实例。场景里的战斗特效我在抬起镜头时把实时阴影关闭换成烘焙贴图。项目用的URP我顺手检查了SRP Batcher是开启状态。这些动作做完之后SetPass Call从210降到了45Draw Call从640降到了260。渲染提交这一块的CPU耗时从4.1ms降到了1.2ms。5.5 优化完成后的数据对比所有改动打完包同一台测试机、同样的流程跑20分钟前后数据是这样的指标优化前优化后GC Alloc/帧4.8MB64KBScriptUpdate6.2ms2.1msCanvas.SendWillRenderCanvases7.8ms0.4msCamera.Render4.1ms1.2msSetPass Call21045总帧时间31.8ms17.2ms5分钟稳定温度39℃上下36℃出头帧率从20~30抖动变成了稳定贴近30机身从“烫手”变成了“温热”玩家反馈里关于发烫的投诉基本消失。整个过程没有动任何画面效果纯粹是CPU侧账目清楚了。6. 优化之后的保温战性能预算与真机复测的长期规矩6.1 手机的降频机制在你察觉之前性能已经被扣了一半手机跟PC不一样PC过热可能只是风扇狂转手机过热会直接触发系统层面的降频保护。芯片温度到达阈值后系统会降低CPU和GPU的最高频率性能断崖式下跌帧率瞬间从30掉到20甚至更低。这个降频过程是渐进的玩家最先感知到的不是帧率数字而是“手机变卡了、变烫了、电量掉得快”。这就带来一个很关键的判断调优时不能只盯着全新的开发机看。开发机性能强、散热好很多问题测不出来。我一般在测试清单里固定放三台设备一台旗舰机、一台两年前的中端机、一台最便宜的低端安卓机。低端机能稳定30帧不降频项目才算真正合格。监测降频我常用两个信号。一是帧时间的P95如果P95随时间不断上涨说明性能在恶化二是系统温度状态部分厂商ROM提供开发者的温度调试接口我看到CPU温度接近温控线时就会停止测试等凉下来再继续。优化不是一锤子买卖而是要保证设备在长时间运行后依然不烫、不掉帧。6.2 建立项目的CPU性能预算表发热优化做完最怕的是下个版本又堆回来。我要求团队每个性能改动都钉在一张预算表上写清楚每个模块允许占用的CPU时间。以30帧项目为例一帧的预算只有33.3ms但我会按16.6ms的“半预算”来卡因为正常设备CPU还有系统线程在跑超了这个线就有发热风险。这张表大致长这样模块预算脚本Update3ms物理1msUI布局重建2ms渲染提交(Draw/SetPass)4msGC Alloc100KB/帧总帧时间(实际工作)16.6ms任何新功能上线前都要在测试机上跑一遍这个清单超了就砍效果不砍就砍需求。开发过程中只要有人动了UI的更新频率、往Update里加了字符串拼接、给特效加了新材质性能组就能在每日构建里发现GC Alloc或SetPass Call出现异动。做发烫优化这几年我最大的体会是发热没有玄学只是一笔笔CPU账目叠加出来的结果。GC、Draw Call、Canvas重建这三块恰恰是Unity项目里最容易吃CPU却最不被重视的角落。别等手机烫了才想起看Profiler把每一帧的CPU账单都摊在桌上算清楚该池化的池化、该隔离的隔离、该合批的合批温度会自己给你答案。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。