Unity中利用委托与监听解耦合的思路
这篇随笔是一篇记录性的随笔,记录了从http://www.sikiedu.com/my/course/304,这门课程中学到的内容,附带了一些自己的思考。
一.单例模式的应用
首先假想一种情况,现在需要有一个按钮和一个Text,当按下按钮时,Text上显示“你好”两个字。
一个最常见的方法是在按钮下挂载一个脚本BtnClick,它持有一个Text组件,它由外部的Text拖入来赋值。
在初始化时BtnClick会获取当前游戏物体下的Button组件并为其添加监听,当按下按钮时会修改Text组件中的文本内容。
具体的效果图和代码如下:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization public Text myText; void Awake () { GetComponent<Button>().onClick.AddListener(()=> { myText.text = "你好"; }); } }
BtnClick中为Button组件添加的监听的方法是用lambda表达式写的,不懂的自行查阅资料。
这种方式有两个问题,一是耦合度过高,假如Text组件不小心被删除,点击按钮会因为找不到Text组件而报错。 二是只能作一对一的操作,无法实现复杂的交互,举个例子,假如现在有3个如上图所示的按钮,同时只有1个Text,现在要实现一个“累加”的功能,任意一个按钮被按下时,计数会加1,当累计计数到3时,让按钮显示“你好”两个字。
假如修改代码如下:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization public Text myText; private int number; void Awake () { GetComponent<Button>().onClick.AddListener(()=> { number++; if (number >= 3) { myText.text = "你好"; } }); } }
此时对一个按钮按3下可以让按钮显示“你好”两个字,那如果我们复制3个相同的按钮,将Text组件拖放到3个按钮的BtnClick脚本中,是否可以满足要求?很显然,不行,因为定义的number属于类本身,3个脚本各自有属于自己的number,所以3个按钮的点击会分别叠加,没有累加效果。
那怎么办?难道要定义个全局变量来累加吗?那太蠢了,而且会很混乱。
我们先来解决问题二:
一个最常见的解决方法是为要操作的组件添加交互脚本,并在脚本中使用单例模式:
我们可以在Text组件下挂载一个脚本ShowText,脚本有一个计数器number,并提供一个Show方法,每次调用Show方法会增加计数,当计数满足条件时会获取Text组件并修改上面的内容。同时在按钮下挂载另一个脚本BtnClick,它在初始化时会获取Button组件并为其添加监听,当按下按钮时会调用ShowText脚本中的Show方法,这样可以起到累加的作用。
具体的效果图和代码如下:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class ShowText : MonoBehaviour { public static ShowText Instance; private int number; private void Awake() { Instance = this; } public void Show(string str) { number++; if (number >= 3) { GetComponent<Text>().text = str; } } }
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization void Awake () { GetComponent<Button>().onClick.AddListener(()=> { ShowText.Instance.Show("你好"); }); } }
你可能会有疑惑,为什么要用单例模式?
要知道,我们这里之所以能实现累加,是因为3个Button的BtnClick都调用了同一个ShowText脚本中的Show方法,从而使计数能被累加。单例模式是为了保证3个BtnClick获取到的都是同一个ShowText实例对象。假如我们不用单例模式,BtnClick中点击事件的回调方法要想调用ShowText中的Show方法,就只能实例化一个对象,先不说Unity下继承自MonoBehaviour的类无法通过new来实例化,就算它可以,我们就只能这样做:
ShowText myShowText = new ShowText(); myShowText.Show();
显然,这种情况下3个按钮下BtnClick获取到的是3个不同的新创建的ShowText脚本,里面的number属性自然也无法一起累加,只能各自累加了。
虽然使用单例模式为我们解决了问题二,但仍没有解决问题一,若Text意外被删除,BtnClick会因为获取不到ShowText的单例对象而报错。
此外,即使不考虑问题一,我们考虑一种情况:假若现在不是3个按钮共同控制1个Text,而是1个按钮控制3个Text,当按下按钮时,需要让3个Text同时显示出"你好"。若用单例模式处理,在BtnClick的添加的监听方法中,需要获取每一个Text的单例对象,假若这一个按钮不只有这些Text要控制,还要控制许多其他的UI控件,那会使代码变得十分臃肿。
如何解决这两个问题?
其实这两个问题主要的症结在于当按钮被按下时,我们在按钮下挂载的脚本中直接去访问了Text组件,大大增加耦合度的同时使代码变得十分臃肿。那为了使按钮按下时不去直接访问Text组件,我们必须设计一层中间的过渡脚本,按钮按下时会到过渡脚本中访问Text组件中的方法,而且在过渡脚本中也不能直接访问Text组件下的脚本,否则一旦Text被删,一样会导致报错,但这是不可能的,过渡脚本若不访问Text组件下的脚本,怎么调用脚本中的方法呢?
我们可以退一步思考,若在Text被删的时候自动让过渡脚本知道,Text下挂载的脚本中的方法已经不存在,从而不再访问,而在Text被创建时也能让过渡脚本知道,此方法已经可以被调用了,是不是可以解决这两个问题呢?显然是的,这么做的话耦合度被大大降低,BtnClick只负责访问过渡脚本,过渡脚本中自己可以识别Text下挂载的脚本的方法是否存在,不存在就不调用,即使Text被意外删除,调用BtnClick也不会报错。
用什么机制来实现呢?用委托与监听来解决,使用委托是为了解决BtnClick关联到许多Text时需要获取很多单例带来的代码臃肿问题,委托可以看作一种函数指针,C#中可以把某些方法作为参数进行传递,参数的类型就是委托,而委托有一种很关键的特性,就是可以相加,一个委托+另一个委托,相当于同时关联了两个方法,调用这个委托时相当于同时对这两个方法进行调用。(当然,委托必须是同类型的才能叠加,比如方法的参数个数,类型要相等),我们可以利用委托的这种特性完成一个BtnClick对许多ShowText脚本下方法的同时调用。
委托的简要介绍可以参考:http://www.runoob.com/csharp/csharp-delegate.html
二.利用委托与监听解耦合
现在来描述具体实现思路:
过渡脚本中维护一个字典,字典中存放事件码(作为键)和对应的委托(作为值),事件码是一个枚举类型,由一个文件单独定义,每个事件码对应一种监听事件,比如按钮的点击,当有新的交互事件要定义时,可以手动添加新的事件码定义。字典中的值是委托类型,但委托对应的方法可以有参数,可以没参数,参数不同的委托无法相加,因此需要一个文件专门声明不同类型的委托,在过渡脚本中需要暴露3个方法,1个用来添加委托,这时就要作委托类型匹配的判断。另外一个用来移除委托,同样要类型验证,还要有一个广播方法,用来供按钮点击后调用,按钮点击时传递事件码,广播方法在字典中找到相应的委托并调用。
那什么时候进行委托的添加和移除呢?肯定是在Text初始化和销毁时,继承自MonoBehavior的类由Unity负责初始化和销毁,Unity初始化它时会调用Awake方法,销毁时会调用Destroy方法,我们在Awake时把供外部调用的方法添加到过渡脚本中的字典中,Destroy时从字典移除此委托,这就相当于让Unity来帮我们维护它们,这样,万一发生意外情况,Text被销毁了,Unity一定会调用Destroy,相应的委托就会从字典中消失,这样即使广播方法在字典中找不到对应的委托,也不至于报错,因为它并没有直接去获取并使用Text上的组件
这样,我们就需要3个文件,1个用来定义事件码,1个用来定义不同参数的委托,1个用来维护管理事件码委托的字典,并提供外部调用。
先来最简单的,事件码的文件的代码,文件名:EventType.cs,代码如下:
public enum EventType { ShowText }
当前只添加了一个事件码,需要时可手动添加
随后是定义不同参数委托的文件,文件名:CallBack.cs,代码如下:
public delegate void CallBack(); public delegate void CallBack<T>(T arg); public delegate void CallBack<T, X>(T arg1, X arg2); public delegate void CallBack<T, X, Y>(T arg1, X arg2, Y arg3); public delegate void CallBack<T, X, Y, Z>(T arg1, X arg2, Y arg3, Z arg4); public delegate void CallBack<T, X, Y, Z, W>(T arg1, X arg2, Y arg3, Z arg4, W arg5);
这里重载了6个同名的委托,使用泛型使委托可以适应更多的情况,这些委托覆盖了参数个数从0到5的所有情况,相应地,在添加委托到字典时,就要设计多个重载的AddListener和RemoveListener函数,以便在添加和删除委托时使用相应的方法。
下面是过渡脚本,文件名:EventCenter.cs,代码如下:
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class EventCenter { private static Dictionary<EventType, Delegate> m_EventTable = new Dictionary<EventType, Delegate>(); private static void OnListenerAdding(EventType eventType, Delegate callBack) { if (!m_EventTable.ContainsKey(eventType)) { m_EventTable.Add(eventType, null); } Delegate d = m_EventTable[eventType]; if (d != null && d.GetType() != callBack.GetType()) { throw new Exception(string.Format("尝试为事件{0}添加不同类型的委托,当前事件所对应的委托是{1},要添加的委托类型为{2}", eventType, d.GetType(), callBack.GetType())); } } private static void OnListenerRemoving(EventType eventType, Delegate callBack) { if (m_EventTable.ContainsKey(eventType)) { Delegate d = m_EventTable[eventType]; if (d == null) { throw new Exception(string.Format("移除监听错误:事件{0}没有对应的委托", eventType)); } else if (d.GetType() != callBack.GetType()) { throw new Exception(string.Format("移除监听错误:尝试为事件{0}移除不同类型的委托,当前委托类型为{1},要移除的委托类型为{2}", eventType, d.GetType(), callBack.GetType())); } } else { throw new Exception(string.Format("移除监听错误:没有事件码{0}", eventType)); } } private static void OnListenerRemoved(EventType eventType) { if (m_EventTable[eventType] == null) { m_EventTable.Remove(eventType); } } //no parameters public static void AddListener(EventType eventType, CallBack callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack)m_EventTable[eventType] + callBack; } //Single parameters public static void AddListener<T>(EventType eventType, CallBack<T> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T>)m_EventTable[eventType] + callBack; } //two parameters public static void AddListener<T, X>(EventType eventType, CallBack<T, X> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X>)m_EventTable[eventType] + callBack; } //three parameters public static void AddListener<T, X, Y>(EventType eventType, CallBack<T, X, Y> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y>)m_EventTable[eventType] + callBack; } //four parameters public static void AddListener<T, X, Y, Z>(EventType eventType, CallBack<T, X, Y, Z> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z>)m_EventTable[eventType] + callBack; } //five parameters public static void AddListener<T, X, Y, Z, W>(EventType eventType, CallBack<T, X, Y, Z, W> callBack) { OnListenerAdding(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z, W>)m_EventTable[eventType] + callBack; } //no parameters public static void RemoveListener(EventType eventType, CallBack callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //single parameters public static void RemoveListener<T>(EventType eventType, CallBack<T> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //two parameters public static void RemoveListener<T, X>(EventType eventType, CallBack<T, X> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //three parameters public static void RemoveListener<T, X, Y>(EventType eventType, CallBack<T, X, Y> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //four parameters public static void RemoveListener<T, X, Y, Z>(EventType eventType, CallBack<T, X, Y, Z> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //five parameters public static void RemoveListener<T, X, Y, Z, W>(EventType eventType, CallBack<T, X, Y, Z, W> callBack) { OnListenerRemoving(eventType, callBack); m_EventTable[eventType] = (CallBack<T, X, Y, Z, W>)m_EventTable[eventType] - callBack; OnListenerRemoved(eventType); } //no parameters public static void Broadcast(EventType eventType) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack callBack = d as CallBack; if (callBack != null) { callBack(); } else { throw new Exception(string.Format("广播事件错误:事件{0}对应委托具有不同的类型", eventType)); } } } //single parameters public static void Broadcast<T>(EventType eventType, T arg) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T> callBack = d as CallBack<T>; if (callBack != null) { callBack(arg); } else { throw new Exception(string.Format("广播事件错误:事件{0}对应委托具有不同的类型", eventType)); } } } //two parameters public static void Broadcast<T, X>(EventType eventType, T arg1, X arg2) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X> callBack = d as CallBack<T, X>; if (callBack != null) { callBack(arg1, arg2); } else { throw new Exception(string.Format("广播事件错误:事件{0}对应委托具有不同的类型", eventType)); } } } //three parameters public static void Broadcast<T, X, Y>(EventType eventType, T arg1, X arg2, Y arg3) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X, Y> callBack = d as CallBack<T, X, Y>; if (callBack != null) { callBack(arg1, arg2, arg3); } else { throw new Exception(string.Format("广播事件错误:事件{0}对应委托具有不同的类型", eventType)); } } } //four parameters public static void Broadcast<T, X, Y, Z>(EventType eventType, T arg1, X arg2, Y arg3, Z arg4) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X, Y, Z> callBack = d as CallBack<T, X, Y, Z>; if (callBack != null) { callBack(arg1, arg2, arg3, arg4); } else { throw new Exception(string.Format("广播事件错误:事件{0}对应委托具有不同的类型", eventType)); } } } //five parameters public static void Broadcast<T, X, Y, Z, W>(EventType eventType, T arg1, X arg2, Y arg3, Z arg4, W arg5) { Delegate d; if (m_EventTable.TryGetValue(eventType, out d)) { CallBack<T, X, Y, Z, W> callBack = d as CallBack<T, X, Y, Z, W>; if (callBack != null) { callBack(arg1, arg2, arg3, arg4, arg5); } else { throw new Exception(string.Format("广播事件错误:事件{0}对应委托具有不同的类型", eventType)); } } } }
这里有很多要注意的点:
1.先看AddListener系列的方法,6个方法分别对应传入6种不同参数个数的委托,之所以每个里面要分两步完成,是为了进行代码精简,OnListenerAdding将6种AddListener的相同部分抽取了出来,注意为了实现抽取使用了多态,用Delegate容纳不同CallBack参数。
简单讲述下AddListener的逻辑:
1.先判断字典中有无对应事件码,没有就添加新的,新的事件码对应的的委托方法设为null
2.取出事件码对应的委托,如果这个委托不是null,且它的类型和传入的CallBack类型不同,则抛出异常。
3.经历了以上两步,显然此时事件码对应的委托要不就是null,要不就是和传入的CallBack类型相同,此时根据AddListener的不同将事件码对应的委托转换 成相同的类型并进行相加,重新赋回到字典中的对应项。
这里有一点很关键:仔细想,为什么我们要把AddListener分成那么多类型,传入不同的CallBack,能不能利用多态,第二个参数能不能直接传入Delegate?
答案是不行,因为上面的第2步会将字典中的相应Delegate与传入的CallBack进行类型比较,在同类型的情况下,第3步会把事件码对应的委托转换成传入的CallBack的类型并加回到字典中,假如传入的就是Delegate,那在第3步根本不存在类型转换,那存入字典的就一定是Delegate类型,那就根本没法在下一个第2步找出类型不同的情况。
同理,叙述一下RemoveListener的逻辑:
1.先判断字典中有无对应事件码,没有就直接抛出异常。
2.有的话取出事件码对应的委托,如果这个委托为null,抛出异常。如果不为null且它的类型和传入的CallBack类型不同,也抛出异常。
3.经历了以上两步,显然此时事件码对应的委托一定和传入的CallBack类型相同,此时根据AddListener的不同将事件码对应的委托转换成相同的类型并进行相减。
4.减完后要判断事件码对应的委托是不是null,是的话移除事件码及对应的委托。
BroadCast也一样要分多种参数类型,因为有可能要传入不同数量的参数,BroadCast的逻辑:
1.用TryGet获取对应事件码的委托,若获取失败不做任何操作(这一条保证了不会报错)
2.若获取成功,将此委托强转为对应的CallBack,这一条很重要,这就是为什么BroadCast也要分多种参数类型原因,不分的话,不知道要把委托转换成哪种具体的CallBack,而以Delegate为类型,自然无法调用相应的参数。
3.判断强转的CallBack是不是null,是代表强转失败,这是因为事件码对应的委托的参数并不是此类BroadCast能处理的,此时要抛出异常。
4.若CallBack不是null,代表强转成功,直接调用方法即可。
接下来给出测试用的ShowText脚本(挂载在Text组件上)和BtnClick脚本(挂载在按钮上)
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class ShowText : MonoBehaviour { private void Awake() { gameObject.SetActive(false); EventCenter.AddListener<string, string, float, int, int>(EventType.ShowText, Show); } private void OnDestroy() { EventCenter.RemoveListener<string, string, float, int, int>(EventType.ShowText, Show); } private void Show(string str,string str1,float a, int b, int c) { gameObject.SetActive(true); GetComponent<Text>().text = str + str1 + a + b + c; } }
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BtnClick : MonoBehaviour { // Use this for initialization private void Awake () { GetComponent<Button>().onClick.AddListener(()=> { EventCenter.Broadcast(EventType.ShowText,"你好","呀",1.0f,1,2); }); } }
这个没什么好说的,ShowText在Awake时把Show方法(这里用5个参数的作演示)添加到字典,在Destroy中作移除。
BtnClick在被点击时调用BroadCast并传入相应的参数。
有一点要注意:AddListener的调用要明确指明使用的泛型,<string,string,float,int,int>,若直接
EventCenter.AddListener(EventType.ShowText, Show);
会报错,这是因为不同版本的AddListener参数个数是相同的,因此调用时若不指定调用的AddListener版本,程序不知道调用哪个。RemoveListener也是同理。但是,对BroadCast的调用则不必指定版本,因为不同版本的BroadCast参数个数不同,根据传入的参数个数,程序能自动识别调用哪个版本的BroadCast。
这种利用委托和监听解耦合的思路也是有缺点的,就是调用时顺序必须匹配,若ShowText以<string,string,float,int,int>的方式添加了委托,则调用时也必须以同样的顺序传入参数,而我们事先无法得知具体的顺序是怎样的,要在BtnClick实现调用,就只能通过跳转代码,跳转到ShowText的源代码中去了解调用顺序。
另外,我自己的看法是,这种方式可能会破坏封装性,因为ShowText中的Show方法,在ShowText中是被定义为private的,而这个方法又在ShowText被初始化的时候作为委托被添加到了EventCenter中的字典里,按下按钮时根据事件码从字典中找到对应的委托并调用,相当于在ShowText类外部可以随意对类内的私有private方法进行随意调用,安全性似乎有点问题。但这不是由这种思路带来的,而是委托机制本身带来的缺陷,抛开这门课不讲,一个方法可以通过委托传递可以让人接受,但一个类中的私有方法可以通过委托被传递,从而供外部函数随意调用,就有点奇怪了,在我看来,至少也应该设计成只有类中的公有方法才能通过委托传递才对啊,C#里的这种委托机制真的不会带来安全性的隐患吗?这只是我的一点小疑惑,可能是目前对委托的理解不够深,等以后明白了再回来填坑吧。