Unity单例模式线程安全陷阱与实战解决方案
2026/7/11 23:31:08 网站建设 项目流程

1. 项目概述:为什么Unity里的单例模式会“翻车”?

在Unity项目里,单例模式(Singleton Pattern)几乎是每个开发者都会用到的设计模式,从游戏管理器(GameManager)、音频管理器(AudioManager)到资源加载器(ResourceLoader),随处可见它的身影。它的核心目标很明确:确保一个类在整个应用程序生命周期中只有一个实例,并提供一个全局访问点。听起来简单又美好,对吧?但正是这种“简单”,让很多开发者,包括一些经验丰富的架构师,都曾在这里栽过跟头。我见过太多项目,前期跑得飞快,一到后期或者某些特定平台(比如WebGL、移动端),就出现各种灵异问题:UI状态错乱、音频播放重叠、资源重复加载导致内存泄漏……追根溯源,往往就是那个看似人畜无害的单例初始化代码埋下的线程安全陷阱。

这个陷阱的根源在于Unity独特的运行时架构。Unity并非一个纯粹的单线程环境。虽然主游戏逻辑(如UpdateFixedUpdate)运行在主线程,但许多后台操作,如资源异步加载(Addressables/AssetBundle)、网络请求、部分平台特定的初始化(尤其是Unity WebGL的漫长初始化过程),甚至是一些Job System或Burst编译后的代码,都可能在其他线程上执行。当你写的单例模式没有考虑这种多线程访问的可能性时,经典的“双重检查锁定”可能失效,导致创建多个实例,状态不一致,最终引发难以调试的崩溃或逻辑错误。

本文将从一线架构师的实战视角,深入拆解Unity环境下单例模式线程安全的那些坑。我们不止讨论理论,更会结合Unity引擎特有的生命周期(如AwakeOnEnable与静态构造函数的执行顺序)、平台差异(如WebGL的线程模型)以及现代Unity技术栈(如Addressables、ECS、Job System),给出可直接复制粘贴的、经过生产环境验证的规避策略与最佳实践。无论你是在优化一个已有的“祖传”项目,还是为新的MOBA或开放世界游戏搭建核心框架,理解这些陷阱都能帮你省下无数个加班调试的夜晚。

2. 单例模式线程安全陷阱的深度解析

2.1 陷阱一:非线程安全的“懒汉式”与它的经典失效场景

我们最常写的、也是问题最多的单例,可能就是下面这种“懒汉式”(Lazy Initialization)的变种:

public class GameManager { private static GameManager _instance; public static GameManager Instance { get { if (_instance == null) { _instance = new GameManager(); } return _instance; } } private GameManager() { } }

这段代码在单线程的Unity编辑器Play模式下,绝大多数时候运行良好。但它的线程不安全是致命的。假设两个线程(比如主线程和一个资源加载完成回调线程)同时首次访问Instance属性,它们可能同时通过if (_instance == null)的判断,进而创建两个不同的GameManager实例。之后,系统中将存在两个“单例”,对它们的操作将导致状态分裂。

在Unity中,这个陷阱极易在以下场景触发:

  1. Unity WebGL初始化期间:WebGL平台的初始化(尤其是涉及WASM模块加载时)可能比我们想象的要复杂。在初始化完成前,如果有多处代码(可能来自不同的插件或你自己的启动脚本)尝试访问单例,而该单例的初始化又依赖某些尚未就绪的Unity子系统,就可能出现竞态条件。
  2. 使用Addressables异步加载资源时Addressables.LoadAssetAsync的回调可能在非主线程上执行。如果在这个回调中首次访问了一个未初始化的单例,就会与主线程可能发生的访问产生竞争。
  3. 配合Unity的Job System或Burst编译代码:当你使用IJob并行处理数据时,多个Job线程同时访问一个用于缓存或配置的单例实例(尽管这不是最佳实践,但有时会发生),风险极高。
  4. 场景异步加载(SceneManager.LoadSceneAsync):在加载过程中,新旧场景的AwakeOnEnable方法调用顺序复杂,如果多个场景中的对象都在Awake中访问同一个单例,也可能在极端情况下出现问题。

