LOADING

加载过慢请开启缓存 浏览器默认开启

Unity 常用面向对象设计模式系列(2):单例模式(Singleton)


一、单例模式核心原理

  • 意图:确保一个类只有一个实例,并提供一个全局访问点。
  • 结构:私有构造函数 + 静态字段保存实例 + 静态属性/方法返回实例。
  • 线程安全:在多线程环境需防止并发创建。
public class Logger {
    private static Logger _instance;
    private Logger() { /* 防止外部 new */ }
    public static Logger Instance {
        get {
            if (_instance == null)
                _instance = new Logger();
            return _instance;
        }
    }
    public void Log(string msg) { /* … */ }
}

二、Unity 中的单例变种

2.1 纯 C# 单例

适用于无须挂载到场景的逻辑组件,如配置表管理、纯算法服务。

public class ConfigService {
    private static readonly ConfigService _instance = new ConfigService();
    public static ConfigService Instance => _instance;
    private Dictionary<string, object> _cache;
    private ConfigService() {
        // 读取、缓存配置
        _cache = LoadAllConfigs();
    }
    // … 方法获取配置数据
}
  • 优点:线程安全(.NET 保证静态构造线程安全)、不依赖 Unity 生命周期。
  • 缺点:无法在 Inspector 编辑、难以序列化,可测试性需借助接口抽象。

2.2 MonoBehaviour 单例

最常见的 Unity 单例,管理器脚本挂载在场景或预制件上。

public class AudioManager : MonoBehaviour {
    public static AudioManager Instance { get; private set; }
    void Awake() {
        if (Instance != null && Instance != this) {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
        // 初始化音频系统
    }
    public void PlaySFX(AudioClip clip) { /* … */ }
}
  • DontDestroyOnLoad:跨场景保持唯一性;
  • Duplicate 检测:防止场景中重复挂载导致冲突;
  • Inspector 绑定:可直接拖拽引用资源;

2.3 ScriptableObject 单例

利用资源驱动特性,将管理器实现为 ScriptableObject,便于打包和测试。

[CreateAssetMenu("Singleton/GameConfig")]
public class GameConfig : ScriptableObject {
    public static GameConfig Instance {
        get {
            if (_instance == null)
                _instance = Resources.Load<GameConfig>("Singleton/GameConfig");
            return _instance;
        }
    }
    private static GameConfig _instance;
    public int maxLives;
    public float spawnInterval;
}
  • 优点:可在编辑器中配置、支持数据驱动;

  • 缺点:须保证资源路径和命名唯一,使用 Resources 带来管理成本。


三、依赖管理

3.1 依赖倒置与单例

为了方便测试与解耦,可以让高层模块依赖接口:

public interface IAudioService { void PlaySFX(AudioClip c); }

public class AudioManager : MonoBehaviour, IAudioService {
    public static IAudioService Instance { get; private set; }
    void Awake() {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
        DontDestroyOnLoad(gameObject);
    }
    public void PlaySFX(AudioClip clip) { /* … */ }
}

// 客户端
public class Gun : MonoBehaviour {
    void Fire() {
        AudioManager.Instance.PlaySFX(shootClip);
    }
}
  • 可 Mock:在单元测试中可替换 IAudioService.Instance
  • 耦合最小化:业务逻辑只依赖抽象接口。

3.2 DI 框架中的单例

使用 Zenject / VContainer 等依赖注入框架管理单例生命周期:

public class GameInstaller : MonoInstaller {
    public override void InstallBindings() {
        Container.Bind<IAudioService>()
                 .To<AudioManager>()
                 .FromComponentInHierarchy()
                 .AsSingle();
    }
}
  • 框架统一管理:无需手动在 Awake 中赋值;
  • 场景切换安全:由容器负责生命周期。

四、注意事项

场景 问题 方案
跨场景重复实例 重新加载场景时挂载的 Manager 会多次 Awake 在 Awake 中检测 Instance != this 即刻 Destroy(gameObject)
Editor 模式下多次加载 进入 Play Mode 两次调用 Awake,导致静态变量残留 使用 [RuntimeInitializeOnLoadMethod] 清理静态单例
依赖测试 业务逻辑直接 AudioManager.Instance 难以替换为 Mock 实现 通过接口抽象 IAudioService,在测试中注入 Mock
多线程访问 在异步任务或 Job System 中访问单例可能造成线程安全问题 只在主线程访问或额外加锁,并避免 Allocate/Destroy
Inspector 赋值不生效 手动在 Inspector 赋值后,动态场景加载覆盖或丢失引用 使用 SerializeField + 检查 Instance == null 时赋值

五、小结

  1. 选对变种
    • **纯 C#**:无挂载、线程安全、易测试;
    • MonoBehaviour:可拖拽、支持生命周期钩子;
    • ScriptableObject:数据驱动、易打包。
  2. 统一访问入口
    • 所有调用通过 Instance 静态属性,避免 FindObjectOfTypeResources.Load 散落各地。
  3. 生命周期控制
    • Awake/OnEnable 做重复检测,保证单例不被意外卸载;
    • 在 Editor 下利用初始化钩子清理历史残留。
  4. 接口与 DI
    • 对外暴露接口而非具体类型,方便 Mock 与切换实现;
    • 推荐使用 DI 框架管理单例依赖,简化初始化流程。