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用同一张图集但中间隔了个BA和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替代旧版TextTMP的合批效率更高而且支持字体图集共享。同一界面统一字体和字号避免动态字体图集分裂。对于纯数字变化的文本比如分数、倒计时可以考虑用图集数字把0-9做成Sprite来替代Text这样能和其他UI元素合批。我做过一个倒计时界面原本用Text每秒刷新DrawCall是12改成图集数字后降到3而且重建成本也低了很多。4. 一个完整界面的优化实录4.1 优化前的状态摸底拿一个模拟的背包界面举例。这个界面有6x8共48个格子每个格子有背景、边框、物品图标、数量文本顶部有标题和关闭按钮底部有整理和排序按钮。优化前用Profiler和Frame Debugger测下来DrawCall约120Canvas.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 优化后的数据对比指标优化前优化后DrawCall12028BuildBatch耗时4.2ms1.8ms帧率中端机38fps58fps图集数量41这个数据是我在一个模拟项目里实测的不同项目会有差异但优化方向和幅度基本一致。核心就是三件事图集合并、层级排序、动静分离。5. 那些年我踩过的合批坑5.1 图集合并后反而更卡了有一次我把一个界面的图集从4张合并成1张2048的结果DrawCall是降了但加载时间变长了而且内存占用上去了。原因是这张图集里有很多没用到的空白区域而且加载时整张图集都要读进内存。后来我调整了策略图集尺寸按实际内容定不要盲目追求大图集。如果界面用到的图不多1024甚至512就够了。另外可以用Sprite Atlas的Variant功能针对不同分辨率设备加载不同精度的图集。5.2 动态字体导致的隐形断批旧版Text用动态字体时如果界面上出现了字体图集里没有的字符Unity会实时生成新的字形并扩充字体图集这个过程会导致字体图集重新打包进而触发所有用到该字体的Text重建。表现就是平时好好的一旦显示某个生僻字或者特殊符号整个界面卡一下。解决办法是预生成常用字符在字体设置里把项目会用到的字符集提前烘焙进去。或者直接用TextMeshProTMP的字体图集管理更成熟支持动态和静态混合。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看批次特效单独Canvas6. 进阶什么时候该考虑换方案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里一眼就能看到是哪个界面、哪个操作触发了重建。这个手段在排查“打开某个界面就卡一下”这类问题时特别好用。
阅读完成 · 觉得有帮助?