首页 > 其他分享 >Unity中单例模式的优雅实现

Unity中单例模式的优雅实现

时间:2023-01-10 21:58:11浏览次数:47  
标签:方案 Singleton 优雅 Instance Unity static 单例 public

引言

系统地整理了下在Unity中实现单例的几种写法。

针对两类情况分别提供了实现方案:

  1. 纯C#实现(7种)
  2. 继承自MonoBehaviour(3种)

分析了各种方案的优劣,记录了思考过程,最后给出了推荐的优雅写法。

在Unity中,继承自MonoBehaviour的类实现单例和不继承自MonoBehaviour的单例实现单例是不同的。
这一点有些人不明白,抄了一部分C#的实现到MonoBehaviour子类里,又跑不通,胡写一气。
关于MonoBehaviour和纯C#在构造函数上的区别可以参考我的这篇知乎回答
至于你要使用MonoBehaviour的方案还是纯C#方案,纯取决于你的需求。
纯C#实现性能更优,但无法挂载组件,调试起来也会麻烦一丢丢。

纯C#实现单例模式(不继承自Mono Behaviour)

感谢

这部分内容很大程度上参考了《C# In Depth》里的单例实现章节。
我只是补充了自己的理解并添加了个泛型实现

正文

《C# In Depth》里提供了6种在c#中实现单例的方案,以从最不优雅到最优雅排序。
第六个版本是碾压式优势,图快可以直接跳到第六个版本。

这6种方案有四个共同特征:

  • 只有一个无参的私有的构造函数。这可以保证其他类无法初始化它(会导致破坏单例的设计模式)。
  • 继承也会导致设计模式的破坏。所以它应该是sealed的。虽然并不是必须的,单推荐使用。可能会帮助JIT去进行优化。
  • 有一个静态变量_Instance。
  • 有一个public的Instance方法用于获取单例。

第一个版本——非线程安全

这段代码是最简单的懒汉式。

// Bad code! Do not use!
public sealed class Singleton
{
    private static Singleton instance = null;
    private Singleton()
    {
    }
    public static Singleton Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new Singleton();
            }
            return instance;
        }
    }
}

它非线程安全。(instance == null)在多个线程中的判断不准确,会各自创建实例,这违反了单例模式。

第二个版本——简单的线程安全写法

这个方案加了个锁。

public sealed class Singleton
{
    private static Singleton instance = null;
    private static readonly object padlock = new object();
    Singleton()
    {
    }
    public static Singleton Instance
    {
        get
        {
            lock (padlock)
            {
                if (instance == null)
                {
                    instance = new Singleton();
                }
                return instance;
            }
        }
    }
}

好处是线程安全了,但是性能受影响——每次get都获取锁。

第三个方案——用双重检查+锁来实现线程安全

这个方案在网上被大量推荐,但作者直接打上了烂代码的标签,不推荐用。

