Unity游戏框架从0到1 (二) 单例模块

一、介绍单例

什么是单例?

  单例是一种设计模式,顾名思义即全局只有一个实例,其需要提供一个访问的接口供其他模块调用1。其实说得再简单点,单例的实例其实就是一个特殊的全局变量。

  单例模式作为一种基础设计模式,相信很多同学都用过,因为写起来很爽,可以在任何的地方直接获取到目标实例做一些操作,在一定程度上提高了开发效率。但如果使用不得当,也很容易带来一些风险,让项目变得混乱起来。接下来列举下单例的优缺点。

优点

1、全局唯一,且可以全局访问

  在游戏的一个完整生命周期内,只存在一个实例,不会被重复创建。一些单例类保存有某些数据,而错误的使用则可能会创建出多个实例,并分别存有某些数据,导致出现不知名的Bug。单例使用一个简单的写法可以保证不会出现重复创建的问题(多线程的话就得慎重了!!)。
  在面向对象编程中,很多时候新手程序员都会很苦恼,这个对象实例要从哪里拿到,才能去调用对应的方法。而引入单例可以降低他们的学习难度,在一定程度上提高了开发效率。

2、使用的时候才初始化

  如果一个单例在游戏过程中一直没有被使用,那它就不会被创建,比如我们写了一个聊天系统的存储单例,而玩家进游戏一直没有进过聊天系统,那就不会去创建这个存储的单例,也就不会去读取大量的聊天数据,这在一定程度上减轻了内存和CPU的压力。

3、运行时初始化

  单例可以在运行的时候初始化,这意味着这个类可以访问到一些额外的数据,并且可以自行控制初始化顺序,自己能把控的东西才会安心。
  单例通常可以使用一个静态类来代替,但是后者在编译的时候做初始化,很多数据是拿不到的;而且因为静态类的编译顺序是依赖于编译器的,所以如果类与类有依赖关系就很容易出问题。(当然一般也不推荐让这种类在初始化的时候互相依赖)。

4、可以继承

  这一点其实目前我用到的不多,不过《游戏编程模式》有提到,这里也放上来,感兴趣的可以直接去微信读书上阅读电子档。

缺点

1、可以被全局访问

  但凡是个程序员,多多少少应该都被告知过,少用全局变量吧。全局变量会让项目变得很混乱,一个数值的改变可能有几百几千个修改的地方,在出现问题的时候简直是噩梦。并且也会加强代码的耦合性。一个类的字段被其他很多个类引用着。

2、在存在多线程的地方有很大风险

  通常单例我们会在全局访问的入口处判断这个单例是否已经被实例化,如果没有被实例化才会实例化出来并返回,否则直接返回。而如果是多线程访问时,很可能在同一时间被两个地方同时访问,并且同时判断成功,并示例化出两个类。(这个好像叫非原子操作,可参考这个博客2)

3、延迟实例化导致卡顿

  这里还是用之前聊天存储单例的例子,因为在调用的时候才做的实例化,而调用的时候可能是在战斗的时候才打开聊天频道呼叫支援,这可能会导致你的战斗卡顿。(这种情况一般是特殊处理,如果一个单例实例化代价可能会比较大,则将这个实例化的时间提前,比如在加载的界面或者是通过其他方式提前手动实例化,避免关键时刻掉链子)

使用情境

  尽管在《游戏编程模式》中关于单例模式那一章里,作者费了很多笔墨来告诉我们不要使用单例,但实在架不住好用啊。单例在游戏开发过程中虽然不是必不可少的,但是基本都会用到。不过我们既然知道了这些缺点,那使用的时候就要小心,别一头栽到里面了。
  我们要实现的是一套Manage of Managers的框架,即用不同的Manager来管理不同的系统,而这些Manager大部分都是一个单例。我们也将引入一些其他的东西来尽可能减少耦合性,比如后一节要讲的事件模块。如果耦合无可避免,那就把耦合的地方都集中到一个类里吧。

