1. 单例模式

你一定经历过这样的场景:游戏中有一个音频管理器,角色死亡时要播放死亡音效,捡到道具时要播放提示音,进入 Boss 战时要切换 BGM——如果这些地方各自创建自己的音频播放器实例,声音会乱成一锅粥。你很快就会意识到,音频管理器必须只有一个,而且必须能在任何地方访问到。这就是单例模式要解决的问题。

为什么每个游戏项目都有一个”全局管理器”?

几乎每一个游戏项目,无论规模大小,都会自然地演化出一些”全局管理器”:音频管理器、存档管理器、场景管理器、网络管理器、UI 管理器……这些管理器有一个共同特征:整个游戏中只需要一个实例,而且需要在任何地方都能访问。

在《原神》(基于 Unity 引擎开发)中,全局的音频系统需要管理数百个音源的混合、3D 空间音效计算和 BGM 无缝切换,这要求整个游戏中只能存在一个音频管理器来统一调度。类似地,在《火箭联盟》(基于 UE5 引擎)中,全局的网络管理器负责所有的在线匹配和数据同步,如果出现两个网络管理器实例,会导致严重的数据冲突。

你可能会想:”那我在脚本开头写一个全局变量不就行了?”确实,全局变量能在一定程度上解决问题,但它带来了严重的封装性问题:任何代码都可以随意修改全局变量的状态,这在大型项目中是一场灾难。单例模式正是在”保证全局唯一访问点”的同时,提供了更好的封装性和访问控制。

1.1 单例模式的标准实现

单例模式的定义

单例模式(Singleton Pattern)属于 GoF 23 种设计模式中的创建型模式。它的核心目标是:确保一个类只有一个实例,并提供一个全局访问点来访问这个实例。在游戏开发中,单例模式被广泛应用于管理器类(Manager)的实现。

单例模式的标准实现需要满足三个关键条件:

  1. 私有构造函数:防止外部代码通过 new 关键字创建实例
  2. 静态成员变量:保存唯一的实例引用
  3. 公共静态方法:提供全局访问点获取实例

1.2 Unity 中的单例

我们先来看一个 C# 的线程安全单例实现:

using UnityEngine;

namespace DesignPatterns
{
    /// <summary>
    /// 单例基类
    /// </summary>
    /// <typeparam name="T"></typeparam>
    [DisallowMultipleComponent]
    public abstract class Singleton<T> : MonoBehaviour where T : Singleton<T>
    {
        private static T instance;

        private static bool applicationIsQuitting;

        public static T Instance
        {
            get
            {
                if (applicationIsQuitting)
                {
                    return null;
                }
                if (instance == null)
                {
                    instance = FindAnyObjectByType<T>();
                    if (instance == null)
                    {
                        GameObject singletonObject = new GameObject(typeof(T).Name);
                        instance = singletonObject.AddComponent<T>();
                        DontDestroyOnLoad(singletonObject);
                    }
                }
                return instance;
            }
        }

        private void Awake()
        {
            if (instance == null)
            {
                instance = this as T;
                DontDestroyOnLoad(gameObject);
                OnAwake();
            }
            else if (instance != this)
            {
                Destroy(gameObject);
            }
        }

        private void OnDestroy()
        {
            if (instance == this)
            {
                instance = null;
                OnDestroyed();
            }
        }

        private void OnApplicationQuit()
        {
            applicationIsQuitting = true;
        }

        protected virtual void OnAwake()
        {

        }

        protected virtual void OnDestroyed()
        {

        }
    }
}

这段代码解决了一个常见的 Unity 陷阱:当游戏退出时,场景被销毁,单例对象也随之被销毁。如果没有 applicationIsQuitting 标志,引擎可能会在退出过程中尝试重新创建单例,导致空引用异常。

1.2.1 游戏管理类

(待补充:本节在原文中无正文内容)

1.2.2 音频管理器

public class AudioManager : Singleton<AudioManager>
{
    [SerializeField] private AudioClip bgmMain;
    [SerializeField] private AudioClip bgmBoss;

    private AudioSource bgmSource;

    protected override void OnAwake()
    {
        base.OnAwake();
        bgmSource = gameObject.AddComponent<AudioSource>();
        bgmSource.loop = true;
    }