public sealed class Singleton
{
    private static Singleton instance = null;
    private static readonly object padlock = new object();
    Singleton()
    {
    }
    public static Singleton Instance
    {
        get
        {
            if (instance == null)
            {
                lock (padlock)
                {
                    if (instance == null)
                    {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
}

这个方案通过加了个(instance == null)判断来优化了方案2,在性能上有改进。但他有四个缺点:

  • 他在Java中不起作用。(这我觉得还好)
  • 没有内存屏障了。可能在.NET 2.0内存模型下他是安全的,但我宁愿不依赖那些更强大的语义。(是个问题)
  • 很容易出错。模式需要与上面代码完全相同——任何重大更改可能会影响性能或正确性。(如果使用泛型,可以规避这个问题。如果没有使用,只是照抄,问题很大。)
  • 它的性能仍然不如后面的实现。(是个问题)

第四个方案——饿汉式(没有延迟初始化,但不用锁也线程安全)

之前三个方案都是懒汉式的,天生就线程不安全,为了线程安全我们使用了锁,使用了锁么就带来了额外开销,难免的。其实呢,直接用饿汉式就很好。

public sealed class Singleton
{
    private static readonly Singleton instance = new Singleton();
    // Explicit static constructor to tell C# compiler
    // not to mark type as beforefieldinit
    static Singleton()
    {
    }
    private Singleton()
    {
    }
    public static Singleton Instance
    {
        get
        {
            return instance;
        }
    }
}

正如你所见,这个代码非常的简单。
c#中的静态构造方法只会在它的类被用来创建一个实例或者它有一个静态成员被引用时调用。
很显然,这个方案比起上面添加了额外检查的二,三方案更快。但是:

  • 它没有其他方案”懒“(即延迟性)。
    尤其是如果你还有别的静态方法,那么当你调用别的静态方法也会生成Instance。(下一个方案可以解决这个问题)
  • 如果一个静态构造函数调用了另一个的静态构造函数,就会出现一些复杂情况。
    查看.NET规范可以看到更多的细节——这个问题可能不会“咬”你,但还是值得去了解一下在一个循环里的静态构造方法的执行顺序。
  • 这里有个坑,静态构造方法和类型初始化器是有区别的,具体可以参考:C# and beforefieldinit

第五个方案——完全懒汉式(完全延迟初始化)

public sealed class Singleton
{
    private Singleton()
    {
    }
    public static Singleton Instance { get { return Nested.instance; } }
    private class Nested
    {
        // Explicit static constructor to tell C# compiler
        // not to mark type as beforefieldinit
        static Nested()
        {
        }
        internal static readonly Singleton instance = new Singleton();
    }
}

这个方案嵌套了一个内部类来实现。从效果上比第四个方案好一点,就是很不传统。

第六个方案——使用.NET 4的Lazy类型

如果你使用的是.NET 4或更高版本,可以使用System.Lazy来轻松实现延迟初始化对象。你所需要的仅仅是把(Delegate)传递给调用这个构造器,用Lambda表达式就可以轻易完成。

public sealed class Singleton
{
    private static readonly Lazy<Singleton> lazy =
        new Lazy<Singleton>(() => new Singleton());
    public static Singleton Instance { get { return lazy.Value; } }
    private Singleton()
    {
    }
}

简单又高效。如果需要,它还允许你使用isValueCreated属性来检查实例是否已经创建好。

### 关于性能与延迟初始化
在许多情况下,其实你并不需要完全的延迟初始化(即你不需要那么追求懒汉式)——除非你的类的构造非常耗时或者有一些别的副作用。


附录-Unity使用的.NET版本
  • Unity 2017.1以前,Unity使用的.NET版本是.NET 3.,无法使用这个新语法
  • Unity 2017.1~Unity 2018.1,引入了.NET 4.1成为试验版
  • Unity 2018.1~至今.NET 4.x成为标配。(.NET Standard 2.1同兼容)
    可以在Unity的Api Compatibility Level*单里查看当前使用的.NET版本

使用这个方法前先确认下.NET版本。

《Depth in C#》作者的结论

Depth in C#的作者推荐的方案是方案4,即简单的饿汉式。
他通常都用方案4,除非他需要能够在不触发初始化的情况下调用别的静态方法。

方案5很优雅,但比起2或者4都更复杂,他提供的好处太少了。

方案6是最佳方案,前提是你使用的.NET 4+。
即便如此,目前还是倾向于用方案4——只是沿用习惯。
但如果他和没有经验的开发人员合作,他会用方案6,作为一种简单且普通使用的模式。

方案1是垃圾。
方案3也不会用,宁可用方案2都不用方案3。
他还抨击了方案3的自作聪明,锁的开销也没有那么大,方案3的写法只是看起来有优化罢了。
他写过测试代码来验证开销,二者的差别非常小。
锁很昂贵是常见的误导。
如果真的觉得昂贵完全可以在调用的循环外存储Instance就行了或者干脆用方案5。
总之,4=6>5>2>3=1

我的推荐——在Unity中使用哪种实现

Unity开发还是有Unity开发的特点的。
Unity中你不使用多线程的话,Mono代码本来就是单线程的,官方也是建议用协程而不是用多线程。
而且延迟初始化对于游戏开发来说可能并不是好事:
就玩家体验角度而言,掉帧比启动慢/Loading长更为糟糕。
所以方案3在大多数情况下并不可取——你都没有用多线程,却为了线程安全付出了额外的开销,还口口声声说做了优化……

方案6是碾压式优势
当然在.NET版本较旧的情况下,我觉得可以考虑方案7——使用泛型实现的方案3。
即使是这样,方案7也差于方案6和方案4。
所以我的偏好排序是:
方案6[Lazy语法实现] > 方案4(简单饿汉式)> 方案7(泛型实现的线程安全的懒汉式) > 1(不写多线程就不用考虑线程安全) > 5>2>3
具体还是看你的需求,没有最优的实现,只有最合适需求的实现

对于方案1排这么前可能有争议。
我的核心观点是:如果你不用多线程,你就不该针对多线程去添加有开销的优化。这是负优化。
你还别不信,可以看看Unity源码里是怎么用纯C#实现单例的。

Unity源码是怎么实现单例的

实际上Unity提供了这么一个单例泛型:ScriptableSingleton,它不继承自MonoBehaviour,是纯C#实现的

ScriptableSingleton允许你在编辑器中创造“Manager”类型。
从ScriptableSingleton衍生的类中,你添加的序列化数据在编辑器重载程序集后依然生效。
如果你在类里使用了FilePathAttribute,序列化数据会在Unity Sessions间保持。

public class ScriptableSingleton<T> : ScriptableObject where T : ScriptableObject
{
    static T s_Instance;
    public static T instance
    {
        get
        {
            if (s_Instance == null)
                CreateAndLoad();
            return s_Instance;
        }
    }
    // On domain reload ScriptableObject objects gets reconstructed from a backup. We therefore set the s_Instance here
    protected ScriptableSingleton()
    {
        if (s_Instance != null)
        {
            Debug.LogError("ScriptableSingleton already exists. Did you query the singleton in a constructor?");
        }
        else
        {
            object casted = this;
            s_Instance = casted as T;
            System.Diagnostics.Debug.Assert(s_Instance != null);
        }
    }
    private static void CreateAndLoad()
    {
        // 一系列初始化代码
    }
    //其他代码……
}

哈,是方案1。

Anyway,这里还是给出第七个方案——方案3的泛型实现

第七个方案——方案3的泛型实现

public class SingletonV7<T> where T : class
{
    private static T _Instance;
    private static readonly object padlock = new object();
    public static T Instance
    {
        get
        {
            if (null == _Instance)
            {
                lock (padlock)
                {
                    if (null == _Instance)
                    {
                        _Instance = Activator.CreateInstance(typeof(T), true) as T;
                    }
                }
            }
            return _Instance;
        }
    }

}

继承自MonoBehaviour的单例

还是看需求把,如果是需要把管理类挂到场景里,可以用这个方案。
如果不需要挂,那么还是推荐纯C#实现。
继承自MonoBehaviour主要问题是无法阻止调用者通过AddComponent的方式去new一个单例对象。
这就已经和单例的设计原则有一定违背了。
为了保持单例的唯一性,我们只能Destroy新生成的Component,这里有开销,不如纯C#实现。

感谢

这部分主要参考了这两篇。

最基础的方案

using UnityEngine;
public class SoundManagerV1 : MonoBehaviour
{
    public static SoundManagerV1 Instance { private set; get; }
    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(this);
        }
        else
            Destroy(this);
    }
    //Unity官方demo里也有写在OnEnable中的情况,有点怪,不知道为什么这么做
    // private void OnEnable()
    // {
    //     if (Instance == null)
    //         Instance = this;
    //     else if (Instance != this) //注意这里,和Awake不同
    //         Destroy(Instance);
    // }
    public void PlaySound(string soundPath)
    {
        Debug.LogFormat("播放音频:{1},内存地址是:{1}", soundPath, this.GetHashCode());
    }
}

这个方案有如下几个问题:

  1. 脚本必须先挂载在初始场景里,否则第一个使用者得通过AddComponent来进行单例的初始化。
    这个行为本身就很不单例。可以说是一种很别扭的饿汉式单例。
  2. 饿汉式单例的通病:若在Awake中访问Instance存在空引用风险。
    这个风险比纯C#实现的简单饿汉单例要严重。
    原因是具体哪个脚本先调用Awake不在我们的控制中。
    在调用TheSingleton.Instance的时候,有可能遇到TheSingleton.Instance遇到空指针报错的情况,原因是TheSingleton的Awake还未执行。

改进版

从饿汉式到懒汉式


using UnityEngine;
public class SoundManagerV2 : MonoBehaviour
{
    private static SoundManagerV2 _Instance = null;
    public static SoundManagerV2 Instance
    {
        get
        {
            if (_Instance == null)
            {
                _Instance = FindObjectOfType<SoundManagerV2>();
                if (_Instance == null)
                {
                    GameObject go = new GameObject();
                    go.name = "SoundManager";
                    _Instance = go.AddComponent<SoundManagerV2>() as SoundManagerV2;
                    DontDestroyOnLoad(go);
                    Debug.LogFormat("初次复制后,单例的地址:{0}", _Instance.GetHashCode());
                }
            }
            Debug.LogFormat("单例的地址:{0}", _Instance.GetHashCode());
            return _Instance;
        }
    }
    private void Awake()
    {
        if (_Instance == null)
            _Instance = this;
        else
            Destroy(this);
    }
    public void PlaySound()
    {
        Debug.LogFormat("v2播放音频,内存地址是:{0}", this.GetHashCode());
    }
}

这个版本就已经实现了懒初始化,因此不用担心发生空指针报错。
同时它能自己生成GameObject,不再有首次添加必须用AddComponent()的限制了。

这么做在实现上已经足够优了,唯一的问题是扩展——下次再写一个单例类我们需要copy paste代码(或者手动再撸一遍)。
改进方式当然使用泛型类来实现啦!

改进版之使用泛型

泛型提供了一种更优雅的方式,可以让多个类型共享一组代码。
这部分代码直接借鉴UnityCommunity/UnitySingleton


using UnityEngine;
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
    #region 局部变量
    public static T _Instance;
    #endregion
    #region 属性
    /// <summary>
    /// 获取单例对象
    /// </summary>
    public static T Instance
    {
        get
        {
            if (null == _Instance)
            {
                _Instance = FindObjectOfType<T>();
                if (null == _Instance)
                {
                    GameObject go = new GameObject();
                    go.name = typeof(T).Name;
                    _Instance = go.AddComponent<T>();
                }
            }
            return _Instance;
        }
    }
    #endregion
    #region 方法
    protected virtual void Awake()
    {
        if (null == Instance)
        {
            _Instance = this as T;
            DontDestroyOnLoad(Instance);
        }
        else
        {
            Destroy(gameObject);
        }
    }
    #endregion
}

结语

我会使用纯C#实现的方案6,或者基于MonoBehaviour实现的最后一个方案。
具体使用哪个方案还是得实际情况实际分析。
代码是为了功能服务的,不能想当然地去做一些优化,结果是负优化。

标签:方案,Singleton,优雅,Instance,Unity,static,单例,public
From: https://www.cnblogs.com/wenqu/p/17041456.html

相关文章

  • VS Code调试Unity程序之2023最新版
    问题换了台开发机,重新安装了下开发环境。突然发现VisualStudioCode无法用来调试Unity了。明明流程都是按照Unity官方教程2023.1进行的,可在创建Launch.json文件时,死活出......
  • Unity中的委托
    目录目录目录基础介绍为什么用委托?源码中的委托用于回调函数用于事件处理其他知识基础介绍在c#中委托是一个类型安全的,面向对象的函数指针。委托的优点是它允许程序员......
  • unityShader入门精要 渲染流水线
    应用阶段把数据加载到显存中设置渲染状态调用DrawCall几何阶段顶点着色器顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照坐标变换:就是对顶点的......
  • 如何优雅地升级一个Creator 2.x 项目到 3.6.2 ?
    最近,我将之前用CocosCreator2.x写的一个微信小游戏《球球要回家》移植到了CocosCreator3.6.2上。编程语言也从JavaScript迁移到了TypeScript,并成功上线微信小......
  • 学习记录-单例模式
    单例模式单例模式(SingletonPattern)是Java中最简单的设计模式之一。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种......
  • 关于Unity的Android工程,写文件的问题
    UnityAndroid工程中的写文件在安卓工程下,写入文件可以直接用:File.WriteAllText(UnityEngine.Application.persistentDataPath+"/XXX.txt","文件内容");路径前面没有加“......
  • Unity+Pico 手柄按键控制
    一、定义手柄按键API1、InputDevices.GetDeviceAtXRNode,通过XRNode获取对应的设备;2、XRNode是一个枚举类型,包含LeftEye、RightEye、CenterEye、Head、LeftHand、RightHa......
  • Unity+Pico 响应射线事件
    1、添加组件为了让场景内的物体能够响应射线的操作,需要在该物体上添加“XRSimpleInteractable”组件,并对射线的交互事件编写脚本看,最常用的是“Hover”和“Select”事件......
  • UnityShader入门精要学习 第二章解惑
    困惑什么是OpenGL、DirectX如果开发者直接访问GPU是一件非常麻烦的事情,我们可能需要和各种寄存器、显存打交道。而图像编程接口再这些硬件的基础上实现了一层抽象。Ope......
  • Unity UI显示3D模型,控件与屏幕分辨率不同,导致在屏幕上鼠标点选模型,无法选中模型的问题
    ​UI界面显示3d模型,需要添加模型相机,通过中间RenderTexture来连接相机与界面的承载容器【RawImage】,根据项目在显示时,会对界面做适当调整,但是RawImage的宽和高会产生运行时......