注意:很多开发者认为只要把实例创建放在Awake()里就安全了,因为Awake()在主线程执行。但问题在于,Awake()的调用时机是对象被创建或激活时。如果你的单例GameObject是通过动态加载(如Resources.LoadAddressables)并在多帧中实例化的,或者脚本执行顺序设置不当,仍然可能产生多个实例。

2.2 陷阱二:看似安全的“双重检查锁定”在Unity中的隐藏缺陷

为了解决懒汉式的问题,大家自然会想到“双重检查锁定”(Double-Checked Locking)模式:

public class AudioManager { private static AudioManager _instance; private static readonly object _lock = new object(); public static AudioManager Instance { get { if (_instance == null) // 第一次检查 { lock (_lock) // 加锁 { if (_instance == null) // 第二次检查 { _instance = new AudioManager(); } } } return _instance; } } private AudioManager() { } }

在标准的C#环境中,这段代码加上volatile关键字修饰_instance后,基本是线程安全的。但在Unity中,它仍然存在几个关键隐患:

  1. Unity对象生命周期与MonoBehaviour:如果单例是MonoBehaviournew AudioManager()是不行的,必须使用GameObjectAddComponent。而在锁内执行GameObject.InstantiateAddComponent,虽然能防止创建多个实例,但锁无法保证Unity引擎内部对Awake()OnEnable()的调用与其他脚本访问的时序。例如,线程A在锁内创建了GameObject并添加了AudioManager组件,在Awake()中开始初始化音频系统(这可能需要几帧)。此时锁已释放,线程B访问Instance,虽然得到的是同一个对象引用,但该对象的Awake可能尚未执行完毕,其内部状态(如audioSource引用)可能还是null,导致线程B访问时抛出NullReferenceException

  2. 跨线程访问Unity API:这是一个更根本的限制。Unity的绝大多数API(除了明确标注为线程安全的,如部分Mathematics库函数)都必须在主线程调用。如果你的单例初始化(Awake或构造函数)中包含了任何Unity API调用(如GetComponentFindObjectOfTypeResources.Load),那么即使有锁保护,从非主线程触发初始化也是非法的,会导致错误或崩溃。上面的双重检查锁并不能解决“调用线程”的问题。

  3. volatile关键字与Unity/IL2CPP的编译优化:在Unity使用IL2CPP后端为某些平台(如iOS、WebGL)编译时,其内存模型和优化策略可能与Mono或.NET Core有所不同。虽然C#的volatile关键字通常有效,但在极其复杂的多线程交织场景下,依赖它来保证所有平台的绝对安全仍需谨慎。更可靠的方式是使用System.Lazy<T>UnityEngine.Object的固有特性。

2.3 陷阱三:静态构造函数与MonoBehaviour生命周期的时序战争

另一种常见的单例实现是利用静态构造函数:

public class ConfigManager { public static readonly ConfigManager Instance = new ConfigManager(); static ConfigManager() { } // 静态构造函数 private ConfigManager() { // 初始化配置,可能读取文件 } }

或者使用静态初始化:

public class ConfigManager { public static readonly ConfigManager Instance = new ConfigManager(); private ConfigManager() { } }

这种方式是线程安全的,因为CLR保证静态字段的初始化是线程安全的。但是,它有一个巨大的问题:初始化时机不可控。静态字段的初始化发生在该类首次被访问前的任何时间,由运行时决定。这可能导致:

