unity —腳本優化— 消息處理系統

     我們經常會遇到在運行狀態下去找到一個現有對象。在這個例子中,我們需要添加新的敵人到EnemyManagerComponent中,以便於在我們的場景中可以按我們想的任何方式來控制敵人對象。由於涉及到開銷,我們需要可靠和快速的方法作用於對象來查找已經存在的對象,而不用Find()方法和sendmessage()方法時,我們又該怎麼做呢。這節主要講解如果不用find(),又該完成對象之間的調用呢。

    我們可以採用多種方法來解決這個問題,每一個都有自己的好處和弊端:
     靜態類;
    單例組件;
    分配引用到預先存在的對象;
    一個全局的信息管理系統。

單例模式是一種常見的方式,以確保我們有一個全球可訪問的對象,在內存中只保留一個實例。然而,單例模式在Unity的項目中的使用,可以很容易的在C#中用靜態類來替代,也不需要實現私有的構造函數以及一個實例變量的不必要的屬性訪問。基本上,實現一個典型的單例設計模式C #需要更多的代碼,時間來實現和靜態類相同的效果。注意每個成員和方法都有靜態關鍵字連接,這意味着只有一個實例,這個對象將永遠駐留在內存中。靜態類根據定義,不允許任何非靜態實例成員被定義,因爲這意味着我們可以在某種程度上複製對象。這種類型的全局類,通常被認爲是更清潔和更容易使用的典型的單例設計模式在C #上的發展版本。靜態類方法的缺點是,它們必須繼承最底層的類----Object。這意味着靜態類不能繼承MonoBehaviour,因此,我們不能使用它的任何一個Unity的功能,包括所有重要事件的回調函數,包括協程。並且,因爲沒有對象可供選擇,我們失去了在運行時通過Inspector面版檢查對象的數據的能力。這些都是我們可以通過單例模式使用的功能。一個常見的解決辦法是“單例作爲組件”,讓一個遊戲對象包含自己。並提供靜態方法以授予全局訪問。需要注意,在這種情況下,我們必須用私有靜態實例變量和一種全局訪問的全局實例方法基本上實現典型的單例設計模式。下面是定義單例模式的類:

public class SingletonAsComponent<T> : MonoBehaviour where T :SingletonAsComponent<T> {
        private static T __Instance;
        protected static SingletonAsComponent<T> _Instance {
        get {
               if(!__Instance) 
                 {
                     T [] managers = GameObject.FindObjectsOfType(typeof(T)) as T[];
                     if (managers != null) {
                     if(managers.Length == 1) 
                    {
                       __Instance = managers[0];
                        return __Instance;
                    } 
                   else if (managers.Length > 1) 
                   {
                     Debug.LogError("You have more than one " +typeof(T).Name + " in the scene. You only need 1, it's a singleton!");
                     for(int i = 0; i < managers.Length; ++i) 
                     {
                           T manager = managers[i];
                          Destroy(manager.gameObject);
                       }
                     }
                 }
                     GameObject go = new GameObject(typeof(T).Name,typeof(T));
                   __Instance = go.GetComponent<T>();
                  DontDestroyOnLoad(__Instance.gameObject);
              }
                   return __Instance;
             }
             set {
                        __Instance = value as T;
                    }
         }
}

這是一個常用的單例組件。因爲我們希望這是一個全球性的和持久的對象,我們需要在遊戲對象創建不久後調用DontDestroyOnLoad()。這是一個特殊的函數用來告訴Unity,只要應用程序存在,在場景切換中就讓它一直存在。基於這一點,當一個新的場景被加載,對象不會被銷燬,並將保留所有的數據。這個類定義假定了兩點。首先,因爲它是使用泛型來定義它的行爲,它必須是來自於創建的一個具體的類。其次,方法將定義指定_instance變量到正確的類型。舉一個例子,以下是需要成功生成新的SingletonAsComponent 類,派生類爲MySingletonComponent:

public class MySingletonComponent :SingletonAsComponent<MySingletonComponent>
{
              public static MySingletonComponent Instance {
                 get { return ((MySingletonComponent)_Instance); }
                 set { _Instance = value; }
               }
}

