Unity单例模式最佳实践(附代码)
引言
系统地整理了下在Unity中实现单例的几种方案。
针对两种情形分别提供了实现方案:
-
纯C#实现(7种)
-
继承自MonoBehaviour(3种)
分析了各种方案优劣,给出了推荐的优雅方案,即在Unity中实现单例模式的最佳方案。
在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](
https://docs.unity3d.com/cn/2021.3/ScriptReference/ScriptableSingleton_1.html),它不继承自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;
}
}
}
说完了纯C#的实现,再来看看继承MonoBehaviour的实现。
如果不需要挂载到场景,那么还是推荐纯C#实现。
如果需要把单例挂到场景里,可以用用继承自MonoBehaviour的单例。
继承自MonoBehaviour的单例
继承自MonoBehaviour单例的一些小问题
继承自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());
}
}
这个方案有如下几个问题:
-
脚本必须先挂载在初始场景里,否则第一个使用者得通过AddComponent来进行单例的初始化。
这个行为本身就很不单例。可以说是一种很别扭的饿汉式单例。
-
饿汉式单例的通病:若在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 局部变量
private 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(gameObject);
}
else
{
Destroy(gameObject);
}
}
#endregion
}
源码
完整代码已上传至[nickpansh/Unity-Design-Pattern | GitHub](https://github.com/nickpansh/Unity-Design-Pattern),有需要的可以自行取用
结语
我会使用纯C#实现的方案6,或者基于MonoBehaviour实现的最后一个方案。
具体使用哪个方案还是得实际情况实际分析。
代码是为了功能服务的,不能想当然地去做一些优化,结果是负优化。