C# Event/UnityEvent辨析

前言

Event作为C#语言特性的一部分,在.Net开发中具有比较重要的作用。当我们使用C#作为脚本语言编写Unity游戏时,也经常会需要使用Event解决各种问题。然而,相比于传统C#语言的Event,UnityEvent基于Unity引擎做了一些改变,并且更加适合我们的游戏开发。为了帮助读者深入理解UnityEvent,本文会从Delegate讲起,并逐步介绍C# Event 与UnityEvent的相似与不同。
本文参考了Unity论坛以及其他前辈的文章和视频,想要进一步了解的可以自行查阅:

一切的渊源——Delegate

要理解Event是什么,首先必须得知道它们的前身——Delegate是啥,中文翻译即“委托”。用一句话让你理解Delegate的作用就是“Delegate是一个可以存放函数的容器”。众所周知,变量是程序在内存中为数据开辟的一块空间,面向对象语言中变量可以存放一个具体的数值,或者某个对象的引用。C#则在该基础上更进一步,使用Delegate的机制让存放“函数(Function)”成为可能。
使用Delegate一般分为三步:

  1. 定义一种委托类型
  2. 声明一个该类型的委托函数
  3. 通过声明的委托调用函数执行相关的操作

下面是在Unity中使用Delegate的一个实例:

using UnityEngine;

public class DelegateExample : MonoBehaviour {
    //Step1. 为Delegate定义一种函数原型
    public delegate void MyDelegate(int args);
    //Step2. 声明一个Delegate变量
    public MyDelegate myDelegate;

    private void Start()
    {
        //Step3. 引用Delegate变量实现相应的函数
        myDelegate = PrintNum;
        myDelegate(5);

        myDelegate = PrintDoubleNum;
        myDelegate(5);
    }

    public void PrintNum(int num)
    {
        print("Print number: " + num);
    }

    public void PrintDoubleNum(int num)
    {
        print("Print double number: " + num*2);
    }
}

运行上述代码,你会发现使用一个delegate变量让我们执行了两种函数实现。这就是Delegate的妙处所在, Delegate定义了一个用于存放相同函数原型(相同参数类型,相同返回值)的容器。因为他们的函数原型相等,所以向delegate传递一次参数,就可以让所有添加到delegate上的函数正常执行
在上述例子中,我们第二次向myDelegate赋值时覆盖掉了第一次赋值的函数,所以第二次引用myDelegate只会调用PrintDoubleNum(int num)函数。实际上,delegate作为函数容器,并不仅仅只能容纳一个函数,而是可以同时被委任多个函数。例如,当你把上述代码中的第二次赋值改为 myDelegate += PrintDoubleNum; 就可以实现同时打印两条语句的效果。这种delegate一般被称为multicast delegate

