UI优化

本文共2857字。
Copyright: 署名-非商业性使用-相同方式共享 | CC BY-NC-SA 2.5 CN

前言

笔者截止到写本篇博客为止,有两年在游戏公司开发Unity客户端的经历。工作内容偶尔会涉及到UI相关的业务逻辑,由于UI是游戏必不可少的部分,所以也会经常接触到UI的优化,以下简述一些UI优化的常用方法。

内容

01 | raycast 勾选过多

通过UGUI的源码我们可以知道,UI事件会在EventSystem中Update的Process触发。UGUI会遍历屏幕中的RaycastTarget是true的UI,接着就会发射线,并且排序找到玩家最先触发的那个UI,再抛出事件给逻辑层去响应。

团队多人在开发游戏界面,很多时候都是复制黏贴,比如上一个图片是需要响应RaycastTarget,然后ctrl+d以后复制出来的也就带了这个属性,很可能新复制出来的图片是不需要响应的,开发人员又没有取消勾选掉,这就出问题了。

所以RaycastTarget如果被勾选的过多的话, 效率必然会低。所以我们应该想一个办法去解决它。

把下面代码挂在游戏中的任意GameObject上,原理其实很简单就是绘制辅助线,当UI中RaycastTarget发生变化,SceneView中的蓝色辅助线也会刷新,还是挺方便的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#if UNITY_EDITOR
using UnityEngine;
using System.Collections;
using UnityEngine.UI;
public class DebugUILine : MonoBehaviour {
static Vector3[] fourCorners = new Vector3[4];
public bool showLine = false;
void OnDrawGizmos()
{
foreach (MaskableGraphic g in GameObject.FindObjectsOfType<MaskableGraphic>())
{
if (g.raycastTarget && showLine)
{
RectTransform rectTransform = g.transform as RectTransform;
rectTransform.GetWorldCorners(fourCorners);
Gizmos.color = Color.blue;
for (int i = 0; i < 4; i++)
Gizmos.DrawLine(fourCorners[i], fourCorners[(i + 1) % 4]);

}
}
}
}
#endif

如下图所示,加上上面的脚本后,可以在scene试图中直接看到,蓝色框表示的就是勾选过RaycastTarget的UI。有了辅助框后,如下图所示,就可以很方便的把不需要响应的RaycastTarget去掉即可。

avatar

02 | 避免频繁调用GameObject.SetActive

如果你隐藏一个较大的UI界面,下面有很多子节点,也会有很大的开销.解决办法可以对频繁切换激活状态的UI采用平移出屏幕、修改Layer等方式来替换。另外也不要对Canvas做频繁的Enable操作,重新激活Canvas以及它的子Canvas,会执行重新构建(Rebuild) 以及重新批处理(Rebatch)操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 设置是否优化active
function Control:set_optimized_active(on)
-- 如果开启,set_active(false)的行为不会真的SetActive(false)
-- 而是把当前对象移到一个很远的地方,让UI相机不要去渲染这个窗口
self.optimized_active = on
end

function Control:set_active(active)
if self.optimized_active then
if not active then
self.ori_pos = self.game_object.transform.localPosition
self.game_object.transform.localPosition = Vector3(-3000, 0, 0)
else
if self.ori_pos then
self.game_object.transform.localPosition = self.ori_pos
end
end
return true
elseif isValidObject(self.game_object) then
self.game_object:SetActive(active)
return true
end
return false
end

03 | UI的布局

UI的布局层级不要太深入,要动静分离,可活动的元素放在一个Canvas下,不可活动的元素放在另一个Canvas下。虽然两个Canvas打断了合批,但是却减少了网格的重建时间,总体上是有优化的,所以一般建议每个较复杂的UI界面,都自成一个Canvas(可以是子Canvas),在UI界面很复杂时,甚至要划分更多的子Canvas。但是,Canvas又不能细分的太多,因为会导致Draw Call的上升。

04 | 不要把image button作为点击

很多人在创建button时用很多不可见的Image作为交互响应的控件,这些image虽然被alpha被设置为0不可见,但是DrawCall依然存在
解决办法实现一个只在逻辑上响应Raycast但是不参与绘制的组件即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using UnityEngine;
using System.Collections;
namespace UnityEngine.UI
{
public class Empty4Raycast : MaskableGraphic
{
protected Empty4Raycast()
{
useLegacyMeshGeneration = false;
}
protected override void OnPopulateMesh(VertexHelper toFill)
{
toFill.Clear();
}
}
}

05 | 关于图集

可以采用以下的几种方向来缩小图集的大小,降低加载解析的时间。

  1. 合理的按照模块分开打包图集
  2. 压缩单个图片大小或者质量
  3. 更紧密的打包图集方式
  4. 重用素材

