UFL1-3——安全性问题解决

前置知识点

细节知识点

  • C#中抽象类的知识点(C#四部曲之C#核心)
  • C#中反射相关知识点(C#四部曲之C#进阶)
  • Unity中Destroy函数(Unity四部曲Unity入门)
  • Unity中特性相关知识(Unity四部曲Unity入门)

唯一性问题——构造函数问题的解决

虽然公共构造函数可能给我们带来破坏唯一性的安全隐患
但是在实际开发中,单例模式是非常基础的知识点,可以说是程序员的必备基础知识
在使用时几乎不会有人马虎到自己去new​单例模式对象

因此即使我们不做这些安全性的处理,其实也不会有太大问题,根据实际情况和需求进行设计即可

构造函数带来的唯一性问题

  1. 对于不继承MonoBehaviour​的单例模式基类:我们要避免在外部 new 单例模式类对象
  2. 对于继承MonoBehaviour​的单例模式基类:由于继承MonoBehaviour​的脚本不能通过new​创建,因此不用过多考虑

类似于下面的代码就会出现安全性问题,我们在外部new​了一个单例模式的类对象,这破坏了单例模式唯一性:

1
2
TestMgr t = new TestMgr();
BaseManager<TestMgr> t2 = new BaseManager<TestMgr>();

解决构造函数带来的问题

  1. 父类变为抽象类

  2. 规定继承单例模式基类的类必须显示实现私有无参构造函数

  3. 在基类中通过反射来调用私有构造函数实例化对象

    主要知识点:
    利用Type​中的GetConstructor(约束条件, 绑定对象, 参数类型, 参数修饰符)​方法
    来获取私有无参构造函数

    1
    2
    3
    4
    5
    ConstructorInfo constructor = typeof(T).GetConstructor(
    BindingFlags.Instance | BindingFlags.NonPublic, //表示成员私有方法
    null, //表示没有绑定对象
    Type.EmptyTypes, //表示没有参数
    null); //表示没有参数修饰符

修改后的单例模式基类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// <summary>
/// 不继承MonoBehaviour的单例模式基类,实现静态变量和静态属性的声明,单例模式类可以直接继承该类,继承后无需自行实现单例声明相关内容
/// </summary>
/// <typeparam name="T">继承该类的类</typeparam>
public abstract class BaseManager<T> where T : class
{
private static T instance;

public static T Instance
{
get
{
if (instance == null)
{
Type type = typeof(T);
//使用反射来获取私有无参构造函数,并用于对象的实例化
ConstructorInfo info = type.GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (info != null)
instance = info.Invoke(null, null) as T;
else
Debug.LogError("没有得到对应的无参构造函数!!!");
}
return instance;
}
}
}

当然,也可以使用Activator.CreateInstance​方法来实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static T Instance
{
get
{
if (instance == null)
{
Type type = typeof(T);
instance = Activator.CreateInstance(type, true) as T;
if (instance == null)
Debug.LogError("无法实例化对象!");
}
return instance;
}
}

最后,如果想要避免他人使用反射来实例化对象,需要一个变量去标识是否实例化,然后在构造函数里检测该标识

一个简单的方式是,在单例模式基类里声明一个protected​的bool​类型属性isInitialized​,
这个属性通过instance​是否为空来决定是否为true​,如果instance​为空就是true​,
然后在其派生类的构造函数里检测这个isInitialized​是否为true​,如果为true​,则抛出错误,代码如下:

