[Unity设计模式与游戏开发]单例模式

前言

单例模式是我们最常用的设计模式,面试的时候如果问任何一个开发者设计模式,单例模式估计是脱口而出吧,23中常见的设计模式之中并不是所有设计模式都是很常用的,而单例模式绝对是最常用的那一个。但如果真正面试深入问到单例模式,那你确定你真的了解嘛?常见的面试会让你现场写个单例模式,如果深入一点的问的话会问单例模式有几种实现方式?用代码实现并说出各个方式的优缺点?想必如果面试官真这么问的话,估计绝大多数人也hold不住吧,今天我就来深入的整理一下单例模式。

为什么要使用单例模式?

单例模式理解起来很简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫做单例设计模式,简称单例模式。

处理资源访问冲突

我们先来看一个例子,我们定义一个往文件中打印日志的Logger类

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/dxw/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }

  public void log(String message) {
    writer.write(mesasge);
  }
}

// Logger类的应用示例:
public class UserController {
  private Logger logger = new Logger();

  public void login(String username, String password) {
    // ...省略业务逻辑代码...
    logger.log(username + " logined!");
  }
}

public class OrderController {
  private Logger logger = new Logger();

  public void create(OrderVo order) {
    // ...省略业务逻辑代码...
    logger.log("Created an order: " + order.toString());
  }
}

上面代码会看到,我们创建了两个Logger对象,但写到同一个txt文件中,就有可能存在日志相互覆盖的情况,因为这里是存在资源竞争的关系,如果我们有两个线程同时个一个共享变量修改,往里面写数据,就有可能相互覆盖了。有人会想到解决这个问题就是加一个锁,但如果加对象锁并不能解决多个对象竞争同一个资源的问题,我们需要加类锁才行,这个时候我们会想到如果将Logger设计成单例就不会存在这样的问题了。

表示全局唯一类

在业务概念上,如果有一些数据在系统中只应该保存一份,那就比较适合用单例类。比如常见的配置信息。在系统中,我们只有一个配置文件,当配置文件被加载到内存后,以对象的形式存在,也理所应当只有一份,常见的就是游戏数据配表。再比如,唯一ID号码生成器,如果程序中有两个对象就会存在重复ID的情况,所以,我们应该将ID生成器设计为单例。

单例设计模式常见写法

  • 饿汉式(静态常量)
//饿汉式(静态变量)
public class Singleton1
{
    //构造器私有化,外部不能new,不写这个构造函数则会默认有一个公有的构造函数,外部就可以new,这样不符合单例模式
    private Singleton1()
    {

    }
    //在内部创建一个实例对象
    private static Singleton1 instance = new Singleton1();

    //提供一个公有的静态方法,返回实例对象
    public static Singleton1 GetInstance()
    {
        return instance;
    }
}

使用测试

Singleton1 instance1 = Singleton1.GetInstance();
Singleton1 instance2 = Singleton1.GetInstance();
Debug.Log(string.Format("instance1和instance2是否相等:{0}", instance1 == instance2));
Debug.Log("instance1的hashCode:" + instance1.GetHashCode() + "     instance2的hashCode:" + instance2.GetHashCode());

测试效果
在这里插入图片描述

优缺点说明:
优点:
写法简单,在类装在的时候就实现了实例化,避免了线程同步的问题。
缺点:
在类装在的时候就完成实例化,没有达到Lazy Loading的效果。如果从开始至终都没有使用过这个实例,则会造成内存的浪费。
结论:
这种方式单例模式可用,可能造成内存的浪费。

  • 饿汉式2

实例化的操作也可以放在私有构造函数内

public class Singleton2
{
    private static Singleton2 instance;

    //将实例化放在私有构造函数里面
    private Singleton2()
    {
        instance = new Singleton2();
    }

    //提供一个公有的静态方法,返回实例对象
    public static Singleton2 GetInstance()
    {
        return instance;
    }
}
  • 懒汉式
public class Singleton3
{
    private static Singleton3 instance;
    private Singleton3() { }

    //提供静态公有方法返回实例对象
    public static Singleton3 GetInstance()
    {
        //需要用到的时候再实例化单例对象,即懒汉式
        if(instance == null)
        {
            instance = new Singleton3();
        }
        return instance;
    }
}

优缺点说明:
1.起到了懒加载的效果,但只能在单线程下使用。
2.如果在多线程下,一个线程还没进入if(instance == null)的逻辑,另外一个线程也进行了访问,又进行了对象的创建就会产生多个实例。
结论:
在实际开发中,不要用这种方式,但Unity游戏开发一般不使用多线程的方式,所以Unity游戏开发中这种模式还是用的挺多的。

  • 懒汉式(线程安全,同步方法)
public class Singleton5
{
    private Singleton5(){ }

    private static readonly object syncObj = new object();
    private static Singleton5 instance = null;
    public static Singleton5 Instance
    {
        get
        {
            lock (syncObj)     //添加同步锁
            {
                if (instance == null)
                    instance = new Singleton5();
            }
            return instance;
        }
    }
}

