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 的管理器。

posted @ 2026-05-29 17:51  CloverJoyi  阅读(20)  评论(0)    收藏  举报