Unity 开发中的常用设计模式(第二章第3节:单例模式)

文章目录

目录


前言

欢迎来到设计模式的第3节🥳,这次要讲的是大名鼎鼎的单例模式。单例模式名声不太好,大家常说要少用单例模式,让我们来一起看看原因吧!

官方示例项目的下载地址在这里

此外,《Level up your code with design patterns and SOLID》已被翻译为中文,现已上传到 Github ,个人翻译。本人水平有限,若有错误还请指正😭,如果可以的话,请帮我点个小星星吧!🥹


单例模式可能是我们遇到的第一个设计模式,同时也是最受诟病的设计模式之一。

根据原始的“四人帮”的定义,单例模式是“确保某个类只能被实例化一次。为该唯一实例提供全局访问”。 

我们经常在一些中心化管理器上应用单例模式,因为我们只希望有一个管理器。单例模式的名声不好是因为它使用起来非常简单,容易导致滥用。开发者往往会在不合适的情况下使用单例模式,从而引入不必要的全局状态或依赖关系。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.Singleton
{
    /// <summary>
    /// 实现了一个简单版本的单例模式,确保 SimpleSingleton 只有一个实例存在
    /// 使用静态变量 Instance 进行全局访问
    ///
    /// 如果尝试创建多个实例,新实例将被销毁
    /// </summary>
    public class SimpleSingleton : MonoBehaviour
    {
        // 全局访问
        public static SimpleSingleton Instance;

        private void Awake()
        {
            if (Instance != null)
            {
                // 如果 Instance 已经设置,销毁这个重复的实例
                Destroy(gameObject);
            }
            else
            {
                // 如果 Instance 未设置,将此实例设为单例
                Instance = this;
            }
        }
    }
}  

 以上是一个简单的单例模式示例,我们使用一个静态变量实现全局访问。在 Awake 方法中检查单例是否已经被设置,如果已经设置过,那么就调用 Destroy(gameObject) 删除此对象,保证场景中只包含一个组件实例。

示例项目

  

点击鼠标左键播放声音,按下 R 键重置场景

示例项目使用了泛型来实现单例模式,如果我们有新的单例对象,由于挂载了同一个单例类组件,他们在场景中无法共存,我们需要将单例的代码复制粘贴到每个类中。那么不妨使用泛型:

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Serialization;

namespace DesignPatterns.Singleton
{
    /// <summary>
    /// 为 MonoBehaviour 类型提供单例设计模式的通用实现
    /// 确保在应用程序中任何时候都只有一个单例实例存在
    /// 如果在访问时没有找到实例,此脚本将创建实例
    /// </summary>
    /// <typeparam name="T">应为单例的 MonoBehaviour 类型。</typeparam>
    public class Singleton<T> : MonoBehaviour where T : Component
    {
        private static T s_Instance;

        public static T Instance
        {
            get
            {
                if (s_Instance == null)
                {
                    s_Instance = (T)FindFirstObjectByType(typeof(T));

                    if (s_Instance == null)
                    {
                        SetupInstance();
                    }
                    else
                    {
                        string typeName = typeof(T).Name;

                        Debug.Log("[Singleton] " + typeName + " 实例已创建: " +
                                  s_Instance.gameObject.name);
                    }
                }

                return s_Instance;
            }
        }

        public virtual void Awake()
        {
            RemoveDuplicates();
        }


        private static void SetupInstance()
        {
            // 延迟实例化
            s_Instance = (T)FindFirstObjectByType(typeof(T));

            if (s_Instance == null)
            {
                GameObject gameObj = new GameObject();
                gameObj.name = typeof(T).Name;

                s_Instance = gameObj.AddComponent<T>();
                DontDestroyOnLoad(gameObj);
            }
        }

        private void RemoveDuplicates()
        {
            if (s_Instance == null)
            {
                s_Instance = this as T;

                // 使用 DontDestroyOnLoad 使其持久化,但要手动清理/处理
                //DontDestroyOnLoad(gameObject);
            }
            else if (s_Instance != this)
            {
                Destroy(gameObject);
            }
        }

    }
}

让我们来看一下代码:

public class Singleton<T> : MonoBehaviour where T : Component

首先在声明类时我们限定泛型 T 必须继承自 Component,也就是 Unity 组件,继承只 Componet 也意为着我们不止可以单例化我们编写的 MonoBehaviour 脚本,也可以让某个组件单例化。

private static T s_Instance;  // 静态实例引用
public static T Instance { get { ... } }  // 单例访问入口

[SerializeField]
private bool m_DelayDuplicateRemoval; // 延迟删除重复实例的调试开关

然后我们声明了相关的字段与属性。

get {
    if (s_Instance == null) {
        s_Instance = (T)FindFirstObjectByType(typeof(T)); // 查找现有实例
        if (s_Instance == null) {
            SetupInstance(); // 不存在则创建新实例
        } else {
            Debug.Log("[Singleton] 实例已存在: " + s_Instance.gameObject.name);
        }
    }
    return s_Instance;
}

 以上代码是单例的初始化逻辑,我们采用懒汉模式,当实例被请求时才创建单例。

