单例的模板与最佳实践

思考并回答以下问题:

  • 为什么管理类和系统类,模块入口类都使用单例模式?
  • 为什么有些老手会建议尽量不要用单例模式?

本章涵盖:

  • 背景
  • 单例模式简介
  • 几种单例的模板实现
  • 单例的利弊分析
  • 单例的最佳实践:如何设计一个令人愉快的API?

感谢凉鞋的文章,此处摘抄只为学习和备份用,原文地址:Unity 游戏框架搭建 2018 (二) 单例的模板与最佳实践

背景

很多开发者或者有经验的老手都会建议尽量不要用单例模式,这是有原因的。

在框架的设计中一些管理类或者系统类多多少少都会用到单例模式,比如QFramework中的UIMgr,ResMgr都是单例。当然在平时的游戏开发过程中也会用到单例模式,比如数据管理类,角色管理类等等,以上这些都是非常常见的使用单例的应用场景。

单例模式简介

定义

1
保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例的模板

Manager of Managers架构,其中每个Manager在QFramework中都是由单例实现,当然也可以使用静态类实现,但是相比于静态类的实现,单例更为合适。

如何设计这个单例的模板?

先分析下需求,当设计一个Manager时候,我们希望整个程序只有一个该Manager类的实例,一般马上能想到的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class XXXManager
{
private XXXManager _instance = null;

private XXXManager()
{
// to do ...
}

public static XXXManager GetInstance()
{
if (_instance == null)
{
_instance = new XXXManager();
}
return _instance;
}
}

如果一个游戏需要10个各种各样的Manager,那么以上这些代码要复制粘贴好多遍。重复的代码太多!想要把重复的代码抽离出来,怎么办?

答案是引入泛型。

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace QFramework
{
public abstract class Singleton<T> where T : Singleton<T>
{
protected static T mInstance = null;

protected Singleton()
{
}

public static T Instance
{
get
{
if (mInstance == null)
{
// 如何 new 一个T???
}

return mInstance;
}
}
}
}

为了可以被继承,静态实例和构造方法都使用了protected修饰符。以上的问题很显而易见,那就是不能new一个泛型。因为泛型本身不是一个类型,那该怎么办呢?答案是使用反射。

这部分以后可能会复用,所以抽出了SingletonCreator.cs,专门用来通过反射创建私有构造示例。

实现如下:

SingletonCreator.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
namespace QFramework
{
using System;
using System.Reflection;

public static class SingletonCreator
{
public static T CreateSingleton<T>() where T : class, ISingleton
{
// 获取私有构造函数
var ctors = typeof(T).GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic);

// 获取无参构造函数
var ctor = Array.Find(ctors, c => c.GetParameters().Length == 0);

if (ctor == null)
{
throw new Exception("Non-Public Constructor() not found! in " + typeof(T));
}

// 通过构造函数,创建实例
var retInstance = ctor.Invoke(null) as T;
retInstance.OnSingletonInit();
return retInstance;
}
}
}

希望在单例类的内部获得初始化事件所以定制了ISingleton接口用来接收单例初始化事件。

ISingleton.cs

1
2
3
4
5
6
7
namespace QFramework
{
public interface ISingleton
{
void OnSingletonInit();
}
}

Singleton.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
namespace QFramework
{
public abstract class Singleton<T> : ISingleton where T : Singleton<T>
{
protected static T mInstance;
static object mLock = new object();

protected Singleton()
{
}

public static T Instance
{
get
{
lock (mLock)
{
if (mInstance == null)
{
mInstance = SingletonCreator.CreateSingleton<T>();
}
}
return mInstance;
}
}

public virtual void Dispose()
{
mInstance = null;
}

public virtual void OnSingletonInit()
{
}
}
}

以上就是最终实现了,并且加上了线程锁,而且实现了一个用来接收初始化事件的接口ISingleton。这个实现是在任何C#程序中都是通用的。其测试用例如下所示:

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using QFramework;
// 1.需要继承Singleton。
// 2.需要实现非public的构造方法。
public class XXXManager : Singleton<XXXManager>
{
private XXXManager()
{
// to do ...
}

public void Operation()
{
Console.WriteLine("Operation");
}

public override void OnSingletonInit()
{
Console.WriteLine("我被自动调用了");
}
}

class Program
{
static void Main(string[] args)
{
XXXManager.Instance.Operation();
Console.ReadKey();

}
}

结果:

1
2
我被自动调用了
Operation

小结

这个单例的模板是平时用得比较顺手的工具了,其实现是在其他的框架中发现的,拿来直接用了。反射的部分可能会耗一些性能,但是第一次调用只会执行一次,所以放心。在Unity中可能会需要继承MonoBehaviour的单例,因为很多游戏可能会只创建一个GameObject,用来获取MonoBehaviour的生命周期,这些内容会再下一节中介绍:)。

MonoBehaviour单例的模板

上一小节讲述了如何设计C#单例的模板。也随之抛出了问题:

  • 如何设计接收MonoBehaviour生命周期的单例的模板?

