UPL9-6——优化 UI 系统
UPL9-6——优化 UI 系统
使用更多 Canvas 组件
Canvas(画布)组件的基本工作原理
任何 UGUI 控件都必须挂在一个 Canvas 下,Unity 才会把它们作为 UI 元素渲染出来,Canvas 的本质作用:
- 收集子节点的 UI 元素(Graphic)信息
- 处理渲染顺序(Sorting、Camera、RenderMode)
- 管理重建,监听子物体的变动,决定哪些需要更新
- 批量生成 UI 顶点网格、更新材质信息
- 最终在渲染阶段合批并提交给 GPU
等等
Canvas 内部维护了一个重建队列,当子元素发生变化时,它会标记自己为 “脏”(dirty)
在本帧渲染前统一执行重建(布局重建 / 图形重建 / 材质重建)
- 布局重建:主要进行布局计算
- 图形重建:主要进行网格生成(比如合并 UI 元素网格信息、前提是材质相同)
- 材质重建:主要进行材质状态更新
所谓“画布污染”,就是有子元素打了脏标记,导致整个 Canvas 的顶点缓存需要刷新
如果 Canvas 很大,就会让大量 UI 元素一并重建,带来性能消耗
可能带来画布污染的情况:
- 修改 UI 元素的
RectTransform相关属性,比如位置、缩放、角度等 - 自动布局组件的更新,比如
LayoutGoup、ContentSizeFitter等自动布局组件在元素变化时会重新计算 - 频繁改变 UI 元素父子关系、排列顺序、失活激活等
- 文本发生变化时,自动调整大小时
- 更改 UI 元素纹理、材质等
- 修改 UI 元素颜色、透明度等
- 禁用启用遮罩
- Shader 参数修改,比如文本边缘线、阴影效果等
等等
说人话:
一个 Canvas 统一管理它下面的所有子 UI 元素,若更新其中一个子元素会让整个 Canvas 进行重建,造成性能消耗
更多的 Canvas(画布)组件
为了避免 Canvas 的重建带来的性能消耗
我们可以把会频繁变化的 UI 元素单独放在一个小 Canvas 当中
避免一个小元素的更新导致整个大 Canvas 重建
我们可以把UI元素分成三大类
- 静态:静态 UI 元素永远不会改变,比如背景图、说明文字、Logo、装饰性分割线等等
- 偶尔动态:只会偶尔为了做出响应时而改变,比如 UI 按钮按下,拖动条,拥有金币数 等等
- 持续动态:会持续甚至每帧发生变化的元素,比如战斗中血条流动动画、计时器、秒表文本、摇杆UI等等
通过这个分类,我们可以将 UI 元素进行 动静分离,将 UI 元素拆分到不同的画布中,高频变化区域独立 Canvas,从而达到节约性能的目的
举例说明:
大 Canvas:存在 500 个 UI(背景 + 血条 + 按钮…),血条变化 导致 整个 500 个 UI 重建
拆分后:
- 大
Canvas管理不变的内容(450 个 UI) - 小
Canvas管理变化的血条(50 个 UI)
血条变化时,只重建 50 个 UI,大 Canvas 部分缓存复用不会重建,从而性能更好
注意:
Canvas并不是越多越好,它也存在一些潜在的缺点
-
Canvas会打断合批处理,会增加 DrawCall-
Canvas本身需要进行排序、合批、DrawCall 提交等事情如果拆的太碎,反而会因为本身的开销带来性能影响
禁用 Raycast Target
Raycast Target 是什么
UGUI 中每个 能显示的图形内容(Image、Text、RawImage、TMP_Text 等等),都有一个布尔选项 Raycast Target(射线检测目标)

