Unity基础框架从0到1(三)高效的全局消息系统

索引

这是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<BirdController>().Init();
}

public void BirdDie()
{
    HudManager.Instance.OnBirdDie();
    TubeSpawn.Instance.StopSpawnTube();
}

  而控制鸟的脚本里还引用了HudManager用来告知分数增加,引用GameManager用来告知游戏结束等等。。。 这违背了设计模式的迪米特法则1,即一个对象应该尽可能少得去了解另一个对象,控制鸟的脚本不需要知道显示的脚本有一个AddScore的方法。

  所有我们不禁想到,是否有那么一个东西来代替我们传导这些东西,类似以前的电话,A想与B通话,A不需要B具体在哪,不需要自己接线过去再通信,而是先连到接线员那里,接线员通过号码帮你转接过去2。这就是我们接下来要讲的全局消息系统。

Unity自带的消息

  为了方便通信,Unity内置了一个消息接口,SendMessage,但是这个方法使用的是反射,速度比较慢,其次传递参数是使用object类型,即传参和调用会带来装箱和拆箱的操作,前者容易导致GC,后者速度慢。

  下面这个代码很简单,就是按下A键后使用Unity的SendMessage发送loopCount(十万)条消息,传递一个数字过去,接受的方法获取数字直接加1,从Profiler可以看到,测试代码带来了1.9MGC 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;
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NEnV7ioR-1614693049803)(C:\Users\xiaolong.tang\AppData\Roaming\Typora\typora-user-images\image-20210301133622150.png)]

  那我们能不能更快呢?当然可以!毕竟Unity自带的这个消息是基于反射实现的,我们改成我们自己的消息系统,咱开始吧!

简版的自定义消息系统

  自定义的消息系统主要由三个文件组成。MessageManager,也就是咱之前说的接线员,负责收到消息后帮它分发给对应的对象或者是方法MessageDefine类,这是一个静态类,用来定义消息的Key,也就是电话号码,通过这个号码可以找到那些要通知的人。另一个是MessageData,这个用来保存发送的消息内容

下面详细讲讲这三个脚本。

MessageManager.cs 提供注册、移除、触发消息和清空的方法。一个对象使用一个特有的Key和对应的触发消息的方法来注册,当Manager收到某个消息时,找到消息Key有哪些注册过,并逐一通知他们。

using System.Collections.Generic;
using UnityEngine.Events;

public class MessageManager : Singleton<MessageManager>
{
    private Dictionary<string, UnityAction<MessageData>> dictionaryMessage;

    public MessageManager()
    {
        InitData();
    }

    private void InitData()
    {
        dictionaryMessage = new Dictionary<string, UnityAction<MessageData>>();
    }

    public void Register(string key, UnityAction<MessageData> action)
    {
        if (!dictionaryMessage.ContainsKey(key))
        {
            dictionaryMessage.Add(key, action);
        }
        else
        {
            dictionaryMessage[key] += action;
        }
    }

    public void Remove(string key, UnityAction<MessageData> action)
    {
        if (dictionaryMessage.ContainsKey(key))
        {
            dictionaryMessage[key] -= action;
        }
    }

    public void Send(string key, MessageData data)
    {
        UnityAction<MessageData> 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的字段名字去取,也没有额外的拆箱操作(你丫的不废话吗!都没装哪来的拆!!) 其次速度上也略微比之前的快一丢丢

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x9eveD5A-1614693049805)(C:\Users\xiaolong.tang\AppData\Roaming\Typora\typora-user-images\image-20210301213240330.png)]