這個類可以在運行時使用任何其他對象在任何時候訪問該實例屬性。如果組件在我們的場景中已經不存在,然後singletonascomponent基類將實例化自己的遊戲對象並將派生類的實例作爲一個組件。基於這一點,對單例屬性的訪問將涉及組件的創建。如果可能的話,我們不應該放SingletonAsComponent 的派生類到我們的場景面版。這是因爲 DontDestroyOnLoad()方法從來沒被調用過。這將使單例組件對象在下一個場景加載的時候不存在了。由於Unity本身場景切換的分割,想適當的清理單例組件有點複雜。當在運行狀態下對象被銷燬的時候都會調用 OnDestroy()方法。在應用程序關閉期間也會調用相同的方法,在Unity中每個對象每個組件都有自己的OnDestroy()方法。當我們在編輯器中結束播放模式進入編輯模式的時候,關閉應用程序也會發生。然而,對象的銷燬是一個隨機的順序,我們不能保證定單例組件將是最後一個被破壞的對象。因此,如果任何對象嘗試在OnDestroy()中做任何與單例相關的事,那它就會調用單例屬性。如果單例在這一刻已經被摧毀,該對象的銷燬過程就會在應用程序關閉期間創建一個新的單例。這可能會損壞我們的場景文件,因爲我們的單例組件被遺留在了場景之後。如果這樣,Unity就會報出一個錯誤:“some objects were not clean up when  closing scence”這段簡單的英文我就不翻譯了。爲什麼某些對象會在銷燬階段調用單例模式是因爲單例經常使用觀察者模式。這種設計模式允許其他對象註冊/註銷他們特定的任務,類似於Unity如何鎖住回調,但是用了一個不太自動化的方式。我們將在即將到來的部分中看到一個全局信息系統的例子。對象將在系統構建的時候註冊,而在關閉的時候取消註冊,要做到這一點的最方便的地方是它的ondestroy()方法內。因此,這樣的對象可能會遇到上述問題,也就是單例模式在應用程序關閉期間出現問題的地方。爲了解決這個問題,我們需要做出三點改變。首先,我們需要添加一個附加標誌給單例組件,用來跟蹤它的活動狀態,並在適當的時候禁用它。這包括了單例自己的毀滅,以及應用程序關閉(OnApplicationQuit()是另一個Unity在這段時間有用的回調):
private bool _alive = true;
void OnDestroy() { _alive = false; }
void OnApplicationQuit() { _alive = false; }

然後,我們應該實現一種外部對象來驗證單例的當前狀態:
public static bool IsAlive {
get {
      if (__Instance == null)
      return false;
      return __Instance._alive;
     }
}

最後,任何試圖在自己OnDestroy()方法裏調用單例的,必須先使用IsAlive屬性來驗證狀態。舉個例子:

public class MySingletonComponent :SingletonAsComponent<MySingletonComponent>
{
              public static MySingletonComponent Instance {
                 get { return ((MySingletonComponent)_Instance); }
                 set { _Instance = value; }
               }
}

這將確保沒有人試圖在銷燬過程中訪問實例。如果我們不遵守這個規則,我們就會因爲返回到編輯模式下,由於單例還存在於場景中而運行報錯。比較諷刺的是單例組件的出現後,在我們訪問單例的實例之前,我們用Find()方法來確定單例組件是否在場景中存在。幸運的是,這將只發生在單例組件的第一次被訪問時。但是單例的初始化不一定發生在場景的初始化的時候,因此可能在這個對象被實例化和調用Find()方法時給我們遊戲的過程中造成一個很不好的表現。解決辦法是一個最高的類通過簡單的調用每一個實例在場景的初始化時確定單例的初始化。這種方法的缺點是,如果我們之後決定存在多個管理類,我們希望把它的行爲分離出來更爲模塊化,就會有很多的代碼需要改變。還有進一步的選擇,我們可以探索,如利用Unity的內置的腳本代碼和檢查界面之間的接口。對象間通信問題的另一種方法是使用Unity的串行化系統。軟件設計的純粹主義者對這一點存在爭議,因爲它打破了封裝;它使得一些變量的私有行爲暴露給了公衆。即使這個值只是暴露給了Unity的Inspector面版,這也一樣是需要特別注意的地方。當我們創建一個共有變量,當組件被選中的時候Unity會自動序列化將該值暴露在Inspector面版上。然而,從軟件設計的角度來看,公共變量是危險的,這些變量可以在任何時候通過代碼修改,會很難跟蹤該變量,並會有很多意想不到的錯誤。作爲替代,我們可以定義私有變量和保護變量,然後用[SerializeField]使之顯示在Unity編輯器Inspector面版。這種方法優於公共變量,因爲它可以在情境中得到更好的控制。這種方法,至少我們知道在運行時在類的外部(或派生類)通過代碼不能改變變量,這樣保證了腳本代碼的封裝。

