“确保一个类只有一个实例,并为其提供一个全局访问入口。”
1 单例模式
单例模式旨在提供一个全局唯一的访问入口,以确保该类只有一个实例。在游戏开发中,单例模式的应用很广泛,我们常常可以看到项目中的各种 Manager
。毫无疑问,单例在实际的开发中使用十分方便,但是滥用单例往往会造成高耦合、修改麻烦等缺点。本文将详细讨论单例模式在游戏开发中使用的一些优缺点和注意事项。
1.1 单例模式的实现
在 C#
中,常见的单例实现如下所示:
但是上述单例实现存在一个问题,那就是线程不安全。因为在多线程环境下,两个或更多的线程可能同时检查到 _instance
为 null
,然后都尝试创建新的实例。这就导致了多个 Singleton
实例的创建,违反了单例模式的原则。
这种情况通常被称为 ” 竞态条件 “。在这个例子中,竞态条件发生在 if (_instance == null)
和 _instance = new T();
之间。如果一个线程在这两行代码之间被操作系统挂起,那么另一个线程可能会进入这个代码块并创建一个新的实例。当第一个线程恢复运行时,它不知道另一个线程已经创建了一个新的实例,所以它也会创建一个新的实例。
为了避免这种竞态条件,可以使用某种形式的同步机制,例如锁(lock)。但是,使用锁可能会导致性能问题,特别是在高并发的情况下。因此,一种更好的解决方案是使用 System.Lazy<T>
类型,如我之前所示的那样。System.Lazy<T>
类型在内部使用了线程安全的方法来创建和初始化对象,所以它可以避免这种竞态条件。
修改后的代码如下所示:
1.2 单例模式的特性
- 懒加载模式:单例的实例化通常是在第一次调用单例之后,这种懒加载模式在一定程度上可以节省 CPU 和内存。如果程序一直没有调用这个单例,它就永远不会初始化。
- 全局访问方便:单例模式提供了一个全局指针以访问唯一实例。当游戏中存在一个需要全局访问的系统,例如日志系统,使用单例来访问是很方便的。
- 单例可以被继承:利用单例可以被继承这一特性,再配合上宏编译文件/条件区分,可以很方便地实现不同平台的派生类,从而降低代码的耦合度。下面是一个多平台文件系统的简单示例:
2 单例模式的局限性
在上文中,我们介绍了单例模式的一些优势,但是单例模式也存在一些局限性,这些局限性在游戏开发中尤为明显。
2.1 单例模式的耦合性
由于单例模式的全局访问特性,单例模式往往会导致代码的耦合性增加。例如,我们在游戏中经常会使用 GameManager
来管理游戏的状态,但是 GameManager
也会被其他系统所依赖,例如 UIManager
、 AudioManager
等等。这样一来,如果我们想要修改 GameManager
的实现,就会导致其他系统的修改,这就违背了开闭原则。
举一个例子,在
UIManager
中,我们可能会使用GameManager.Instance
来获取游戏的状态,如果我们对GameManager
进行了修改,那么UIManager
也需要进行修改。
高耦合会降低代码的可维护性与可扩展性,具体来说,高耦合会带来以下几点问题:
- 难以理解和修改:当系统的各个部分紧密连接时,理解其中一个部分如何影响另一个部分变得更加困难。这会使得修改现有代码或添加新功能变得更加复杂和耗时。
- 影响测试:高耦合的代码通常难以进行单元测试,因为测试一个组件可能需要配置和理解其依赖的多个组件。
- 变更传播:在高耦合的系统中,对一个组件的改动可能会影响到许多其他组件,这使得变更更加困难,也增加了引入错误的风险。
2.2 对并发不友好
在多线程的环境下,单例模式的全局访问特性会导致线程安全问题。设置全局变量的时候会创建一段内存,每个线程都能访问和修改这段内存,这会导致一系列死锁、条件竞争等问题。
2.3 单例模式的懒加载不可控
单例是在第一次调用单例模式的时候进行实例化,这种懒加载模式有自己的优点,如在加载之前节约 CPU 和内存。但是,由于我们不能主动控制第一次调用单例的时机,单例模式的初始化时间就会变得不可控,该问题在游戏开发中尤为明显。
在游戏的开发中,实例化一个系统需要加载资源、分配内存等操作。如果我有一个音频管理系统 AudioManager
,其实例化的时候需要加载一系列音频文件,需要耗费大量的资源。如果调用的时机不对,比如说在游戏资源大量加载的时候调用了 AudioManager.Instance
,那么就会导致游戏卡顿,影响用户体验。
此外,游戏通常需要仔细地控制内存在堆中的布局来防止碎片化。如果我们的音频系统在初始化时分配了内存,我们需要知道初始化发生的时间,以便让我们控制它在堆中的内存布局。
2.4 小结
其实,单例模式本身的特点是保证唯一变量,至于全局访问的特性反而是单例模式的副产品。但是在实际开发的过程中,我们往往为了快速实现全局访问而选择了单例模式,并没有思考我们选择单例模式是为了解决一个问题还是解决两个问题。这就导致了单例模式的滥用,从而带来了一系列的耦合与线程安全问题。
3 一些优化的方向
从单例的定义出发,对于单例模式解决的两个问题,我们可以根据自己的需求做出不同的架构方案。
3.1 仅需要单一实例
如果我们我们仅仅需要一个单一实例,而不需要全局访问接口,可以通过静态布尔值来做判断或者将类转换为静态类。
3.2 仅需要全局访问接口
通常来讲,需要一个全局访问的接口是使用单例的最主要的原因。但是全局接口的实现方式并不是只有单例这一个方法,我们可以通过依赖注入、全局访问对象、服务定位器模式等方法来获取全局访问接口。不同的方法都有各自的优缺点,例如依赖注入能提高代码的灵活性、但是会导致项目的复杂度增加,服务定位器模式提供了一个中央位置,用于存储和检索服务器组件,但是可能会导致项目依赖不明确的问题。
3.3 总结
单例模式在 Unity 游戏开发中广泛应用,但它并非万能。开发者应根据项目需求和环境特点,合理选择和应用单例模式。关键在于理解其优势和局限性,以及如何在不同场景下平衡这些因素,从而做出最合适的架构决策。