如何设计?

先分析下需求:

  • 约束脚本实例对象的个数。
  • 约束GameObject的个数。
  • 接收MonoBehaviour生命周期。
  • 销毁单例和对应的GameObject。

首先,第一点,约束脚本实例对象的个数,这个在上一篇中已经实现了。但是第二点,约束GameObject的个数,这个需求,还没有思路,只好在游戏运行时判断有多少个GameObject已经挂上了该脚本,然后如果个数大于1抛出错误即可。第三点,通过继承MonoBehaviour实现,只要覆写相应的回调方法即可。第四点,在脚本销毁时,把静态实例置空。完整的代码就如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
using UnityEngine;
/// <summary>
/// 需要使用Unity生命周期的单例模式
/// </summary>
namespace QFramework
{
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
{
protected static T mInstance = null;
public static T Instance()
{
if (mInstance == null)
{
mInstance = FindObjectOfType<T>();
if (FindObjectsOfType<T>().Length > 1)
{
Debug.LogError("More than 1!");
return instance;
}
if (instance == null)
{
string instanceName = typeof(T).Name;
Debug.Log ("Instance Name: " + instanceName);
GameObject instanceGO = GameObject.Find(instanceName);
if (instanceGO == null)
instanceGO = new GameObject(instanceName);
instance = instanceGO.AddComponent<T>();
DontDestroyOnLoad(instanceGO); //保证实例不会被释放
Debug.Log ("Add New Singleton " + mInstance.name + " in Game!");
}
else
{
Debug.Log("Already exist: " + mInstance.name);
}
}
return mInstance;
}
protected virtual void OnDestroy()
{
mInstance = null;
}
}
}

这样一个独立的MonoSingleton就实现了。

小结:

目前已经实现了两种单例的模板,一种是需要接收MonoBehaviour生命周期的,一种是不需要接收生命周期的C#单例的模板,可以配合着使用。虽然不是本人实现的,但是用起来可是超级爽快。

SingletonProperty

文章写到这,我们已经实现了C#单例的模板和MonoBehaviour单例的模板,这两个模板已经可以满足大多数实现单例的需求了。但是偶尔还是会遇到比较奇葩的需求的。

比如这样的需求:

  • 单例要继承其他的类,比如Model.cs等等。

虽然单例继承其他类是比较脏的设计,但是难免会遇到不得不继承的时候。没有最好的设计,只有最合适的设计。

解决方案:

  • 首先要保证实现单例的类从使用方式上应该不变,还是
    1
    XXX.Instance.ABCFunc();

之前的单例的模板代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
namespace QFramework
{
public abstract class Singleton<T> : ISingleton where T : Singleton<T>
{
protected static T mInstance;
static object mLock = new object();

protected Singleton()
{
}

public static T Instance
{
get
{
lock (mLock)
{
if (mInstance == null)
{
mInstance = SingletonCreator.CreateSingleton<T>();
}
}
return mInstance;
}
}

public virtual void Dispose()
{
mInstance = null;
}

public virtual void OnSingletonInit()
{
}
}
}

按照以前的方式,如果想实现一个单例的代码应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using QFramework;
// 1.需要继承QSingleton。
// 2.需要实现非public的构造方法。
public class XXXManager : QSingleton<XXXManager>
{
private XXXManager()
{
// to do ...
}
}
public static void main(string[] args)
{
XXXManager.Instance().xxxyyyzzz();
}

如果我想XXXManager继承一个BaseManager代码就变成这样了

1
2
3
4
5
6
7
8
9
10
using QFramework;
// 1.需要继承QSingleton。
// 2.需要实现非public的构造方法。
public class XXXManager : BaseManager
{
private XXXManager()
{
// to do ...
}
}

这样这个类就不是单例了,怎么办?
答案是通过C#的属性器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using QFramework;
// 1.需要继承QSingleton。
// 2.需要实现非public的构造方法。
public class XXXManager : BaseManager,ISingleton
{
private XXXManager()
{
// 不建议在这里初始化代码
}
void ISingleton.OnSingletonInit()
{
// to do ...
}
public static XXXManager Instance
{
get
{
return SingletonProperty<XXXManager>.Instance;
}
}
}
public static void main(string[] args)
{
XXXManager.Instance.xxxyyyzzz();
}

好了,又看到陌生的东西了,SingletonProperty是什么?

和之前的单例的模板很相似,贴上代码自己品吧…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
namespace QFramework
{
public static class SingletonProperty<T> where T : class, ISingleton
{
private static T mInstance;
private static readonly object mLock = new object();
public static T Instance
{
get
{
lock (mLock)
{
if (mInstance == null)
{
mInstance = SingletonCreator.CreateSingleton<T>();
}
}
return mInstance;
}
}
public static void Dispose()
{
mInstance = null;
}
}
}

这样无法继承的问题就解决啦。
缺点是:相比于Singleton,SingletonProperty在使用时候多了一次函数调用,而且还要再实现个getter,不过问题解决啦…

单例的利弊