它决定了该 图形 是否作为 射线检测的目标,可以简单的把 Raycast Target 类比成 能不能被点中 的开关
勾选它,该对象可以被检测到选中;不勾选,该对象不会被检测
它的工作流程是:
-
GraphicRaycaster(射线检测组件)在处理输入事件(点击、拖拽、触摸)时,会从EventSystem发出一条射线 - 这条射线会检测 当前
Canvas 下所有RaycastTarget = true的 UI 元素 - 每个元素会计算射线是否落在它的矩形或多边形范围内
- 命中后,事件系统再决定触发哪个控件的(
Button、Toggle、Scrollbar) 交互逻辑
为什么要禁用 Raycast Target
勾选 Raycast Target 可能带来的性能问题
-
检测内容太多
默认所有
Image、Text等图形控件的 Raycast Target(射线检测目标)都是开启的
即使它只是一个装饰图,也要被遍历,遍历就会存在开销 -
每帧检测
射线检测每次输入(移动 / 点击 / 拖拽)都要做,可能需要每帧都执行,检测就会带来开销
-
计算成本
每个目标要进行矩形碰撞或更复杂的遮罩判断(
Mask、RectMask2D),子元素越多,计算成本越高
因此,如果一个不需要进行交互的 UI 对象,开启 Raycast Target 就会增加开销
通过禁用它可以有效的提高性能,降低开销,主要带来的好处是:
- 事件系统在遍历时直接跳过它,减少遍历开销
- 避免了无意义的射线计算,减少计算开销
使用建议:
-
一定要禁用的
背景图、装饰图标、分割线、阴影、UI 框架图、纯装饰性、说明性文字
-
必须保留的
Button、Toggle、Slider、ScrollRect可交互区域或控件
通过禁用 Canvas(画布)组件来隐藏 UI 元素
在 UGUI 中,有以下几种方式可以隐藏 UI 元素
-
通过
SetActive方法 设置 UI 元素依附对象的激活失活状态会触发
Canvas重建,恢复时也要重建,开销较大 -
通过失活 UI 元素的 图形组件
该元素不渲染,但仍会导致所在 Canvas 重建
-
通过
CanvasGroup组件的整体改变透明度为 0 -
通过组件颜色
Color属性改变透明度为 0通过
CanvasGroup 或Color属性设置为透明 仍然会生成网格、送入渲染流程;
GPU 依然在画,只是全透明 -
通过将
Canvas(画布)组件失活只禁用这个
Canvas 的渲染器
整个Canvas下 UI 元素 不参与渲染和事件检测
从以上方案来考虑
如果想要隐藏某些 UI 控件时,可以通过失活其所属 Canvas(画布)达到目的
并且好处是可以直接跳过渲染和事件检测,避免无意义的重建和渲染!
注意:通过禁用
Canvas(画布)的方式去隐藏 UI 元素的前提是 动静分离、多Canvas 合理分配
否则禁用一个Canvas可能会让很多不相关 UI 一起消失
避免在 UI 使用 Animator
如果在 UI 中使用 Animator 组件来播放一些 UI 动画
Animator 会在每帧根据动画状态机计算 Transform 或其他属性变化
比如:
- 位置、旋转、缩放
- 颜色、图片变换
等等
通过之前的学习我们知道,如果一个 Canvas(画布)下 UI 元素发生变换会产生画布污染
画布污染就意味着会造成 Canvas(画布)重建,也就说 Animator 动画组件会为 UGUI 带来更多的性能开销
并且 Animator 本质是状态机驱动,每帧都会运行状态逻辑,会有更多开销
如果 UI 界面上一定需要动态效果,可以采用以下方案
- 利用 DoTween 插件做缓动效果
- 自己实现动态效果,比如利用协程或
Lerp实现插值动画,控制更新频率和时机,避免每帧无意义更新 - 用 Shader 实现动态效果,把计算交给 GPU,比如进度条流光、动态血条更新、按钮高亮等
- 动静分离进行拆分,将动态效果分离到一个专门的
Canvas下,减少重建开销
为世界空间画布定义摄像机
什么是世界空间画布(Canvas)
所谓世界空间画布,就是将 Canvas 中的 Render Mode(渲染模式)参数,设置为 World Space(世界空间)的画布

