尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UGUI视觉优化实战:从Canvas重建到图集管理的性能优化指南

UGUI视觉优化实战:从Canvas重建到图集管理的性能优化指南 这阵子一直在整理项目里UGUI相关的优化内容翻完一堆历史提交和性能报告之后感触最深的一点是UGUI视觉优化表面上拼的是美术资源实际上拼的是“谁更懂引擎在怎么渲染UI”。如果只是把UI做得好看一跑起来就卡顿、掉帧、发热用户根本不会觉得这界面好看。所谓的视觉优化不是重新画一套UI而是在不改变最终视觉效果的前提下把CPU和GPU的开销压下来让画面在低端机上也稳得住。这篇内容就是把我在实际项目里积累的UGUI视觉优化方案做一次系统总结覆盖了从Canvas重建机制、图集管理、渲染层级控制到高频动态UI隔离、源码级避坑和性能排查工具的完整链路适合Unity客户端开发、UI技术美术以及所有被UI性能问题折腾过的游戏开发者参考。1. 认识UGUI视觉优化的本质不是改画面是改开销很多新人在接手优化任务时会有一个误区以为“视觉优化”就是改UI的设计稿、调整图标风格、换配色把界面弄得更好看。但真正在项目里做性能优化的人都知道视觉优化的核心方向恰恰相反它追求的是“视觉不变开销变小”。在低端安卓机上用户感受到的流畅度远比一个高光渐变更重要。1.1 UI卡顿的根源先从Canvas重建说起UGUI渲染的最小单位是Canvas只要某个UI元素被标记为“脏”那么它所在的Canvas就可能在渲染前触发一次重建流程。这个重建流程包含两个阶段布局重建Layout Rebuild和图形重建Graphic Rebuild。布局重建主要处理RectTransform尺寸、位置、锚点变化以及LayoutGroup、ContentSizeFitter这类布局组件重新计算子物体位置图形重建则是重新生成立方体网格、更新顶点颜色和UV数据之后引擎再把这些网格收集起来做合批、提交渲染。这里我用一个生活化的类比来解释整个Canvas像是一张画布上面画了几百个子物体。只要有人在这张画布上改了一个小点引擎为了保证画面正确往往要把整张画布都重新刷一遍而不是只刷那一个小点。这个“重新刷一遍”的动作在代码层面就是Canvas.SendWillRenderCanvases和Canvas.BuildBatch。所以当同一个Canvas下挂了几百个UI元素其中一个Text每帧都在变数字整张Canvas的所有UI都会跟着一起重建顶点、重新合批。这就是UI卡顿最常见的隐藏原因。提示优化UI的第一步不是去调代码算法而是先从结构上减少“脏标记被频繁触发”的可能。你给Text赋值一次新字符串就等于在整张画布上捅了一杆子。1.2 优化指标拆解Draw Call、重建开销与OverdrawUGUI视觉优化最终要盯住三个指标Draw Call、Canvas重建开销、Overdraw。Draw Call是CPU向GPU发出的一次绘制命令UI的Draw Call数量主要取决于被拆成多少个批次Batch。同一个图集、材质、渲染顺序相邻的UI元素理论上可以合批但一旦中间夹了一个不同图集的图片或者材质属性不同的元素批次就会被切断多出一个SetPass Call。移动端对Draw Call尤其敏感GPU填满率不高CPU提交命令过多会直接造成帧时间上升。Canvas重建开销我们在前面已经说了它消耗的是CPU。任何UI组件的属性变化都可能触发重建包括位置、尺寸、颜色、文本内容、图集引用等。频繁触发重建哪怕每个UI元素都很简单叠加起来一样能把主线程帧时间吃掉一大截。Overdraw指同一像素在一帧内被绘制了多次。UI层的Overdraw常见于半透明图片层层叠放、全屏特效、多个全屏Canvas堆叠。GPU像素填充率有限Overdraw特别高的时候就算Draw Call低帧率照样上不去。理解了这三个指标后面的优化方案就有了明确的目标图集管理控制Draw CallCanvas拆分控制重建开销层级和特效控制Overdraw。2. 图集管理从源头上控制Draw Call和内存UGUI的合批前提是使用同一张图集。图集没切好后面怎么做层级调整都白搭。图集管理是UGUI视觉优化里最基础也最容易被忽视的一环但它直接决定了Draw Call的上限。2.1 Sprite Atlas的正确用法与常见误区我见过不少项目还在用十几年前的Sprite Packer打包方式或者在运行时用Texture2D.PackTextures动态拼图集。这里先说结论在新版Unity里统一使用Sprite Atlas资产并且把它作为图集管理和加载的核心单元。Sprite Atlas资产的好处在于它是引擎层显式管理的图集对象支持Include in Build选项你可以控制它是否常驻内存也可以配合AssetBundle做依赖管理。同一个Atlas里的Sprite在运行时渲染时会被引擎自动归到同一张纹理图集上不会因为切图散落在各个目录而导致合批失效。图集怎么切分我总结了几条实际经验按界面切分一个复杂主界面单独一个图集弹窗单独一个图集公共通用图标单独一个图集。按使用频率切分常驻UI用常驻图集不随界面关闭而卸载低频界面图集可以随界面加载和卸载。按更新方式切分静态内容图集和动态刷新内容图集分开这样动态Canvas重建时不会拖累静态图集的合批。这里要特别提醒一个常见误区不是图集越大越好。有人觉得把所有UI塞进一张2048的大图集里Draw Call会最低。但这么做会带来两个问题一是内存驻留过大低端机直接撑爆二是大图集里任何一个小图变动都可能导致整张大图集参与纹理上传和采样。UI优化是一个权衡不要为了追求单一指标走极端。2.2 纹理参数与格式选择不是小事图集的纹理参数设置会直接影响内存和渲染效率。我在项目里对UI纹理有一套固定的参数模板直接作为规范推行Generate Mip Maps关闭。UI是屏幕空间绘制不做3D透视采样Mipmap只会白白增加约33%的内存。Filter Mode按需选择。普通UI用Bilinear像素风或者希望锐利边缘的用Point不要用Trilinear。Compression根据平台选择。Android统一用ETC2或ASTCiOS用ASTC纯色、渐变、无透明通道的图可以考虑RGB变体透明图片务必用带Alpha的格式。用表格整理一下不同格式的适用场景压缩格式适用平台透明通道支持适用场景ETC2Android支持Android UI纹理默认选择ASTC 4x4Android/iOS支持高质量UI大图兼容性好ASTC 6x6Android/iOS支持中等质量内存更低RGBA32全平台支持原始质量一般用于编辑器调试RGB24全平台不支持无透明要求的背景图这里经常有人踩坑同一个UI图集在编辑器显示正常打包后在Android上出现明显色块和噪点多半是压缩格式没选对或者用了不支持透明通道的RGB压缩。2.3 运行时加载与图集碎片化的坑图集管理不只是打包前切图的事运行时加载策略同样关键。我之前接过一个项目UI图片全部直接从AssetBundle里单张加载每张图都是独立纹理导致界面上一排按钮就出现好几个Draw Call而且每张图片都占独立内存。正确做法是把同一界面的图集做成一个AssetBundle包运行时一次性加载这整包图集UI节点只引用图集里的Sprite。还有一种情况是“图集碎片化”就是一张图集被拆成多个AssetBundle引用运行时图集被加载了多次虽然显示的是同一张纹理但引擎里存在多份纹理句柄。排查方法就是在Profiler的Memory模块里搜索纹理名称如果发现同名同尺寸的纹理出现两份以上基本就是图集依赖和加载策略出了问题。图集这块的原理并不复杂但它是整个优化链条的地基。图集切得合理后面优化好做图集切得稀烂后面调层级、砍批次都是事倍功半。3. 渲染顺序、层级与Overdraw的实战控制图集解决的是“用什么纹理画”层级则解决“按什么顺序画”。UGUI的渲染顺序由Hierarchy里的节点顺序和Canvas的SortingOrder共同决定。同一个Canvas下节点顺序越靠下渲染越靠后也就是显示在最上层。这个顺序如果安排得不好合批会被打断Overdraw也会直线上升。3.1 元素排序与批次合并之间的关系合批的本质是让引擎把多个UI网格在同一个批次里提交。要满足合批除了使用同一张图集渲染顺序上还要求这些同图集的元素在绘制序列里是连续相邻的。假如你的界面里有一排按钮背景是A图集按钮图标是B图集文字是C图集那么渲染顺序会像这样A图集背景、B图集按钮、A图集装饰引擎在提交批次时会被迫在A和B之间来回切换产生比想象中多得多的批次。实际项目中一个面板里的元素类型五花八门要做到完全有序很难。所以常用的策略是在Hierarchy里尽量把相同图集的UI元素聚拢在一起而不是按照“背景、按钮、文字”的直观逻辑来排。比如先放所有背景层元素再放所有按钮层元素最后统一放文字层让每一类元素在渲染顺序上相邻批次就不会被反复打断。还可以利用Parent层级来做划分背景相关的节点挂在一个空物体下按钮相关的节点挂在另一个空物体下。前提是这些空物体不影响UI的布局和渲染这种方式在维护上也更清晰。3.2 Raycast Target最容易被忽视的隐形开销UGUI的每个Graphic组件比如Image、Text都有一个Raycast Target选项勾选后这个元素会参与EventSystem的射线检测。问题在于新建的Image和Text默认都勾选了Raycast Target实际上大部分UI元素根本不需要被点击。一场战斗界面里几十个飘字、头像、血条背景它们的Raycast Target默认全部开启EventSystem每帧都要对它们做一次相交计算虽然单个计算量不大但累积起来在某些低端机上会有明显耗时。我现在的做法是在开发规范里强制要求只有需要响应点击的元素才保留Raycast Target其余UI一律关闭。为了防止美术和开发漏改我写了一个校验和批量处理的工具脚本挂在面板根节点下using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public class RaycastTargetCleaner : MonoBehaviour { [Tooltip(需要保留射线检测的节点路径例如Btn_Close)] public Liststring keepRaycastPaths new Liststring(); [ContextMenu(清理所有子物体Raycast Target)] public void CleanupRaycastTarget() { Graphic[] allGraphics GetComponentsInChildrenGraphic(true); int cleaned 0; foreach (Graphic graphic in allGraphics) { if (!graphic.raycastTarget) { continue; } string path GetHierarchyPath(graphic.transform); bool shouldKeep false; foreach (string keepPath in keepRaycastPaths) { if (path.Contains(keepPath)) { shouldKeep true; break; } } if (!shouldKeep) { graphic.raycastTarget false; cleaned; } } Debug.Log($[RaycastTargetCleaner] {gameObject.name} 清理了 {cleaned} 个元素的Raycast Target); } private string GetHierarchyPath(Transform target) { string path target.name; Transform parent target.parent; while (parent ! null) { path parent.name / path; parent parent.parent; } return path; } }这个工具在节点树结构复杂时特别省事一键清理后肉眼基本看不出任何显示上的变化但EventSystem的负担会明显下降。3.3 遮罩组件选择Mask与RectMask2D的取舍UI里有列表、滚动区域时经常要用到遮罩裁剪。这里有个非常典型的性能陷阱使用Mask组件而不是RectMask2D。Mask组件的实现方式是为遮罩区域单独生成一个模板缓冲所有被遮罩的子物体都要额外参与模板测试渲染时还会打断合批。在滚动列表这种需要频繁移动、大量元素参与裁剪的场景里Mask带来的额外顶点和批次开销非常可观。RectMask2D则是在Shader层面通过矩形范围裁剪不生成模板对象对合批的影响小得多性能表现也好很多。实际项目里我基本只用RectMask2D。只有遇到不规则形状的遮罩需求圆形头像外的扇形遮罩等才会用Mask而且会严格控制被遮罩元素的数量。如果一个界面里存在大量Mask嵌套建议直接改造成RectMask2D性能提升会很直观。3.4 特效与Overdraw全屏叠加要慎重UI里加了粒子特效、全屏动画之后Overdraw会快速飙升。最常见的场景是一个全屏底框用半透明黑色遮罩上面又叠一层全屏光晕特效再叠一个半透明背景图三层叠加下来低端机GPU直接满载。处理特效Overdraw的思路有三条。第一UI相机和特效相机分离不要把粒子特效渲染到UI Canvas内部让特效层单独一个相机或者Screen Space Camera Canvas便于控制渲染顺序和层级。第二能不用全屏粒子就不用全屏粒子尤其是透明混合粒子在半透明叠加时填充率消耗非常大。第三全屏遮罩尽量复用不透明区域直接不画透明区域也要控制层级数量不要无脑叠三四层半透明黑框。3.5 多Canvas分层隔离高频更新的UI多Canvas拆分的核心目的是把动态内容和静态内容分开避免静态元素跟着动态元素一起重建。我的分法是四层底层静态Canvas背景、固定标题栏、常驻按钮、动态内容Canvas飘字、血条、计时器这类高频变化的UI、窗口Canvas弹窗、面板开合时才变化、特效Canvas独立相机渲染的特效层。静态Canvas的内容几乎不触发重建动态Canvas即使每帧刷新也只会重建动态Canvas自己的网格不会拖累整个界面的其他部分。注意Canvas不是拆得越多越好。每个Canvas是一层独立的渲染放在Screen Space Overlay下会引入额外的合批层级和排序开销。一个界面拆到四五个Canvas已经算多超过十个就要重新检查是否过度拆分了。4. 动态UI场景从高频刷新到源码级避坑如果说前三章是在解决静态界面的优化问题那这一章专门处理动态UI。很多项目在静态界面上表现很好一旦进入战斗、排行榜、实时数据刷新这些高频场景就露馅。动态UI的优化思路和静态UI完全不同重点在于减少重建频率、隔离高频元素、规避布局组件的连锁反应。4.1 高频刷新的UI如何隔离与合并战斗飘字、伤害数字、Buff计时器、红点提醒这些UI元素的特点是单帧内会被频繁赋值。这样做会把它们所在Canvas的重建频率拖到极限。我的实践做法是把这些高频UI统一收敛到一个或者两个“动态Canvas”之下。比如所有飘字放到一个DynamicTextCanvas该Canvas下只放Text组件和必要的布局容器既不放背景大图也不放按钮。这样飘字的任何变化都只会触发这个轻量级Canvas的重建对主界面零影响。同时高频刷新内容尽量合并刷新时机。比如一个界面里同时有血量、蓝量、经验条三个数值它们在战斗里可能都在变化但每次变化时间戳不一样。如果每个数值一变化就立刻刷新UI一帧内可能触发三次Canvas重建。处理办法是在数据层做合并同一帧内多条数据变化只标记一次UI刷新在帧末统一更新一次显示。4.2 避免无意义的SetDirty操作很多UI性能问题不是功能需要而是代码写得粗糙。最常见的例子是每帧给Text组件赋值同一个字符串虽然内容没变化但赋值动作本身就会把组件标记为脏导致Canvas重建。优化方式很简单赋值前先判断using UnityEngine; using UnityEngine.UI; public static class TextEx { public static void SetTextSafely(this Text text, string value) { if (text.text ! value) { text.text value; } } }还有一个容易忽略的问题字符串拼接。战斗飘字里如果每帧调用string.Concat或者直接用拼接多段字符串会产生大量临时内存触发GCGC卡顿在表现上就是UI掉帧卡顿。改进方案是使用StringBuilder或者对象池化的字符串缓存。颜色属性也是重灾区。某些UI在做闪烁、渐入渐出动画时每帧把Color赋值给Image或Text。如果动画没有实际改变颜色结果或者数值精度变化微乎其微也要加阈值判断不要无脑赋值。4.3 LayoutGroup与ContentSizeFitter的连锁反应动态列表、聊天框、排行榜这类UI几乎避不开LayoutGroup和ContentSizeFitter。这两个组件是动态UI里隐藏的深坑它们不只影响自身节点还可能在父节点上引发连锁重建。LayoutGroup在重新布局时会修改所有子物体的RectTransform位置和尺寸每个子物体的变化又可能触发它们各自的图形重建。内容里一旦有Text组件Text重建还会触发字体图集采样。整个链路下来一次新增列表项可能放大成几百次重建操作。实战经验是能不用ContentSizeFitter就不用它会在尺寸变化时递归计算父链上的布局。列表项尽量固定尺寸不要依赖自动适配。如果需要动态增删项目优先使用对象池把Item的RectTransform先挂载在池中激活时再放入布局组减少布局重算的次数。还有一点GridLayoutGroup的约束模式要设置成FixedColumnCount或FixedRowCount不要让引擎每帧动态计算行列数这对高刷列表的性能有很大提升。4.4 从源码角度理解Rebuild触发的源码级避坑UGUI的源码并不复杂核心流程在CanvasUpdateRegistry里。引擎通过监听Canvas.willRenderCanvases事件在渲染前收集所有需要重建的布局和图形然后按顺序执行。也就是说只要你在UI属性上做了SetDirty相关的操作不管是在脚本的Update里、协程里还是事件回调里最终都会汇聚到这一步统一处理。理解了这一点很多问题就好排查了。如果Profiler里Canvas.SendWillRenderCanvases的耗时异常高说明有太多的脏UI标记或者重建的网格过于复杂这时候就应该去检查是不是有Text在每帧赋值、是不是有元素在每帧改颜色、是不是有LayoutGroup在每帧重排。我经常用Profiler里的CPU耗时定位具体脚本打开Profiler后搜索Canvas.SendWillRenderCanvases然后展开调用栈就能看到是哪个脚本触发标记了重建。这个方法几乎是我排查UI卡顿时的第一动作。5. 常见问题排查与优化工具实录最后这部分是我在实际项目里沉淀下来的一套排查流程可以当作一个速查工具来用。遇到UI性能问题按这个顺序走一遍大部分坑都能找到根源。5.1 Profiler里需要盯住的UGUI关键指标打开Unity Profiler在CPU模块里重点看下面几个函数函数/指标含义异常表现Canvas.SendWillRenderCanvasesUI重建入口耗时数值高说明重建频繁或网格复杂Canvas.BuildBatchUI合批提交耗时数值高说明批次过多或网格顶点过多Graphic.RecalculateRectTransform布局重算耗时数值高说明LayoutGroup/ContentSizeFitter在频繁工作UI.Batches / SetPass Calls实际绘制批次数值高说明合批被频繁打断Texture Streaming纹理加载数值高说明图集加载策略有问题定位问题时不要只看总帧时间要深入到这几个细分指标里才能知道瓶颈是在CPU重建、GPU绘制还是内存加载。5.2 Frame Debugger定位合批打断点Frame Debugger是Unity自带的逐Draw Call调试工具。打开后能看到每一帧实际提交了哪些绘制调用每个Draw Call用的是什么纹理、哪个Shader、哪个Mesh。排查合批问题时特别有用如果发现两个相邻UI元素明明应该合批却被分到了两个Draw Call里可以在Frame Debugger里看到它们之间夹了一个不同图集或者不同材质的元素然后回到Hierarchy调整排序。在Frame Debugger里还能看到每个Draw Call的顶点数如果某个UI元素的顶点数异常高很可能是因为它的网格过于复杂比如用Mask嵌套生成了大量额外几何体或者Text使用了大字体字号生成了过多的字符顶点。5.3 常见问题速查表我把这些年遇到的UGUI视觉优化问题整理成了一个速查表遇到类似情况可以直接对照排查现象可能原因处理思路UI整体卡顿帧时间波动大Canvas重建频繁多个动态元素共用一个Canvas拆分静态/动态Canvas隔离高频UI某界面Draw Call异常高图集未合并或合批顺序被打断检查图集引用调整Hierarchy渲染顺序滚动列表卡顿LayoutGroupContentSizeFitter连锁重建固定Item尺寸使用对象池减少自动布局打开面板瞬间卡顿图集动态加载、Shader编译、大纹理上传预加载图集规避运行时加载EventSystem操作响应慢Raycast Target被大量误开启批量清理非交互UI的Raycast Target低端机发热严重Overdraw过高全屏特效叠加控制半透明层数分离特效相机文字显示模糊或闪烁字体动态图集频繁扩容重建静态文本烘焙动态文本限定字符集5.4 一个实战案例排行榜列表卡顿排查实录这里说一个我真实处理过的案例。某次项目里排行榜界面在滑动的过程中总是卡顿帧时间在40ms到80ms之间跳完全没法流畅滑动。当时我按前面说的步骤做了一轮排查。第一步看Profiler发现Canvas.SendWillRenderCanvases耗时不正常说明UI频繁重建。继续展开调用栈定位到一个Item上的Text组件每帧都在更新。但排行榜数据并没有每帧变化问题出在代码里做滑动阻尼效果时给每个Item的Text重新赋值了一次相同内容。第二步看Frame Debugger发现排名和昵称使用的是两张独立的Texture导致每行Item内部就有两个Draw Call。排行榜整体视觉上没有任何区别因为两张图里的内容和风格几乎一样属于典型的图集划分不合理。第三步清理Raycast Target排行榜里很多装饰性Image和Text默认开启射线检测虽然单个代价不高但几十行Item叠加起来也是一笔开销。处理方案很简单在数据未变化时加一个徽标判断不重复赋值把排名和头像合并进同一张图集然后清理所有非交互元素的Raycast Target。改动完成后再测帧时间稳定到了12ms以下滑动也顺滑了。这个案例能说明一个问题UI性能问题往往是多个因素叠加的结果不是某一个环节单独造成的。排查的时候一定要按流程把重建、批次、射线检测、图集这四个方向都过一遍不要只盯着某一个指标。干货说完了。我在项目里每次提交UI相关代码前现在都会固定做三步检查看一眼图集划分是否合理扫一遍Raycast Target是否有多余再用Profiler确认没有高频的Canvas重建。这套习惯养成后UI性能问题在开发阶段就能被拦下一大半省去了后面大量返工的时间。
返回列表