我们经常会遇到在运行状态下去找到一个现有对象。在这个例子中,我们需要添加新的敌人到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