06 | 镜像复制

avatar

以上是皇室战争的一个场景,如果你对UI的素材敏感,你可能一眼就知道最下面的红色丝带将是你最不想见到的素材。尺寸大、纹理变化大(渐变)、曲线设计。这就表示了,这个红色丝带图不能使用压缩尺寸、压缩颜色丰富度和使用九宫缩放来减小图片大小。

可是你再认真看看,你会发觉,它是左右对称的。对的,在游戏开发的时候,经常会遇到这种对称的UI素材。一般这种情况,我们只需要用其中一部分(1/2,1/4),然后使用镜像复制等,我们就可以把整个UI拼出来了。

当然,这样做唯一的缺点就是三角面和顶点数的增加,不过大多数的情况下,增加不了多少,所以对性能影响并不大。

首先,我们拿到我们的素材。然后进行1/2、1/4的裁切。

avatar

接下来就开始考虑用什么方法做镜像复制了。

  • 两个Image组件组合
  • 重写/修改Image或者MaskableGraphic
  • 使用BaseMeshEffect扩展

第一种方案最容易,不用修改任何代码,只需要使用两个Image,然后利用RectTransform进行位置和缩放就可以达到效果,然而这种做法每次修改时都需要繁琐的修改参数。

第二种方案需要改动大量的代码,而且需要对Image的逻辑要有一定的理解。

第三种方案,UI里面的Outline和Shadow组件就是使用BaseMeshEffect进行扩展的,BaseMeshEffect是对Graphic的顶点进行修改,而且可以实现动态添加和拆除。是UGUI原框架提供给我们扩展功能的方法。

综合上面的优缺点,最后选择使用第三种的方案进行扩展是比较方便的。
首先是创建一个Mirror类,继承BaseMeshEffect,然后创建镜像类型的枚举值和相应的字段和变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
[AddComponentMenu("UI/Effects/Mirror", 20)]
[RequireComponent(typeof(Graphic))]
public class Mirror : BaseMeshEffect
{
public enum MirrorType
{
/// <summary>
/// 水平
/// </summary>
Horizontal,

/// <summary>
/// 垂直
/// </summary>
Vertical,

/// <summary>
/// 四分之一
/// 相当于水平,然后再垂直
/// </summary>
Quarter,
}

/// <summary>
/// 镜像类型
/// </summary>
[SerializeField]
private MirrorType m_MirrorType = MirrorType.Horizontal;

public MirrorType mirrorType
{
get { return m_MirrorType; }
set
{
if (m_MirrorType != value)
{
m_MirrorType = value;
if(graphic != null){
graphic.SetVerticesDirty();
}
}
}
}

[NonSerialized]
private RectTransform m_RectTransform;

public RectTransform rectTransform
{
get { return m_RectTransform ?? (m_RectTransform = GetComponent<RectTransform>()); }
}
}

接下来是创建一个SetNativeSize方法,这个方法跟Image.SetNativeSize()是一样的,都是根据原始的Sprite尺寸变更Image尺寸,只是这里会有一点不同,就是素材是裁切了一半的,我们要相应的把尺寸翻倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/// <summary>
/// 设置原始尺寸
/// </summary>
public void SetNativeSize()
{
if (graphic != null && graphic is Image)
{
Sprite overrideSprite = (graphic as Image).overrideSprite;

if(overrideSprite != null){
float w = overrideSprite.rect.width / (graphic as Image).pixelsPerUnit;
float h = overrideSprite.rect.height / (graphic as Image).pixelsPerUnit;
rectTransform.anchorMax = rectTransform.anchorMin;

switch (m_MirrorType)
{
case MirrorType.Horizontal:
rectTransform.sizeDelta = new Vector2(w * 2, h);
break;
case MirrorType.Vertical:
rectTransform.sizeDelta = new Vector2(w, h * 2);
break;
case MirrorType.Quarter:
rectTransform.sizeDelta = new Vector2(w * 2, h * 2);
break;
}

graphic.SetVerticesDirty();
}
}
}

接下来是BaseMeshEffect的最重要的方法了————ModifyMesh()。这个方法会获得Image里面的顶点数据VertexHelper。 然后我们就可以对这个VertexHelper进行加工了。 首先使用VertexHelper.GetUIVertexStream()方法获取所有的顶点数据,然后调用相应的镜像处理函数,最后使用AddUIVertexTriangleStream()方法把顶点数据写入。 然后我们就开始对Simple类型的图像进行镜像操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public override void ModifyMesh(VertexHelper vh)
{
if (!IsActive())
{
return;
}

var output = ListPool<UIVertex>.Get();
vh.GetUIVertexStream(output);

int count = output.Count;

if (graphic is Image)
{
Image.Type type = (graphic as Image).type;

switch (type)
{
case Image.Type.Simple:
DrawSimple(output, count);
break;
case Image.Type.Sliced:

break;
case Image.Type.Tiled:

break;
case Image.Type.Filled:

break;
}
}
else
{
DrawSimple(output, count);
}

vh.Clear();
vh.AddUIVertexTriangleStream(output);

ListPool<UIVertex>.Recycle(output);
}