    public void PlayBGM(bool isBossFight)
    {
        bgmSource.clip = isBossFight ? bgmBoss : bgmMain;
        bgmSource.Play();
    }

    public void PlaySFX(AudioClip clip)
    {
        AudioSource.PlayClipAtPoint(clip, transform.position);
    }
}

// 在任何地方使用
public class PlayerHealth : MonoBehaviour
{
    public void Die()
    {
        AudioManager.Instance.PlaySFX(deathSound);
        // 一行代码即可调用,无需持有引用
    }
}

Unity 的 DontDestroyOnLoad 是实现单例跨场景存活的关键。当调用 DontDestroyOnLoad(gameObject) 后,该对象会在场景切换时被保留。但你需要注意一个常见陷阱:如果场景中已经有一个同类型的单例对象,新场景加载时可能又会创建一个新的,这时候就需要在 Awake 方法中做去重检查。

1.3 UE5 中的单例

UE5 作为一款商业引擎,在架构层面已经内置了单例模式的应用。最典型的例子就是 UGameInstance 类。

UGameInstance 在整个游戏运行期间只会创建一个实例,它不会随着关卡切换而被销毁。这意味着你可以在 GameInstance 中存储全局数据——比如玩家的总分数、已解锁的成就、游戏设置等——这些数据在场景切换时会自动保留。

// MyGameInstance.h
#pragma once
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"

UCLASS()
class MYGAME_API UMyGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable)
    static UMyGameInstance* Get(const UObject* WorldContext);

    UPROPERTY(BlueprintReadWrite)
    int32 TotalScore = 0;

    UPROPERTY(BlueprintReadWrite)
    TArray<FName> UnlockedLevels;
};
// MyGameInstance.cpp
UMyGameInstance* UMyGameInstance::Get(const UObject* WorldContext)
{
    if (const UWorld* World = WorldContext->GetWorld())
    {
        return Cast<UMyGameInstance>(World->GetGameInstance());
    }
    return nullptr;
}

除了 GameInstance,UE5 从 4.20 版本开始引入了 Subsystem 机制,这是一种更优雅的”伪单例”方案。Subsystem 由引擎自动创建和管理生命周期,开发者不需要手动处理实例化和销毁逻辑。UE5 提供了多种 Subsystem 类型:

(待补充:此处原飞书文档中的 Subsystem 类型列表未能导出)

在 UE5 中,优先考虑使用 Subsystem 而非手写单例。Subsystem 由引擎管理生命周期,天然避免了传统单例的”悬空引用”和”退出时销毁顺序”等问题。例如,要创建一个音频管理器,继承自 UGameInstanceSubsystem 即可。

1.4 单例的常见陷阱与替代方案

单例模式虽然简单好用,但它也是被滥用最严重的模式之一。在工程实践中,我们需要清醒地认识到单例的局限性。

单例的三大陷阱:

  • 隐式依赖:单例让类之间的依赖关系变得不可见,增加了代码的理解成本
  • 测试困难:单元测试时很难替换单例的实现,也无法在测试间重置状态
  • 生命周期失控:单例的”永生”特性可能导致内存泄漏,特别是在场景频繁切换的游戏中

让我们通过一个对比表格来审视单例模式与常见替代方案的优劣:

(待补充:此处原飞书文档中的对比表格未能导出)

适合使用单例的场景

  • 音频管理器:全局只需一个
  • 存档管理器:文件操作必须统一
  • 场景切换管理器:全局调度
  • 网络管理器:连接必须唯一

不适合使用单例的场景

  • 实体组件(Entity):需要多个实例
  • 弹药管理器:可以挂在武器上
  • 伤害计算器:无状态,函数即可
  • UI 面板管理:每个面板独立运作

单例使用的黄金法则

在你的游戏中引入单例之前,先问自己三个问题:

  1. 它真的需要全局唯一吗?
  2. 如果不使用单例,通过参数传递引用能否解决问题?
  3. 它的状态需要跨场景存活吗?

如果三个答案都是”是”,那么单例是合理的选择。

UE5 的 Subsystem 机制在很大程度上避免了传统单例的陷阱——它由引擎管理生命周期,不需要手动实例化,也天然支持作用域隔离。如果你在使用 UE5 开发,建议优先使用 Subsystem 而非手写单例。Unity 方面,Unity 2022 LTS 之后社区也在推动类似的”Managed Subsystem”方案,值得持续关注。