二、具体实现

  我们这一节要讲的单例有两种,一种是只存在于内存中的单例,例如存储模块和资源加载模块。而另一种则是依赖于Unity Mono的单例,其需要和Unity的一些物体做交互。例如对象池模块和音频管理模块。下面我们来具体实现这两种单例,并逐步完善。

1、只存在于内存的单例

  在前面有提到,我们会有很多个Manager,如果每个Manager都写一个单例,那可能是下面这样的:

public class ClassA
{
    private static ClassA _instance;
    public static ClassA Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new ClassA();
            }

            return _instance;
        }
    }
}

public class ClassB
{
    private static ClassB _instance;
    public static ClassB Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new ClassB();
            }

            return _instance;
        }
    }
}

  仔细观察不难发现,上面的代码很大一部分是一样的,由于我们使用的是C#,它有一个泛型的概念,因此我们可以把这个单例模板变成一个泛型类,这样不同的单例只需要传入自己的类就能创建对应的单例了。因此我们代码变成下面这样:

public class Singleton<T> where T : new()
{
    private static T _instance;

    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new T();
            }

            return _instance;
        }
    }
}

  上面的<T>则表示这是一个泛型类,后面的where T 则是定义了一个约束关系,表示这个T必须是可以被new出来的。泛型的具体使用这里不多涉及了。
  而要变成单例的类则只需要继承这个泛型类即可。如下:

public class ClassA : Singleton<ClassA>
{
}

  上面的代码便创建了一个单例。通过ClassA.Instance即可访问到这个ClassA的实例了。不过上面的代码还不够,我们很多单例在创建的时候还需要做实例化操作。因此我们给它加入初始化操作。这里我选择增加一个IInitable的接口,用来修饰所有需要实例化的类。这个接口很简单,就声明一个Init的方法,如果一个类要实现接口,则必须实现接口中的Init方法。

public interface IInitable
{
    void Init();
}

  同时,我们修改泛型单例的代码,在实例化的时候判断下这个类是否实现了IInitable接口,如果实现了则在实例化完调用一次。所以泛型单例最终的代码如下:

public class Singleton<T> where T : new()
{
    private static T _instance;

    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new T();
                (_instance as IInitable)?.Init();
            }

            return _instance;
        }
    }
}

2、Mono单例

  一些Manager依赖于Unity Mono的一些东西,所以我们也可以为这些Manager写一个单例模板便于使用。这个和前面的不同的是,我们这个Manager是挂载到一个游戏物体身上存在于场景中的,所有我们要保证场景跳转时不被销毁。代码如下:

using UnityEngine;

public class MonoSingleton<T> : MonoBehaviour where T : Component
{
    private static T _instance = null;

    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = FindObjectOfType<T>();
                if (_instance == null)
                {
                    GameObject obj = new GameObject(typeof(T).Name, new[] {typeof(T)});
                    DontDestroyOnLoad(obj);
                    _instance = obj.GetComponent<T>();
                    (_instance as IInitable)?.Init();
                }
                else
                {
                    Debug.LogWarning("Instance is already exist!");
                }
            }

            return _instance;
        }
    }

    /// <summary>
    /// 继承Mono单例的类如果写了Awake方法,需要在Awake方法最开始的地方调用一次base.Awake(),来给_instance赋值
    /// </summary>
    private void Awake()
    {
        _instance = this as T;
        DontDestroyOnLoad(this);
    }
}

  以上就是单例模块的全部内容了,后期如果有更多需要,我们还可以继续对其进行拓展。单例写起来很容易,难还是难在怎么让大家都保持克制,不要写的太放飞自我 … …

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

在这里插入图片描述


  1. 游戏编程模式 ↩︎

  2. 读书笔记(十八) 《C++性能优化指南》三http://www.luzexi.com/2020/12/27/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B018 ↩︎

猜你喜欢

转载自blog.csdn.net/l1606468155/article/details/113925532
今日推荐