  • 初始化过早:游戏还没开始,甚至Unity引擎都没完全初始化好,单例就被创建了。如果它的构造函数里调用了Debug.Log(这本身是Unity API),在编辑器启动时可能导致错误。
  • 初始化顺序不可预测:如果GameManager单例依赖ResourceManager单例,而两者的静态初始化顺序是未定义的,可能导致GameManager初始化时ResourceManager还是null
  • 无法用于MonoBehaviour:你无法在静态构造函数里创建GameObject或调用Instantiate。因此,这种方法只适用于纯粹的C#类单例。

对于MonoBehaviour单例,很多人会写一个Awake方法来保证唯一性:

void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); // 其他初始化... }

这引入了“时序战争”。如果场景中存在两个该脚本的GameObject,Awake的调用顺序取决于脚本执行顺序和对象激活顺序。虽然最终能销毁一个,但那个被销毁的对象的Awake方法可能已经执行了一部分初始化代码,这些代码如果有副作用(如向全局事件注册监听),就会造成污染,且难以清理。

3. Unity环境下线程安全的单例实现策略

3.1 策略一:适用于MonoBehaviour单例的“主线程安全初始化”模式

对于必须继承MonoBehaviour的单例(需要用到Unity生命周期、组件系统或Coroutine),我推荐以下经过实践检验的模式。它的核心思想是:将实例引用赋值与复杂的Unity API初始化分离,并确保初始化逻辑只在主线程、且只执行一次。

using UnityEngine; public class SafeMonoSingleton<T> : MonoBehaviour where T : Component { private static T _instance; private static bool _isApplicationQuitting = false; private static object _lock = new object(); private static bool _isInitialized = false; // 关键:初始化状态标志 public static T Instance { get { if (_isApplicationQuitting) { Debug.LogWarning($"[{typeof(T)}] 实例已在应用退出时被销毁。返回null。"); return null; } lock (_lock) { if (_instance == null) { // 查找场景中是否已存在实例 _instance = FindAnyObjectByType<T>(); if (_instance == null) { // 创建新的GameObject和组件 GameObject singletonObject = new GameObject(); singletonObject.name = $"{typeof(T).Name} (Singleton)"; _instance = singletonObject.AddComponent<T>(); // 注意:此时Awake()已经被Unity调用! } // 确保初始化逻辑只执行一次 if (!_isInitialized && _instance != null) { // 将真正的初始化任务派发到主线程的下一个更新周期 // 这确保了即使Instance是在非主线程被首次访问,初始化也在主线程进行 // 可以通过一个初始化管理器或简单的Coroutine runner来实现 InitializeOnMainThread(_instance as SafeMonoSingleton<T>); } } return _instance; } } } // 这个方法由Instance属性内部调用,确保在主线程执行初始化 private static void InitializeOnMainThread(SafeMonoSingleton<T> instance) { // 这里可以借助Unity主线程调度器,例如: // UnityMainThreadDispatcher.Instance.Enqueue(() => instance.DoRealInitialization()); // 为了示例清晰,我们假设这里直接调用,但前提是getter已在主线程被调用。 // 更稳健的做法是下面这样: if (Application.isPlaying && instance != null) { instance.StartCoroutine(instance.DelayedInitializationCoroutine()); } } private System.Collections.IEnumerator DelayedInitializationCoroutine() { // 等待一帧,确保所有Awake都执行完毕,对象处于稳定状态 yield return null; DoRealInitialization(); _isInitialized = true; } // 真正的初始化逻辑,只执行一次 protected virtual void DoRealInitialization() { Debug.Log($"[{typeof(T)}] 执行线程安全的单例初始化。"); // 在这里进行你的初始化:加载配置、获取组件引用、注册事件等。 DontDestroyOnLoad(gameObject); // 如果需要的话 } protected virtual void Awake() { lock (_lock) { if (_instance == null) { _instance = this as T; // 不在Awake中做复杂初始化,只做最基本的设置 // 复杂初始化留给DoRealInitialization } else if (_instance != this) { Debug.LogWarning($"[{typeof(T)}] 检测到重复实例,销毁新创建的。"); Destroy(gameObject); } } } protected virtual void OnApplicationQuit() { _isApplicationQuitting = true; } protected virtual void OnDestroy() { lock (_lock) { if (_instance == this) { _instance = null; _isInitialized = false; } } } }

使用方式:

public class MyAudioManager : SafeMonoSingleton<MyAudioManager> { private AudioSource _audioSource; protected override void DoRealInitialization() { base.DoRealInitialization(); _audioSource = gameObject.AddComponent<AudioSource>(); // 其他安全的Unity API调用 LoadAudioConfig(); } private void LoadAudioConfig() { /* ... */ } public void PlaySound(AudioClip clip) { // 可以安全调用,因为单例已完全初始化 _audioSource.PlayOneShot(clip); } }

这个模式的关键点:

  1. 锁保护实例创建:使用lock确保即使在多线程环境下,也只有一个实例被创建。
  2. 初始化状态分离:引入_isInitialized标志,将“获取实例引用”和“执行初始化逻辑”两个步骤解耦。实例可以很快被创建和返回,但繁重的初始化(尤其是涉及Unity API的)被延迟并确保只执行一次。
  3. 主线程初始化:通过StartCoroutineDoRealInitialization延迟到下一帧执行。这保证了:
    • 初始化逻辑100%在主线程执行。
    • 避开了Awake周期内可能存在的对象依赖问题。
    • 即使Instance的getter是在非主线程首次被调用(返回了尚未初始化的实例引用),实际的初始化工作也会在之后安全的主线程上下文中完成。调用方需要处理实例可能“未就绪”的状态(可通过事件或isInitialized属性通知)。
  4. 处理应用退出_isApplicationQuitting标志避免了应用退出时可能发生的Instance创建,防止无意义的警告和错误。

3.2 策略二:使用.NET的Lazy 实现纯C#单例(非MonoBehaviour)

对于不需要Unity生命周期、纯粹是数据管理或逻辑处理的工具类(如配置解析器、网络协议处理器、本地化管理器),使用System.Lazy<T>是最简洁、最线程安全的选择。.NET Framework内置的Lazy<T>默认就是线程安全的。

using System; public class NetworkService { // Lazy<T> 默认使用 LazyThreadSafetyMode.ExecutionAndPublication,是线程安全的。 private static readonly Lazy<NetworkService> _lazyInstance = new Lazy<NetworkService>(() => new NetworkService()); public static NetworkService Instance => _lazyInstance.Value; private NetworkService() { // 初始化网络库,这里可以包含复杂的逻辑,Lazy会保证只执行一次。 InitializeNetwork(); } private void InitializeNetwork() { /* ... */ } // 业务方法 public void SendData(string data) { /* ... */ } }

优点:

  • 代码极其简洁:无需手动处理锁和空检查。
  • 线程安全由.NET保证Lazy<T>的内部实现已经处理了所有线程同步问题。
  • 延迟初始化:只有在第一次访问Instance.Value时才会执行构造函数。
  • 高性能:在实例创建后,后续访问几乎没有开销。

注意事项:

  • 绝对不能在其中调用Unity API:因为构造函数的执行线程是不确定的(虽然首次访问Value的线程会触发构造,但如果你在子线程访问,构造函数就在子线程运行了)。务必确保构造函数和所有方法都是纯C#逻辑。
  • 适用于工具类:如日志管理器、加密工具、数据序列化器等。

3.3 策略三:利用Unity运行时初始化(RuntimeInitializeOnLoadMethod)进行预加载

对于必须在游戏一开始就存在、且初始化成本较高的MonoBehaviour单例,我们可以利用[RuntimeInitializeOnLoadMethod]属性,在游戏运行时第一时间(早于任何Awake)进行创建和初始化,变“懒加载”为“预加载”,从而彻底避免多线程竞争初始化的问题。

using UnityEngine; public class PreloadedSingleton : MonoBehaviour { private static PreloadedSingleton _instance; public static PreloadedSingleton Instance => _instance; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void InitializeOnLoad() { if (_instance != null) return; GameObject go = new GameObject("PreloadedSingleton"); _instance = go.AddComponent<PreloadedSingleton>(); DontDestroyOnLoad(go); // 可以在这里执行一些非常早期的初始化 Debug.Log("PreloadedSingleton 在场景加载前已初始化。"); // 注意:此时Awake()尚未被调用! } private void Awake() { if (_instance != null && _instance != this) { Destroy(gameObject); return; } // 在这里进行正常的Awake初始化 Debug.Log("PreloadedSingleton Awake called."); } // 真正的业务初始化可以放在Start或自定义方法中 private void Start() { DoHeavyInitialization(); } private void DoHeavyInitialization() { /* ... */ } }

策略解析:

  • RuntimeInitializeOnLoadMethod:这是Unity提供的一个强大特性。标记了[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]的静态方法,会在游戏运行开始、第一个场景加载之前,由Unity主线程自动调用。
  • 线程安全:因为这个方法是由Unity主线程在启动时同步调用的,此时还没有其他用户线程(如Job System、网络回调)启动,所以创建实例是绝对线程安全的。
  • 时机最早:它比所有GameObject的Awake方法都早。这保证了当任何脚本在Awake中访问Instance时,它肯定已经存在。
  • 适用场景:非常适合核心管理器,如游戏状态管理器、存档系统、音频系统核心等,这些需要在游戏任何逻辑开始前就准备就绪的服务。

潜在缺点:

  • 增加启动时间:如果预加载的单例初始化非常耗时,会拖慢游戏进入第一个场景的速度。
  • 内存占用:即使当前用不到,单例也会一直存在。
  • 初始化顺序:如果有多个使用此特性的单例,它们的初始化顺序是未定义的(尽管都在主线程)。如果它们之间有依赖,需要小心处理。

4. 针对特定Unity模块与场景的进阶规避策略

4.1 Addressables异步加载与单例的协同

Addressables的异步加载回调是线程安全问题的重灾区。一个常见的错误模式是:在Addressables.LoadAssetAsync<GameObject>().Completed回调中(该回调可能在非主线程触发),尝试访问或初始化一个单例,而这个单例的初始化又涉及实例化Unity对象或调用其他Unity API。

安全策略:将回调调度回主线程。

using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public class SafeAddressablesLoader : MonoBehaviour { // 假设这是一个单例 public static SafeAddressablesLoader Instance { get; private set; } void Awake() { /* 单例初始化 */ } public void LoadPrefabAndSpawn(string key) { Addressables.LoadAssetAsync<GameObject>(key).Completed += handle => { // 回调可能在工作线程! if (handle.Status == AsyncOperationStatus.Succeeded) { GameObject prefab = handle.Result; // 错误:直接在主线程之外实例化 // GameObject.Instantiate(prefab); // 正确:将实例化操作派发到主线程 UnityMainThreadDispatcher.Instance.Enqueue(() => { // 现在在主线程了,可以安全访问单例和Unity API GameObject instance = Instantiate(prefab); Instance.RegisterSpawnedObject(instance); Addressables.Release(handle); // 释放资源句柄 }); } }; } }

你需要一个主线程调度器(UnityMainThreadDispatcher),这是一个经典的工具类,网上有很多实现。其核心是利用Update队列和System.Action,将任务从任何线程安全地转移到主线程执行。任何在非主线程回调中需要与单例(特别是MonoBehaviour单例)交互的操作,都必须通过这种方式。

4.2 在ECS与Job System中安全地访问单例数据

Unity的ECS(实体组件系统)和Job System鼓励数据导向设计和多线程并行。传统的MonoBehaviour单例在这里往往不适用,因为Job中不能访问MonoBehaviour或任何非blittable类型的托管对象。

策略:使用ECS的Singleton Component或线程安全的数据容器。

  1. ECS Singleton Component:在ECS中,你可以定义一个IComponentData,并将其作为单例实体添加到世界中。通过EntityManager.CreateEntity()创建,并确保只有一个。然后在System中通过GetSingleton<T>()SetSingleton<T>()来安全访问。ECS框架本身保证了其线程安全性(在正确的System调度下)。

  2. 配合Job System的线程安全数据:如果你需要在Job中读取一些全局配置(例如游戏平衡参数),应该将这些数据存储在纯C#的结构体或NativeArray中,这些数据可以在主线程初始化,然后以[ReadOnly]的方式传递给Job。

using Unity.Collections; using Unity.Jobs; using UnityEngine; public class GameConfig : MonoBehaviour { public float EnemySpeed = 5.0f; public int MaxEnemies = 100; // 将配置数据转换为线程安全的NativeArray private NativeArray<float> _jobSafeConfig; void Start() { // 在主线程初始化NativeArray _jobSafeConfig = new NativeArray<float>(1, Allocator.Persistent); _jobSafeConfig[0] = EnemySpeed; } void OnDestroy() { // 记得释放 if (_jobSafeConfig.IsCreated) _jobSafeConfig.Dispose(); } // 在Job中安全使用 public struct MyJob : IJobParallelFor { [ReadOnly] public NativeArray<float> ConfigData; public NativeArray<Vector3> Positions; public void Execute(int index) { float speed = ConfigData[0]; // 使用speed进行计算... Positions[index] += Vector3.forward * speed * Time.deltaTime; } } }

关键点:在涉及多线程的架构中,要彻底摒弃“通过单例随时获取可变数据”的想法,转而采用“主线程准备数据,Job只读使用”或“通过ECS的CommandBuffer进行线程安全的写操作”的模式。

4.3 WebGL平台的特殊考量

Unity WebGL由于其单线程(主线程)模拟多线程的特性,线程安全问题有时表现得不像原生平台那么明显,但依然存在,尤其是在漫长的初始化阶段和异步操作中。

  • 初始化阻塞:WebGL模块初始化可能很慢。如果你的单例在Awake中执行了大量同步的IO或计算,会阻塞主线程,导致页面卡死。解决方案是将初始化拆分为多帧,使用Coroutineyield return null
  • “看似单线程”的陷阱:虽然JavaScript是单线程的,但Unity WebGL使用Promise和回调来模拟异步。当你在一个async方法中await一个UnityWebRequest,或者使用Addressables时,后续的代码虽然仍在主线程执行,但可能已经过了好几帧,执行时机变得不确定。如果在此期间有其他逻辑访问单例状态,可能遇到中间状态。因此,使用第3.1节提到的“初始化状态分离”模式在WebGL上同样重要
  • 避免在单例构造函数中进行WebGL交互:与原生平台一样,避免在静态构造函数或Lazy<T>的初始化逻辑中调用任何可能涉及浏览器API或Unity WebGL交互模块的代码。

5. 实战:构建一个线程安全的Unity单例管理器模板

结合以上所有策略,我们可以设计一个更通用、更健壮的单例基类模板。这个模板旨在解决大部分常见陷阱:

using System; using System.Collections; using UnityEngine; public abstract class ThreadSafeSingleton<T> : MonoBehaviour where T : ThreadSafeSingleton<T> { private static T _instance; private static readonly object _lock = new object(); private static bool _isInitialized = false; private static bool _isApplicationQuitting = false; // 提供一个属性来检查单例是否已完全初始化就绪 public static bool IsReady => _isInitialized; public static T Instance { get { if (_isApplicationQuitting) { Debug.LogWarning($"[Singleton] Application is quitting. Returning null for {typeof(T)}."); return null; } lock (_lock) { if (_instance == null) { // 尝试在场景中查找 _instance = FindAnyObjectByType<T>(); // 如果没找到,自动创建一个 if (_instance == null) { string singletonName = typeof(T).Name; Debug.Log($"[Singleton] Creating new instance of {singletonName}."); GameObject singletonObject = new GameObject(singletonName); _instance = singletonObject.AddComponent<T>(); // Awake()会紧接着被Unity调用 } // 如果实例存在但未初始化,且应用正在运行,则启动初始化协程 if (!_isInitialized && _instance != null && Application.isPlaying) { // 我们无法保证当前是否在主线程,所以通过实例启动协程是安全的(如果实例已创建) // 但如果_instance是在本次调用中刚创建的,则其MonoBehaviour生命周期已开始,可以启动协程。 _instance.StartCoroutine(_instance.InitializeRoutine()); } } return _instance; } } } // 真正的初始化协程,确保在主线程执行且只执行一次 private IEnumerator InitializeRoutine() { // 等待一帧,让所有Awake和OnEnable调用完毕 yield return null; lock (_lock) { if (!_isInitialized) { Debug.Log($"[Singleton] Starting initialization for {typeof(T).Name}."); Initialize(); _isInitialized = true; Debug.Log($"[Singleton] Initialization completed for {typeof(T).Name}."); } } } // 子类必须实现的抽象初始化方法 protected abstract void Initialize(); // 可选:子类可重写的清理方法 protected virtual void Shutdown() { } protected virtual void Awake() { lock (_lock) { if (_instance == null) { _instance = this as T; // 不在这里调用Initialize,交给Instance属性或Start } else if (_instance != this) { Debug.LogWarning($"[Singleton] Duplicate instance of {typeof(T).Name} found. Destroying the new one."); DestroyImmediate(gameObject); } } } // 建议在Start中确保初始化,如果之前没有被触发的话 protected virtual void Start() { if (!_isInitialized && this == _instance) { StartCoroutine(InitializeRoutine()); } } protected virtual void OnApplicationQuit() { _isApplicationQuitting = true; } protected virtual void OnDestroy() { lock (_lock) { if (_instance == this) { Shutdown(); _instance = null; _isInitialized = false; } } } // 提供一个静态方法用于显式获取实例并等待初始化(适用于异步场景) public static System.Collections.IEnumerator WaitForInstance() { while (Instance == null || !IsReady) { yield return null; } } }

使用示例:

public class MyResourceManager : ThreadSafeSingleton<MyResourceManager> { private Dictionary<string, GameObject> _loadedPrefabs; protected override void Initialize() { base.Initialize(); _loadedPrefabs = new Dictionary<string, GameObject>(); DontDestroyOnLoad(gameObject); // 如果需要 Debug.Log("MyResourceManager Initialized."); // 这里可以安全地调用任何Unity API,因为我们在主线程的协程中 PreloadEssentialResources(); } private void PreloadEssentialResources() { /* ... */ } protected override void Shutdown() { base.Shutdown(); _loadedPrefabs.Clear(); Debug.Log("MyResourceManager Shutdown."); } public GameObject GetPrefab(string path) { if (!IsReady) { Debug.LogError("ResourceManager not ready yet!"); return null; } // ... 业务逻辑 return null; } }

这个模板的核心优势:

  1. 线程安全的实例获取:通过lock保护。
  2. 主线程安全的初始化:通过协程将Initialize()延迟到主线程执行,并保证只执行一次。
  3. 状态明确:提供了IsReady属性,让外部代码可以检查单例是否已就绪。
  4. 生命周期管理:妥善处理了应用退出和对象销毁时的清理。
  5. 支持异步等待:提供了WaitForInstance()协程,方便在其他异步流程中等待单例准备完毕。
  6. 模板化:通过抽象方法Initialize()和虚方法Shutdown(),强制子类关注自己的初始化和清理逻辑,基类负责管理线程安全和生命周期。

在实际项目中,你可以根据具体需求对此模板进行微调,例如增加不同的初始化阶段(如EarlyInit, LateInit),或者集成依赖注入框架的接口。但它的基本框架已经能够规避Unity项目中绝大多数与单例相关的线程安全陷阱。记住,没有一种模式是银弹,理解其背后的原理,根据你的具体架构(是否用ECS,是否重度依赖Addressables,目标平台是什么)做出合适的选择,才是资深架构师的价值所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询