1. 从一个卡顿的背包界面说起
做过Unity项目的人大概率都经历过这种场景:项目前期跑得挺流畅,UI一多、界面一复杂,帧率就开始往下掉,尤其是打开背包、排行榜、商城这类格子特别多的界面时,手机直接烫手,帧率从60掉到30甚至更低。打开Profiler一看,Canvas.BuildBatch或者Canvas.SendWillRenderCanvases占了一大块耗时,这时候你就知道,UI的合批出问题了。
这篇内容聊的就是Unity UI优化里最核心的一块——Batch(合批)。我会把UGUI的合批机制拆开讲清楚,包括它到底怎么工作的、什么情况下会断批、有哪些实操手段能把DrawCall压下来,以及我在实际项目里踩过的坑和验证过的方案。内容适合已经写过UGUI、但被UI性能问题折磨过的开发者,也适合刚接触UI优化、想系统搞明白合批原理的新手。看完你应该能自己动手把一个复杂界面的DrawCall从几百降到几十,并且知道每一步为什么这么做。
需要先说明一点:UGUI的合批和3D渲染的静态/动态合批不是一回事,它走的是Canvas自己的重建和网格合并逻辑,理解这一点是后面所有优化的前提。
2. UGUI合批机制到底是怎么运转的
2.1 Canvas是合批的基本单位
UGUI里所有的UI元素都必须挂在一个Canvas下面,Canvas是合批的最小管理单元。一个Canvas下的所有UI元素,Unity会尝试把它们合并成尽量少的DrawCall。这里有个关键点:不同Canvas之间不会合批。也就是说,如果你场景里有三个Canvas,那至少就是三个批次起步,哪怕它们用的是同一张图集。
所以第一个原则就出来了:能合并到一个Canvas的UI,就别拆成多个。但这里有个矛盾——Canvas下的元素一旦有任何一个发生变化(位置、颜色、显隐、文本内容),整个Canvas都会触发重建(Rebuild),重建又分两种:Layout重建和Graphic重建。元素越多,重建成本越高。所以Canvas的拆分本质上是在DrawCall数量和重建成本之间做权衡。
我一般的做法是:静态不动的UI(比如背景、边框、固定按钮)放一个Canvas,动态变化的UI(比如血条、倒计时、滚动列表)单独放一个Canvas。这样动态部分重建时不会连累静态部分。
2.2 合批的判定条件
Unity判断两个UI元素能不能合批,核心看几个条件,我用一个表格列清楚:
| 判定条件 | 说明 | 是否影响合批 |
|---|---|---|
| 材质(Material) | 必须是同一个材质实例 | 是,材质不同必断批 |
| 纹理(Texture) | 同一材质下纹理需一致,或来自同一图集 | 是,不同图集断批 |
| 渲染层级重叠 | 两个元素的渲染顺序区间不能交叉 | 是,交叉会强制断批 |
| Mask/RectMask2D | 使用Mask会打断合批 | 是,Mask内外通常断批 |
| 层级顺序 | 中间插入不同材质的元素会断批 | 是 |
这里最容易踩坑的是渲染层级重叠。举个例子,你有元素A(图集1)、元素B(图集2)、元素C(图集1),按渲染顺序排下来是A、B、C。虽然A和C用同一张图集,但中间隔了个B,A和C的渲染区间被B隔开了,Unity就没法把它们合到一起,只能拆成A、B、C三个批次。这就是所谓的“交叉断批”。
解决办法很简单:把用同一图集的元素在Hierarchy里排到一起。这也是为什么很多优化文章都强调“图集要按界面模块划分”,因为划分合理了,同图集的元素天然就挨在一起。
2.3 为什么图集是合批的命根子
UGUI默认的UI材质是UI/Default,这个材质本身是共享的,所以理论上只要所有图片都来自同一张图集,它们就能合批。但现实是,一个项目里图片资源成百上千,不可能全塞一张图集(图集太大加载慢、内存高、还容易浪费空间)。
所以图集划分策略直接决定了合批效果。我见过不少项目图集是按“功能”分的,比如按钮图集、图标图集、背景图集,结果一个界面里同时用到这三种,DrawCall直接翻三倍。更合理的做法是按界面/模块划分图集,让同一个界面用到的图尽量集中在一到两张图集里。
提示:图集不是越小越好,也不是越大越好。单张图集建议控制在1024x1024到2048x2048之间,具体看目标平台的内存和加载策略。
3. 把DrawCall压下来的实操手段
3.1 图集合并与Sprite Packer的正确用法
Unity自带的Sprite Packer(在Window > 2D > Sprite Atlas里配置)是合批的基础工具。配置的时候有几个参数必须搞清楚:
- Type:选Master还是Variant。Master是主图集,Variant是变体(用于不同分辨率)。一般项目用Master就够了。
- Include in Build:勾上才会打进包,不勾的话运行时加载会失败。
- Allow Rotation:允许旋转能提高图集利用率,但某些情况下旋转后的Sprite在UI里显示会有问题,建议UI图集关掉。
- Tight Packing:紧密打包能省空间,但可能导致Sprite边缘采样问题,UI图集一般关掉,用Rectangle方式。
配置好之后,把同一个界面用到的Sprite拖进同一个Atlas。这里有个实操细节:Sprite的Packing Tag和Atlas配置要对应,否则可能打不进你想要的图集。我一般直接手动指定Atlas,不依赖Packing Tag自动分组,这样更可控。
3.2 层级顺序调整的实战技巧
前面说了交叉断批,实操中怎么排查?我的方法是打开Frame Debugger,一帧一帧看DrawCall的合并情况。Frame Debugger里会明确显示每个DrawCall合并了哪些元素,如果发现某个批次只合了一两个元素,那基本就是被断批了。
调整层级顺序有个技巧:把同一图集的元素在Hierarchy里连续排列。比如一个背包界面,格子背景、格子边框、物品图标如果来自不同图集,那就把所有的格子背景放一起、所有边框放一起、所有图标放一起,而不是按格子一个个排。这样虽然视觉上Hierarchy结构变了,但渲染顺序优化了,合批效果立竿见影。
不过要注意,改变层级顺序可能影响遮挡关系。UI是按下标顺序渲染的,后面的盖前面的。所以调整顺序前要确认元素之间没有遮挡需求,或者用Canvas Group、Sorting Order等机制单独控制。
3.3 Mask和RectMask2D的取舍
Mask组件是合批杀手,因为它会引入Stencil缓冲,导致Mask内外的元素无法合批。如果非要用遮罩,优先用RectMask2D,它只做矩形裁剪,不涉及Stencil,对合批的破坏比Mask小得多。
我实测过一个滚动列表,用Mask的时候DrawCall是48,换成RectMask2D之后降到22。当然RectMask2D只能做矩形裁剪,如果要做圆形头像遮罩这种,还是得用Mask,那就只能接受断批,或者用Shader自己实现裁剪。
注意:RectMask2D虽然对合批友好,但它本身也会触发Canvas重建,滚动列表里如果每个item都挂RectMask2D,重建成本会很高。建议只在列表容器上挂一个,而不是每个item都挂。
3.4 文本组件的合批陷阱
Text组件(尤其是旧版Text)是另一个合批黑洞。每个Text都会生成自己的网格,而且如果字体图集不同(比如用了不同的字体、不同的字号导致动态字体图集分裂),就会断批。
优化手段有几个:
- 尽量用TextMeshPro替代旧版Text,TMP的合批效率更高,而且支持字体图集共享。
- 同一界面统一字体和字号,避免动态字体图集分裂。
- 对于纯数字变化的文本(比如分数、倒计时),可以考虑用图集数字(把0-9做成Sprite)来替代Text,这样能和其他UI元素合批。
我做过一个倒计时界面,原本用Text每秒刷新,DrawCall是12,改成图集数字后降到3,而且重建成本也低了很多。
4. 一个完整界面的优化实录
4.1 优化前的状态摸底
拿一个模拟的背包界面举例。这个界面有6x8共48个格子,每个格子有背景、边框、物品图标、数量文本,顶部有标题和关闭按钮,底部有整理和排序按钮。优化前用Profiler和Frame Debugger测下来:
- DrawCall:约120
- Canvas.BuildBatch耗时:约4.2ms
- 帧率:中端机上约38fps
打开Frame Debugger看,发现批次非常碎,很多批次只合了一两个元素。主要问题有三个:图集按功能划分导致同界面用了4张图集;格子元素按单个格子排列导致交叉断批;数量文本用了旧版Text且字号不统一。
4.2 分步优化过程
第一步,重新划分图集。把背包界面用到的所有图片(格子背景、边框、图标、按钮)合并到一张2048x2048的图集里。图标数量多,但单个尺寸小,2048的图集完全放得下。这一步之后DrawCall从120降到约60。
第二步,调整层级顺序。把48个格子的背景全部提到前面连续排列,然后是所有边框,再是所有图标,最后是所有文本。这一步之后DrawCall降到约35。这里有个细节:调整后要检查物品图标的遮挡关系,确保图标还在背景之上。
第三步,替换文本组件。数量文本改用TextMeshPro,统一字体和字号。这一步之后DrawCall降到约28。
第四步,处理滚动和遮罩。这个界面没有滚动,但如果有,容器用RectMask2D而不是Mask。这一步在这个例子里没体现,但在滚动列表场景里很关键。
第五步,动静分离。把顶部标题、底部按钮这些静态元素放一个Canvas,格子区域放另一个Canvas。这样格子刷新时不会触发静态部分重建。这一步之后BuildBatch耗时从4.2ms降到约1.8ms。
4.3 优化后的数据对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| DrawCall | 120 | 28 |
| BuildBatch耗时 | 4.2ms | 1.8ms |
| 帧率(中端机) | 38fps | 58fps |
| 图集数量 | 4 | 1 |
这个数据是我在一个模拟项目里实测的,不同项目会有差异,但优化方向和幅度基本一致。核心就是三件事:图集合并、层级排序、动静分离。
5. 那些年我踩过的合批坑
5.1 图集合并后反而更卡了
有一次我把一个界面的图集从4张合并成1张2048的,结果DrawCall是降了,但加载时间变长了,而且内存占用上去了。原因是这张图集里有很多没用到的空白区域,而且加载时整张图集都要读进内存。
后来我调整了策略:图集尺寸按实际内容定,不要盲目追求大图集。如果界面用到的图不多,1024甚至512就够了。另外可以用Sprite Atlas的Variant功能,针对不同分辨率设备加载不同精度的图集。
5.2 动态字体导致的隐形断批
旧版Text用动态字体时,如果界面上出现了字体图集里没有的字符,Unity会实时生成新的字形并扩充字体图集,这个过程会导致字体图集重新打包,进而触发所有用到该字体的Text重建。表现就是:平时好好的,一旦显示某个生僻字或者特殊符号,整个界面卡一下。
解决办法是预生成常用字符,在字体设置里把项目会用到的字符集提前烘焙进去。或者直接用TextMeshPro,TMP的字体图集管理更成熟,支持动态和静态混合。
5.3 粒子特效和UI混排的断批
UI界面里经常要加粒子特效,比如按钮点击的闪光、物品获得的光效。粒子用的是Particle材质,和UI材质不同,必然断批。而且粒子如果渲染层级夹在UI元素中间,还会把UI的合批也打断。
我的处理方式是:把粒子特效单独放一个Canvas,并设置合适的Sorting Order,让它整体渲染在UI之上或之下,而不是夹在中间。这样UI的合批不受影响,粒子自己一个批次,代价可控。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| DrawCall居高不下 | 图集分散、交叉断批 | Frame Debugger看批次合并情况 | 合并图集、调整层级 |
| 界面打开瞬间卡顿 | Canvas重建成本高 | Profiler看BuildBatch耗时 | 动静分离、减少单Canvas元素 |
| 滚动列表卡 | 每帧重建、Mask断批 | Profiler看Rebuild频率 | 用RectMask2D、对象池复用 |
| 文本变化导致卡顿 | 动态字体图集重建 | Profiler看Font重建 | 预烘焙字符、用TMP |
| 特效出现时掉帧 | 粒子断批、层级交叉 | Frame Debugger看批次 | 特效单独Canvas |
6. 进阶:什么时候该考虑换方案
UGUI的合批优化是有天花板的。当界面元素数量到几千个、DrawCall优化到极限还是压不下来的时候,就该考虑换思路了。几个方向:
一是用UI Toolkit(原UIElements)。它的渲染机制和UGUI不同,基于Retained Mode,对大量静态元素的处理更高效。但UI Toolkit对复杂交互和动画的支持还不如UGUI成熟,适合做工具类界面或者相对静态的界面。
二是自己写Mesh合并。对于特别复杂的列表,可以把多个UI元素的网格在代码里手动合并成一个Mesh,然后用一个DrawCall渲染。这种做法灵活但工作量大,适合对性能要求极高的场景。
三是用Shader做UI。比如把整个背包界面用一张Shader渲染,格子和图标通过UV偏移来采样。这种做法性能极好,但失去了UGUI的交互便利性,适合展示型界面。
不过对绝大多数项目来说,把前面讲的图集、层级、动静分离这三件事做到位,UI性能就已经够用了。换方案是最后的手段,不要一上来就想着造轮子。
7. 我在实际项目里的几点体会
合批优化这件事,工具和原理都不复杂,难的是在项目早期就建立规范。我见过太多项目是等到卡顿了才来优化,这时候图集已经乱成一团,层级结构也改不动了,优化成本极高。所以我的建议是:项目一开始就定好图集划分规则、Canvas拆分规则、文本组件规范,后面能省掉大量返工。
另外,优化要有数据支撑。不要凭感觉说“这个界面卡”,要用Profiler和Frame Debugger测出具体数字,优化完再测一遍对比。我习惯给每个主要界面建一个性能基线,DrawCall、BuildBatch耗时、内存占用都记录下来,版本迭代时如果数字涨了,立刻排查。
最后分享一个小技巧:Unity的Frame Debugger可以配合Profiler.SetAreaEnabled在代码里打标记,把UI重建的耗时单独圈出来,这样在Profiler里一眼就能看到是哪个界面、哪个操作触发了重建。这个手段在排查“打开某个界面就卡一下”这类问题时特别好用。