文章目录
- 代码使用 UnityEvent
- Inspector 面板使用 UnityEvent
- UnityEvent 绑定有参方法
- Dynamic & Static 回调方法列表(常见的问题)
- 用代码给 UnityEvent 添加持久化监听器
- UnityEvent 使用方式总结
前置知识:
C#委托
C#事件
简要概括:使用 UnityEvent 可以在编辑器的 Inspector 面板中为事件绑定事件触发函数。 下文将会着重介绍一些细节。
之前在介绍委托的时候有提到 UntiyAction,它是 Unity 对 C# Action 委托的一个封装。而本文将要介绍的 UnityEvent,则是对 C# 事件的一个封装。因为事件是委托的包装器,所以 UnityEvent 也是 UnityAction 委托的一个包装器,但要注意的是 C# 的事件是运用 event 关键字为委托字段提供包装器, UnityEvent 则是一个类。
和 UnityAction 一样,在使用之前要先引入 UnityEngine.Events 命名空间:
using UnityEngine.Events;
代码使用 UnityEvent
之前使用 C# 事件时,我们是用 “+=” 和 “-=” 添加和移除事件处理器。
触发事件是用和调用委托的方式是一样的,不过只能在事件所属的类的内部触发事件。
//简略声明
public event Action myEvent;
//添加、移除事件处理器
myEvent+=Function; //Function 是无参无返回值的方法
myEvent-=Function;
//触发事件
myEvent();
myEvent.Invoke();
UnityEvent 对事件的操作提供了不一样的 API
- 用 AddListener 和 RemoveListener 分别为事件添加和移除方法。这里只能传入 UnityAction 类型的委托。虽然我们平时基本都是传入一个方法来代表委托,影响也不大,但我们心里要清楚这个方法实际上是和 UnityAction 匹配的。
- UnityEvent 的调用只能用 Invoke
- 这个 UnityEvent 包装的是不带泛型的 UnityAction ,也就是它只能封装无参无返回值的方法。不过和 UnityAction 一样, UnityEvent 也有泛型版本,能够匹配多个参数的方法,这个在后文会介绍。
还是用之前介绍 C# 事件的那个顾客&商人的例子。我要用 UnityEvent 模拟 “顾客按下空格键呼叫商人,商人收到消息” 的场景。(只是简单地用输出语句表示,重点是展示 UnityEvent 的语法)
Customer.cs
using UnityEngine;
using UnityEngine.Events;
public class Customer : MonoBehaviour
{
private static Customer instance;
public static Customer Instance
{
get => instance;
}
//声明 UnityEvent 事件
public UnityEvent unityEvent;
void Awake()
{
instance = this;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Call();
}
}
public void Call()
{
unityEvent?.Invoke();
}
}
Merchant.cs
public class Merchant : MonoBehaviour
{
void Start()
{
Customer.Instance.unityEvent.AddListener(ReceiveCall);
}
private void OnDestroy()
{
Customer.Instance.unityEvent.RemoveListener(ReceiveCall);
}
public void ReceiveCall()
{
print("I am coming");
}
}
(为了调用方便,我把 Customer 类设为单例,实际应用中不一定要这么做)
我把添加监听的逻辑写在 Start 中是防止多个物体 Awake 执行顺序不同带来的问题,如果 Merchant 的 Awake 比 Customer 先执行,那么 Customer 类的对象还未实例化,会报 NullReferenceException!
运行结果:
这么看 UnityEvent 和 C# event 的作用是差不多的,只是操作的 API 有所不同。那为什么 Unity 还要再搞个 UnityEvent 呢?
我们先查看 UnityEvent 的 API 注释(来自 UnityEvent 类):
这里有几个关键词: non persistent listener (非持久化监听器),runtime and persistent(运行时和持久化的)
我们知道想要触发一个事件首先要为事件添加事件处理器,也就是添加监听器。而根据注释,用代码给 UnityEvent 添加监听器时只能添加 non persistent listener,可是 Invoke 方法却可以调用 runtime 和 persistent 的回调(先预告一下这个 runtime 和 non persistent 说的是同一个东西)。也就是说,触发事件时又能触发 persistent 方法回调。这个方法必定是以 persistent listener(持久化监听器) 的形式添加给事件的。那在 UnityEvent 类中没有添加持久化监听器的方法,持久化监听器是怎么添加给 UnityEvent 的呢?其实这就是 UnityEvent 的独特之处。它确实是可以添加持久化监听器的,只不过可以不用代码添加。这里先来介绍一下持久化与非持久化。
Inspector 面板使用 UnityEvent
这是 UnityEvent 最最重要的部分!!
先来了解下非持久化和持久化监听器。
参考资料(英文):
https://answers.unity.com/questions/1674606/unityevent-how-can-i-add-a-persistent-listener.html
https://forum.unity.com/threads/unityevent-persistent-vs-runtime-listeners.330861/
英文难懂?不要紧,我这里进行一个总结:
首先刚刚用 AddListener 和 RemoveListener 操作的就是非持久化监听器。
- 它只能在程序运行时被添加、移除。所以它也叫 runtime listener。
- 非持久化监听器无法在 Inspector 面板中显现,因此它是不可序列化的。
- 非持久化监听器是强引用,也就是物体被销毁后,非持久化监听器不会被移除。因为监听器方法和委托绑定的缘故导致监听器所属的对象仍然存在于内存中,无法被释放。除非我们在物体销毁时移除监听器。
持久化监听器则不一样。
- 它可以在 Unity 编辑器的 Inspector 面板中显现,它是可序列化的。
- 只能通过面板来添加和移除事件处理器。
- 持久化监听器是弱引用,物体销毁后监听器在内存中会被释放。
还是用刚刚演示的那个程序,我们会发现如果把 UnityEvent 类型的变量设置为 public,我们在把脚本挂给游戏物体之后会发现 Inspector 面板中多了这个:
面板中的 Unity Event () 对应了我在脚本中给 UnityEvent 类型的变量取的名字:unityEvent。编辑器会让首字母大写,并且变量名里每一个大写字母处又加个空格来分割。
这个是不是在 UGUI 的 Button 组件中也会看到类似的东西?
我们可以在面板中通过加号和减号为按钮按下事件添加和移除对应的触发函数。其实它就是运用了 UnityEvent。
在编辑阶段,当我们在 Inspector 面板上操作数据的时候, Unity 就会把这些数据序列化为文件,当游戏在运行的时候,Unity会反序列化这些文件来赋值给运行的对象。 UnityEvent 类的对象可以在 Inspector 面板中看到,这也说明它是可序列化的。
而且 UnityEvent 在编辑阶段被显示到 Inspector 面板上时,会自动被实例化。注意刚刚写的 Customer 脚本中,我只是持有 unityEvent 这一引用,并没有在其余代码处 new 它的对象。
public UnityEvent unityEvent;
可是程序运行起来居然没有报空引用异常。
我们尝试在 Customer 脚本的 Awake 方法里加段测试,来验证这一结论:
void Awake()
{
instance = this;
if (unityEvent != null) print(unityEvent);
}
输出了:
说明 unityEvent 变量被自动实例化, Unity 帮助我们 new 了一个对象出来,而不是默认初始化为 null。
大家可以再试一下,如果把 unityEvent 的访问修饰符改为 private,或者加上 static,这时候面板中就不再出现 UnityEvent,并且我们运行程序后(一些原有的代码要做些小修改)会发现马上报了空引用异常。这个时候,unityEvent 无法被序列化,系统并不会自动帮我们实例化。
这种情况下我们只有手动地去 new 一个对象才不会报错。
所以当你在 Inspector 面板的脚本组件能看到你在脚本内定义的 UnityEvent ,说明编辑器已经为这个 UnityEvent 准备好了一个持久化监听器。我们其实也可以不通过代码,改用在面板上操作为事件添加和移除事件处理器,只要提供对应的 GameObject ,匹配的方法,有时还要有传给方法的参数。
那么具体怎么使用呢?我们参考 UnityEvent 面板操作的官方指南(英文版):
简单说明一下,首先我们在脚本中还是要先声明一个 public 的 UnityEvent类型变量,保证它能被序列化。
然后可以点击面板中的“+”号,你会看到面板发生了变化。
然后可以将 Merchant 游戏物体拖到红色方框处,接下来就能在紫色方框处能选择事件触发时执行的操作。这些操作来自于之前拖拽赋值的游戏物体身上的所有组件和游戏物体本身,所以你能看到此时 GameObject,Transform组件,还有 Merchant 脚本是可选的。我们选择 Merchant 脚本的 ReceiveCall 方法,原本我们是使用 AddListener 代码来添加,现在改用面板添加。
注:要想让需要绑定的方法显示在选项中,必须把访问修饰符声明为 public。因为这个相当于通过拖拽先获取一个对象,然后去访问这个对象的公共方法。
值得一提的是此时 UnityEngine.Object 和它一些子类中的方法和属性能够直接调用。我们的 Merchant 脚本中只有 ReceiveCall() 这个方法是我们自己写的,其他的操作例如 CanelInvoke 全是物体自带的一些方法或属性,并且这些方法当中有的并没有遵循 UnityEvent 的约束(有些竟然可以传参,而 UnityEvent 本身规定只能绑定无参无返回值的方法)。这是 UnityEvent 的一个例外,可能也是它针对 Unity 游戏开发封装的一些特殊功能,便于开发者使用。
UnityEvent 的持久化监听器会为我们提供一些默认的函数和属性,它们可能没有遵循 UnityEvent 原本的约束。(实际上可能是 Unity 内部做了一些转换,让它们能够遵循 UnityEvent 的约束,这个会在“UnityEvent 绑定有参方法”的部分进行讨论)
那么如果我选用了面板添加监听器,我就可以把代码中添加监听器的部分删掉,保留触发事件的代码部分(Invoke) 就能实现同样的功能。
UnityEvent 绑定有参方法
之前说 UnityEvent 只能绑定无参无返回值方法。但有时候我的事件触发函数需要传参怎么办?和 UnityAction 类似, UnityEvent 提供了泛型来应对有参数的方法,不过它也只有4个参数的泛型重载。
需要注意的是,UnityEvent 的泛型类是抽象类。因为抽象类无法被实例化,所以我们只能自定义一个类去继承抽象类。
将之前演示的程序添加一个交易功能,按下 J 键触发:
public class TradeEvent : UnityEvent<float> { }
public class Customer : MonoBehaviour
{
private static Customer instance;
public static Customer Instance
{
get => instance;
}
public UnityEvent unityEvent;
public TradeEvent tradeEvent;
void Awake()
{
instance = this;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Call();
}
if (Input.GetKeyDown(KeyCode.J))
{
Trade(10.2f);
}
}
public void Call()
{
unityEvent?.Invoke();
}
public void Trade(float price)
{
tradeEvent?.Invoke(price);
}
}
先用代码实现添加监听器:
public class Merchant : MonoBehaviour
{
void Start()
{
Customer.Instance.unityEvent.AddListener(ReceiveCall);
Customer.Instance.tradeEvent.AddListener(ReceiveMoney);
}
private void OnDestroy()
{
Customer.Instance.unityEvent.RemoveListener(ReceiveCall);
Customer.Instance.tradeEvent.RemoveListener(ReceiveMoney);
}
public void ReceiveCall()
{
print("I am coming");
}
public void ReceiveMoney(float price)
{
print($"收到{price}元");
}
}
这个时候运行程序会报错:
此时我们注意 Inspector面板的 Customer 脚本组件,发现只有之前的那个 UnityEvent 被显示在面板上,我们自定义的 UnityEvent 的子类并没有显示。
这个是因为自定义的类无法被序列化,导致我们的 tradeEvent 还没被实例化,报出空引用异常,除非加上 [System.Serializable] (非常重要)
[System.Serializable]
public class TradeEvent : UnityEvent<float> { }
现在自定义类就能被序列化了。
运行程序:
接下来改为用面板赋值:
Customer.cs 完整脚本:
using UnityEngine;
using UnityEngine.Events;
[System.Serializable]
public class TradeEvent : UnityEvent<float> { }
public class Customer : MonoBehaviour
{
private static Customer instance;
public static Customer Instance
{
get => instance;
}
public UnityEvent unityEvent;
public TradeEvent tradeEvent;
void Awake()
{
instance = this;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Call();
}
if (Input.GetKeyDown(KeyCode.J))
{
Trade(10.2f);
}
}
public void Call()
{
unityEvent?.Invoke();
}
public void Trade(float price)
{
tradeEvent?.Invoke(price);
}
}
然后我们在面板中为 TradeEvent 添加监听器:
Dynamic & Static 回调方法列表(常见的问题)
如果仔细看这时的选项,会发现和之前的不带泛型的 UnityEvent 略有不同。
可选的方法分为了两类:一类是 Dynamic ,一类是 Static
Unity 官方文档中有介绍:
dynamic 相当于有参方法的传参要在代码中实现,比如 Customer 脚本中的 Trade(10.2f),触发事件时我们是在代码中将 10.2 这一参数值作为事件参数,传进了 Invoke,进而传给 Merchant 中需要调用的方法。
static 相当于为有参的方法提前在编辑器中设置好了参数值,然后触发事件时就会传递预先设置好的参数。
这时收到的不是原先在代码中设置的 10.2 元,而是我们在编辑器中设置的 100.5 元。
如果你再仔细看看刚刚可选的方法,你会发现一个神奇的事:
事件继承了 UnityEvent < float >,却能调用无参无返回值方法,这不是和事件的约束相违背吗?
这里可能是 Unity 内部进行了一些转化。我参考了这些文章(英文):Why can I add function taking 0 argument To UnityEvent taking 1 argument In the Editor?
Dynamic vs Static UnityEvents?
注意:只有在 Static Parameter 列表下的方法才会做这些转化!!!可以看到 Dynamic 列表下的方法还是要严格遵循 UnityEvent 的约束的。
虽然此时我只能绑定有一个 float 参数,无返回值类型的方法,但是我其实可以稍加修改去调用无参无返回值类型的方法。
void Start()
{
Customer.Instance.tradeEvent.AddListener(FunctionChange);
}
//新增一个中转方法
public void FunctionChange(float args)
{
ReceiveCall();
}
public void ReceiveCall()
{
print("I am coming");
}
用 lamda 表达式简化后就是:
void Start()
{
Customer.Instance.tradeEvent.AddListener((args)=>ReceiveCall());
}
public void ReceiveCall()
{
print("I am coming");
}
这么看确实是绑定了一个有1个 float 类型参数,无返回值的方法,在这个方法里又调用了无参无返回值方法,只是这个参数始终没有用到。这可能就是 Unity 内部做的事。
如果你回看之前定义的不带泛型的 UnityEvent 面板,你会发现它的 static 列表居然也可以选择 Merchant 类的 ReceiveMoney(float price) 方法,这可能是 Unity 内部做了如下转换:
()=>ReceiveMoney(args)
这个 args 参数是我们在编辑器面板中预先设置好的。
注意:只有含有一个参数的方法,并且参数类型为 int, float, string, bool, UnityEngine.Object 中的其中一个时才能在 Static Parameter 列表显现!!!
比如我在 Customer 脚本中再新增一个自定义 UnityEvent:
[System.Serializable]
public class TradeEvent2 : UnityEvent<float, float> { }
在 Merchant 脚本中再增加几个方法:
public void Test(float s, float s1) { print(s + " " + s1); }
public void Test2(Object obj) { }
public void Test3(double d) { }
public void Test4(string s) { }
回看 Inspector 面板:
Test2 和 Test4 出现在了 Static Parameters列表中,说明它们事符合条件的。这个 UnityEngine.Object 实际上也可以是它的子类,如 Transform,GameObject 之类的。
Test 方法有2个参数,Test3 方法参数是 double,所以不符合条件,无法在 Static Parameter 列表中显现。但是 Test 与定义的 TradeEvent2 是相匹配的,所以它会出现在 Dynamic 列表。
大家也可以自己多试几种参数类型。
用代码给 UnityEvent 添加持久化监听器
之前说 UnityEvent 持久化监听器可以在 Inspector 面板中操作。但实际上,也可以用代码在程序运行时为 UnityEvent 添加或移除持久化监听器(运行时再到 Inspector 面板中显示事件的方法绑定情况)。不过这种做法用的比较少。
(真要这么用的话,可能是不喜欢预先拖拽赋值选用代码进行事件注册,但是又想让它显示在编辑器面板上方便查看和调试,毕竟用 AddListener 添加的方法不会出现在 Inspector 面板中,让人无法一目了然地看出事件和绑定函数的关系)
这里我们就要使用 UnityEventTools 类的API:
使用前要先引入 UnityEditor.Events 命名空间
using UnityEditor.Events;
官方文档链接:https://docs.unity3d.com/2020.3/Documentation/ScriptReference/Events.UnityEventTools.html
官方文档写的也比较简略,可能真的是不常用吧。?
那这里就简单演示一下 AddPersistentListener 的使用。
将刚刚的程序稍作修改,主要改动 Merchant 脚本,因为 AddPersistentListener 有很多重载形式,这里选用下面这种:
public static void AddPersistentListener(UnityEvent unityEvent, UnityAction call);
Merchant.cs:
using UnityEditor.Events;
using UnityEngine;
public class Merchant : MonoBehaviour
{
void Start()
{
UnityEventTools.AddPersistentListener(Customer.Instance.unityEvent, ReceiveCall);
}
private void OnDestroy()
{
UnityEventTools.RemovePersistentListener(Customer.Instance.unityEvent, ReceiveCall);
}
public void ReceiveCall()
{
print("I am coming");
}
}
在程序还没运行之前,面板是这样的:
运行之后就会变成这样:
所以程序在运行时自动添加了持久化监听器。
UnityEvent 使用方式总结
声明事件
先 using UnityEngine.Events;
声明 UnityEvent 的时候如果声明为 public,则可以在 Inspector 面板中显示,系统也会自动帮我们实例化。如果想声明带泛型的 UnityEvent,必须自定义一个类用于继承,并且在类上方加上[System.Serializable]就可以将自定义继承自泛型 UnityEvent 的类显示到面板上并且自动实例化。
触发事件
触发 UnityEvent 事件都要用代码调用 Invoke 方法。
事件绑定函数的添加和移除
对于监听器的添加和删除,可分为用代码操作和用面板操作,监听器又分为持久化监听器和非持久化监听器。
代码操作非持久化监听器:
- 用 AddListener,RemoveListener 代码操作 UnityEvent 的添加、移除只能发生在程序运行期间,操作的是非持久化监听器。
- 添加、移除时只能选择和 UntiyEvent 约束相匹配的方法。
- 添加后如果未在物体销毁时被移除,事件绑定函数所属的对象会一直存在于内存中,无法被释放。
- 无法用 RemoveListener 移除持久化监听器绑定的方法(面板上设置好的那些方法)。
面板操作:
- 用 Inspector 面板操作 UnityEvent 的添加、移除只能发生在编辑器模式下,操作的是持久化监听器。它是可序列化的,相当于在程序运行之前预先设置好这个事件能绑定什么样的方法,然后程序运行时将我们设置好的信息反序列化给游戏物体,让它获取事件绑定的方法。并且只有当脚本里的类继承自 MonoBehaviour,它的方法才能在面板中和 UnityEvent 绑定。 因为用面板添加监听器首先要把一个游戏物体拖拽过来获取物体的所有组件,只有继承自 MonoBehaviour 的脚本才能成为游戏物体的组件。
- UnityEvent 的持久化监听器为我们提供了一些默认的函数和属性。
- 面板中可选的事件分为 static 和 dynamic。如果涉及方法的传参,dynamic 方法的传参只能在代码中实现。static 相当于为有参的方法提前在编辑器中设置好了参数值,然后触发事件时就会传递预先设置好的参数。
- 只有含有一个参数的方法,并且参数类型为 int, float, string, bool, UnityEngine.Object 中的其中一个时才能在 Static Parameter 列表显现。
- 只有在 Static Parameter 列表下的方法才会通过内部的一些转化使得方法能与定义的 UnityEvent 匹配 【通过 (args)=>method() 或 ()=>method(args) 】 ;Dynamic 列表下的方法(一般是我们在脚本中自行添加的)还是要严格遵循 UnityEvent 的约束。
代码操作持久化监听器:
先 using UnityEditor.Events,然后调用 UnityEventTools 类的方法。
UnityEvent 将事件与 Unity 编辑器更好地结合在一起,有时候的确会更省时省力,方便在编辑器中查看、调试。但是这种拖拽赋值的方式也会有缺点:可能会使对象之间的耦合性增高。特别是项目量大的时候,拖来拖去的引用关系可能会让人眼花缭乱。文章来源:https://uudwc.com/A/BvjG3
委托与事件系列:
C#委托(结合 Unity)
C#事件(结合 Unity)
观察者模式(结合C# Unity)
Unity 事件管理中心
事件番外篇:UnityEvent
Unity 事件番外篇:事件管理中心(另一种版本)文章来源地址https://uudwc.com/A/BvjG3