基于delegate实现的Event(C# Event)

如果你基本理解了delegate是什么,那么理解event基本不需要花费什么时间,因为event就是在multicast delegate的基础上演变来的。关于Event,一个比较形象的比喻就是广播者和订阅者。把Event想象成一个视频作者,并且他还具有一大堆热情的粉丝,每天都在等待新视频的发布。为了在第一时间收看到新发布的视频,粉丝们大多会选择订阅视频作者(大多数视频网站的套路),这样作者更新时你就会收到一条即时消息。在程序的世界里,event可能不再是一个做视频的,毕竟做视频赚不到什么钱,但是他依然为喜爱他的观众(具有相同函数类型的函数)提供了订阅他的途径(即把自身加入到event的函数容器中),这样无论他有什么动向,都可以直接通知所有他知道的粉丝(调用event会立即引用所有函数容器中的函数)。当然,一千个观众心中就有一千个哈姆雷特,就如同真爱粉无论如何都会支持自己的偶像,而黑粉无时无刻不在带节奏一样,event只负责告诉每个函数什么时候被调用,这些函数到底干了什么,event并不关心。

下面以代码的形式演示上述过程:

Idol.cs

using UnityEngine;

public class Idol : MonoBehaviour {
    public delegate void IdolBehaviour(string behaviour);
    public static event IdolBehaviour IdolDoSomethingHandler;

    private void Start()
    {
        //Idol 决定搞事了, 如果他还有粉丝的话, 就必须全部都通知到
        if (IdolDoSomethingHandler != null)
        {
            IdolDoSomethingHandler("Idol give up writing.");
        }
    }
}

SubscriberA.cs

using UnityEngine;

public class SubscriberA : MonoBehaviour {
    /// <summary>
    /// OnEnable在该脚本被启用时调用,你可以把它看做路转粉的开端
    /// </summary>
    private void OnEnable()
    {
        //粉丝通过订阅偶像来获取偶像的咨询, 并在得到讯息后执行相应的动作
        Idol.IdolDoSomethingHandler += LikeIdol;
    }

    /// <summary>
    /// OnEnable在该脚本被禁用时调用,你可以把它看做粉转路的开端
    /// </summary>
    private void OnDisable()
    {
        Idol.IdolDoSomethingHandler -= LikeIdol;
    }

    /// <summary>
    /// 粉丝A是一个脑残粉
    /// </summary>
    /// <param name="idolAction"></param>
    public void LikeIdol(string idolAction)
    {
        print(idolAction + " I will support you forever!");
    }
}

SubscriberB.cs

using UnityEngine;

public class SubscriberB : MonoBehaviour {
    /// <summary>
    /// OnEnable在该脚本被启用时调用,你可以把它看做路转粉的开端
    /// </summary>
    private void OnEnable()
    {
        //粉丝通过订阅偶像来获取偶像的咨询, 并在得到讯息后执行相应的动作
        Idol.IdolDoSomethingHandler += HateIdol;
    }

    /// <summary>
    /// OnEnable在该脚本被禁用时调用,你可以把它看做粉转路的开端
    /// </summary>
    private void OnDisable()
    {
        Idol.IdolDoSomethingHandler -= HateIdol;
    }

    /// <summary>
    /// 粉丝B是一个无脑黑
    /// </summary>
    /// <param name="idolAction"></param>
    public void HateIdol(string idolAction)
    {
        print(idolAction + " I will hate you forever!");
    }
}

把他们分别绑定在一个GameObject上,并运行游戏,你就可以在Console上看到两种粉丝对爱豆发表的见解。这里有几点使用时的注意事项希望引起你的重视:

  1. 想让爱豆直接通知你TA的近况,你就必须在他发出消息前完成订阅。在本例中,虽然两个粉丝和爱豆位于不同的GameObject上,但是他们都提前订阅了Idol,所以Idol才能正确通知到他们(OnEnable()在执行顺序上优先于Start(),关于OnEnable/Start/Awake的辨析,欢迎阅读我的博客: Awake/Start/OnEnable 辨析
  2. 在本例中两个粉丝均采用了调用静态字段IdolDoSomethingHandler的方法实现订阅,实际上你也可以为每个粉丝添加一个public Idol idol;然后在Editor上直接绑定。(这点是Unity特有的)
  3. 偶像并不关心他的粉丝对自己的行为作出何种反映。甚至在他发出消息时,除了确认一下自己还没有过气(IdolDoSomethingHandler != null)之外,对粉丝的行为不会有任何了解。(在降低耦合性loose decoupling 的同时,隐藏了事件函数的实现细节)
  4. 你并不需要担心偶像受不受得了同时给那么多粉丝发邮件,因为一般有经纪人代办(误)。细心的人可能会发现我们在声明event delegate时并没有给它分配内存,使用时直接赋值或添加即可。

UnityEvent

经过上一节的解释,你应该对Event是什么,怎么用有了一个大概的体会,那么这一节我们就接着讲一讲Unity在Event的基础上进行的改良,即UnityEvent。Event设计之初并不会想到应用于Unity游戏开发,所以它的弊端就在于纯代码编程,没有通过使用Unity Editor提高工作效率。而UnityEvent就可以看做是发挥Editor作用的正确改良。还记得上一节中粉丝是怎么订阅的嘛?你必须在每个粉丝对象中访问Idol的IdolDoSomethingHandler,然后把自己将采取的行动添加上去。这样有两个坏处——其一就是你必须时刻提防订阅的时机,假如不小心在Idol发动态之后才订阅,那你就永远收不到那条动态了。其二就是不方便管理,想要查看订阅偶像的所有粉丝,我们就得查找项目中所有IdolDoSomethingHandler的引用,然后再把每个粉丝的文件打开,可以说是非常麻烦了。
为了避免上述的缺点,UnityEvent使用Serializable让用户可以在Editor中直接绑定所有粉丝的调用,即一目了然又不用担心把握不准订阅的时机。

话不多说,直接上代码:

Idol.cs

using UnityEngine;
using UnityEngine.Events;

//使用Serializable序列化IdolEvent, 否则无法在Editor中显示
[System.Serializable]
public class IdolEvent : UnityEvent<string> {

}

public class Idol : MonoBehaviour {
    //public delegate void IdolBehaviour(string behaviour);
    //public event IdolBehaviour IdolDoSomethingHandler;
    public IdolEvent idolEvent;

    private void Start()
    {
        //Idol 决定搞事了, 如果他还有粉丝的话, 就必须全部都通知到
        if (idolEvent == null)
        {
            idolEvent = new IdolEvent();
        }
        idolEvent.Invoke("Idol give up writing.");
    }
}

SubscriberA.cs

using UnityEngine;

public class SubscriberA : MonoBehaviour {
    /// <summary>
    /// 粉丝A是一个脑残粉
    /// </summary>
    /// <param name="idolAction"></param>
    public void LikeIdol(string idolAction)
    {
        print(idolAction + " I will support you forever!");
    }
}

SubscriberB.cs

using UnityEngine;

public class SubscriberB : MonoBehaviour {
    /// <summary>
    /// 粉丝B是一个无脑黑
    /// </summary>
    /// <param name="idolAction"></param>
    public void HateIdol(string idolAction)
    {
        print(idolAction + " I will hate you forever!");
    }
}

把上面三个脚本绑定到三个GameObject上,但是不要着急立刻运行游戏,因为我们还没有让两个粉丝实现订阅。和使用Event时不同,UnityEvent在序列化后可以在Editor上显示,并且可以让我们在Editor阶段就设置好需要执行的函数。选中Idol所在的GameObject,然后就可以在Inspector中设置IdolEvent可以引用的函数。设置完成后应该如图所示。

设置IdolEvent

此时再运行游戏,你会得到和使用基于delegate的Event时相同的效果。

除此之外,UnityEvent依然提供和C# Event 类似的运行时绑定的功能,不过不同的是,UnityEvent是一个对象,向其绑定函数是通过AddListener()方法实现的。以SubscriberB为例,我们可以在代码中实现同等效果的绑定:

SubscriberB.cs

using UnityEngine;

public class SubscriberB : MonoBehaviour {
    public Idol myIdol;

    /// <summary>
    /// OnEnable在该脚本被启用时调用,你可以把它看做路转粉的开端
    /// </summary>
    private void OnEnable()
    {
        //粉丝通过订阅偶像来获取偶像的咨询, 并在得到讯息后执行相应的动作
        myIdol.idolEvent.AddListener(HateIdol);
    }

    /// <summary>
    /// OnEnable在该脚本被禁用时调用,你可以把它看做粉转路的开端
    /// </summary>
    private void OnDisable()
    {
        myIdol.idolEvent.RemoveListener(HateIdol);
    }

    /// <summary>
    /// 粉丝B是一个无脑黑
    /// </summary>
    /// <param name="idolAction"></param>
    public void HateIdol(string idolAction)
    {
        print(idolAction + " I will hate you forever!");
    }
}

由于UnityEvent是一个对象,所以自然可以允许我们通过继承实现自己的Event,实际上Unity中包括Button在内的许多UI组件的点击事件都是通过继承自UnityEvent来复写的。
可访问性(public/private)决定了UnityEvent的默认值,当可访问性为public时,默认会为其分配空间(new UnityEvent());当可访问性为private时,默认UnityEvent为null,需要在Start()中为其分配内存。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页