所以我們想讓變量在面板上看到,但同時又不想讓其他的類訪問這個變量,我們通常用[SerializeField]來進行修飾。


 對於對象間通信問題的一個建議是實現一個全局消息傳遞系統,任何對象都可以通過對可能感興趣的任何對象發送消息到特定類型的消息。對象或發送消息或偵聽消息,對監聽者它的責任是查找它感興趣的東西。消息的發送者則可以廣播該消息。這種方式可以很好的保持我們代碼的模塊化和解耦。

我們希望發送的各種信息可以採取多種形式,如包括數據值,引用,監聽者的指令等等,但是它們應該有一個共同的前提,我們的消息系統可以用來確定消息是什麼,目的是什麼。下面是消息對象的一個簡單的類定義:

public class BaseMessage {
                  public string name;
                 public BaseMessage() { name = this.GetType().Name; }
}

Basemessage緩存的類的構造函數的類型在局部屬性中是用來爲以後的編寫和分配。每次調用GetType()緩存該值是很有必要的。名稱將導致在堆上分配的新的字符串,我們要儘可能地減少這一可能性。我們的自定義消息必須從該類派生,這使得他們可以添加任何他們希望的數據,同時仍保持通過我們的信息系統發送的能力。注意到,儘管在基類構造函數中獲得了類型名稱,名稱屬性仍將包含派生類的名稱,而不是基類。

移動到我們的MessagingSystem 類,我們應該用什麼樣的需求來定義它的特性:
 1 它應該是全局訪問的;
 2 任何對象(無論MonoBehaviour與否)應該能夠註冊/註銷監聽來接收特定的消息類型(即觀察者模式);
 3註冊對象應該提供一種在給定消息被廣播時調用的方法;
 4 系統應該在一個合理的時間內將信息發送給所有的聽衆,但是不要一次扼制太多的請求。

第一個要求使得消息傳遞系統是一個優秀的單例對象的候選對象,因爲我們只需要一個系統的實例。儘管,在實行單例之前反覆思考是明智的。如果我們後來決定,我們希望這個對象存在的多個實例,然後我們會因爲所有的依賴而很難重構,我們會隨着我們代碼的使用逐步介紹我們的系統。第一個要求使得消息傳遞系統是一個優秀的單例對象的候選對象,因爲我們只需要一個系統的實例。儘管,在實行單例之前反覆思考是明智的。如果我們後來決定,我們希望這個對象存在的多個實例,然後我們會因爲所有的依賴而很難重構,我們會隨着我們代碼的使用逐步介紹我們的系統。這個消息機制的原理就是c#的委託。委託是這個消息機制的根本。所以學好委託是很有必要的。

在某些情況下,我們可能要廣播一個一般的通知信息,讓有所有的監聽者做一些反應,比如敵人的孵化信息。其他時候,我們可能會發送一個消息,專門針對一組中的某一個聽衆。舉個例子,當一個敵人受傷的時候需要發送一個“敵人的健康價值改變”的消息,讓敵人血條發生變化。如果我們實現了一種方法,讓監聽者在早期停止信息處理,如果有許多偵聽器等待相同的消息類型,我們可以節省大量的處理器週期。