优缺点说明:
1.解决了线程不安全的问题
2.效率太低,每个线程想访问的时候都需要执行一次同步,如果一个线程加锁,第二个线程只能等待,效率太低。
结论:
实际开发中,不推荐使用这种方式。

  • 懒汉式(双重检测)
public class Singleton6
{
    private Singleton6() { }

    private static readonly object syncObj = new object();
    private static Singleton6 instance = null;
    public static Singleton6 Instance
    {
        get
        {
            //双重检测提高效率
            if (instance == null)
            {
                lock (syncObj)     //添加同步锁
                {
                    if (instance == null)
                        instance = new Singleton6();
                }
            }
            return instance;
        }
    }
}

优点说明:
既解决了懒加载的问题,又解决了线程同步的效率问题。
总结:
在实际开发中推荐使用的方式。

Unity中常见的通用单例模式

Unity开发中最常创建单例模式的就是各种Manager,在程序启动的时候首先实例化各种单例的Manager,而我见过一个主程是这样写的,每一个Manager自己内部定义一个static xxx instance,然后在GetInstance方法中实例化这个instance返回,就会显得很重复,一般Unity中是这样创建通用泛型单例类的

创建非MonoBehavior单例

public class Singleton<T> where T : new()
{
    protected static T _instance;
    public static T sInstance
    {
        get
        {
            if (_instance == null)
                _instance = new T();
            return _instance;
        }
    }
}

我们使用的时候

public class BattleManager : Singleton<BattleManager>
{
}

因为Unity里面几乎不太使用多线程,所以这里就没考虑加锁的情况。

创建MonoBehavior单例

public class MonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
    public static T sInstance
    {
        get { return _instance; }
    }

    protected void  Awake()
    {

        if(_instance != null)
        {
            DestroyImmediate(gameObject);
            return;
        }
        _instance = gameObject.GetComponent<T>();
        InitOnAwake();
    }

    protected void OnDestroy () {
        if(_instance == this)
        {
            ReleaseOnDestroy();
            _instance = null;
        }
    }

    protected virtual void InitOnAwake() {}
    protected virtual void ReleaseOnDestroy() {}
}

使用

public class BattleCameraConfig : MonoSingleton<BattleCameraConfig>
{
}

顾名思义就是MonoBehavior的单例是可以挂在GameObject上的。

单例模式的弊端以及替代方案

1.单例模式存在的问题?

  • 单例对OOP特性的支持不友好
    OOP的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中抽象、继承、多台都支持的不好,举例说明:
    public class Order {
      public void create(...) {
        //...
        long id = IdGenerator.getInstance().getId();
        //...
      }
    }

    public class User {
      public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        //...
      }
    }

IdGenerator的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的OOP的抽象特性。如果未来某一天,我们希望针对不同的业务采用不同的ID生成算法。比如,订单ID和用户ID采用不同的ID生成器生成。为了应对这个需求的变化,我们需要修改到所有用到IdGenerator类的地方,这样改动就会比较大。

    public class Order {
      public void create(...) {
        //...
        long id = IdGenerator.getInstance().getId();
        // 需要将上面一行代码,替换为下面一行代码
        long id = OrderIdGenerator.getIntance().getId();
        //...
      }
    }

    public class User {
      public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        // 需要将上面一行代码,替换为下面一行代码
        long id = UserIdGenerator.getIntance().getId();
      }
    }

除此之外,单例对继承、多态特性的支持也不友好。这里"不友好"并不是"完全不支持",从理论上讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,导致代码可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦选择将某个类设计成单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就是相当于损失了可以应对未来需求变化的扩展性。

  • 单例会隐藏类与类之间的依赖关系
    我们知道代码的可读性非常重要,在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。所以在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才知道这个类到底依赖了哪些类。

  • 单例对代码的扩展性不友好
    我们知道单例类智能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或者多个实例,那就要对代码有比较大的改动。举个例子,软件系统设计初期,我们可能会觉得系统中只应该有一个数据库连接池,这样能方便我们控制数据库连接资源的消耗,所以数据库连接池就被设计成了单例类。但之后发现,我们可能会用到好几种数据库,但不同的数据库的访问接口都不一样,这样我们就需要不同的连接池对象,也就是不能设计成单例类,实际上一些开源的数据库连接池也确实没设计成单例类,我一开始刚接触.NET开发的时候就碰到过MSSQL和Oracle之间的切换。

  • 单例对代码的可测试性不友好

  • 单例不支持有参数的构造函数

2.单例有什么替代方案?

为了保证全局唯一性,除了使用单例,我们还可以用静态方法来实现。不过静态方法这种实现思路,并不能解决上面提到的问题。如果要完全解决这些问题,我们需要从根本上寻找其他方式来实现全局唯一类,可以由程序员自己来保证不要创建两个类的对象。

设计模式系列教程汇总

http://dingxiaowei.cn/tags/设计模式/

教程代码下载

https://github.com/dingxiaowei/UnityDesignPatterns

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页