一、单例模式核心原理
- 意图:确保一个类只有一个实例,并提供一个全局访问点。
- 结构:私有构造函数 + 静态字段保存实例 + 静态属性/方法返回实例。
- 线程安全:在多线程环境需防止并发创建。
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 时赋值 |
五、小结
- 选对变种
- **纯 C#**:无挂载、线程安全、易测试;
- MonoBehaviour:可拖拽、支持生命周期钩子;
- ScriptableObject:数据驱动、易打包。
- 统一访问入口
- 所有调用通过
Instance静态属性,避免FindObjectOfType、Resources.Load散落各地。
- 所有调用通过
- 生命周期控制
- 在
Awake/OnEnable做重复检测,保证单例不被意外卸载; - 在 Editor 下利用初始化钩子清理历史残留。
- 在
- 接口与 DI
- 对外暴露接口而非具体类型,方便 Mock 与切换实现;
- 推荐使用 DI 框架管理单例依赖,简化初始化流程。