Unity 游戏开发实用工具——通用单例基类
单例模式的核心诉求是:一个类在整个程序生命周期中只有一个实例,并提供全局访问点。
如果每个管理器都各自实现一遍单例逻辑,代码不仅冗余,还容易出 bug。所以我们需要一个通用的单例基类。
思路
本方案将单例分为三类,对应不同的使用场景:
| 基类 | 适用对象 | 生命周期 | 线程安全 |
|---|---|---|---|
Singleton<T> |
纯 C# 类 | 手动管理 | 双检锁 |
MonoSingleton<T> |
MonoBehaviour | 跟随场景 | Unity 主线程 |
MonoSingleton<T> + IDontDestroyOnLoad |
MonoBehaviour | 跨场景持久化 | Unity 主线程 |
它们共享一个统一的标记接口 ISingleton,方便后续扩展(比如做单例收集、自动化测试等)。
一、ISingleton —— 统一标记接口
public interface ISingleton { }
用于标记一个类型为单例。所有单例基类都实现此接口,便于:
- 通过反射批量查找项目中的所有单例
- 在编辑器工具中对单例做统一管理
- 未来扩展时做类型约束
二、Singleton<T> —— 纯 C# 单例基类
适用于不需要依赖Unity生命周期的纯逻辑类。
public abstract class Singleton<T> : ISingleton where T : Singleton<T>
{
private static readonly object s_Lock = new();
private static T s_Instance;
public static T Instance
{
get
{
//线程安全
if (s_Instance == null)
{
lock (s_Lock)
{
if (s_Instance == null)
{
s_Instance = (T)Activator.CreateInstance(typeof(T), true);
s_Instance.SingletonInit();
}
}
}
return s_Instance;
}
}
private void SingletonInit()
{
if (isInitialized) return;
isInitialized = true;
OnSingletonInit();
}
/// <summary>
/// 单例初始化回调(子类可重写)
/// </summary>
protected virtual void OnSingletonInit()
{
}
/// <summary>
/// 单例销毁回调(子类可重写)
/// </summary>
protected virtual void OnSingletonDestroy()
{
Debug.Log($"[Singleton] 销毁单例: {typeof(T).Name}");
}
/// <summary>
/// 销毁单例并释放资源
/// </summary>
public static void DestroyInstance()
{
lock (s_Lock)
{
if (s_Instance != null)
{
s_Instance.OnSingletonDestroy();
s_Instance = null;
Debug.Log($"[Singleton] 销毁单例: {typeof(T).Name}");
}
}
}
}
1. 双检锁(Double-Check Locking)保证线程安全
第一次检查:快速路径,实例已存在时直接返回,无锁开销
lock:多线程竞争时只有一个线程进入创建逻辑
第二次检查:防止等待锁的线程重复创建
2. 反射创建实例
s_Instance = (T)Activator.CreateInstance(typeof(T), true);
Activator.CreateInstance 的第二个参数 true 表示使用非公开构造函数,所以子类可以把构造函数设为 protected,防止外部 new 出实例。
3. 泛型约束 where T : Singleton<T>
让基类"知道"子类的具体类型,从而无需强制转换。
4. 自定义生命周期
protected virtual void OnSingletonInit() { } // 首次创建时调用
protected virtual void OnSingletonDestroy() { } // 销毁时调用
子类通过重写这两个方法来执行初始化和清理逻辑,而不是在构造函数中(避免虚方法调用问题)。
5. 显式销毁
public static void DestroyInstance()
{
lock (s_Lock)
{
if (s_Instance != null)
{
s_Instance.OnSingletonDestroy();
s_Instance = null;
}
}
}
纯 C# 单例不会随场景自动销毁,需要手动调用。这在切换关卡、重置游戏状态时很有用。
使用示例
public class EventManager : Singleton<EventManager>
{
private Dictionary<string, Action> m_Events = new();
protected override void OnSingletonInit()
{
Debug.Log("事件管理器初始化");
}
public void Register(string key, Action callback) { /* ... */ }
public void Unregister(string key) { /* ... */ }
public void Trigger(string key) { /* ... */ }
}
// 使用
EventManager.Instance.Register("OnScoreChanged", UpdateUI);
三、MonoSingleton<T> —— MonoBehaviour 单例基类
适用于需要挂载到 GameObject 上、需要依赖Unity 生命周期(Update、Coroutine 等)的组件。
public abstract class MonoSingleton<T> : MonoBehaviour, ISingleton
where T : MonoSingleton<T>
{
private static T s_Instance;
public static T Instance
{
get
{
if (!Application.isPlaying) return null;
if (s_Instance == null)
{
// 1. 先在场景中查找
T[] instances = FindObjectsOfType<T>(true);
T found = instances.FirstOrDefault();
if (found != null)
{
s_Instance = found;
// 销毁重复实例
foreach (var dup in instances.Where(i => i != s_Instance))
Destroy(dup.gameObject);
}
else
{
// 2. 场景中不存在,自动创建
var go = new GameObject($"{typeof(T).Name}");
s_Instance = go.AddComponent<T>();
}
s_Instance.transform.SetParent(GetOrCreateContainer().transform);
s_Instance.SingletonInit();
}
return s_Instance;
}
}
}
1. 自动去重
通过 FindObjectsOfType<T>(true) 查找场景中的所有实例(包括未激活的),如果存在多个就销毁多余的。这解决了"场景中误拖了多个单例"的问题。
2. 懒加载自动创建
如果场景中没有该单例,会自动创建一个新 GameObject 并挂载组件。这意味着你不需要在场景中预放置单例,直接 XXXManager.Instance 即可。
3. 容器分组
所有 MonoSingleton 会被自动归类到父对象下:
Hierarchy 视图:
├── Singletons ← 普通单例容器(随场景销毁)
│ ├── GameManager
│ └── BoardViewController
└── PersistentSingletons ← 持久化单例容器(DontDestroyOnLoad)
├── SoundsManager
└── UIManager
- Hierarchy 面板整洁有序
- 方便调试时快速定位单例对象
- 批量操作方便(比如统一销毁场景单例)
4. 可选持久化 —— IDontDestroyOnLoad 标记接口
public interface IDontDestroyOnLoad { }
不需要持久化的单例默认跟随场景销毁。需要跨场景保持的,只需实现这个空接口:
// 随场景销毁(默认)
public class GameManager : MonoSingleton<GameManager> { }
// 跨场景持久化
public class SoundsManager : MonoSingleton<SoundsManager>, IDontDestroyOnLoad { }
基类通过反射在静态构造时检查子类是否实现了该接口:
private static readonly bool s_IsPersistent =
typeof(IDontDestroyOnLoad).IsAssignableFrom(typeof(T));
用接口代替继承,不需要额外的泛型参数或配置字段,子类只需声明"我是持久化的"即可。
5. 非播放模式保护
if (!Application.isPlaying) return null;
在编辑器的非播放模式下访问 Instance 会返回 null,避免在 Edit 模式下意外创建 GameObject。
使用示例
// 音频管理器(跨场景持久化)
public class SoundsManager : MonoSingleton<SoundsManager>, IDontDestroyOnLoad
{
protected override void OnSingletonInit()
{
// 初始化音频源对象池
}
public void PlaySound(string clipName) { /* ... */ }
}
// 游戏管理器(跟随场景)
public class GameManager : MonoSingleton<GameManager>
{
private FSM<GameStateBase> m_FSM;
protected override void OnSingletonInit()
{
m_FSM = new FSM<GameStateBase>();
RegisterGameStates();
}
protected override void OnSingletonDestroy()
{
m_FSM?.Dispose();
}
}
结语
单例虽好,切勿滥用。
单例模式滥用的根本问题在于它是合法化的全局变量。所有代码都可以随时随地访问和修改单例的状态,导致隐式依赖散落在各处,比如:类 A 直接调用XXXManager.Instance,从接口签名完全看不出它依赖了这个管理器,重构时很容易遗漏。
在 Unity 中,单例的生命周期更加棘手,场景切换时哪些该销毁、哪些该保留、销毁顺序如何保证,稍有不慎就会出现空引用。随着项目膨胀,"只需要全局访问"的诱惑会让人把越来越多功能塞进同一个单例,最终演变成职责混乱的上帝类。正确的做法是把"全局访问"和"唯一实例"这两个需求分开,单例基类只留给那些真正在 Unity 层面需要唯一 GameObject 的管理器。

浙公网安备 33010602011771号