泛型参数的消息系统

  我们的消息系统写完了吗?其实还没有,因为在我们实际使用过程中,这几种基础类型还不够用,我们可能需要在消息传递时携带更多更丰富的数据,所以我们再来改一改,将传递的数据改成泛型数据。

  由于我们想要使用泛型数据来作为data,而MessageManager中消息注册保存的那个字典数据类型是没办法设置为泛型的,其在定义的时候就确定好了,我们没法中途修改它。为了实现这种一对多的关系,我们我们可以加一个接口,让一个类去实现接口,并在类中使用泛型;然后MessageManager这边直接使用这个接口类型作为Value。由于原来我们是在保存消息的字典里同时存了对应触发的事件,而现在字典改成了一个接口,所以我们把事件放到实现类中。下面是我们的新代码:

MessageManager.cs

using System.Collections.Generic;
using UnityEngine.Events;

public class MessageManager : Singleton<MessageManager>
{
    private Dictionary<string, IMessageData> dictionaryMessage;

    public MessageManager()
    {
        InitData();
    }

    private void InitData()
    {
        dictionaryMessage = new Dictionary<string, IMessageData>();
    }

    public void Register<T>(string key, UnityAction<T> action)
    {
        if (dictionaryMessage.TryGetValue(key, out var previousAction))
        {
            if (previousAction is MessageData<T> messageData)
            {
                messageData.MessageEvents += action;
            }
        }
        else
        {
            dictionaryMessage.Add(key, new MessageData<T>(action));
        }
    }

    public void Remove<T>(string key, UnityAction<T> action)
    {
        if (dictionaryMessage.TryGetValue(key, out var previousAction))
        {
            if (previousAction is MessageData<T> messageData)
            {
                messageData.MessageEvents -= action;
            }
        }
    }

    public void Send<T>(string key, T data)
    {
        if (dictionaryMessage.TryGetValue(key, out var previousAction))
        {
            (previousAction as MessageData<T>)?.MessageEvents.Invoke(data);
        }
    }

    public void Clear()
    {
        dictionaryMessage.Clear();
    }
}

MessageData.cs

using UnityEngine.Events;

public interface IMessageData
{
}

public class MessageData<T> : IMessageData
{
    public UnityAction<T> MessageEvents;

    public MessageData(UnityAction<T> action)
    {
        MessageEvents += action;
    }
}

MessageDefine.cs没变,下面是测试代码的用法

TestMessage.cs

using UnityEngine;

public class TestMessage : MonoBehaviour
{
    [SerializeField] private int loopCount = 100000;

    void Start()
    {
        MessageManager.Instance.Register<int>(MessageDefine.TEST_MESSAGE, TestTMessage);
    }

    private void OnDestroy()
    {
        MessageManager.Instance.Remove<int>(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<int>(MessageDefine.TEST_MESSAGE, 1);
            }
        }
    }

    void TestTMessage(int data)
    {
        int i = data + 1;
    }
}

  同样贴一下Profiler,运行费时基本和前面的简单消息系统差不多,但是加入了强大的泛型,使得拓展性大大增加。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IPBri51f-1614693049806)(C:\Users\xiaolong.tang\AppData\Roaming\Typora\typora-user-images\image-20210301223725875.png)]

额外的知识点

消息系统的Key到底要使用什么类型?

枚举类型?

  在写这篇文章的时候,发现网上有些人是使用枚举类型作为消息的Key,相信本意上也是为了修改方便,因为枚举也是可以直接通过枚举名字点出来值的,同样删除也会直接报错。但是我测试了一下,发现枚举类型的Key会比字符串Key要慢。如下图所示,我增加一个枚举类型,并将字典Key改成这个类型,并在测试的时候调用。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

  针对枚举类型作为Dictionary的key速度慢的问题,我在网上查阅了一下,大家的普遍说法是当使用枚举类型做为Key时,会发生装箱操作,主要是因为在Dictionary在去查找时,会使用this.comparer去计算Hash值和比较相等,而枚举类型没有实现IEquatable接口,所以在比较的时候会使用System.Object.EqualsGetHashCode,这就造成了装箱操作。

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;
}

