2.单例模块
2.1 知识点
为什么要写单例模式基类
在游戏开发中,我们需要创建各种管理器类,如资源管理器、音效管理器、UI管理器等。这些管理器通常需要在整个游戏生命周期中保持唯一性,避免重复创建造成资源浪费或状态混乱。如果每次都重复编写单例模式的代码,会造成大量冗余。通过面向对象的思想,我们可以创建一个泛型基类,让所有需要单例功能的管理器类都继承这个基类,从而避免重复代码。
单例模式基类的主要作用是减少单例模式重复代码的书写,通过面向对象的思想避免代码冗余,让开发者能够快速创建各种管理器类。
单例的基本要素
静态实例
存储单例对象的静态字段,确保全局唯一性
私有构造函数
防止外部直接创建实例,保证单例的唯一性
公共访问属性
提供全局访问点,通常命名为 Instance
不继承MonoBehaviour的单例模式基类
初步实现(存在问题)
对于纯C#类(不继承MonoBehaviour),我们可以尝试一个最简单的泛型单例实现。此时,为了方便外部创建,我们可能会使用 public 构造函数。
初步源码实现:Singleton.cs (初步版本)
using System;
using UnityEngine;
/// <summary>
/// 单例模式基类(初步版本 - 存在构造函数唯一性问题)
/// </summary>
public class Singleton<T> where T : class, new()
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = new T(); // 直接通过new创建,此时T必须有public无参构造函数
}
return instance;
}
}
// 此时构造函数可以是public,或者不写(默认为public)
public Singleton() { }
}
初步测试代码:TestMgr.cs 和 SingletonTest.cs
// TestMgr.cs
// 继承初步的Singleton类,此时TestMgr需要一个public无参构造函数
public class TestMgr : Singleton<TestMgr>
{
public TestMgr()
{
UnityEngine.Debug.Log("TestMgr 构造函数被调用");
}
public void Speak()
{
UnityEngine.Debug.Log("TestMgr Speak");
}
}
// SingletonTest.cs
using UnityEngine;
public class SingletonTest : MonoBehaviour
{
void Start()
{
// 正常获取单例
TestMgr.Instance.Speak();
// 输出:TestMgr 构造函数被调用
// 输出:TestMgr Speak
// 此时外部可以通过new创建新的实例,破坏了单例的唯一性
TestMgr newMgr = new TestMgr();
// 输出:TestMgr 构造函数被调用 (第二次调用构造函数)
newMgr.Speak();
// 输出:TestMgr Speak
// 问题:会发现构造函数被调用了两次,且newMgr和TestMgr.Instance是不同的对象
}
}
继承MonoBehaviour的单例模式基类
实现挂载式的单例模式基类(初步实现)
对于需要利用Unity生命周期(如 Update, Awake 等)的管理器,我们需要让单例类继承 MonoBehaviour。挂载式的单例意味着我们需要手动将脚本挂载到场景中的GameObject上。
初步源码实现:SingletonMono.cs (初步版本)
using UnityEngine;
/// <summary>
/// 继承MonoBehaviour的单例模式基类(挂载式 - 初步版本,存在重复挂载问题)
/// </summary>
public class SingletonMono<T> : MonoBehaviour where T : SingletonMono<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 尝试从场景中查找现有实例
instance = FindObjectOfType<T>();
if (instance == null)
{
// 如果没有,则创建一个新的GameObject并挂载脚本
GameObject obj = new GameObject(typeof(T).Name);
instance = obj.AddComponent<T>();
}
}
return instance;
}
}
protected virtual void Awake()
{
// 此时没有重复挂载检查
// instance = this as T; // 简单的赋值
}
}
初步测试代码:Test2Mgr.cs 和 MonoSingletonTest.cs
// Test2Mgr.cs
public class Test2Mgr : SingletonMono<Test2Mgr>
{
public void Speak()
{
UnityEngine.Debug.Log("Test2Mgr Speak");
}
}
// MonoSingletonTest.cs
using UnityEngine;
public class MonoSingletonTest : MonoBehaviour
{
void Start()
{
// 确保场景中有一个挂载了Test2Mgr脚本的GameObject
Test2Mgr.Instance.Speak();
// 输出:Test2Mgr Speak
}
}
实现自动挂载式的单例模式基类(初步实现)
为了进一步简化使用,我们可以实现一个”自动挂载式”的单例。如果场景中不存在该单例,它会自动创建一个GameObject并挂载自身。
初步源码实现:SingletonAutoMono.cs (初步版本)
using UnityEngine;
/// <summary>
/// 继承MonoBehaviour的单例模式基类(自动挂载式 - 初步版本,存在重复挂载问题)
/// </summary>
public class SingletonAutoMono<T> : MonoBehaviour where T : SingletonAutoMono<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 尝试从场景中查找现有实例
instance = FindObjectOfType<T>();
if (instance == null)
{
// 如果没有,则创建一个新的GameObject并挂载脚本
GameObject obj = new GameObject(typeof(T).Name);
instance = obj.AddComponent<T>();
}
}
return instance;
}
}
protected virtual void Awake()
{
// 此时没有重复挂载检查
// instance = this as T; // 简单的赋值
}
}
初步测试代码:Test2Mgr2.cs
// Test2Mgr2.cs
public class Test2Mgr2 : SingletonAutoMono<Test2Mgr2>
{
public void Speak()
{
UnityEngine.Debug.Log("Test2Mgr2 Speak");
}
}
// AutoMonoSingletonTest.cs (继续使用,但现在Test2Mgr2会自动创建)
using UnityEngine;
public class AutoMonoSingletonTest : MonoBehaviour
{
void Start()
{
// 如果场景中没有Test2Mgr2,它会自动创建一个GameObject并挂载
Test2Mgr2.Instance.Speak();
// 输出:Test2Mgr2 Speak
}
}
唯一性问题—构造函数
之前存在什么问题
通过上面的测试代码,我们发现了一个严重的问题:外部可以通过 new 关键字创建新的实例,破坏了单例的唯一性。
问题分析:
- 对于不继承MonoBehaviour的单例模式基类:我们要避免在外部
new单例模式类对象,因为这会创建新的实例,破坏单例的唯一性。 - 对于继承MonoBehaviour的单例模式基类:由于继承MonoBehaviour的脚本不能通过
new创建,因此不用过多考虑。
如何解决构造函数问题
为了强制单例的唯一性,我们需要阻止外部通过 new 关键字创建实例。这可以通过将构造函数设为 private 或 protected 来实现。然而,如果构造函数是私有的,基类又如何创建实例呢?答案是使用反射。
解决方案:
- 父类变为抽象类:将单例基类设为抽象类,可以强制子类实现某些行为,并阻止基类被直接实例化。
- 规定继承单例模式基类的类必须显示实现私有无参构造函数:这是为了配合反射机制,让基类能够找到并调用子类的私有构造函数。
- 在基类中通过反射来调用私有构造函数实例化对象:
主要知识点:
利用Type中的GetConstructor(约束条件, 绑定对象, 参数类型, 参数修饰符)方法来获取私有无参构造函数。ConstructorInfo constructor = typeof(T).GetConstructor( BindingFlags.Instance | BindingFlags.NonPublic, // 表示成员私有方法 null, // 表示没有绑定对象 Type.EmptyTypes, // 表示没有参数 null); // 表示没有参数修饰符
解决后的源码实现:Singleton.cs (解决构造函数问题)
using System;
using System.Reflection;
using UnityEngine;
/// <summary>
/// 单例模式基类(解决构造函数唯一性问题,未考虑线程安全)
/// </summary>
public abstract class Singleton<T> where T : class
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 通过反射获取私有无参构造函数
ConstructorInfo constructor = typeof(T).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (constructor == null)
{
throw new Exception($"Class {typeof(T).Name} must have a private parameterless constructor.");
}
instance = constructor.Invoke(null) as T;
}
return instance;
}
}
// 规定继承单例模式基类的类必须显示实现私有无参构造函数
protected Singleton() { }
}
测试代码:TestMgr.cs (更新) 和 ConstructorTest.cs
// TestMgr.cs (更新后,需要私有构造函数)
public class TestMgr : Singleton<TestMgr>
{
// 必须实现私有无参构造函数
private TestMgr()
{
UnityEngine.Debug.Log("TestMgr 构造函数被调用");
}
public void Speak()
{
UnityEngine.Debug.Log("TestMgr Speak");
}
}
// ConstructorTest.cs
using UnityEngine;
public class ConstructorTest : MonoBehaviour
{
void Start()
{
// 正常获取单例
TestMgr.Instance.Speak();
// 输出:TestMgr 构造函数被调用
// 输出:TestMgr Speak
// 此时尝试通过new创建会报错,因为构造函数是私有的
// TestMgr newMgr = new TestMgr(); // 编译错误或运行时反射失败
// 问题解决:外部无法再创建新的实例,保证了单例的唯一性
}
}
唯一性问题—重复挂载
之前存在什么问题
对于继承 MonoBehaviour 的单例模式,存在以下重复挂载问题:
- 同个对象的重复挂载:在同一个GameObject上不小心多次添加了同一个单例脚本。
- 不同对象的重复挂载:在场景中存在多个GameObject,每个都挂载了同一个单例脚本。
如何解决重复挂载问题
为了确保 MonoBehaviour 单例的唯一性,我们需要在代码层面进行检查和限制。
解决方案:
- 同个对象的重复挂载:为脚本添加特性
[DisallowMultipleComponent] - 修改代码逻辑:判断如果存在对象,移除脚本
- 对于自动挂载式的单例模式脚本:制定使用规则,不允许手动挂载或代码添加
解决后的源码实现:SingletonMono.cs 和 SingletonAutoMono.cs
using UnityEngine;
/// <summary>
/// 继承MonoBehaviour的单例模式基类(挂载式 - 解决重复挂载问题)
/// </summary>
[DisallowMultipleComponent] // 防止同一个GameObject上挂载多个相同脚本
public class SingletonMono<T> : MonoBehaviour where T : SingletonMono<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 尝试从场景中查找现有实例
instance = FindObjectOfType<T>();
if (instance == null)
{
// 如果没有,则创建一个新的GameObject并挂载脚本
GameObject obj = new GameObject(typeof(T).Name);
instance = obj.AddComponent<T>();
}
}
return instance;
}
}
protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject); // 确保在场景切换时不被销毁
}
else if (instance != this)
{
// 如果已经存在一个实例,并且当前实例不是这个,则销毁当前实例
Destroy(gameObject);
}
}
}
// SingletonAutoMono.cs (与SingletonMono.cs的Awake逻辑相同,确保唯一性)
using UnityEngine;
/// <summary>
/// 继承MonoBehaviour的单例模式基类(自动挂载式 - 解决重复挂载问题)
/// </summary>
[DisallowMultipleComponent] // 防止同一个GameObject上挂载多个相同脚本
public class SingletonAutoMono<T> : MonoBehaviour where T : SingletonAutoMono<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 尝试从场景中查找现有实例
instance = FindObjectOfType<T>();
if (instance == null)
{
// 如果没有,则创建一个新的GameObject并挂载脚本
GameObject obj = new GameObject(typeof(T).Name);
instance = obj.AddComponent<T>();
}
}
return instance;
}
}
protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject); // 确保在场景切换时不被销毁
}
else if (instance != this)
{
// 如果已经存在一个实例,并且当前实例不是这个,则销毁当前实例
Destroy(gameObject);
}
}
}
测试代码:RepeatMountTest.cs
using UnityEngine;
public class RepeatMountTest : MonoBehaviour
{
void Start()
{
// 确保场景中只有一个Test2Mgr实例
Test2Mgr.Instance.Speak();
// 输出:Test2Mgr Speak
// 尝试在运行时创建另一个Test2Mgr的GameObject并挂载脚本
// 由于[DisallowMultipleComponent]和Awake中的检查,新的实例会被销毁
GameObject anotherObj = new GameObject("AnotherTest2Mgr");
anotherObj.AddComponent<Test2Mgr>();
// 结果:新创建的实例会被自动销毁,保证唯一性
}
}
线程安全问题
之前存在什么问题
如果程序当中存在多线程,我们需要考虑当多个线程同时访问同一个内存空间时出现的问题。如果不加以控制,可能会导致数据出错。我们一般称这种问题为多线程并发问题,指多线程对共享数据的并发访问和操作。
而一般解决该问题的方式,就是通过C#中的 lock 关键字进行加锁。我们需要考虑我们的单例模式对象们是否需要加锁(lock)。
lock 的原理保证了在任何时刻只有一个线程能够执行被锁保护的代码块,从而防止多个线程同时访问或修改共享资源,确保线程安全。
如何解决线程安全问题
解决方案:针对不同单例模式的加锁策略
- 不继承MonoBehaviour的单例模式:建议加锁,避免以后使用多线程时出现并发问题。比如在处理网络通讯模块、复杂算法模块时,经常会进行多线程并发处理。
- 继承MonoBehaviour的单例模式:可加可不加,但是建议不加。因为Unity中的机制是,Unity主线程中处理的一些对象(如GameObject、Transform等等)是不允许被其他多线程修改访问的,会直接报错。因此我们一般不会通过多线程去访问继承MonoBehaviour的相关对象,既然如此,就不会发生多线程并发问题。
解决线程安全问题总结
对于不继承MonoBehaviour的单例模式对象,建议加锁。
对于继承MonoBehaviour的单例模式对象,可以不加。
但是具体是否加锁,都根据需求来定。如果你的项目中压根就不会使用多线程,那么完全可以不用考虑加锁问题。
最终实现:带线程安全的Singleton
根据上述讨论,我们为不继承 MonoBehaviour 的单例基类 Singleton<T> 添加线程安全机制。
最终源码实现:Singleton.cs (最终版本)
using System;
using System.Reflection;
using UnityEngine;
/// <summary>
/// 单例模式基类(最终版本 - 解决构造函数唯一性问题和线程安全问题)
/// </summary>
public abstract class Singleton<T> where T : class
{
private static T instance;
private static readonly object LockObject = new object(); // 用于线程同步的锁对象
public static T Instance
{
get
{
// 双重检查锁定,确保线程安全
if (instance == null)
{
lock (LockObject) // 加锁
{
if (instance == null)
{
// 通过反射获取私有无参构造函数
ConstructorInfo constructor = typeof(T).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (constructor == null)
{
throw new Exception($"Class {typeof(T).Name} must have a private parameterless constructor.");
}
instance = constructor.Invoke(null) as T;
}
}
}
return instance;
}
}
// 规定继承单例模式基类的类必须显示实现私有无参构造函数
protected Singleton() { }
}
测试代码:ThreadSafetyTest.cs
using UnityEngine;
using System.Threading;
public class ThreadSafetyTest : MonoBehaviour
{
void Start()
{
// 模拟多线程访问TestMgr单例
for (int i = 0; i < 5; i++)
{
new Thread(() =>
{
TestMgr.Instance.Speak();
// 输出:TestMgr 构造函数被调用 (只调用一次)
// 输出:TestMgr Speak (可能输出多次,但构造函数只调用一次)
}).Start();
}
// 结果:即使多线程并发访问,单例实例也只创建一次,保证了线程安全
}
}
2.2 知识点代码
Singleton.cs(不继承Mono的单例基类)
using System;
using System.Reflection;
using UnityEngine;
/// <summary>
/// 单例模式基类(最终版本 - 解决构造函数唯一性问题和线程安全问题)
/// </summary>
public abstract class Singleton<T> where T : class
{
private static T instance;
private static readonly object LockObject = new object(); // 用于线程同步的锁对象
public static T Instance
{
get
{
// 双重检查锁定,确保线程安全
if (instance == null)
{
lock (LockObject) // 加锁
{
if (instance == null)
{
// 通过反射获取私有无参构造函数
ConstructorInfo constructor = typeof(T).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (constructor == null)
{
throw new Exception($"Class {typeof(T).Name} must have a private parameterless constructor.");
}
instance = constructor.Invoke(null) as T;
}
}
}
return instance;
}
}
// 规定继承单例模式基类的类必须显示实现私有无参构造函数
protected Singleton() { }
}
SingletonAutoMono.cs(自动挂载式单例基类)
using UnityEngine;
/// <summary>
/// 继承MonoBehaviour的单例模式基类(自动挂载式 - 解决重复挂载问题)
/// </summary>
[DisallowMultipleComponent] // 防止同一个GameObject上挂载多个相同脚本
public class SingletonAutoMono<T> : MonoBehaviour where T : SingletonAutoMono<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 尝试从场景中查找现有实例
instance = FindObjectOfType<T>();
if (instance == null)
{
// 如果没有,则创建一个新的GameObject并挂载脚本
GameObject obj = new GameObject(typeof(T).Name);
instance = obj.AddComponent<T>();
}
}
return instance;
}
}
protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject); // 确保在场景切换时不被销毁
}
else if (instance != this)
{
// 如果已经存在一个实例,并且当前实例不是那个,则销毁当前实例
Destroy(gameObject);
}
}
}
SingletonMono.cs(手动挂载式单例基类)
using UnityEngine;
/// <summary>
/// 继承MonoBehaviour的单例模式基类(挂载式 - 解决重复挂载问题)
/// </summary>
[DisallowMultipleComponent] // 防止同一个GameObject上挂载多个相同脚本
public class SingletonMono<T> : MonoBehaviour where T : SingletonMono<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// 尝试从场景中查找现有实例
instance = FindObjectOfType<T>();
if (instance == null)
{
// 如果没有,则创建一个新的GameObject并挂载脚本
GameObject obj = new GameObject(typeof(T).Name);
instance = obj.AddComponent<T>();
}
}
return instance;
}
}
protected virtual void Awake()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject); // 确保在场景切换时不被销毁
}
else if (instance != this)
{
// 如果已经存在一个实例,并且当前实例不是那个,则销毁当前实例
Destroy(gameObject);
}
}
}
TestMgr.cs(不继承Mono的单例测试)
using UnityEngine;
public class TestMgr : Singleton<TestMgr>
{
// 必须实现私有无参构造函数
private TestMgr()
{
Debug.Log("TestMgr 构造函数被调用");
}
public void Speak()
{
Debug.Log("TestMgr Speak");
}
}
Test2Mgr.cs(手动挂载式单例测试)
using UnityEngine;
public class Test2Mgr : SingletonMono<Test2Mgr>
{
public void Speak()
{
Debug.Log("Test2Mgr Speak");
}
}
Test2Mgr2.cs(自动挂载式单例测试)
using UnityEngine;
public class Test2Mgr2 : SingletonAutoMono<Test2Mgr2>
{
public void Speak()
{
Debug.Log("Test2Mgr2 Speak");
}
}
SingletonUsageTest.cs(使用不继承Mono的单例)
using UnityEngine;
public class SingletonUsageTest : MonoBehaviour
{
void Start()
{
Debug.Log("SingletonUsageTest Start");
TestMgr.Instance.Speak();
// 输出:TestMgr 构造函数被调用
// 输出:TestMgr Speak
// 此时无法通过new创建TestMgr实例,保证了唯一性
// TestMgr newMgr = new TestMgr(); // 编译错误
}
}
MonoSingletonUsageTest.cs(使用继承Mono的单例)
using UnityEngine;
public class MonoSingletonUsageTest : MonoBehaviour
{
void Start()
{
Debug.Log("MonoSingletonUsageTest Start");
// 确保场景中有一个挂载了Test2Mgr脚本的GameObject
Test2Mgr.Instance.Speak();
// 输出:Test2Mgr Speak
// Test2Mgr2会自动创建GameObject并挂载
Test2Mgr2.Instance.Speak();
// 输出:Test2Mgr2 Speak
}
}
ConstructorUniquenessTest.cs(验证构造函数唯一性)
using UnityEngine;
public class ConstructorUniquenessTest : MonoBehaviour
{
void Start()
{
Debug.Log("ConstructorUniquenessTest Start - 验证构造函数唯一性");
// 正常获取单例,构造函数只会被调用一次
TestMgr.Instance.Speak();
// 输出:TestMgr 构造函数被调用
// 输出:TestMgr Speak
// 尝试通过new创建会报错,因为构造函数是私有的
// TestMgr newMgr = new TestMgr(); // 编译错误
}
}
RepeatMountUniquenessTest.cs(验证重复挂载问题)
using UnityEngine;
public class RepeatMountUniquenessTest : MonoBehaviour
{
void Start()
{
Debug.Log("RepeatMountUniquenessTest Start - 验证重复挂载问题");
// 确保场景中只有一个Test2Mgr实例
Test2Mgr.Instance.Speak();
// 输出:Test2Mgr Speak
// 尝试在运行时创建另一个Test2Mgr的GameObject并挂载脚本
// 由于[DisallowMultipleComponent]和Awake中的检查,新的实例会被销毁
GameObject anotherObj = new GameObject("AnotherTest2Mgr");
anotherObj.AddComponent<Test2Mgr>();
// 结果:新创建的实例会被自动销毁,保证唯一性
}
}
ThreadSafetyVerificationTest.cs(验证线程安全)
using UnityEngine;
using System.Threading;
public class ThreadSafetyVerificationTest : MonoBehaviour
{
void Start()
{
Debug.Log("ThreadSafetyVerificationTest Start - 验证线程安全");
// 模拟多线程访问TestMgr单例
for (int i = 0; i < 5; i++)
{
new Thread(() =>
{
TestMgr.Instance.Speak();
// 输出:TestMgr 构造函数被调用 (只调用一次)
// 输出:TestMgr Speak (可能输出多次,但构造函数只调用一次)
}).Start();
}
// 结果:即使多线程并发访问,单例实例也只创建一次,保证了线程安全
}
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com