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腳本

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#里的這種委托機制真的不會帶來安全性的隱患嗎?這只是我的一點小疑惑,可能是目前對委托的理解不夠深,等以后明白了再回來填坑吧。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM