UnityActionUnityEvent

按我的理解的话,UnityAction 是委托,之前我也提到了,我觉得委托就是同类型方法指针的集合,而 UnityEvent 感觉是 UnityAction 的集合,但需要注意的是,这里的 UnityAction 也需要是同一类型的,所以自己想一下,感觉这样设置的好处可能只是能够将这么多同类型的方法再次进行一个分类,可以调用 AddListenerRemoveListener 来管理 UnityAction 。
使用事件的好处在于可以减少代码中的耦合,避免类和类之间相互调用,减少后期修改带来的维护成本,比如游戏中将网络请求、发送请求和处理请求的结果都放在同一个类(假设叫 A 类)中处理,在网络模块中有一个类似 AddListener 的接口,A 将自己的方法注册进去,那么在网络模块将网络消息处理结束之后,再执行这个类似 UnityEvent 的方法,那么未来 A 类发生了变化也不会影响到网络模块。
UnityAction 有 0-4 个参数的形式,都是无返回值的,而关于 UnityEvent,在
Scripting API上虽然也写了 4 种形式,但是可以看到都需要继承 UnityEvent 来实现。

1
2
3
4
[System.Serializable]
public class MyIntEvent : UnityEvent<int>
{
}

书上的完整例子是这样的,要特别注意的是 UnityAction 可添加泛型参数,但是 UnityEvent 如果要带参数的话,需要再写个继承类。

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
53
54
55
56
// 文件名:Script_05_09.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;

// 这里的继承的泛型需要与 UnityAction 的泛型类型相对应。
[System.Serializable]
public class MyEvent : UnityEvent<int, string> { }
public class Script_05_09 : MonoBehaviour
{
public UnityAction<int, string> Action1;
public UnityAction<int, string> Action2;
public MyEvent myEvent;
// Start is called before the first frame update
void Start()
{
// 注意如果使用 += 的话,没有对应的 -=,会有隐患
Action1 = MyFunction1;
Action2 = MyFunction2;
myEvent.AddListener(Action1);
myEvent.AddListener(Action2);
}

private void MyFunction1(int n,string str)
{
Debug.LogFormat("My Function1 被调用,n: {0},str: {1}", n, str);
}
private void MyFunction2(int n,string str)
{
Debug.LogFormat("My Function2 被调用,n: {0},str: {1}", n, str);
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
Action1(1, "miao");
// Action1.Invoke(1, "miao"); // 跟上面一样的
}
if (Input.GetKeyDown(KeyCode.B))
{
Action2(2, "wang");
// Action2.Invoke(2, "wang"); // 跟上面一样的
}
if (Input.GetKeyDown(KeyCode.C))
{
// myEvent(3, "wu~"); // UnityEvent 不能这么使用
myEvent.Invoke(3, "wu~");
}
}
private void OnGUI()
{
GUILayout.TextField("请尝试按下 A/B/C 键");
}
}

UI 事件

事件依赖于 Graphic Raycaster 组件,应该是挂在 Canvas 上的,代表这个 Canvas 下面的 UI 元素都接收点击事件,如果有部分元素是不需要接收点击事件的,那么可以将这部分放到一个单独的 Canvas 里面,然后把该 Canvas 上的 Graphic Raycaster 组件的 enable 设置为 false,或者直接删除 Graphic Raycaster 组件。
UGUI 有很多点击方法的时间,下面来看一下,接口对应的方法就不写了,这里好像都是一个接口里面只有一个方法,而且继承了接口的话,在代码中不实现也会报错。

  • IPointerEnterHandler 当指针进入对象时调用。
  • IPointerExitHandler 当指针退出对象时调用。
  • IPointerDownHandler 在对象上按下指针时调用。
  • IPointerUpHandler 松开指针时调用(在指针正在点击的游戏对象上调用)。
  • IPointerClickHandler 在同一对象上按下再松开指针时调用。
  • IInitializePotentialDragHandler在找到拖动目标时调用,可用于初始化值,不能获取到方向。
  • IBeginDragHandler 刚开始拖动的时候调用,并且可以获取到拖动的方向。
  • IDragHandler 滑动持续时调用。
  • IEndDragHandler 滑动结束时调用。
  • IDropHandler 落下时调用。
  • IScrollHandler 当鼠标滚轮滚动时调用。
  • IUpdateSelectedHandler 每次勾选时在选定对象上调用,只针对 Selectable 起作用。
  • ISelectHandler 当对象成为选定对象时调用,只针对 Selectable 起作用。
  • IDeselectHandler 取消选择的时候调用,因为只能选择一个 Selectable ,当选择新的之后,之前选择的都会回调取消选择事件。
  • IMoveHandler 选择后,可监听上下左右 WASD 方向键。如果访问 eventData.moveDir 还可以取到具体移动的方向。
  • ISubmitHandler 按下 Submit 按钮时调用。
  • ICancelHandler 按下 Cancel 按钮时调用。
    下面演示如何让 image 反馈点击事件:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 文件名:Script_05_06.cs
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.EventSystems;

    public class Script_05_06 : MonoBehaviour, IPointerClickHandler
    {
    public void OnPointerClick(PointerEventData eventData)
    {
    Debug.Log("image 被点击了");
    }
    }

