三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

C# 中的奇异递归模板模式:MonoSingleton<T> 的实现

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>,也就是基类的模板参数又填写了派生类自身,因此称为“奇异递归”。

它通常用于以下场景:

  1. 编译期静态多态。
  2. 为多个派生类复用通用代码。
  3. 避免虚函数调用带来的运行时多态开销。
  4. 让基类能够获得具体派生类型。

在 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
  • 使用AwakeStartUpdate等生命周期函数。
  • 通过 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;}

这里保留先注册成功的实例,销毁后到达的重复实例。


六、场景中创建,还是代码中懒加载

上面的实现同时支持两种使用方式。

方式一:在场景中放置对象

操作步骤:

  1. 创建一个空 GameObject。
  2. 添加AudioManager组件。
  3. 在 Inspector 中配置AudioSource
  4. 运行时通过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

不要在静态字段初始化器中调用FindFirstObjectByTypeAddComponent等 Unity API:

// 不建议privatestaticT_instance=FindFirstObjectByType<T>();

Unity 场景和对象生命周期并不等同于普通 C# 静态初始化。把查找和创建放到InstanceAwake等明确的生命周期入口中更容易控制。

错误五:忽略 Enter Play Mode 的 Domain Reload 设置

如果项目关闭了 Domain Reload,静态字段不会像默认设置那样在每次进入 Play Mode 时完整重置。单例、事件和缓存类都可能因此保留上一次运行的数据。

可选处理方式:

  1. 开发阶段保持 Domain Reload 开启。
  2. 为静态状态提供显式重置方法。
  3. 使用统一的运行时初始化入口重置静态字段。
  4. 不在静态单例中保存不必要的可变状态。

九、线程安全问题应该怎么处理

很多 C# 单例实现会使用lock

lock(_lock){// 创建实例}

但 Unity 的大多数对象 API,包括 GameObject 创建、组件添加和场景对象查找,都必须在主线程执行。给这些代码外面加锁,并不能让 Unity API 变成线程安全。

所以对于MonoSingleton<T>

  • 默认假设所有访问来自 Unity 主线程。
  • 不要从后台线程直接访问Instance并创建组件。
  • 后台线程只处理纯数据。
  • 回到主线程后,再访问 Unity 对象。

如果项目确实需要多线程服务,应将“纯 C# 数据服务”和“UnityMonoBehaviour外壳”拆开,分别管理生命周期。


十、如何让单例更容易测试

可以为基类增加清理入口,但不要让业务代码随意调用:

publicabstractclassMonoSingleton<T>:MonoBehaviourwhereT:MonoSingleton<T>{// 其他代码省略internalstaticvoidResetForTests(){_instance=null;}}

更推荐的测试策略是:

  1. 将核心业务逻辑放到普通 C# 类中。
  2. MonoSingleton<T>只负责 Unity 生命周期和依赖装配。
  3. 测试时直接构造普通 C# 服务。
  4. 少量 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# 对象。不要依赖构造函数完成组件初始化,应使用AwakeOnSingletonInitialize

3. 明确初始化顺序

如果管理器之间存在依赖,不要依赖脚本执行顺序碰运气。可以使用启动器集中初始化:

publicsealedclassBootstrap:MonoBehaviour{privatevoidAwake(){_=AudioManager.Instance;_=SaveManager.Instance;_=GameManager.Instance;}}

更大型的项目可以使用显式启动流程或依赖注入容器。

4. 限制Instance的调用范围

Instance适合访问全局服务,不适合替代所有对象引用。局部对象、场景对象和临时控制器应通过 Inspector、构造式初始化或方法参数传递依赖。


十二、最终理解

MonoSingleton<T>的价值不只是让我们少写几行static代码,更重要的是它展示了三个层面的结合:

  1. 泛型约束:通过where T : MonoSingleton<T>保证派生类型关系正确。
  2. CRTP-like 结构:把具体派生类型传回通用基类。
  3. Unity 生命周期:在AwakeOnDestroy和场景切换中管理真实对象。

它适合用来实现少量真正的全局服务,例如音频、存档、网络或游戏流程管理器;但不应该把所有系统都包装成单例。

可以把本文的设计原则总结为:

泛型解决重复代码,生命周期代码解决对象有效性,架构约束解决单例滥用。

当这三点同时考虑时,MonoSingleton<T>才不仅是一个方便调用的Instance属性,而是一个边界清晰、行为可预测的 Unity 基础设施。


参考方向

  • C++ Reference:Curiously Recurring Template Pattern(CRTP)
  • Unity 官方文档:MonoBehaviourObject.DontDestroyOnLoad
  • Unity 官方教程:MonoSingleton
  • .NET 文档:泛型类型约束与静态成员
← 返回列表