在介绍单例的最佳实践之前,我们要先分析下单例的利弊。

首先我们先从定义上入手。

1
保证一个类仅有一个实例,并提供一个访问它的全局访问点。

就两句话:

  • 保证一个类仅有一个实例。
  • 提供一个访问它的全局访问点。

保证一个类仅有一个实例,这个是对单例的一个需求。但是这句话没有告诉你,这个实例什么时候应该去创建。而笔者所知到的创建方式一般是有两种,第一种是在程序编译后马上创建,一般实现方式是在声明静态成员变量的时候去new一个实例,实现如下。

1
2
3
4
public class Test
{
public static readonly Test Instance = new Test();
}

这种方式最简单,也最容易实现。

第二种则第一次获取实例时去创建,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test
{
public static Test mInstance;
public static Test Instance
{
get
{
if (mInstance == null)
{
mInstance = new Test();
}
return mInstance;
}
}
}

这种单例实现也比较常见,被称为懒单例模式,称作懒的原因是用到的时候再去创建,这样可以减缓内存和CPU压力。造成的风险则是,声明周期不可控。

所以说第一个利弊是懒加载的利弊。

  • 懒加载
    • 优点:减少内存和 CPU 压力。
    • 缺点:声明周期不可控。

懒加载是可用不可用的,在Unity开发中一般用单例的模板时候都是用懒加载的方式的。

其他的还有全局唯一和全局访问。

全局唯一这个没什么好说的,单例的存在就是为了保证全局唯一,只有个优点吧。

提供全局访问。提供全局访问这个功能,优点是方便获取单例实例。缺点就很明显了,在文章的开始,笔者说“很多开发者或者有经验的老手都会建议尽量不要用单例模式,这是有原因的。”

这个原因就是因为全局访问。一个实例的全局访问会有很多风险,当然静态类也是可以全局访问的。但是静态类一般我们用作工具或者Helper,所以没什么问题。但是单例本身是一个实例,是一个对象。所以对象有的时候是有声明周期的,并且有时候还有上下文(缓存的数据、状态)。而有时候还须有一定特定的顺序去使用 API。这些都是非常有可能的。所以说要设计一个好的单例类,好的管理类。是对开发者要求是非常高的。不过在这里笔者提醒一下,不是说要把单例类设计得非常好才是完全正确的。有的时候,我们来不及花精力去设计,考虑周全,但是可以完成工作,完成任务,这样最起码是对得起公司付的工资的,而且功能完成了,等不忙的时候可以回来再思考的嘛,罗马不是一天建成的,但是罗马可以通过一点一点迭代完成。具体要求高在哪里,主要是符合设计模式的六大设计原则就好。

接下来笔者就贴出一个笔者认为比较严格的单例类设计。

最佳实践

原则上是,保留单例优点的同时,去削弱使用它的风险。

目前来看,单例使用的风险主要是全局访问,所以削弱全局访问就好了。笔者所分享的方式是,对外提供的API都用静态API。Instance变量不对外提供,外部访问只能通过静态的API。而内部则维护一个私有的单例实例。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using System;
using QFramework;
using UnityEngine;
/// <summary>
/// 职责:
/// 1. 用户数据管理
/// 2. 玩家数据管理
/// 3. Manager 容器: List/Dictionary 增删改查
/// </summary>
///
///
public class PlayerData
{
public string Username;
public int Level;
public string Carrer;
}
[QMonoSingletonPath("[Game]/PlayerDataMgr")]
public class PlayerDataMgr : MonoBehaviour,ISingleton
{
private static PlayerDataMgr mInstance
{
get { return MonoSingletonProperty<PlayerDataMgr>.Instance; }
}
/// <summary>
/// 对外阉割
/// </summary>
void ISingleton.OnSingletonInit()
{
mPlayerData = new PlayerData();
// 从本地加载的一个操作
}
#region public 对外提供的 API
public static void SavePlayerData()
{
mInstance.Save();
}
public static PlayerData GetPlayerData()
{
return mInstance.mPlayerData;
}
#endregion
private PlayerData mPlayerData;
private void Save()
{
// 保存到本地
}
}

使用上非常干净简洁:

1
2
3
4
5
6
7
8
9
10
11
12
public class TestMonoSingletonA : MonoBehaviour {
// Use this for initialization
private void Start()
{
var playerData = PlayerDataMgr.GetPlayerData();
playerData.Level++;
PlayerDataMgr.SavePlayerData();
}
// Update is called once per frame
void Update () {
}
}

**命名小建议**

到这里还要补充一下,笔者呢不太喜欢Instance这个命名。在命名上,很多书籍都建议用业务命名而不是用技术概念来命名。

比如PlayerDataSaver是业务命名,但是SerializeHelper则是技术命名,本质上他们两个都可以做数据存储相关的任务。但是PlayerDataSaver,更适合人类阅读。

Instance是技术概念命名,而不是业务命名。尽量不要让技术概念的命名出现在UI/逻辑层。只可以在框架层或者插件层出现是允许的。

0%