效果是在点击了这个图片之后控制台会显示 image 被点击了

UI 事件管理

上面有很多接口,实现这些接口可以监听对应的事件,但这种方式要在每一个需要监听这个事件的物体上挂上这个脚本,也不符合 MVC(模型、视图和控制器) 的设计模式,所以最好是写一个类来统一管理 UI 事件,比如用 MyOnClick 方法来处理按钮、文本、图片元素的点击事件。这里需要用到 EventSystems.EventTrigger,这个类实现了上述的各种接口,并且都是 virtual 的,只需要继承了之后想要监听哪个事件就复写对应的方法即可,下面是 EventSystems.EventTrigger 的声明:

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
namespace UnityEngine.EventSystems
{
[AddComponentMenu ("Event/Event Trigger")]
public class EventTrigger : MonoBehaviour, IEventSystemHandler, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerUpHandler, IPointerClickHandler, IBeginDragHandler, IInitializePotentialDragHandler, IDragHandler, IEndDragHandler, IDropHandler, IScrollHandler, IUpdateSelectedHandler, ISelectHandler, IDeselectHandler, IMoveHandler, ISubmitHandler, ICancelHandler
{
//
// Fields
//
[Obsolete ("Please use triggers instead (UnityUpgradable) -> triggers", true)]
public List<EventTrigger.Entry> delegates;

//
// Properties
//
public List<EventTrigger.Entry> triggers {
get;
set;
}

//
// Constructors
//
protected EventTrigger ();

//
// Methods
//
private void Execute (EventTriggerType id, BaseEventData eventData);

public virtual void OnBeginDrag (PointerEventData eventData);

public virtual void OnCancel (BaseEventData eventData);

public virtual void OnDeselect (BaseEventData eventData);

public virtual void OnDrag (PointerEventData eventData);

public virtual void OnDrop (PointerEventData eventData);

public virtual void OnEndDrag (PointerEventData eventData);

public virtual void OnInitializePotentialDrag (PointerEventData eventData);

public virtual void OnMove (AxisEventData eventData);

public virtual void OnPointerClick (PointerEventData eventData);

public virtual void OnPointerDown (PointerEventData eventData);

public virtual void OnPointerEnter (PointerEventData eventData);

public virtual void OnPointerExit (PointerEventData eventData);

public virtual void OnPointerUp (PointerEventData eventData);

public virtual void OnScroll (PointerEventData eventData);

public virtual void OnSelect (BaseEventData eventData);

public virtual void OnSubmit (BaseEventData eventData);

public virtual void OnUpdateSelected (BaseEventData eventData);

//
// Nested Types
//
[Serializable]
public class Entry
{
public EventTriggerType eventID;

public EventTrigger.TriggerEvent callback;

public Entry ();
}

[Serializable]
public class TriggerEvent : UnityEvent<BaseEventData>
{
public TriggerEvent ();
}
}
}

