Unity中利用委托与监听解耦合的思路

2019/03/09 00:21
阅读数 98

这篇随笔是一篇记录性的随笔,记录了从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脚本

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 = "你好";
            }
        });
        
    }
}
BtnClick脚本

此时对一个按钮按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;
        }
    }
}
ShowText脚本
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("你好");
        });
        
    }
}
BtnClick脚本

你可能会有疑惑,为什么要用单例模式?

要知道,我们这里之所以能实现累加,是因为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
}
EventType

当前只添加了一个事件码,需要时可手动添加

随后是定义不同参数委托的文件,文件名: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);
CallBack

这里重载了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));
            }
        }
    }
}
EventCenter

这里有很多要注意的点:

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;
    }
}
ShowText脚本
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);
        });
        
    }
}
BtnClick脚本

这个没什么好说的,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#里的这种委托机制真的不会带来安全性的隐患吗?这只是我的一点小疑惑,可能是目前对委托的理解不够深,等以后明白了再回来填坑吧。

 

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部