这里引用了一个数组对象池的类:ListPool。由于可能会大量使用到List,所以使用对象池,有效减少内存分配 。你可以替换成自己的对象池,也可以使用本实例里面的ListPool。
本实例的ListPool与UGUI源码的ListPool逻辑是一样的,只是修改了部分命名。
然后是镜像的主要逻辑DrawSimple。 我们先来看看下面的图解,了解镜像的过程。

avatar

  • 使用SetNativeSize()后,得到拉伸的图片。(步骤1)

  • 接着调用graphic.GetPixelAdjustedRect()方法,获取当前Graphic矩形绘制范围,这个很重要,后面用来做顶点的偏移。

  • 然后会调用SimpleScale()方法,将原始顶点进行缩放。(步骤2)

  • 然后根据相应的镜像类型,进行不同的镜像操作。

  • 镜像前先调用ExtendCapacity()对List进行扩容,例如水平镜像和垂直镜像是原来顶点数的两倍,四分之一镜像的顶点数是原来的四倍。

  • 最后就是调用MirrorVerts(),这是对顶点进行复制和水平(垂直)翻转的函数。(步骤3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// <summary>
/// 绘制简单版
/// </summary>
/// <param name="output"></param>
/// <param name="count"></param>
protected void DrawSimple(List<UIVertex> output, int count)
{
Rect rect = graphic.GetPixelAdjustedRect();

SimpleScale(rect, output, count);

switch (m_MirrorType)
{
case MirrorType.Horizontal:
ExtendCapacity(output, count);
MirrorVerts(rect, output, count, true);
break;
case MirrorType.Vertical:
ExtendCapacity(output, count);
MirrorVerts(rect, output, count, false);
break;
case MirrorType.Quarter:
ExtendCapacity(output, count * 3);
MirrorVerts(rect, output, count, true);
MirrorVerts(rect, output, count * 2, false);
break;
}
}

List扩容只是判断一下容器大小,然后赋值就行了。这是减少多次List.Add()可能出现的性能消耗。

1
2
3
4
5
6
7
8
protected void ExtendCapacity(List<UIVertex> verts, int addCount)
{
var neededCapacity = verts.Count + addCount;
if (verts.Capacity < neededCapacity)
{
verts.Capacity = neededCapacity;
}
}

然后是原始顶点的缩放了,看了上面的步骤图,步骤2是把所有顶点往左边挤,其实就是顶点横坐标相对于绘制区最左边宽度减半。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected void SimpleScale(Rect rect, List<UIVertex> verts, int count)
{
for (int i = 0; i < count; i++)
{
UIVertex vertex = verts[i];

Vector3 position = vertex.position;

if (m_MirrorType == MirrorType.Horizontal || m_MirrorType == MirrorType.Quarter)
{
position.x = (position.x + rect.x) * 0.5f;
}

if (m_MirrorType == MirrorType.Vertical || m_MirrorType == MirrorType.Quarter)
{
position.y = (position.y + rect.y) * 0.5f;
}

vertex.position = position;

verts[i] = vertex;
}
}

减半的核心算法就是这两条

$position.x = (position.x + rect.x) * 0.5f;$

$position.y = (position.y + rect.y) * 0.5f;$

然后把顶点复制一份,以rect.center为对称轴,进行翻转,就可以把右边的部分绘制出来了。 根据参数isHorizontal判断是水平还是垂直翻转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void MirrorVerts(Rect rect, List<UIVertex> verts, int count, bool isHorizontal = true)
{
for (int i = 0; i < count; i++)
{
UIVertex vertex = verts[i];

Vector3 position = vertex.position;

if (isHorizontal)
{
position.x = rect.center.x * 2 - position.x;
}
else
{
position.y = rect.center.y * 2 - position.y;
}

vertex.position = position;

verts.Add(vertex);
}
}

水平(垂直)翻转的核心算法就是这两条

$position.x = rect.center.x * 2 - postion.x;$

$position.y = rect.center.y * 2 - postion.y;$

根据上面的代码,垂直和1/4镜像也是没问题的。 1/4镜像的做法就是先垂直(水平)镜像复制,然后再水平(垂直)复制完成。

avatar

好了,Simple的镜像类就完成了。