下面是符合代码实现监听两个 button 、一个 image 和一个 text 的点击事件的代码。

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 文件名:Script_05_07.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class Script_05_07 : MonoBehaviour
{
public Button button1;
public Button button2;
public Image image;
public Text text;

// 最好写在 Awake 里面?
private void Start()
{
button1.onClick.AddListener(delegate() {
this.MyOnClick(button1.gameObject);
});

button2.onClick.AddListener(delegate ()
{
this.MyOnClick(button2.gameObject);
});

UIEventListener.Get(image.gameObject).OnClick = MyOnClick;
UIEventListener.Get(text.gameObject).OnClick = MyOnClick;
}

public void MyOnClick(GameObject go)
{
// 判断的时候一定要 .gameObject
if(go == button1.gameObject)
{
Debug.LogFormat("{0} 被按下了(button1)",go.name);
}
else if (go == button2.gameObject)
{
Debug.LogFormat("{0} 被按下了(button2)", go.name);
}
else if (go == image.gameObject)
{
Debug.LogFormat("{0} 被按下了(image)", go.name);
}
else if(go == text.gameObject)
{
Debug.LogFormat("{0} 被按下了(text)", go.name);
}
}
}

public class UIEventListener : EventTrigger
{
public UnityAction<GameObject> OnClick;
public override void OnPointerClick(PointerEventData eventData)
{
// 复写方法的时候都需要考虑一下要不要执行基类的方法。
base.OnPointerClick(eventData);

// 因为已经将这个脚本挂到了当前的物体身上,所以参数就是当前的物体
// 下面的使用方法为如果委托不为空那么就调用
OnClick?.Invoke(gameObject);
}

// 如果 go 上没有该脚本,那么给 go 添加上该脚本,如果有的话,直接返回
static public UIEventListener Get(GameObject go)
{
UIEventListener listener = go.GetComponent<UIEventListener>();
if(listener == null)
{
listener = go.AddComponent<UIEventListener>();
}
return listener;
}
}

代码中要注意理清传递方法和方法中使用的参数的过程,还有传参和比较的时候要注意转换成 GameObject 的类型。
关于方法的注册是应该写在 Start 里面还是 Awake 里面,个人的理解是,Awake 用来初始化自身的一些成员,Start 可以引用其他类的成员了,这样可以保证在引用其他类的成员时不会出现空指针,但是方法的注册,只能通过订阅者去注册,就是虽然 UnityAction 成员是在发布者的类里面的,但是自己身无法完成赋值,所以需要订阅者尽早的完成赋值,以免导致时间发生时方法还没有注册进去。
再说一下 MVC 的设计思路,一般是在控制层接收事件,再将事件传递给模块层,等模块层处理完毕再通知 UI 层刷新显示。

RaycastTarget 优化

UGUI 的点击事件是基于射线的,像是 image 或者 text 不需要相应点击事件的话,那么就需要把身上的 RayCastTarget 取消选中。因为 UI 事件会在 EventSyetem 的 Update() 方法中调用 Process 时触发。UGUI 会遍历屏幕中所有的 RayCastTarget 为 True 的 UI,然后发射线,排序找到玩家最先触发的 UI,再抛出事件给出逻辑层去响应,这样无形中会带来很多开销。
优化的方式是尽量将每个不需要相应点击事件的 UI 身上的 RayCastTarget 给取消勾选,但是有时 UI 太多的话并不能保证自己能注意到每一个 UI,所以可以重写 OnDrawGizmos 方法将所有 RayCastTarget 为 true 的 UI 的边框在 Scene 中画出来。

用到的相关 API 介绍

  • OnDrawGizmos
  • Gizmos 用于画各种图形,方块,圆,线之类的。在 UnityEngine 命名空间下。
  • MaskableGraphic(UGUI 手册) / MaskableGraphic(老版 Unity 官方 API 手册),MaskableGraphic 是一个抽象类,Image,Text,RayImage 都是继承于它,MaskableGraphic 又是 Graphic 的子类,一会将用到的 raycastTarget 成员就是继承于 Graphic。
    1
    2
    public abstract class MaskableGraphic : Graphic, 
    ICanvasElement, IClippable, IMaskable, IMaterialModifier

运行效果

如图所示,RayCastTarget 为 true 的组件在 Scene 面板中会有一个红色的框框,然后在 Gizmos 勾选或者取消勾选对应的脚本名称,即可显示或者不显示这个自定义功能。

