【设计模式】单例模式(Unity3DC#)

    xiaoxiao2021-03-25  83

    【题外话】

      最近整理博客,虽然文章不多,但是文章命名和文章分类好像也整的很混乱- -。之后会本着标题命名便于检索、文章分类依据文章所涉及知识概念的原则进行划分。

    【单例概述】

    定义:单例,顾名思义,单个实例,即应用单例模式的类有且只有一个实例对象,并提供一个全局访问点来共其他类与对象访问。

    (1)有且只有一个实例对象。   有实例对象说明该类不是抽象类;只有一个实例对象表示不能随时随地的new一个该类的对象出来(这不是对开发者的约定,而是代码层面上的约定,即如果你这样做了,编辑器会提示你错误),即该类的构造函数是private级别,只能在类的内部构建实例对象; (2)提供全局访问点使其他类与对象访问。   这说明应用单例模式的类在提供实例访问方法时,该方法应该是静态的。

      有了如上的分析,就可以很轻松的创建单例类了,代码并不是很复杂。为什么要分成“C#中的单例模式”和“Unity中的伪单例模式”,前者指C#语言本身,后者指在Unity的Mono框架下。还有一点要说的,下面所有所有的例子都只是说明单例如何创建,不是说类中就这么点东西。。。- -

    【C#中的单例模式】

      在单例模式中,根据应用情况的不同,也有着不同的实现方式。先把统一的访问方式写出来,这种访问方式应用于该命题下述的所有模式。

    namespace CSharpTest { class Program { static void Main(string[] args) { Singleton s = Singleton.GetInstance(); } } }

    饿汉模式

      意思是,在该类装载时构建类的单例。(类的装载可以粗浅的理解为发生在程序启动时,Main之前)也就是说,这个单例跟你什么时候用,是否要用无关,只要运行程序,这个单例就存在了。这样做的坏处是如果程序初始化时要载入的资源过多时显然这种方式又提高了加载的负担,其次如果没有使用到的话也浪费了内存。

    namespace CSharpTest { public class Singleton { //私有化构造函数 使得外部无法构造类的实例 private Singleton() { } //定义实例对象时便创建实例 private static Singleton _instance = new Singleton(); //提供全局访问点 public static Singleton GetInstance() { return _instance; } } }

    懒汉模式

      意思是,在该类的单例被使用时构造类的单例。它相比饿汉模式更加灵活,所以应用更为广泛。

    基本模式(单线程模式)

      在单线程中,只需做如下定义:

    namespace CSharpTest { public class Singleton { //私有化构造函数 使得外部无法构造类的实例 private Singleton() { } //定义一个空的单例对象 private static Singleton _instance; //提供全局访问点 public static Singleton GetInstance() { //第一次访问时会创建实例 if (_instance == null) _instance = new Singleton(); return _instance; } } }

    多线程模式

      在多线程的程序中,构造单例的方式要发生什么变化呢?我们来依据单线程模式构造单例的代码来分析:全局访问方法内提供了如下的判断条件:

    if (_instance == null) _instance = new Singleton();

      这会引发什么问题?以两个线程情况为例。当两个线程运行到这里时,可能线程1刚经过判断还没创建实例时,线程2就也已经通过判断要创建实例了,这会造成两个线程都创建了实例,这就违背了我们单例模式的初衷。所以我们要对其进行“加锁”,进行争用条件判断。即谁先来的谁先访问,我访问的时候你不许访问,我访问完了你再访问。

    实现思路如下:   这里使用一个辅助对象(必须是引用类型)充当锁,当多个线程同时访问GetInstance方法时,第一个进来的锁定该对象,这时,其他线程遇到锁时会挂起等待,当这个线程执行完锁定代码块时解锁,这时第二个进来的线程再锁,解锁之后第三个进来的线程再锁。。。依次类推。这样就避免了多线程访问同一对象时会引发的风险。此例中的风险便是创建多个实例。

    namespace CSharpTest { public class Singleton { //私有化构造函数 使得外部无法构造类的实例 private Singleton() { } //定义一个空的单例对象 private static Singleton _instance; //辅助对象 private static object obj = new object(); //提供全局访问点 public static Singleton GetInstance() { //加锁,此时其他线程挂起,等待上锁的那个线程执行完事 lock (obj) { //第一次访问时会创建实例 if (_instance == null) _instance = new Singleton(); } //运行完代码块就解锁了,其他线程此时可以进入 return _instance; } } }

      这边对线程做个小说明。通常我们学习编程基础时都是单线程模式。当我们开启第二条线程时,两条线程的运行是各自独立,处理各自的逻辑,他们基本上是同时运行的。可能上述例子会有个疑问,为什么可以同时通过判断而不能同时加锁呢?这涉及到两个问题。判断与锁的区别多线程的执行顺序。 (1)判断与锁的区别。   判断中,只要满足条件即可执行相应的代码块,并无其他限制;锁是当一个访问者进入锁的代码块之后马上加锁,其他访问者只能等前一个访问者出来后才能进去,当然,无论谁进去都会马上加锁。 (2)多线程的执行顺序。   这里做两个合理的猜想。一是多个线程各自独立,只是执行的快慢有微小差别,这种速度差别能使一个线程刚通过判断语句还没创建实例时,另外的线程也通过了判断语句;二是多个线程的确是同时过来的,但是在锁之前会出现顺序之分可能是底层的处理机制,因为每个线程都是有自己的标识的,当遇到琐时线程管理器会自动为多个线程分配优先顺序,保证他们有序申请锁定。

    优化多线程模式

      多线程模式主要解决的问题是当单例未创建时,多个线程同时访问GetInstance方法造成单例的多次创建。但现在的解决方案显然是有问题的。首先,单例没创建时,多线程是否会同时访问我们是不清楚的;在单例已经创建时,我们再去访问GetInstance方法时其实只需判断_instance是否为空就可以了,因为它已经被创建过,所以不会造成多次创建的问题,那么此时再加锁解锁的就很画蛇添足、耗费性能了,更何况很可能程序运行的后来只有单个线程频繁访问单例,那还锁它干啥- -。   在分析中很显然提出了解决的办法,加个判断就好了~

    namespace CSharpTest { public class Singleton { //私有化构造函数 使得外部无法构造类的实例 private Singleton() { } //定义一个空的单例对象 private static Singleton _instance; //辅助对象 private static object obj = new object(); //提供全局访问点 public static Singleton GetInstance() { //后续再访问时只要判断实例是否为null就行了 //不为null直接返回_instance //只有未创建时才会启动锁的功能 if (_instance == null) { //加锁,此时其他线程挂起,等待上锁的那个线程执行完事 lock (obj) { //第一次访问时会创建实例 if (_instance == null) _instance = new Singleton(); } //运行完代码块就解锁了,其他线程此时可以进入 } return _instance; } } }

    【Unity中的伪单例模式】

      以下模式的前提都是单场景。下述的单例模式都是伪单例模式。   Unity实际是脚本编程,基于Mono框架,类默认继承自MonoBehavior可以直接附加到物体上作为组件,组件所在的物体就是这个脚本类的对象,它提供了一种除了new之外新的对象构建方式。   将脚本类应用于单例模式通常是想应用例如Update、Start等Message方法,或者应用其组件化的特性在编辑器中设置脚本的成员等等。基本套路是脚本指定给物体上,获取单例使用FindObjectOfType方法,这也解释了为什么只能单场景使用,因为场景中的物体会随着场景变更而销毁,而脚本依附在物体上面也会被销毁。


    基本模式

    using UnityEngine; public class Singleton : MonoBehaviour { //不写也无妨,创建继承自MonoBehavior的类使不允许的 //虽然不会报错而是产生警告,但仍不可直接new //因为其作为组件来使用,继承关系如下 //Object->Component->Behaviour->MonoBehaviour->Singleton private Singleton() { } private static Singleton _instance; public static Singleton GetInstance() { if (_instance == null) { Debug.Log("Create singleton..."); _instance = GameObject.FindObjectOfType<Singleton>(); } return _instance; } }

    复用模式

      可能有多个类都需要应用单例模式,它们用于处理不同的逻辑块。为每个类都写一个提供单例的创建方式显然太低效率了,那就直接写个泛型来剥离出创建单例的代码吧!

    静态类

      用来创建实例的SingletonStatic类:

    using UnityEngine; public static class SingletonStatic<T> where T : MonoBehaviour //FindObjectOfType方法的泛型参数必须继承自Object类,所以这里对T要进行约束 { private static T _instance; public static T GetInstance() { if (_instance == null) { Debug.Log("Create " + typeof(T).ToString() + " singleton..."); _instance = GameObject.FindObjectOfType<T>(); if (_instance == null) Debug.LogError("Class of " + typeof(T).ToString() + " not found!"); } return _instance; } }

      需要应用单例模式的两个类,SingletonClass1类和SingletonClass2类:

    using UnityEngine; public class SingletonClass1 : MonoBehaviour { private SingletonClass1() { } public int myInt = 2; } using UnityEngine; public class SingletonClass2 : MonoBehaviour { private SingletonClass2() { } public int myInt = 5; }

      用来访问单例的测试类,TestClass类:

    public class TestClass : MonoBehaviour { void Awake () { SingletonClass1 s1 = SingletonStatic<SingletonClass1>.GetInstance(); SingletonClass2 s2 = SingletonStatic<SingletonClass2>.GetInstance(); Debug.Log(s1.myInt); Debug.Log(s2.myInt); Debug.Log(s1.myInt); Debug.Log(s2.myInt); } }

      除了静态类,将这三个脚本分别指定给不同的对象,运行查看Console面板:   可以看到两个类的单例都实例了一次。很多人会有疑问,应用泛型会不会导致另外一个类型创建实例时会覆盖掉之前类型的实例,经过这样的测试我们发现这样的担忧完全是不必要的。

    继承抽象类

      继承抽象类的原理其实与静态类比较相似,这里直接给出父类,应用单例模式类,以及测试类的代码。 父类SingletonBase类:

    using UnityEngine; public abstract class SingletonBase<T> : MonoBehaviour where T : MonoBehaviour { private static T _instance; public static T GetInstance() { if (_instance == null) { Debug.Log("Create " + typeof(T).ToString() + " singleton..."); _instance = GameObject.FindObjectOfType<T>(); if (_instance == null) Debug.LogError("Class of " + typeof(T).ToString() + " not found!"); } return _instance; } }

      应用单例模式的类SingleClass1类:

    using UnityEngine; public class SingletonClass1 : SingletonBase<SingletonClass1> { private SingletonClass1() { } }

      测试类TestClass类:

    using UnityEngine; public class TestClass : MonoBehaviour { void Awake () { SingletonClass1 s1 = SingletonClass1.GetInstance(); } }

    为什么称为伪单例

      假设应用单例模式的类(脚本)的名称为SingletonClass:

    (一)根本问题

      无法避免脚本挂在多个物体上,因为SingletonClass会继承MonoBehavior类。虽然我们在任何时候访问SingletonClass对象都是同一个,但是这不代表场景中这个对象是唯一的。说白了就是当脚本挂在物体上时已经是个实例了,FindObjectOfType方法只是去找到其中一个实例,并不是在创造独一无二。每个实例都会执行Monobehaviour中的Message方法(Start、Update这些)。   总结是当你同样的脚本挂在两个物体上的时候这个脚本类的对象就不唯一了,且没有方法阻止脚本挂在物体上除非不继承MonoBehaviour类。那既然不需要MonoBehaviour类,何不写成标准C#中的真单例模式呢?

    (二)衍生问题

      前面说到这样的伪单例只适合单场景,其实使用Object类的静态方法DontDestroyOnLoad方法可以将对象加载到内存中,只有整个程序结束的时候才会被清除。但这样做又会引发新的问题。这里做个演示,将“继承抽象类”例子中的代码修改至如下所示:

    using UnityEngine; public abstract class SingletonBase<T> : MonoBehaviour where T : MonoBehaviour { private static T _instance; public static T GetInstance() { if (_instance == null) { Debug.Log("Create " + typeof(T).ToString() + " singleton..."); _instance = GameObject.FindObjectOfType<T>(); //创建完实例后使其不会因场景切换被销毁 Object.DontDestroyOnLoad(_instance); if (_instance == null) Debug.LogError("Class of " + typeof(T).ToString() + " not found!"); } return _instance; } }

      新建一个场景,两个场景都添加个按钮,点击按钮能来回切换场景。这里单例的物体名称为SingletonClass1,测试类所在物体叫TestClass,该场景为Scene1,新创建场景为Scene2,。现在我们从Scene1运行,点击按钮切换到Scene2,再点击按钮切换回Scene,资源面板显示如图所示:   出现了两个实例!这是因为我们将其加载到内存中时它已经不属于场景本身了,而场景初始化的时候会创建预制的资源,这就导致了我们再次回到场景时,出现了两个SingletonClass1。这进一步的违背单例模式的初衷。


    为什么C#中没写“复用模式”?

      上面介绍过,Unity中脚本挂在物体上,我们构建所谓的单例是找到这个物体,并不是创建对象的方式;而C#中都是用new关键字创建对象的形式来构造单例。假设我们构造了这样一个泛型类,来看看具体的写法:

    namespace CSharpTest { public class Singleton<T> where T : new() { //私有化构造函数 使得外部无法构造类的实例 private Singleton() { } //定义实例对象时便创建实例 private static T _instance; //提供全局访问点 public static T GetInstance() { if (_instance == null) return new T(); return _instance; } } }

      与最开始例子给出的代码的不同之处,除了所有的类型都写成了泛型T以为,还有很关键的一点,我们对T的类型进行了约束,约束T类型必须含有public级别的构造函数。问题就在于这里。我们为了应用单例模式的类不被随意创建,会将其构造函数设为private级,这就造成了冲突,导致需要应用单例模式的类无法作为该泛型类的的类型参数。


    【为什么要用单例】

      比如我们玩游戏,游戏的目标是把三个任务都完成就可以通关。在游戏内部机制中,应该是没完成一个任务就通知管理器该任务已经完成,此时游戏管理器就是一个单例,这样当游戏管理器检测到三个任务都完成时才会通知玩家游戏通关。如果每个任务都创建了一个游戏管理器,那么这个游戏是不可能通关的——每个游戏管理器中只有一个任务被完成的记录啊!这些任务记录并没有集中到同一个游戏管理器中。此时是一定要使用单例模式的。


    【总结】

      举了很多例子,单例的性质已经很清晰了。但现在还有的疑问可能是,既然基继承自Monobehaviour写的单例是伪单例,为什么还要一一列举出来呢?存在必有道理。MonoBehaviour的确提供了极大地便利,很多开发者在Unity中都会用这样的伪单例形式,的确可以这样用,而且对于某些需求,这样做会极大的提高开发效率,但是要跟真正的单例模式区分开,不是说死记硬背一种设计模式,而是掌握其核心思想,更加安全高效的开发才是最重要的。


    【参考资料】

    [1]. C#设计模式(1)-单例模式. 2013-07-12. Learning hard [2]. 单例模式-维基百科

    转载请注明原文地址: https://ju.6miu.com/read-40189.html

    最新回复(0)