我們定義的委託,提供了一種通過一個參數來檢索消息的方法,並返回一個響應,確定當偵聽器完成時該消息的處理是否應該停止。關於
是否停止處理或不返回通過一個簡單的布爾值的來決定,爲真表示監聽者已處理該消息,消息的處理就將停止。
這裏是關於委託的定義:
public delegate bool MessageHandlerDelegate(BaseMessage message);

當在MessagingSystem註冊時,監聽者必須定義一個方法表來傳遞它的引用。藉此在廣播消息被廣播時提供一個入口點。
我們的信息系統的最終要求是,該對象具有某種基於時序的機制,以防止它一次被阻塞了太多的消息。這也意味着,在過程中的某個地方,在Unity的update()期間,我們需要用MonoBehaviour事件回調來計數時間。這可以通過我們早些時候說的靜態類和單例來實現,這將需要一些MonoBehaviour的管理類調用它,通知它的場景已經更新。另外,我們可以用singletonascomponent來完成同樣的事情,但這樣做獨立於任何管理類。兩者的區別在於,系統是否依賴於對象的控制。singletonascomponent方法可能是最的因爲沒有太多的場合是我們要讓這個系統獨立的,即使我們的遊戲邏輯的大部分取決於它。舉個例子,即使遊戲被暫停,我們不希望遊戲邏輯暫停我們的消息系統。我們仍然希望這個消息系統能夠繼續接收和處理信息,以便我們可以在遊戲是在暫停狀態下,保持用戶界面相關的組件能夠互相溝通。
通過我們提取singletonascomponent類來定義我們的通訊系統,並提供一個對象的方法來註冊它,代碼如下:

using System.Collections.Generic;
public class MessagingSystem : SingletonAsComponent<MessagingSystem> 
{
      public static MessagingSystem Instance
      {
              get { return ((MessagingSystem)_Instance); }
               set { _Instance = value; }
       }
       private Dictionary<string,List<MessageHandlerDelegate>> _listenerDict =new Dictionary<string,List<MessageHandlerDelegate>>();
       public bool AttachListener(System.Type type, MessageHandlerDelegatehandler) 
       {
                        
              if (type == null) 
              {
                   Debug.Log("MessagingSystem: AttachListener failed due to no messagetype specified");
                    return false;
              }
              string msgName = type.Name;
              if (!_listenerDict.ContainsKey(msgName)) 
              {
                     _listenerDict.Add(msgName, new List<MessageHandlerDelegate>());
              }
              List<MessageHandlerDelegate> listenerList = _listenerDict[msgName];
              if (listenerList.Contains(handler)) 
              {
                  return false; // listener already in list
              }
              listenerList.Add(handler);
              return true;
         }
}

_listenerdict是一個映射到字符串列表字典messagehandlerdelegates的變量,通過監聽者想聽到的消息類型,用字典來安排監聽的委託。因此,如果我們知道正在發送什麼消息類型,然後,我們可以快速檢索已註冊爲該消息類型的所有代表的列表。然後我們可以遍歷列表,查詢每個偵聽器,看看是否其中一個想要處理它。

AttachListener()方法需要兩個參數;一個系統中的消息類型,和一個通過系統發送消息時的messagehandlerdelegate。
爲了處理消息,我們的MessagingSystem 讓傳進來的對象保持一個隊列,使我們能夠讓他們的順序播放。

   private Queue<BaseMessage> _messageQueue = new Queue<BaseMessage>();
                    public bool QueueMessage(BaseMessage msg) {
                         if (!_listenerDict.ContainsKey(msg.name)) {
                              return false;
                       }
                    _messageQueue.Enqueue(msg);
                     return true;
                     }