代码

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
// 文件名:Script_05_10.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

// 找到场景中所有的 MaskableGraphic 的子类(比如 image,text,rayImage 等)
// 然后画出是否勾选了 Raycast Target
public class Script_05_10 : MonoBehaviour
{
// static 节约内存资源,应该还可以再优化节约计算资源。
private Vector3[] corners = new Vector3[4];

#if UNITY_EDITOR
private void OnDrawGizmos()
{
foreach(MaskableGraphic go in GameObject.FindObjectsOfType<MaskableGraphic>())
{
if (go.raycastTarget)
{
// RectTransform go_trans = go.transform as RectTransform;
RectTransform go_trans = go.rectTransform;
go_trans.GetWorldCorners(corners);
Gizmos.color = Color.red;
int length = corners.Length;
for(int i = 0; i < length; i++)
{
// 取模是为了首尾相连
Gizmos.DrawLine(corners[i], corners[(i + 1) % length]);
}
}
}
}
#endif
}

关于 corners 是否需要加 static ,个人理解是,全场景只需要一个实例化的脚本,因为一个实例就可以遍历场景所有的 UI,而 corners 只是一个数据计算的工具台而已,只需要一个就够了,所以可以加 static,但其实这个可以直接写成单例,不然新建一个实例就会在每一帧多调用一次 OnDrawGizmos,对计算力来说是不必要的开销。
然后关于 Unity 自带的一些回调函数,比如说 Awake、Start、Update,其实不是在 MonoBehaviours 及其父类中定义的,而是采用了一种反射(具体不知道)机制实现的,所以不需要加 override,也不能写错方法的名字。
这里其实不需要特地考虑 corners 的长度,因为肯定是 4 个,GetWorldCorners 方法也限制了必须是 4 个。
关于 GetWorldCorners 方法,定义如下:

1
public void GetWorldCorners(Vector3[] fourCornersArray);

但是从功能上来说的话,其实 fourCornersArray 的参数更像是出参,我也做了一个实验,可以看到如果在调用前数组没有分配内存的话,Fun2 是会报错的,因为数组此时为 null,但是使用 Fun1 的话就不会出现这个问题,而官方应该是按照 Fun2 来实现的,所以有些不明白其中的奥义所在。

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
// 解决方案名:csharpTest
// 项目名:inOutTest
using System;

namespace inOutTest
{
class Program
{
static void Fun1(out A[] a)
{
a = new A[4] { new A{ a=1,b = "m"},new A{ a = 2, b = "mm" },
new A{ a=3,b = "mmm"},new A{ a = 4, b = "mmmm" }};
}
static void Fun2(A[] ass)
{
ass[0] = new A { a = 1, b = "m" };
ass[1] = new A { a = 2, b = "mm" };
ass[2] = new A { a = 3, b = "mmm" };
ass[3] = new A { a = 4, b = "mmmm" };
}
static void Main(string[] args)
{
A[] ass /* 可以在这里断开来试试,Fun2 会报错 */ = new A[4];
// Fun1(out ass);
Fun2(ass);

foreach (A aa in ass)
{
Console.WriteLine(aa);
}
Console.ReadKey();
}
}
class A
{
public int a;
public string b;
public override string ToString()
{
return string.Format("a = {0},b = {1}", a, b);
}
}
}
```
# 渗透 UI 事件
## 面生 API 简介
- [EventSystem.RaycastAll](https://docs.unity3d.com/Packages/com.unity.ugui@1.0/api/UnityEngine.EventSystems.EventSystem.html?q=raycastall#UnityEngine_EventSystems_EventSystem_RaycastAll_UnityEngine_EventSystems_PointerEventData_System_Collections_Generic_List_UnityEngine_EventSystems_RaycastResult__)
``` cs
RaycastAll(PointerEventData, List<RaycastResult>)

与当前事件关联的 RaycastResult。

