《游戏编程模式》六、单例模式(Unity 实现)

179 阅读4分钟

确保一个类只有一个实例,并为其提供一个全局访问入口

本节告诉你如何避免使用这一模式

概要

使用场景

  • 游戏管理器:如游戏状态管理、关卡管理等。像在一个角色扮演游戏中,游戏状态管理器负责控制游戏的开始、暂停、结束等状态,单例模式能保证全局只有一个管理器来统一协调。
  • 资源管理器:管理游戏中的各种资源,如图片、音频、模型等。避免重复加载资源,节省内存和加载时间。
  • 音频管理器:控制游戏中的音频播放,如背景音乐、音效等。保证音频播放的一致性和协调性。

优点

  • 全局唯一访问:确保全局只有一个实例,方便其他对象访问。
  • 资源控制:避免重复创建对象,节省系统资源。

缺点

  • 违反单一职责原则:单例类既负责创建实例,又负责业务逻辑,职责过重。
  • 扩展性差:修改单例类的功能可能影响整个系统。
  • 多线程问题:非线程安全的实现方式在多线程环境下可能创建多个实例。

6.1 单例模式

6.1.1 确保一个类只有一个实例

6.1.2 提供一个全局指针以访问唯一实例

通常实现方法(线程不安全)

class FileSystem
{
    private static FileSystem _instance;

    public static FileSystem Instance
    {
        get
        {
            _instance ??= new FileSystem();
            return _instance;
        }
    }
    
    private FileSystem() {}
}

更多实现方法

6.2 使用情境

优良特性:

  • 如果我们不使用它,就不会创建实例
  • 它在运行时初始化。包含静态成员/方法的静态类,是此模式的替代品,但静态类有个局限:自动初始化。编译器早在主函数调用之前就初始化静态数据了。这意味着
    • 不能利用那些只有运行起来才知道的信息
    • 这些静态类之间不能相互依赖——编译器不能保证它们的初始化顺序
  • 你可以继承单例,这是个强大但被忽略的特性。假设我们需要跨平台的文件系统封装类。 为了达到这一点,我们将它实现为一个抽象接口,而由它的子类为每个平台提供实现。 这是基类:
abstract class FileSystem
{
    private static FileSystem _instance;

    public static FileSystem Instance
    {
        get
        {
            #if PLATFORM == PS3
            _instance ??= new PS3FileSystem();
            #elif PLATFORM == Wii
            _instance??= new WiiFileSystem();
            #endif

            return _instance;
        }
    }

    protected FileSystem() { }

    public abstract void OpenFile(); 
}

之后我们为不同平台定义派生类

class PS3FileSystem : FileSystem
{
    public override void OpenFile()
    {
        Debug.Log("Open file in PS3");
    }
}

class WiiFileSystem : FileSystem
{
    public override void OpenFile()
    {
        Debug.Log("Open file in PS3");
    }
}

6.3 后悔使用单例的原因

6.3.1 它是一个全局变量

随着游戏变得更大更复杂,架构和可维护性开始成为瓶颈。阻碍我们发布游戏的不再是硬件,而是开发效率

  • 它们令代码晦涩又难懂。若一个出问题的函数中引入全局状态,我们将要花更多时间来排查原因
  • 全局变量促进了耦合
  • 它对并发不友好。当设置全局变量时,这一块内存每个线程都可以访问和修改它。这有可能导致死锁、条件竞争等线程同步 bug

6.3.2 它是个画蛇添足的解决方案

  • 确保一个单例是很有用,但谁说我们希望任何人都能操作它?
  • 全局访问是很方便,但对于允许多实例的类,访问也并不麻烦。

我们通常会将日志类变为单例,起初只写一个日志文件时并无问题。随着开发的深入,我们希望可以通过将日志分割为不同的文件来诊断问题,显然单例的日志类不能胜任这个工作,要修改也十分麻烦。

6.3.3 延迟初始化剥离了你的控制

实例化一个系统需要花费时间,若我们在首次播放声音时实例化音频系统,且遇到游戏步入高潮时,这将会导致掉帧和卡顿

6.4 那我们该怎么做

6.4.1 看你究竟是否需要类

  • 避免创建那么多的“Manager”,来帮助对象实现本来属于它们自己的功能

6.4.2 将类限制为单一实例

6.4.3 为实例提供便捷的访问方式

在盲目地采用具有全局作用域的单例对象前,让我们考虑下访问一个对象的其他途径:

  • 作为参数传递进去
  • 在基类中获取它
  • 通过其他全局对象访问
  • 通过服务定位器来访问

6.5 剩下的问题

我们应该在什么情况下使用真正的单例呢?老实说,我没有在任何游戏中使用 GoF 实现版本的单例。为了确保只实例化一次,我通常只是简单地使用静态类。若那不起作用,我就会用一个静态标识位在运行时检查