这是Unity基础框架从0到1的第三篇文章,前面的文章和对应的视频我一起列到这里:
Unity基础框架从0到1 开篇
Unity游戏框架从0到1 (二) 单例模块
Unity基础框架从0到1 开篇
Unity游戏框架从0到1 (二) 单例模块
在前一章配套的视频中,我们实现了Flappy Bird
的原型,并引入了单例来优化代码,但是也还是有一些不好的地方,比如耦合性太强。比如:GameManager
中引用了HudManager
和管道生成的脚本,用来在游戏刚开始做初始化工作。
public void BeginGame()
{
HudManager.Instance.BeginGame();
TubeSpawn.Instance.BeginSpawnTube();
FindObjectOfType().Init();
}
public void BirdDie()
{
HudManager.Instance.OnBirdDie();
TubeSpawn.Instance.StopSpawnTube();
}
而控制鸟的脚本里还引用了HudManager
用来告知分数增加,引用GameManager
用来告知游戏结束等等。。。 这违背了设计模式的迪米特法则1,即一个对象应该尽可能少得去了解另一个对象,控制鸟的脚本不需要知道显示的脚本有一个AddScore
的方法。
所有我们不禁想到,是否有那么一个东西来代替我们传导这些东西,类似以前的电话,A想与B通话,A不需要B具体在哪,不需要自己接线过去再通信,而是先连到接线员那里,接线员通过号码帮你转接过去2。这就是我们接下来要讲的全局消息系统。
为了方便通信,Unity内置了一个消息接口,SendMessage
,但是这个方法使用的是反射,速度比较慢,其次传递参数是使用object
类型,即传参和调用会带来装箱和拆箱的操作,前者容易导致GC
,后者速度慢。
下面这个代码很简单,就是按下A
键后使用Unity的SendMessage
发送loopCount
(十万)条消息,传递一个数字过去,接受的方法获取数字直接加1,从Profiler
可以看到,测试代码带来了1.9M
的GC Alloc
[SerializeField] private int loopCount = 100000;
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
for (int i = 0; i < loopCount; i++)
{
SendMessage("TestUnityMessage", 1, SendMessageOptions.DontRequireReceiver);
}
}
}
void TestUnityMessage(int obj)
{
obj = obj + 1;
}
而下面这段代码则直接将参数以object
发的方式传递,少了装箱的操作,可以看到GC Alloc
立马降下来了,同时耗时也减少了一部分。
[SerializeField] private int loopCount = 1000;
object o = 1;
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
for (int i = 0; i < loopCount; i++)
{
SendMessage("TestUnityMessage", o, SendMessageOptions.DontRequireReceiver);
}
}
}
void TestUnityMessage(int obj)
{
obj = obj + 1;
}
那我们能不能更快呢?当然可以!毕竟Unity自带的这个消息是基于反射实现的,我们改成我们自己的消息系统,咱开始吧!
自定义的消息系统主要由三个文件组成。MessageManager
,也就是咱之前说的接线员,负责收到消息后帮它分发给对应的对象或者是方法。MessageDefine
类,这是一个静态类,用来定义消息的Key,也就是电话号码,通过这个号码可以找到那些要通知的人。另一个是MessageData
,这个用来保存发送的消息内容。
下面详细讲讲这三个脚本。
MessageManager.cs
提供注册、移除、触发消息和清空的方法。一个对象使用一个特有的Key和对应的触发消息的方法来注册,当Manager收到某个消息时,找到消息Key有哪些注册过,并逐一通知他们。
using System.Collections.Generic;
using UnityEngine.Events;
public class MessageManager : Singleton
{
private Dictionary> dictionaryMessage;
public MessageManager()
{
InitData();
}
private void InitData()
{
dictionaryMessage = new Dictionary>();
}
public void Register(string key, UnityAction action)
{
if (!dictionaryMessage.ContainsKey(key))
{
dictionaryMessage.Add(key, action);
}
else
{
dictionaryMessage[key] += action;
}
}
public void Remove(string key, UnityAction action)
{
if (dictionaryMessage.ContainsKey(key))
{
dictionaryMessage[key] -= action;
}
}
public void Send(string key, MessageData data)
{
UnityAction action = null;
if (dictionaryMessage.TryGetValue(key, out action))
{
action(data);
}
}
public void Clear()
{
dictionaryMessage.Clear();
}
}
MessageData.cs
定义了几个基础的数据类型,并提供了对应的构造方法,便于不同消息的传递。内部的数据是只读的,防止某个注册的方法修改类中的值出现异常。
public class MessageData
{
public readonly bool valueBool;
public readonly int valueInt;
public readonly float valueFloat;
public readonly string valueString;
public readonly System.Object valueObject;
public MessageData(bool value)
{
valueBool = value;
}
public MessageData(int value)
{
valueInt = value;
}
public MessageData(float value)
{
valueFloat = value;
}
public MessageData(string value)
{
valueString = value;
}
public MessageData(System.Object value)
{
valueObject = value;
}
}
MessageDefine.cs
一个静态的类,内部定义各种消息的Key,用来做唯一标识,同样的,Key也是只读的,防止被误修改。有些人写的消息系统直接使用的字符串,那种不利于修改,最好是通过MessageDefine.someKey
的方式进行,这样一个是修改方便,例如我要删除一个Key,直接把这里的删掉,其他的地方就都报错了;另外免去手误的可能,我相信IDE
。
public static class MessageDefine
{
public static readonly string TEST_MESSAGE = "TEST_MESSAGE";//这是测试消息Key
}
上述三个文件就是我们的简单消息系统类了,我们写个简单的脚本来测试下。
TessMessage.cs
using UnityEngine;
public class TestMessage : MonoBehaviour
{
[SerializeField] private int loopCount = 100000;
void Start()
{
MessageManager.Instance.Register(MessageDefine.TEST_MESSAGE, TestSimpleMessage);
}
private void OnDestroy()
{
MessageManager.Instance.Remove(MessageDefine.TEST_MESSAGE, TestSimpleMessage);
}
private MessageData _data = new MessageData(1);
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
for (int i = 0; i < loopCount; i++)
{
MessageManager.Instance.Send(MessageDefine.TEST_MESSAGE, _data);
}
}
}
void TestSimpleMessage(MessageData data)
{
int i = data.valueInt + 1;
}
}
郑重的按下A
键,Profiler
信息如下,可以看到,首先是0GC Alloc
,因为我们为数据类写了几个不同的构造函数,避免了装箱,同时在使用的时候我们明确我们这个消息会传递过来什么类型,我们直接通过data
的字段名字去取,也没有额外的拆箱操作(你丫的不废话吗!都没装哪来的拆!!) 其次速度上也略微比之前的快一丢丢。
我们的消息系统写完了吗?其实还没有,因为在我们实际使用过程中,这几种基础类型还不够用,我们可能需要在消息传递时携带更多更丰富的数据,所以我们再来改一改,将传递的数据改成泛型数据。
由于我们想要使用泛型数据来作为data,而MessageManager
中消息注册保存的那个字典数据类型是没办法设置为泛型的,其在定义的时候就确定好了,我们没法中途修改它。为了实现这种一对多的关系,我们我们可以加一个接口,让一个类去实现接口,并在类中使用泛型;然后MessageManager
这边直接使用这个接口类型作为Value
。由于原来我们是在保存消息的字典里同时存了对应触发的事件,而现在字典改成了一个接口,所以我们把事件放到实现类中。下面是我们的新代码:
MessageManager.cs
using System.Collections.Generic;
using UnityEngine.Events;
public class MessageManager : Singleton
{
private Dictionary dictionaryMessage;
public MessageManager()
{
InitData();
}
private void InitData()
{
dictionaryMessage = new Dictionary();
}
public void Register(string key, UnityAction action)
{
if (dictionaryMessage.TryGetValue(key, out var previousAction))
{
if (previousAction is MessageData messageData)
{
messageData.MessageEvents += action;
}
}
else
{
dictionaryMessage.Add(key, new MessageData(action));
}
}
public void Remove(string key, UnityAction action)
{
if (dictionaryMessage.TryGetValue(key, out var previousAction))
{
if (previousAction is MessageData messageData)
{
messageData.MessageEvents -= action;
}
}
}
public void Send(string key, T data)
{
if (dictionaryMessage.TryGetValue(key, out var previousAction))
{
(previousAction as MessageData)?.MessageEvents.Invoke(data);
}
}
public void Clear()
{
dictionaryMessage.Clear();
}
}
MessageData.cs
using UnityEngine.Events;
public interface IMessageData
{
}
public class MessageData : IMessageData
{
public UnityAction MessageEvents;
public MessageData(UnityAction action)
{
MessageEvents += action;
}
}
MessageDefine.cs
没变,下面是测试代码的用法
TestMessage.cs
using UnityEngine;
public class TestMessage : MonoBehaviour
{
[SerializeField] private int loopCount = 100000;
void Start()
{
MessageManager.Instance.Register(MessageDefine.TEST_MESSAGE, TestTMessage);
}
private void OnDestroy()
{
MessageManager.Instance.Remove(MessageDefine.TEST_MESSAGE, TestTMessage);
}
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
MessageManager1.Instance.Send(MessageDefine.TEST_MESSAGE, new MessageData1());
for (int i = 0; i < loopCount; i++)
{
MessageManager.Instance.Send(MessageDefine.TEST_MESSAGE, 1);
}
}
}
void TestTMessage(int data)
{
int i = data + 1;
}
}
同样贴一下Profiler
,运行费时基本和前面的简单消息系统差不多,但是加入了强大的泛型,使得拓展性大大增加。
在写这篇文章的时候,发现网上有些人是使用枚举类型作为消息的Key,相信本意上也是为了修改方便,因为枚举也是可以直接通过枚举名字点出来值的,同样删除也会直接报错。但是我测试了一下,发现枚举类型的Key会比字符串Key要慢。如下图所示,我增加一个枚举类型,并将字典Key改成这个类型,并在测试的时候调用。
针对枚举类型作为Dictionary
的key速度慢的问题,我在网上查阅了一下,大家的普遍说法是当使用枚举类型做为Key时,会发生装箱操作,主要是因为在Dictionary
在去查找时,会使用this.comparer
去计算Hash
值和比较相等,而枚举类型没有实现IEquatable
接口,所以在比较的时候会使用System.Object.Equals
和GetHashCode
,这就造成了装箱操作。
Dictionary
部分代码:
public bool TryGetValue(TKey key, out TValue value)
{
int entry = this.FindEntry(key);
if (entry >= 0)
{
value = this.entries[entry].value;
return true;
}
value = default (TValue);
return false;
}
private int FindEntry(TKey key)
{
if ((object) key == null)
throw new ArgumentNullException(nameof (key));
if (this.buckets != null)
{
int num = this.comparer.GetHashCode(key) & int.MaxValue;
for (int index = this.buckets[num % this.buckets.Length]; index >= 0; index = this.entries[index].next)
{
if (this.entries[index].hashCode == num && this.comparer.Equals(this.entries[index].key, key))
return index;
}
}
return -1;
}
虽然很多人都那么说,但是我自己测试其实并没有测试出来有所谓的装箱操作。正如上图的Profiler
里显示那样,虽然慢了一丢丢,但并没有在那一帧有GC Alloc
。所以我又找人请教了下,阅读了一下源码,查了下Profiler
里框出的EnumEqualityComparer
,发现这个类的比较方法其实是有对枚举值做特殊处理的,所以不会导致有GC
分配。
那么问题来了,为啥大家都说有呢?百思不得其解,我又回头看了看Enum
的定义,才发现问题,原来我使用的是具体的某一类型枚举,而他们说的枚举是基类Enum
类型,于是我又改了一下代码,一测,果然熟悉的GC Alloc
出来了。而且可以看到,比较的时候的确是使用了System.Object
的Equals
和GetHashCode
方法。
前面提到了很多拆箱装箱GC
,这里也简单提一下这些概念。简单的说,当值类型转换成引用类型,就是装箱。而将引用类型转换成值类型,就是拆箱3。装箱需要为值类型分配一块内存空间,并将值类型的地址写入到那块空间里,所以装箱会导致内存分配,频繁的内存分配则更容易导致GC
,而且会让内存碎片化,使得利用效率降低;而拆箱需要将引用类型保存的地址里的值复制出来,所以也会拖慢速度。
GC
全称是Garbage Collection
,我用自己的理解,就是在某些时候(内存不够,或者手动调用时,或者被定期执行),CLR
(不严谨得说,就可以认为是系统)会检查内存,把一些没有被使用的内存给清理掉,让那块内存空间重新变成可用状态,同时可能会将离散的内存整理好,让内存块变得更连续,这样就能更高效得利用内存(比如两个三个内存块中间都有三个单位空间,那我申请六个空间的内存就没法在这里申请到,但是如果它们是连续的,那我们就能成功在这里申请到),而整理内存也就涉及数据的拷贝和移动了,所以可能会导致卡顿等情况。
而上文中提到的GC
其实是Profiler
中的堆内存分配,刚解释装箱我们也提到了,当频繁分配这种碎小的内存会更容易导致GC
,所以我们要尽量避免这个内存分配。
我也测试了下int类型作为Key,但是基本和使用字符串类型差不多,而且使用int类型需要自己维护一个方便好用的使用流程,方便阅读,也需要便于增删改查等操作,我觉得为了这一点点速度的提升没必要,所以建议直接用字符串类型就行了。
这一篇文章内容已经太多了,所以消息系统的实战代码就留到下一篇吧。
最后尝试来总结一下这篇文章提到的这些内容:
string
类型,为了方便维护,可以使用一个静态类去定义不同的静态字段作为Key。使用Enum
类型会有装箱操作,使用自定义枚举类型没有装箱,但是比string
类型慢。使用int
类型比string
类型快,但是不利于维护。readonly
,免得被误修改。设计模式—六大原则 https://zhuanlan.zhihu.com/p/67094969 ↩︎
顺德第一代电话接线员讲那个年代,献给没了手机活不了的你 https://www.sohu.com/a/16732367_115246 ↩︎
《Unity3D高级编程之进阶主程》第一章,C#要点技术(四) 委托、事件、装箱、拆箱 http://www.luzexi.com/2019/01/26/Unity3D%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B%E4%B9%8B%E8%BF%9B%E9%98%B6%E4%B8%BB%E7%A8%8B-CSharp%E8%A6%81%E7%82%B9%E6%8A%80%E6%9C%AF4 ↩︎