看到这个 ExecuteEvents.EventFunction 形式,我第一反应 EventFunction 应该是一个内部类或者结构体,但其实是一个委托声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public delegate void EventFunction<T1>(T1 handler, BaseEventData eventData);
```
类似 `pointerClickHandler` 的 static 对象都很多,可以直接通过参数传递,只需要再凑齐实现了 IPointerClickHandler 接口的对象,和 BaseEventData 就可以了,这里一般是 [PointerEventData](https://docs.unity3d.com/Packages/com.unity.ugui@1.0/api/UnityEngine.EventSystems.PointerEventData.html)。最后三个参数一起调用 `ExecuteEvents.Execute()`
## 效果展示
3 个 UI 元素元素在 Scene 面板的摆放如图所示,(两个 Button 重叠在一起了):
![](/img/xys_5.2_3.png)
![](/img/xys_5.2_4.png)

在两个 Button 上注册点击事件,代码如下:
``` cs
// 文件名:Script_05_11_2.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Script_05_11_2:MonoBehaviour
{
private void Awake()
{
Button button = gameObject.GetComponent<Button>();
button.onClick.AddListener(() => { Debug.LogFormat("{0}的 OnPointerClick 方法被调用", gameObject.name); });
}
}

如果不加其他处理的话,那么当鼠标点击在 Image 和 Button 有重合的地方时,是不会有任何反应的,如果此时让 Image 实现 IPointerClickHandler 接口,再点击,会触发 Image 上点击事件的回调函数,但是不会触发两个 Button 的。所以如果想要让事件渗透到后面的 UI,参考代码如下:

// 文件名:Script_05_11.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;

// 实现渗透 UI 事件
public class Script_05_11 : MonoBehaviour, IPointerClickHandler// , IPointerDownHandler, IPointerUpHandler
{
    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.LogFormat("{0} 的 OnPointerClick 方法被调用", gameObject.name);

        // PassEvent<ISubmitHandler>(eventData, ExecuteEvents.submitHandler);
        PassEvent<IPointerClickHandler>(eventData, ExecuteEvents.pointerClickHandler);
    }

    // 感觉没啥必要,干扰试验,一个 Click 就够了
    //public void OnPointerDown(PointerEventData eventData)
    //{
    //    Debug.LogFormat("{0} 的 OnPointerDown 方法被调用", gameObject.name);
    //    PassEvent<IPointerDownHandler>(eventData, ExecuteEvents.pointerDownHandler);
    //}

    //public void OnPointerUp(PointerEventData eventData)
    //{
    //    Debug.LogFormat("{0} 的 OnPointerUp 方法被调用", gameObject.name);
    //    PassEvent<IPointerUpHandler>(eventData, ExecuteEvents.pointerUpHandler);
    //}

    private void PassEvent<T>(PointerEventData data,ExecuteEvents.EventFunction<T> function)where T:IEventSystemHandler
    {
        List<RaycastResult> results = new List<RaycastResult>();
        EventSystem.current.RaycastAll(data, results);

        // 可以看一下 results 的顺序,也因为发现了 Button 的话是 Text 在前面。
        //foreach(RaycastResult re in results)
        //{
        //    Debug.Log(re.gameObject.name);
        //}

        GameObject currentGo = data.pointerCurrentRaycast.gameObject;
        foreach(RaycastResult result in results)
        {
            if(result.gameObject != currentGo)
            {
                ExecuteEvents.Execute(result.gameObject, data, function);
                // 如果只想穿透一层就 break
                // break;
            }

        }
    }
}

将上面的代码挂到 Image 上,再点击 Image 和 Button 有重合的地方,就会出现三个点击事件的回调函数的输出。
这里要注意一下 EventSystem.current.RaycastAll 的返回值,如果不确定的话,最好打印出来看一下,因为当我将 Break 注释给取消掉之后,理所当然的认为应该会输出 Image 和 Button2 的点击事件回调函数的输出,但事实上只有 Image,因为在 Image 下面是 Text,而不是 Button2 里面的 Image。
书中是有注释中 PassEvent<ISubmitHandler>(eventData, ExecuteEvents.submitHandler); 这一句的,但是实验之后发现这样会导致每点击一次,每个 Button 上的点击事件回调函数都会执行两次。暂时不太清楚 ISubmitHandlerIPointerClickHandler 的区别和联系。

例子——新手引导聚合动画