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