Enum定义
在这里插入图片描述

  虽然很多人都那么说,但是我自己测试其实并没有测试出来有所谓的装箱操作。正如上图的Profiler里显示那样,虽然慢了一丢丢,但并没有在那一帧有GC Alloc。所以我又找人请教了下,阅读了一下源码,查了下Profiler里框出的EnumEqualityComparer,发现这个类的比较方法其实是有对枚举值做特殊处理的,所以不会导致有GC分配。

在这里插入图片描述

  那么问题来了,为啥大家都说有呢?百思不得其解,我又回头看了看Enum的定义,才发现问题,原来我使用的是具体的某一类型枚举,而他们说的枚举是基类Enum类型,于是我又改了一下代码,一测,果然熟悉的GC Alloc出来了。而且可以看到,比较的时候的确是使用了System.ObjectEqualsGetHashCode方法。

在这里插入图片描述
在这里插入图片描述

PS.啥是拆箱装箱

  前面提到了很多拆箱装箱GC,这里也简单提一下这些概念。简单的说,当值类型转换成引用类型,就是装箱。而将引用类型转换成值类型,就是拆箱3。装箱需要为值类型分配一块内存空间,并将值类型的地址写入到那块空间里,所以装箱会导致内存分配,频繁的内存分配则更容易导致GC,而且会让内存碎片化,使得利用效率降低;而拆箱需要将引用类型保存的地址里的值复制出来,所以也会拖慢速度

  GC全称是Garbage Collection,我用自己的理解,就是在某些时候(内存不够,或者手动调用时,或者被定期执行),CLR(不严谨得说,就可以认为是系统)会检查内存,把一些没有被使用的内存给清理掉,让那块内存空间重新变成可用状态,同时可能会将离散的内存整理好,让内存块变得更连续,这样就能更高效得利用内存(比如两个三个内存块中间都有三个单位空间,那我申请六个空间的内存就没法在这里申请到,但是如果它们是连续的,那我们就能成功在这里申请到),而整理内存也就涉及数据的拷贝和移动了,所以可能会导致卡顿等情况。

在这里插入图片描述

  而上文中提到的GC其实是Profiler中的堆内存分配,刚解释装箱我们也提到了,当频繁分配这种碎小的内存会更容易导致GC,所以我们要尽量避免这个内存分配。

int类型?

​ 我也测试了下int类型作为Key,但是基本和使用字符串类型差不多,而且使用int类型需要自己维护一个方便好用的使用流程,方便阅读,也需要便于增删改查等操作,我觉得为了这一点点速度的提升没必要,所以建议直接用字符串类型就行了。

在这里插入图片描述

​ 这一篇文章内容已经太多了,所以消息系统的实战代码就留到下一篇吧。

总结

  最后尝试来总结一下这篇文章提到的这些内容:

  • Unity自带的消息使用的是反射,而且参数传递有装箱操作。
  • 自己写的消息机制,可以使用接口、实现类加泛型,拓展消息参数,既便于拓展,也没有装箱操作。如果数据类内置几种基础类型并提供对应的实例化方法,没有装箱操作(每次new一个的话还是有内存分配的),但是拓展性不强;使用Object类型,通用性强,但可能会有装箱操作。
  • 消息的Key最好使用string类型,为了方便维护,可以使用一个静态类去定义不同的静态字段作为Key。使用Enum类型会有装箱操作,使用自定义枚举类型没有装箱,但是比string类型慢。使用int类型比string类型快,但是不利于维护。
  • 一些不可修改的东西,最好是设置为readonly,免得被误修改。

欢迎关注我的微信公众号,我们一起探讨更多的技术细节!

在这里插入图片描述


  1. 设计模式—六大原则 https://zhuanlan.zhihu.com/p/67094969 ↩︎

  2. 顺德第一代电话接线员讲那个年代,献给没了手机活不了的你 https://www.sohu.com/a/16732367_115246 ↩︎

  3. 《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 ↩︎

猜你喜欢

转载自blog.csdn.net/l1606468155/article/details/114077006