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

本文适合已经了解 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>,也就是基类的模板参数又填写了派生类自身,因此称为"奇异递归"。

它通常用于以下场景:

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

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

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


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

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

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

操作步骤:

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

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

csharp 复制代码
// 不建议
private static T _instance = FindFirstObjectByType<T>();

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

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

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

可选处理方式:

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

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

很多 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;
    }
}

更推荐的测试策略是:

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

3. 明确初始化顺序

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

csharp 复制代码
public sealed class Bootstrap : MonoBehaviour
{
    private void Awake()
    {
        _ = 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 文档:泛型类型约束与静态成员
相关推荐
有点。1 小时前
C++深度优先搜索(二)
开发语言·c++·深度优先
二进制杯莫停1 小时前
A和B环境的python版本相同,B环境无法安装pip依赖,离线安装
开发语言·python·pip
小林敲代码77882 小时前
Spring Boot 3 升级踩坑:aj-captcha 行为验证码底图加载失效
java·spring boot·后端
l1t2 小时前
kryonix提交的DuckDB 统一并优化标量执行器基础设施 - #24564 PR
开发语言·数据库·数据仓库·sql
带刺的坐椅2 小时前
Solon I18n:三种解析器,零样板代码
java·国际化·i18n·solon·多语言
掉鱼的猫2 小时前
Solon I18n:三种解析器,零样板代码
java
程序员阿明2 小时前
spring boot3访问resources下的文件,使得在浏览器url中可以直接访问
java·spring boot·后端
雪隐3 小时前
WPF + MVVM 实战系列02-告别 INPC 手写时代,做个体面的现代 WPF 人
前端·后端·c#
wuminyu3 小时前
JUC包的AQS与JVM的ObjectMonitor机制对比分析
java·linux·c语言·jvm·c++