private static void SetupInstance() {
    s_Instance = (T)FindFirstObjectByType(typeof(T));
    if (s_Instance == null) {
        GameObject gameObj = new GameObject();
        gameObj.name = typeof(T).Name;
        s_Instance = gameObj.AddComponent<T>();
        DontDestroyOnLoad(gameObj); // 使单例跨场景保留(默认注释)
    }
}

 这里是示例创建的具体方法,如果场景中没有实例,我们就创建新的 GameObject 并挂载组件 T,这里还使用了 DontDestroyOnLoad 方法,如果单例跨场景也保证其不被销毁。

public void RemoveDuplicates() {
    if (s_Instance == null) {
        s_Instance = this as T; // 当前实例设为单例
        // DontDestroyOnLoad(gameObject); 需手动启用
    } else if (s_Instance != this) {
        Destroy(gameObject); // 销毁重复实例
    }
}

 这里是重复代码的处理,在 Awake 中调用,确保场景中只有一个实例。

public virtual void Awake() {
    RemoveDuplicates(); // 立即清理重复实例
}

这里是生命周期函数,当场景加载时,直接销毁重复的实例。

补充

实现方式

另外提一下单例模式的实现方式,最常用的是饿汉式和懒汉式。

饿汉式指的是在类加载时就已经完成了实例的创建,不管后面创建的实例有没有使用,先创建再说,所以叫做 “饿汉”。如以下代码:

public class Singleton : MonoBehaviour
{
    private static Singleton instance = new Singleton(); 
    
    public static Singleton Instance => instance; 

    private Singleton() { }

    private void Awake()
    {
        if (instance != null && instance != this)
        {
            Destroy(gameObject);
            return;
        }

        instance = this;
        DontDestroyOnLoad(gameObject);
}

而懒汉式指的是只有在请求实例时才会创建,如果在首次请求时还没有创建,就创建一个新的实例,如果已经创建,就返回已有的实例,意思就是需要使用了再创建,所以称为“懒汉”。如以下代码:

public class Singleton : MonoBehaviour
{
    private static Singleton instance;
    public static Singleton Instance
    {
        get
        {
            if (instance == null)
            {
                SetupInstance();
            }
            return instance;
        }
    }

    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(this.gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }

    private static void SetupInstance()
    {
        instance = FindObjectOfType<Singleton>();
        if (instance == null)
        {
            GameObject gameObj = new GameObject();
            gameObj.name = “Singleton”;
            instance = gameObj.AddComponent<Singleton>();
            DontDestroyOnLoad(gameObj);
        }
    }
}

在属性中可以看到,我们在调用时才创建了实例。

此外,在使用懒汉单例模式还有一个问题就是线程安全,如果多个线程同时访问 Instance 属性,并且同一时刻检测到该实例没有被创建,就可能同时创建实例,从而导致多个实例被创建,而懒汉模式因为在在程序启动阶段就完成了实例的初始化,因此不存在多个线程同时尝试初始化实例的问题。这个时候可以使用一些同步机制来保证在任何时刻只有一个线程能执行实例的创建。

Unity 的主线程是单线程的,且绝大多数 Unity API 只能在主线程调用。尽管 C# Job System 允许多线程处理计算密集型任务,但它通过严格的约束(如 Native 容器和值类型操作)避免了直接访问 Unity 对象,因此开发者通常无需手动处理线程同步。但在以下场景仍需注意线程安全:

  1. 使用第三方库或异步操作时,需确保回调在主线程执行。

  2. 多线程读写共享数据时,需通过锁或原子操作避免竞争。

  3. Unity 的部分异步 API(如 UnityWebRequest)的回调可能不在主线程。

单例模式和静态类的区别

在一个静态类中,声明类对象为一个静态成员,这样的实现方式虽然可以满足容易获取对象的需求,但是静态类的拓展性较差,无法继承其他类、接口,也无法实例化,而且 Unity 是无法序列化静态类的。所以一般在工具类、常量类或拓展方法上使用静态类。

反对使用单例模式的原因

单例模式似乎名声不太好,大家总是在说不要使用单例模式。单例模式使用方便,直接调用 Instance 就行了,而且可以省略参数传递,所以很多程序员(包括俺自己)就滥用使用单例模式。

这会造成什么问题呢?

  • 单例是一个全局变量。
    • 全局变量就是个问题,它会让代码变得晦涩难懂,全局变量在整个程序中都是可访问的,这意味着它们可以在任何位置修改,当我们在多个地方修改同一个全局变量时,其他开发人员或自己在阅读代码时,难以立刻知道这个变量的变化会影响到哪些部分,增加了理解的复杂性。
    • 全局变量会增加代码之间的耦合,当一个函数或模块修改了全局变量的值,其他函数或模块可能会受到影响。不同部分的代码不再是独立的,而是互相影响和依赖。
    • 使用全局变量还会让单元测试变得困难。
  • 单例模式违反了开闭原则。因为通过 Instance 获取的对象是实现类,是包含了实现细节的实体类。因此,当设计变更或需求增加时,我们只有修改实现类中的源码,这违背了开闭原则中“对修改关闭”的要求。当然,我们可以通过让子类继承自父类单例,子类向父类注册实体对象,再调用父类的 Instance 根据条件查表去获取子类对象,这样当需求变更后修改子类的代码就行了,满足了开闭原则(其实此方法也有点违背得墨忒耳定律)。

总结

单例模式可以限制对象的产生数量;提供方便获取唯一对象的方法。但容易造成设计思考不周和过度使用的问题,但并不是要求设计者完全不使用这个模式,而是应该在仔细设计和特定的前提之下,适当地采用单例模式。

优缺点

优点:

