游戏开发设计模式-单例模式
1. 单例模式
你一定经历过这样的场景:游戏中有一个音频管理器,角色死亡时要播放死亡音效,捡到道具时要播放提示音,进入 Boss 战时要切换 BGM——如果这些地方各自创建自己的音频播放器实例,声音会乱成一锅粥。你很快就会意识到,音频管理器必须只有一个,而且必须能在任何地方访问到。这就是单例模式要解决的问题。
为什么每个游戏项目都有一个”全局管理器”?
几乎每一个游戏项目,无论规模大小,都会自然地演化出一些”全局管理器”:音频管理器、存档管理器、场景管理器、网络管理器、UI 管理器……这些管理器有一个共同特征:整个游戏中只需要一个实例,而且需要在任何地方都能访问。
在《原神》(基于 Unity 引擎开发)中,全局的音频系统需要管理数百个音源的混合、3D 空间音效计算和 BGM 无缝切换,这要求整个游戏中只能存在一个音频管理器来统一调度。类似地,在《火箭联盟》(基于 UE5 引擎)中,全局的网络管理器负责所有的在线匹配和数据同步,如果出现两个网络管理器实例,会导致严重的数据冲突。
你可能会想:”那我在脚本开头写一个全局变量不就行了?”确实,全局变量能在一定程度上解决问题,但它带来了严重的封装性问题:任何代码都可以随意修改全局变量的状态,这在大型项目中是一场灾难。单例模式正是在”保证全局唯一访问点”的同时,提供了更好的封装性和访问控制。
1.1 单例模式的标准实现
单例模式的定义
单例模式(Singleton Pattern)属于 GoF 23 种设计模式中的创建型模式。它的核心目标是:确保一个类只有一个实例,并提供一个全局访问点来访问这个实例。在游戏开发中,单例模式被广泛应用于管理器类(Manager)的实现。
单例模式的标准实现需要满足三个关键条件:
- 私有构造函数:防止外部代码通过
new关键字创建实例 - 静态成员变量:保存唯一的实例引用
- 公共静态方法:提供全局访问点获取实例
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 面板管理:每个面板独立运作
单例使用的黄金法则
在你的游戏中引入单例之前,先问自己三个问题:
- 它真的需要全局唯一吗?
- 如果不使用单例,通过参数传递引用能否解决问题?
- 它的状态需要跨场景存活吗?
如果三个答案都是”是”,那么单例是合理的选择。
UE5 的 Subsystem 机制在很大程度上避免了传统单例的陷阱——它由引擎管理生命周期,不需要手动实例化,也天然支持作用域隔离。如果你在使用 UE5 开发,建议优先使用 Subsystem 而非手写单例。Unity 方面,Unity 2022 LTS 之后社区也在推动类似的”Managed Subsystem”方案,值得持续关注。