本文适合已经了解 C# 泛型、继承和 Unity 生命周期的开发者。我们从一个简单的单例需求出发,分析
MonoSingleton<T>的自引用泛型结构,再实现一个可用于项目的基础版本。
一、先说结论
MonoSingleton<T> 常见的声明方式如下:
csharp
public abstract class MonoSingleton<T> : MonoBehaviour
where T : MonoSingleton<T>
{
}
这里的 T 既是基类的泛型参数,又要求具体类型继承自这个基类:
csharp
public sealed class AudioManager : MonoSingleton<AudioManager>
{
}
这种"派生类把自己作为基类泛型参数"的结构,与 C++ 中的奇异递归模板模式(CRTP,Curiously Recurring Template Pattern)非常相似。
不过需要先澄清一个容易混淆的概念:
- CRTP 是 C++ 模板编程中的经典模式。
- C# 没有 C++ 意义上的模板,只有泛型。
MonoSingleton<T>是 C# 中一种"CRTP-like"自引用泛型写法。- Unity 单例的核心问题仍然是对象生命周期管理,而不是单纯的泛型技巧。
如果只记住一句话,可以记成:
CRTP 负责把"具体派生类型"传回基类;
MonoSingleton<T>再利用这个具体类型,统一完成Instance、查找、创建和销毁逻辑。
二、什么是奇异递归模板模式
CRTP 的基本结构是:
cpp
template <typename Derived>
class Base
{
public:
void CallImplementation()
{
static_cast<Derived*>(this)->Implementation();
}
};
class Derived : public Base<Derived>
{
public:
void Implementation()
{
// 派生类实现
}
};
表面上看,Derived 继承了 Base<Derived>,也就是基类的模板参数又填写了派生类自身,因此称为"奇异递归"。
它通常用于以下场景:
- 编译期静态多态。
- 为多个派生类复用通用代码。
- 避免虚函数调用带来的运行时多态开销。
- 让基类能够获得具体派生类型。
在 C# 中,可以写出结构类似的代码:
csharp
public abstract class Base<T>
where T : Base<T>
{
protected T Self => (T)this;
}
public sealed class Derived : Base<Derived>
{
}
但它不应该被简单描述成"C# 版 CRTP"。C# 泛型主要服务于类型安全和代码复用,运行时仍然由 CLR 管理类型信息;它与 C++ 模板的编译期实例化模型不同。
三、为什么 Unity 需要 MonoSingleton<T>
普通 C# 单例可以通过静态字段保存实例:
csharp
public sealed class ConfigManager
{
private static readonly ConfigManager _instance = new();
public static ConfigManager Instance => _instance;
private ConfigManager()
{
}
}
但是 Unity 的管理器通常需要:
- 继承
MonoBehaviour。 - 使用
Awake、Start、Update等生命周期函数。 - 通过 Inspector 配置字段。
- 参与协程、事件和场景加载。
- 作为 GameObject 组件存在于场景中。
因此,Unity 中更常见的是:
csharp
public sealed class GameManager : MonoSingleton<GameManager>
{
public int Score { get; private set; }
}
调用方可以直接使用:
csharp
GameManager.Instance.Score++;
如果每个管理器都重复实现一遍静态字段、Awake 去重和跨场景逻辑,项目很快会出现大量重复代码。MonoSingleton<T> 的目标,就是把这些通用逻辑集中到一个泛型基类中。
四、一个可用的基础实现
下面的实现支持:
- 场景中已有实例时直接复用。
- 场景中不存在实例时按需创建。
- 同一场景出现多个实例时保留第一个。
- 可选跨场景持久化。
- 在
OnDestroy中清理静态引用。 - 为派生类提供一次性的初始化入口。
csharp
using UnityEngine;
public abstract class MonoSingleton<T> : MonoBehaviour
where T : MonoSingleton<T>
{
private static T _instance;
private bool _initialized;
public static T Instance
{
get
{
if (_instance == null)
{
_instance = FindFirstObjectByType<T>(
FindObjectsInactive.Include);
}
if (_instance == null)
{
var singletonObject = new GameObject(typeof(T).Name);
_instance = singletonObject.AddComponent<T>();
}
return _instance;
}
}
public static bool HasInstance => _instance != null;
/// <summary>
/// 派生类可以覆盖此属性,决定是否跨场景保留。
/// </summary>
protected virtual bool KeepAliveAcrossScenes => false;
protected virtual void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = (T)this;
if (KeepAliveAcrossScenes)
{
DontDestroyOnLoad(gameObject);
}
if (_initialized)
{
return;
}
_initialized = true;
OnSingletonInitialize();
}
/// <summary>
/// 只在当前实例第一次完成注册时调用一次。
/// </summary>
protected virtual void OnSingletonInitialize()
{
}
protected virtual void OnDestroy()
{
if (_instance == this)
{
_instance = null;
}
}
}
派生类示例:
csharp
using UnityEngine;
public sealed class AudioManager : MonoSingleton<AudioManager>
{
[SerializeField]
private AudioSource musicSource;
protected override bool KeepAliveAcrossScenes => true;
protected override void OnSingletonInitialize()
{
if (musicSource == null)
{
musicSource = GetComponent<AudioSource>();
}
}
public void PlayMusic(AudioClip clip)
{
if (clip == null || musicSource == null)
{
return;
}
musicSource.clip = clip;
musicSource.loop = true;
musicSource.Play();
}
}
五、代码中最关键的三处设计
1. where T : MonoSingleton<T>
这个约束有两个作用。
第一,它限制了泛型参数的类型范围:
csharp
public sealed class InvalidManager : MonoSingleton<SomeOtherType>
{
}
这样的声明无法通过编译。
第二,它让基类可以安全地把 this 转换为具体类型:
csharp
_instance = (T)this;
如果没有泛型约束,基类无法保证 T 与当前组件之间存在有效关系。
2. 静态字段属于每个具体泛型类型
MonoSingleton<AudioManager> 和 MonoSingleton<GameManager> 是两个不同的封闭泛型类型,因此它们各自拥有独立的 _instance。
这正是一个泛型基类可以同时服务多个单例管理器的原因:
csharp
AudioManager.Instance;
GameManager.Instance;
两者不会共用同一个实例变量。
3. Awake 必须处理重复对象
场景中可能已经放置了一个 AudioManager,但代码又通过 AudioManager.Instance 创建了另一个对象;或者切换场景时,旧对象被保留,新场景又包含了一个同类型对象。
因此 Awake 中必须有去重逻辑:
csharp
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
这里保留先注册成功的实例,销毁后到达的重复实例。
六、场景中创建,还是代码中懒加载
上面的实现同时支持两种使用方式。
方式一:在场景中放置对象
操作步骤:
- 创建一个空 GameObject。
- 添加
AudioManager组件。 - 在 Inspector 中配置
AudioSource。 - 运行时通过
AudioManager.Instance访问。
优点是配置直观,适合音频、UI、输入和游戏流程等需要序列化资源的管理器。
方式二:第一次访问时自动创建
csharp
var manager = AudioManager.Instance;
manager.PlayMusic(backgroundMusic);
当场景中没有 AudioManager 时,基类会创建一个名为 AudioManager 的 GameObject 并挂载组件。
优点是减少场景配置,但自动创建的对象没有 Inspector 配置,依赖资源需要通过代码、配置文件或其他初始化系统注入。
实际项目中建议遵守一个规则:
需要 Inspector 配置的管理器放入启动场景;纯代码型服务才考虑懒加载。
七、DontDestroyOnLoad 的边界
跨场景单例不等于"所有对象都应该跨场景存在"。
适合跨场景保留的对象通常包括:
- 全局音频管理器。
- 存档或账号状态管理器。
- 全局网络连接管理器。
- 游戏运行时配置管理器。
不适合跨场景保留的对象通常包括:
- 当前关卡的敌人管理器。
- 只服务于某个场景的 UI 管理器。
- 当前关卡的刷怪器。
- 持有大量场景对象引用的临时控制器。
可以通过派生类控制行为:
csharp
public sealed class SaveManager : MonoSingleton<SaveManager>
{
protected override bool KeepAliveAcrossScenes => true;
}
public sealed class LevelEnemyManager : MonoSingleton<LevelEnemyManager>
{
protected override bool KeepAliveAcrossScenes => false;
}
如果跨场景对象保存了场景内对象的引用,切换场景后这些引用可能已经失效。因此,持久化管理器应尽量保存数据和服务,不要直接持有关卡对象。
八、常见错误与改进方式
错误一:在 Awake 中无条件覆盖实例
错误写法:
csharp
protected virtual void Awake()
{
_instance = (T)this;
}
这会导致后加载的重复对象覆盖原实例,最终出现两个对象都在工作、事件被注册两次等问题。
错误二:在 OnDestroy 中不清空静态引用
Unity 的 Object 有特殊的销毁语义。组件被销毁后,静态字段可能仍然保存一个看似不为 null 的引用,造成后续访问异常。
因此应在销毁时明确释放:
csharp
protected virtual void OnDestroy()
{
if (_instance == this)
{
_instance = null;
}
}
错误三:把单例当成任意对象的依赖注入方案
单例调用很方便,但也会带来隐式依赖:
csharp
public class ShopPanel : MonoBehaviour
{
private void OnEnable()
{
AudioManager.Instance.PlayMusic(null);
}
}
ShopPanel 看起来没有依赖任何服务,但实际依赖了 AudioManager 的存在和初始化顺序。这会增加测试难度,也会让模块之间耦合。
更好的做法是:
csharp
public class ShopPanel : MonoBehaviour
{
private AudioManager _audioManager;
public void Initialize(AudioManager audioManager)
{
_audioManager = audioManager;
}
}
建议把 MonoSingleton<T> 限制在真正的全局服务上,而不是把所有系统都做成单例。
错误四:在静态初始化阶段调用 Unity API
不要在静态字段初始化器中调用 FindFirstObjectByType、AddComponent 等 Unity API:
csharp
// 不建议
private static T _instance = FindFirstObjectByType<T>();
Unity 场景和对象生命周期并不等同于普通 C# 静态初始化。把查找和创建放到 Instance、Awake 等明确的生命周期入口中更容易控制。
错误五:忽略 Enter Play Mode 的 Domain Reload 设置
如果项目关闭了 Domain Reload,静态字段不会像默认设置那样在每次进入 Play Mode 时完整重置。单例、事件和缓存类都可能因此保留上一次运行的数据。
可选处理方式:
- 开发阶段保持 Domain Reload 开启。
- 为静态状态提供显式重置方法。
- 使用统一的运行时初始化入口重置静态字段。
- 不在静态单例中保存不必要的可变状态。
九、线程安全问题应该怎么处理
很多 C# 单例实现会使用 lock:
csharp
lock (_lock)
{
// 创建实例
}
但 Unity 的大多数对象 API,包括 GameObject 创建、组件添加和场景对象查找,都必须在主线程执行。给这些代码外面加锁,并不能让 Unity API 变成线程安全。
所以对于 MonoSingleton<T>:
- 默认假设所有访问来自 Unity 主线程。
- 不要从后台线程直接访问
Instance并创建组件。 - 后台线程只处理纯数据。
- 回到主线程后,再访问 Unity 对象。
如果项目确实需要多线程服务,应将"纯 C# 数据服务"和"Unity MonoBehaviour 外壳"拆开,分别管理生命周期。
十、如何让单例更容易测试
可以为基类增加清理入口,但不要让业务代码随意调用:
csharp
public abstract class MonoSingleton<T> : MonoBehaviour
where T : MonoSingleton<T>
{
// 其他代码省略
internal static void ResetForTests()
{
_instance = null;
}
}
更推荐的测试策略是:
- 将核心业务逻辑放到普通 C# 类中。
MonoSingleton<T>只负责 Unity 生命周期和依赖装配。- 测试时直接构造普通 C# 服务。
- 少量 Play Mode 测试验证场景加载、销毁和重复对象行为。
例如,把存档逻辑从 Unity 组件中拆出来:
csharp
public sealed class SaveService
{
public void Save()
{
// 纯业务逻辑
}
}
public sealed class SaveManager : MonoSingleton<SaveManager>
{
private SaveService _service;
protected override void OnSingletonInitialize()
{
_service = new SaveService();
}
public void Save()
{
_service.Save();
}
}
这样,SaveService 可以进行普通 Edit Mode 单元测试,而 SaveManager 只需要验证 Unity 侧的装配流程。
十一、一个更严格的使用约定
为了避免项目逐渐失控,可以制定以下约定:
1. 单例类型使用 sealed
csharp
public sealed class GameManager : MonoSingleton<GameManager>
{
}
这样可以避免继续继承导致的类型关系复杂化。
2. 不在构造函数中写 Unity 初始化逻辑
MonoBehaviour 不是普通 C# 对象。不要依赖构造函数完成组件初始化,应使用 Awake 或 OnSingletonInitialize。
3. 明确初始化顺序
如果管理器之间存在依赖,不要依赖脚本执行顺序碰运气。可以使用启动器集中初始化:
csharp
public sealed class Bootstrap : MonoBehaviour
{
private void Awake()
{
_ = AudioManager.Instance;
_ = SaveManager.Instance;
_ = GameManager.Instance;
}
}
更大型的项目可以使用显式启动流程或依赖注入容器。
4. 限制 Instance 的调用范围
Instance 适合访问全局服务,不适合替代所有对象引用。局部对象、场景对象和临时控制器应通过 Inspector、构造式初始化或方法参数传递依赖。
十二、最终理解
MonoSingleton<T> 的价值不只是让我们少写几行 static 代码,更重要的是它展示了三个层面的结合:
- 泛型约束 :通过
where T : MonoSingleton<T>保证派生类型关系正确。 - CRTP-like 结构:把具体派生类型传回通用基类。
- Unity 生命周期 :在
Awake、OnDestroy和场景切换中管理真实对象。
它适合用来实现少量真正的全局服务,例如音频、存档、网络或游戏流程管理器;但不应该把所有系统都包装成单例。
可以把本文的设计原则总结为:
泛型解决重复代码,生命周期代码解决对象有效性,架构约束解决单例滥用。
当这三点同时考虑时,MonoSingleton<T> 才不仅是一个方便调用的 Instance 属性,而是一个边界清晰、行为可预测的 Unity 基础设施。
参考方向
- C++ Reference:Curiously Recurring Template Pattern(CRTP)
- Unity 官方文档:
MonoBehaviour、Object.DontDestroyOnLoad - Unity 官方教程:MonoSingleton
- .NET 文档:泛型类型约束与静态成员