一般在以下情况可能使用这种 UI 模式
-
VR / AR 项目中
- UI 元素希望在 3D 世界中和场景物体混合显示
- VR 中常见的 悬浮菜单、手腕面板
- 场景内的交互 UI,比如 箱子、门、机关上方的 交互提示
- 贴在墙上的按钮、开关 UI
等等
-
游戏项目中
玩家或 NPC 头顶的 血条 、 名字 、 Buff 图标
等等
总体而言
UI 必须是 3D 世界的一部分,而不是固定在屏幕上的 2D 界面时
就可能会用到 世界空间画布(Canvas)
为世界空间画布(Canvas)显示定义摄像机
当 Canvas 的模式是 World Space 时,如果不设置它的 Event Camera 参数,并且所有摄像机都设置的会渲染 UI 层的话
那么 UI 元素就可能被多个摄像机渲染,从而带来性能浪费
我们的项目中经常可能存在多个摄像机,比如 主相机、小地图相机、后处理相机、预渲染摄像机 等等
如果这些相机没有进行专门的设置,可能都会渲染UI层
那带来的问题就是:同一份 UI 可能会被多次渲染,从而浪费性能
它可能带来的性能开销有:
- CPU => 会重复提交 DrawCall 等
- GPU => 重复进行像素填充 等
因此,我们应该养成良好的习惯,为世界空间画布(Canvas)显示的定义关联摄像机,明确设置 Event Camera
并且 让其他不需要渲染 UI 的相机在 Culling Mask (剔除遮罩) 中排除 UI 层
这样就可以有效解决该问题,减少我们的性能浪费!
不要使用透明度组件隐藏组件
在 UGUI 中,有以下几种方式可以隐藏 UI 元素
通过
SetActive方法 设置 UI 元素依附对象的激活失活状态会触发
Canvas重建,恢复时也要重建,开销较大通过失活 UI 元素的 图形组件
该元素不渲染,但仍会导致所在 Canvas 重建
通过
CanvasGroup组件的整体改变透明度为 0通过组件颜色
Color属性改变透明度为 0通过
CanvasGroup 或Color属性设置为透明 仍然会生成网格、送入渲染流程;
GPU 依然在画,只是全透明通过将
Canvas(画布)组件失活只禁用这个
Canvas 的渲染器,整个Canvas下 UI 元素 不参与渲染和事件检测在上文通过禁用
Canvas(画布)组件来隐藏 UI 元素中,
就强调了通过禁用Canvas组件的方式隐藏组件是相对最节约性能的方式
这里再来强调一次不要使用透明度隐藏组件的相关内容
为什么不要使用透明度隐藏组件
主要原因
透明度隐藏依旧会触发渲染
-
生成网格
UI 元素依然会生成顶点数据
-
提交 DrawCall
CanvasRenderer还是会把看似隐藏的 UI 元素打包提交给 GPU -
GPU 渲染
GPU 照常进行光栅化、像素着色,只是最终混合结果是透明像素
也就是说,GPU 做了同样的计算,只是输出颜色的透明通道为 0,看不到而已 -
事件检测
如果
RaycastTarget = true,透明 UI 还会继续参与射线检测,CPU 也有性能浪费
所以如果通过透明度隐藏元素,看不见 ≠ 不渲染,它只是画了一个看不见的内容
原因:
- CPU消耗依旧
- GPU消耗依旧
- 事件检测消耗依旧
如何尽量避免使用透明度隐藏组件
如果是想要直接隐藏对象
-
最佳选择:
禁用
Canvas组件 -
备用选择:
- 失活 UI 图形组件(
Image、RawImage、Text等) - 失活依附的
GameObject对象
- 失活 UI 图形组件(
如果需要淡入淡出消失效果,可以在透明度为 0 淡出结束后,将其直接隐藏
优化 ScrollRect
ScrollRect 带来的性能开销
ScrollRect 是 UGUI 中一个 可滚动的 UI 容器,常用于列表、背包、排行榜 等功能
它的工作流程大概如下所述:
- 有一个 显示区域(
Viewport)和一个 实际内容节点(Content) -
Content下放了很多子元素(背包格子、排行榜玩家信息等等) - 滚动时,
ScrollRect 会移动Content 的位置,驱动子元素的RectTransform变化 - 因为
RectTransform 在变,Canvas 会认为子元素脏了(dirty),触发Canvas重建
因此,当 Content 下子元素很多时(几百几千个),哪怕屏幕只显示几十个,滚动时所有元素都会被算一次布局和渲染
因此,由于 ScrollRect 的本身工作机制,当子元素太多时,就会带来一些无意义的性能开销
优化 ScrollRect 核心思想
核心优化思想:只保留 在屏幕可见的元素,其他元素不参与渲染
它的核心优化思路就是
- 利用缓存池复用对象,只显示可见范围内的元素
- 使用自定义布局,不使用 Unity 自带的布局组件
除此之外,我们应该把 ScrollRect 相关内容单独放入一个独立的 Canvas 中,达到动静分离的目的
ScrollRect 遮罩优化
用 RectMask2D 替换 Mask 遮罩,当 ScrollRect 的裁剪区域只是矩形的情况下,完全可以用 RectMask2D 来替代 Mask 遮罩,
因为 RectMask2D 的性能开销相对 Mask 要低很多,当非矩形时再使用 Mask
使用 UIText 或 自定义非渲染UI脚本 进行全屏交互
常见的全屏交互方式
在进行项目开发时,我们经常会有全屏交互层
用于拦截点击,屏蔽底层 UI(后方内容不能点击,只能点击前方内容,一般称为 模态窗口)或 制作类似全屏按钮功能(点击屏幕触发流程)
我们常见的方式有:
-
透明
Image在
Canvas 下放一个Image,拉满全屏,颜色设为几乎透明,开启RaycastTarget -
空
Text 或TMP_Text用一个没有文字显示的文本控件,拉满全屏,开启
RaycastTarget -
Button全屏按钮
等等
这些方法的主要目的,都是用来将输入事件拦截,不再往下方传递
使用 UIText 进行全屏交互
在这些方法中,如果我们只是为了让后面层的 UI 不能被点击,那么我们推荐使用 空 Text 或 TMP_Text 的方式
因为它的性能表现相对较好,不需要进行额外渲染工作
注意:如果你的背景需要类似半透明效果,那么这种方式不太适用
自定义脚本进行全屏交互
如果担心使用 UIText 会导致用途混淆,可以自定义一个 UI 脚本,用于响应事件但不绘制任何网格
1 | using UnityEngine; |
更多方案
-
打图集(降低 DrawCall)
将 UI 图片元素打图集,从而保证元素使用材质相同,达到批处理作用
注意:布局时,不同图集之间元素分层显示,避免打断合批处理
-
少用自带布局组件
UGUI 中提供自动布局组件,比如:
-
ContentSizeFitter(内容适配器) -
HorizontalLayoutGroup /VerticalLayoutGroup(水平、垂直布局组) -
AspectRatioFitter(宽高比适配器)
等等
建议少用或者不用这些组件,布局相关逻辑自己根据需求实现,可以有效控制更新时机,布局方式
-
-
UI 元素关闭 Mipmap 功能
对于大部分情况下,只要不是 3D UI 元素,存在距离变化
可以关闭 Mipmap 功能 -
图集压缩
UI 图集可以选择合适的 Unity 自带压缩格式
比如移动端使用 ASTC 或 ETC2,可以有效降低内存并保持清晰度 -
合理使用九宫格拉伸
只有需要保持比例进行 9 宫格缩放的UI元素再启用该功能,避免不必要的开销
-
减少 OverDraw
尽量少让 UI 元素重叠显示,并且减少半透明效果的使用
但是 UI 界面基本上是个元素堆叠而成的,因此很难减少重叠我们可以使用以下方案避免:
-
合并背景层,把一些装饰性元素直接和背景图做在一起
-
避免大面积半透明遮罩,比如不使用半透明实现暗化效果
而是直接使用一张暗化图片代替,或是使用实时模糊的 Shader
等等
-
Unity 官方优化文档
- 优化 Unity 用户界面:https://learn.unity.com/tutorial/optimizing-unity-ui
- Unity UI 优化技巧:https://unity.com/cn/how-to/unity-ui-optimization-tips
UGUI 源码
下载地址:https://github.com/Unity-Technologies/uGUI
我们可以探究 UGUI 源码,可以给我们带来以下好处:
- 理解 UGUI 工作原理
- 基于原理制定优化策略
- 学习借鉴 UGUI 设计思想
- 提升调试能力
- 便于定制和拓展功能
- 提升就业竞争力
等等