基类的代码(实测ConstructorInfo​或者Activator.CreateInstance​方法都可正常使用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class BaseManager<T> where T : class
{
private static T instance;

protected static bool isInitialized => instance != null;

public static T Instance
{
get
{
if (instance == null)
{
Type type = typeof(T);
//使用反射来获取私有无参构造函数,并用于对象的实例化
instance = Activator.CreateInstance(type, true) as T;
if (instance == null)
Debug.LogError("无法实例化对象!");
}
return instance;
}
}
}

派生类的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UnityEngine;

public class TestMgr : BaseManager<TestMgr>
{
public void Speak()
{
Debug.Log("我是TestMgr");
}

private TestMgr()
{
if (isInitialized)
throw new System.Exception("请勿重复实例化单例模式类对象!");
}
}

美中不足的一点是,无法防止单例模式的类在第一次调用Instance​前,外部调用反射构造方法实例化对象的情况,因此需要更进一步:

在基类声明一个私有的bool​类型标识,默认为false​,这个标识只有在Instance​内实例化对象时赋值true​,
实例化结束后赋值为false​,根据该标识是否为false​决定构造函数内是否抛出错误:

基类的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// <summary>
/// 不继承MonoBehaviour的单例模式基类,实现静态变量和静态属性的声明,单例模式类可以直接继承该类,继承后无需自行实现单例声明相关内容
/// </summary>
/// <typeparam name="T">继承该类的类</typeparam>
public abstract class BaseManager<T> where T : class
{
private static T instance;

private static bool internalInitializeToken = false; //类内部实例化标识,只有Intance内才可以修改它,用于确保只有内部可以实例化对象

//当instance不为null时且内部标识是false时,则表示不能实例化
protected static bool CanInitialize => instance == null && internalInitializeToken;

public static T Instance
{
get
{
if (instance == null)
{
Type type = typeof(T);
//使用反射来获取私有无参构造函数,并用于对象的实例化
//在这里实例化前先让该标识为true
internalInitializeToken = true;
instance = Activator.CreateInstance(type, true) as T;
if (instance == null)
Debug.LogError("无法实例化对象!");
//实例化后恢复为false
internalInitializeToken = false;
}
return instance;
}
}
}

//派生类的代码
public class TestMgr : BaseManager<TestMgr>
{
public void Speak()
{
Debug.Log("我是TestMgr");
}

private TestMgr()
{
if (!CanInitialize)
throw new System.Exception("请勿在外部实例化或多次实例化单例模式类对象!");
}
}

测试代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using UnityEngine;

public class Main : MonoBehaviour
{
void Start()
{
Type type = typeof(TestMgr);
Activator.CreateInstance(type, true); //如果运行正确,这里应当报错
TestMgr.Instance.Speak(); //如果运行正确,这里会正常输出内容
Activator.CreateInstance(type, true); //如果运行正确,这里应当报错
}
}

唯一性问题——重复挂载

为了避免重复挂载我们一般采用以下几种方案:

  1. 对于挂载式的单例模式基类,相同对象上重复挂载问题,通过添加[DisallowMultipleComponent]​特性解决
  2. 对于挂载式的单例模式基类,不同对象上的重复挂载,通过逻辑判断,代码移除多余的脚本
  3. 最好的避免重复挂载的方式,就是使用自动挂载式的单例模式基类,并且制定使用规则(不允许手动挂载和代码添加)

对于继承MonoBehaviour​的挂载式的单例模式基类

  1. 手动挂载多个相同单例模式脚本

  2. 代码动态添加多个相同单例模式脚本

    1
    this.gameObject.AddComponent<>()

这些行为都会破坏单例模式的唯一性

  • 对于挂载式的单例模式脚本

    1. 同个对象的重复挂载

      为脚本添加特性[DisallowMultipleComponent]
      该特性只能使脚本不能在一个对象上重复挂载,但是不能解决在不同对象上挂多个脚本的问题

    2. 修改代码逻辑

      判断如果存在对象,移除脚本

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      public class SingletonMono<T> : MonoBehaviour where T : MonoBehaviour
      {
      private static T instance;

      public static T Instance
      {
      get { return instance; }
      }

      /// <summary>
      /// 在重写该Awake函数时切记保留base.Awake()!!!
      /// </summary>
      protected virtual void Awake()
      {
      //已经存在一个对应的单例模式对象了,不需要再有一个
      if (instance != null)
      {
      Destroy(this);
      return;
      }
      instance = this as T;
      DontDestroyOnLoad(this.gameObject);
      }
      }
  • 对于自动挂载式的单例模式脚本,制定使用规则,不允许手动挂载或代码添加

线程安全——是否加锁

  • 对于不继承MonoBehaviour​的单例模式对象,建议加锁
  • 对于继承MonoBehaviour​的单例模式对象,可以不加

如果程序当中存在多线程,我们需要考虑当多个线程同时访问同一个内存空间时出现的问题
如果不加以控制,可能会导致数据出错,我们一般称这种问题为多线程并发问题,指多线程对共享数据的并发访问和操作。

而一般解决该问题的方式,就是通过C#中的lock​关键字进行加锁,我们需要考虑我们的单例模式对象们是否需要加锁(lock)

lock​ 的原理保证了在任何时刻只有一个线程能够执行被锁保护的代码块
从而防止多个线程同时访问或修改共享资源,确保线程安全

但是具体是否加锁,都根据需求来定,如果你的项目中压根就不会使用多线程,那么完全可以不用考虑加锁问题

  1. 不继承MonoBehaviour​的单例模式

    建议加锁,避免以后使用多线程时出现并发问题, 比如在处理网络通讯模块、复杂算法模块时,经常会进行多线程并发处理

    例如多线程访问Instance​时,需要避免instance​单例对象被多个线程同时访问Instance​导致重复实例化
    因此需要为实例化instance​的代码块加锁,为了性能考虑,判断instance == null​是不需要放入锁中的

    但是如果多个线程同时进入了外层instance == null​的代码块,线程先后进入带锁代码块,还是会出现重复实例化instance​的情况
    因此锁内必须再添加判断instance == null​,确保最先进入锁内的线程在实例化instance​后,后进入带锁代码块的线程将不能再实例化instance

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    /// <summary>
    /// 不继承MonoBehaviour的单例模式基类,实现静态变量和静态属性的声明,单例模式类可以直接继承该类,继承后无需自行实现单例声明相关内容
    /// </summary>
    /// <typeparam name="T">继承该类的类</typeparam>
    public abstract class BaseManager<T> where T : class
    {
    private static T instance;

    private static bool internalInitializeToken = false; //类内部实例化标识,只有Intance内才可以修改它,用于确保内部可以实例化对象

    protected static readonly object lockObj = new object(); //用于加锁的对象

    //当instance不为null时且内部标识是false时,则表示不能实例化
    protected static bool CanInitialize => instance == null && internalInitializeToken;

    public static T Instance
    {
    get
    {
    if (instance == null)
    {
    lock (lockObj)
    {
    //这里的判空不可以删除!它确保最先进入锁内的线程在实例化结束后,后进入带锁代码块的线程不能再实例化instance
    if (instance == null)
    {
    Type type = typeof(T);
    //使用反射来获取私有无参构造函数,并用于对象的实例化
    //在这里实例化前先让该标识为true
    internalInitializeToken = true;
    instance = Activator.CreateInstance(type, true) as T;
    if (instance == null)
    Debug.LogError("无法实例化对象!");
    //实例化后恢复为false
    internalInitializeToken = false;
    }
    }
    }
    return instance;
    }
    }
    }
  2. 继承MonoBehaviour​的单例模式

    可加可不加,但是建议不加
    因为Unity中的机制是,Unity主线程中处理的一些对象(如GameObject​、Transform​等等)是不允许被其他多线程修改访问的,会直接报错
    因此我们一般不会通过多线程去访问继承MonoBehaviour​的相关对象,既然如此,就不会发生多线程并发问题