該方法簡單地檢查給定的消息類型在我們的字典中是否存在,並將其添加到隊列中。這有效地測試了一個對象是否真的很在乎在我們排隊它被處理後的信息之前聽過的信息,我們引入了一個新的私有成員變量_messageQueue 來達到這個目的。下一步,我們將添加update()定義。這個方法由Unity引擎週期性調用。其目的是遍歷消息隊列的當前內容,一次一個消息,驗證我們開始處理之前是否過多的時間已經過去了,如果沒有,把它們傳給下一個階段的過程中。

      private float maxQueueProcessingTime = 0.16667f;
          void Update() 
          {
               float timer = 0.0f;
               while (_messageQueue.Count > 0) 
              {
                   if (maxQueueProcessingTime > 0.0f) 
                  {
                              if (timer > maxQueueProcessingTime)
                                  return;
                  }
                 BaseMessage msg = _messageQueue.Dequeue();
                if (!TriggerMessage(msg))
                   Debug.Log("Error when processing message: " + msg.name);
                if (maxQueueProcessingTime > 0.0f)
                  timer += Time.deltaTime;
             }
     }

基於時間的維護是爲了確保不超過處理時限閾值。如果太多的消息被推到系統太快,這阻止了消息系統凍結我們的遊戲。如果超過總時間限制,然後所有消息處理都將停止,下所有剩餘的消息處理到下一幀。最後,我們需要定義triggermessage()方法,來向聽衆分發消息:

public bool TriggerMessage(BaseMessage msg) 
{
           string msgName = msg.name;
           if (!_listenerDict.ContainsKey(msgName)) 
           {
                      Debug.Log("MessagingSystem: Message \"" + msgName + "\" has nolisteners!");
                      return false; // no listeners for message so ignore it
          }
           List<MessageHandlerDelegate> listenerList = _listenerDict[msgName];
           for(int i = 0; i < listenerList.Count; ++i)
          {
               if (listenerList[i](msg))
                      return true; // message consumed by the delegate
           }
          return true;
}

這種方法是信息系統工作的主要負荷,triggerevent()的目的也是爲了獲得對於給定的消息類型的聽衆名單,給他們每個對象一個機會去處理它。如果其中一個委託返回真,當前消息的處理停止,方法退出,讓update()方法處理下一個消息。通常情況下,我們將要使用的queueevent()廣播消息,但是可以調用TriggerEvent()來代替。這種方法允許消息發送者強迫他們要處理的消息立即處理而不用在等待下一個update()事件。這避開了扼殺機制,但是這可能是一個在遊戲運行時關鍵時刻重要的消息,如果等待下一幀可能導致奇怪的表現。

我們創建了信息系統,但是一個關於如何使用它的例子將幫我們理清我們腦子中的概念。讓我們先定義一個簡單的消息類,我們可以用它來傳輸一些數據:

public class MyCustomMessage : BaseMessage
 {
          public readonly int _intValue;
          public readonly float _floatValue;
          public MyCustomMessage(int intVal, float floatVal
        {
            _intValue = intVal;
            _floatValue = floatVal;
       }
}

消息對象的好的做法是讓他們的成員變量是隻讀的。這確保了在對象的構建之後,數據不能更改。這防止我們的消息內容在傳遞過程被修改。

這裏是一個簡單的類用來註冊信息系統,當MyCustomM essage 對象從別處廣播進入我們的代碼,要求用HandleMyCustomMessage()方法調
用。

public class TestMessageListener : MonoBehaviour 
{
           void Start() 
         {
             MessagingSystem.Instance.AttachListener(typeof(MyCustomMessage),
             this.HandleMyCustomMessage);
         }
         bool HandleMyCustomMessage(BaseMessage msg) 
          {
               MyCustomMessage castMsg = msg as MyCustomMessage;
               Debug.Log (string.Format("Got the message! {0}, {1}",castMsg._intValue, castMsg._floatValue));
              return true;
          }
}

每當MyCustomMessage對象被廣播(不管從哪),該偵聽器將通過handlemycustommessage()方法檢索消息。它可以轉換成相應的衍生信息類型和以自己獨特的方式處理消息。其他類可以註冊相同的消息,通過它自己的自定義委託方法(假設一個較早的對象沒有返回它自己的委託)處理它。我們知道什麼樣的消息將會通過HandleMyCustomMessage()方 法被提供,本節最重要的核心就是委託,熟悉委託就能很輕鬆的駕馭這個消息處理系統,如果需要討論的朋友可以加qq:1850761495

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章