  • 单例相对易于学习: 其核心模式本身并不复杂。
  • 单例使用上更便捷: 要从另一个组件使用单例,只需引用其公共的静态实例即可。该实例在场景中的任何对象都能随时访问。
  • 单例性能更高: 因为可以全局访问静态的单例实例,可以避免使用 GetComponent 或 Find 之类的开销操作。

缺点:

上面提到过,详见反对使用单例模式的原因

改进

我们为什么要使用单例模式?因为单例模式的两个重要特性:唯一的对象和容易获取对象。在使用这个类时我们就可考虑是否需要以上特性。可以通过以下方法来实现:

依赖注入

最简单的方法,既然经常使用这个类,为什么不直接将其变为成员变量呢?这一方法被称作依赖注入,也是一种设计模式。简而言之,依赖注入就是将一个类所需要的依赖(即其他对象)传递给它,而不是让它自己去创建或获取这些依赖。有两种方法减少单例类的使用:分别设置和指定类的静态成员。

分别设置

就是直接将对象设置为类成员,如果后面有其他方法要使用对象就直接使用这个类成员。来看看示例:

传统单例模式:

public class GameManager : MonoBehaviour
{
    private void Start()
    {
        AudioManager.Instance.PlayBGM();
        SaveManager.Instance.LoadGame();
    }
}

 依赖注入改进

public class GameManager : MonoBehaviour
{
    private readonly IAudioManager _audioManager;
    private readonly ISaveManager _saveManager;

    // 通过构造函数注入依赖
    public GameManager(IAudioManager audioManager, ISaveManager saveManager)
    {
        _audioManager = audioManager;
        _saveManager = saveManager;
    }

    private void Start()
    {
        _audioManager.PlayBGM();
        _saveManager.LoadGame();
    }
}

 直接在构造函数中注入依赖,减少了单例模式的使用。

指定类的静态成员

A 类的功能中若需要使用到B类的方法,并且A类在产生其对象时具有下列几种情况:

  • 产生对象的位置不确定;
  • 有多个地方可以产生对象;
  • 生成的位置无法引用到;
  • 有众多子类。

当满足上述情况之一时,可以直接将B类对象设置为A类中的“静态成员属性”,让该类的对
象都可以直接使用。这一方法既保证了唯一的对象又实现了容易获取对象的特性。

举个例子,敌人 AI 类(EnemyAI),在运行时需要使用关卡系统(StageSystem)的信息,但 EnemyAI 对象产生的位置是在敌方单位建造者(EnemyBuilder)之下。

按照“得墨忒耳定律”,会希望敌方单位的建造者减少对其他无关类的引用。因此,在产生敌方单位AI对象时,敌方单位建造者无法将关卡系统对象设置给敌方单位 AI,这是属于上述“生成的位置无法引用到”的情况。所以,可以在敌方单位 AI 类中,提供一个静态成员属性和静态方法,让关卡系统对象产生的当下,就设置给敌方单位 AI 类:

public class nemyAI : ICharacterAI
{
    private static StageSystem mStageSystem = null;
    ...
    //将关卡系统直接注入给EnemyAI类使用
    public static void SetStageSystem(StageSystem StageSystem)
    {
        m_StageSystem = StageSystem;
    }
    ...
    //是否可以攻击Heart
    public override bool CanAttackHeart()
    {
        //通知少一个Heart
        m_StageSystem.LoseHeart()
        return true;
    }
    ...
}

使用类的静态方法

如果没有限制全局引用,可以使用静态方法。

比如,一个负责资源产生的工厂,AssetFactory,可以全局引用,所以考虑使用静态方法。

public static class GameAssetFactory
{
    private static IssetFactorymAssetFactory = null;

    //获取将 Unity asset 实现化的工厂
    public static IAssetFactory GetAssetFactory()
    {
        if(m_ssetFactory=- null)
        {
            if(m_bloadFromResource)
                m_Assetractory = new ResourceAssetFactory();
            else
                m_AssetFactory = new RemoteAssetFactory();
        }
        return m_AssetFactory;
    }
}

这样在要获取对象时直接使用 GetAssetFactory 方法就行了。

引用

书籍

蔡升达.(2016). 《设计模式与游戏完美开发》.清华大学出版社

Robert Nystrom.(2016).《游戏编程模式》.人民邮电出版社

Unity.(2023).《Level up your code with design patterns and SOLID》. Unity E-Book

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值