一个比较全面的单例组件代码分享

🌴 前言

在 Unity 中使用单例最根本的原因是想要解决全局访问的需要.

全局访问有多种实现方式, 尤其是使用 C# 语言, 其中的静态便可以实现全局访问, 但是静态类有几个明显的缺点:

  1. 静态类全部都在程序启动的时候加载, 不能做到用到的时候加载, 即不支持懒加载, 这样可能会导致游戏启动事件长.

  2. 静态类无法继承自 MonoBehaviour, 也就无法使用 Unity 的生命周期函数以及 协程.

因此考虑使用另一种实现方式 "单例" 来替换静态类实现全局访问需求.

🍄 单例组件

下面是 Unity 中一个单例的基本实现:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/// <summary>
/// 单例
/// </summary>
private static T instanceBase;

/// <summary>
/// 单例
/// </summary>
protected static Singleton<T> InstanceBase
{
get
{
// 只有第 1 次调用的时候, instanceBase = null, 后续这个 if 全部为 false
if (ReferenceEquals(instanceBase, null))
{
// 判断是否需要新建单例物体
if (IsNeedCreateSingleton())
{
// 在场景中创建单例物体
CreateSingleton();
}
}

return instanceBase;
}

set => instanceBase = value as T;
}

/// <summary>
/// 检测场景中的单例
/// </summary>
private static bool IsNeedCreateSingleton()
{
var toCreate = false;

var components = FindObjectsOfType<T>();

// 场景中没有预先创建此单例
if (components.Length == 0)
{
toCreate = true;
}

// 场景中预先创建了此单例
else if (components.Length == 1)
{
instanceBase = components[0];
DontDestroyOnLoad(instanceBase);
}

// 错误, 场景中预先创建了多个此单例
else if (components.Length > 1)
{
DebugUtil.LogError("错误: 预先创建了多个单例组件在场景中! 请检查并修改!", null, "red");
foreach (var component in components)
{
Destroy(component);
}
toCreate = true;
}

return toCreate;
}

/// <summary>
/// 创建一个单例
/// </summary>
private static void CreateSingleton()
{
var gameObject = new GameObject($"Singleton_{typeof(T).Name}", typeof(T));
instanceBase = gameObject.GetComponent<T>();
DontDestroyOnLoad(instanceBase);
}

实现了全局访问, 以及单例的懒加载. 下面是单例的使用:

1
2
3
4
5
6
7
8
9
10
11
public class UIManager : Singleton<UIManager>
{
/// <summary>
/// 单例
/// </summary>
public static UIManager Instance
{
get => InstanceBase as UIManager;
set => InstanceBase = value;
}
}

🌲 Some objects were not cleaned up when closing the scene

但是这样单例会有一个问题, 假如某些游戏物体在场景销毁的时候调用单例, 此时 Unity 会报错:

1
Some objects were not cleaned up when closing the scene. (Did you spawn new GameObjects from OnDestroy?)

因此我们需要一个标志位, 以供调用单例的位置进行判断.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <summary>
/// 单例活动标志
/// </summary>
private bool active = true;

/// <summary>
/// 销毁
/// </summary>
private void OnDestroy()
{
active = false;
}

/// <summary>
/// 隐藏
/// </summary>
private void OnApplicationQuit()
{
active = false;
}

OnDestroy() 会在单例被销毁时调用, OnApplicationQuit() 会在程序退出时优先调用, Unity 2021 中的事件顺序是 OnApplicationQuit => OnDisable => OnDestroy.

强调: 编辑器不同版本之间 Unity 生命周期事件的执行顺序有很大的差异, 建议去手册查询自己使用的 Unity 版本的事件执行顺序, 这也是为什么不要轻易大幅度更改 Unity 版本的原因之一

下面是提供为外部用于判断的属性:

1
2
3
4
/// <summary>
/// 单例活动标志
/// </summary>
public static bool IsActive => ReferenceEquals(instanceBase, null) == false && InstanceBase.active;

外部的使用方式:

1
2
3
4
5
6
7
8
if (UIManager.IsActive)
{
var controller = UIManager.Instance.Panel.Open<UI_Loading_Controller>() as UI_Loading_Controller;
controller?.SetAfterLoadingEvent(() =>
{
AsyncLoadScene.Async.StartLoad("Menu");
});
}

😈 禁区

看网上很多人都写了自己对于上述错误的解决办法, 但是! 很多很多人都把方向搞错了, 这个问题出现的本质原因是单例的使用方式不对, 下面是问题出现的场景之一:

在物体销毁时, 游戏逻辑中可能确实需要发送一些消息出去, 但是如果此时物体被销毁的原因不是正常情况, 像物体被玩家破坏, 物体一段时间后自动消失等等, 而是一种特殊情况: 整个场景被销毁. 场景被销毁时, 物体的 OnDestroy 也会被触发.

因为场景中物体销毁的顺序是不可控的, 如果单例先被销毁了, 物体后被销毁, 此时物体需要调用单例发送消息, 但由于单例为空, 进而调用了新建单例物体, 而 Unity 在销毁场景时再次创建的游戏物体不会被清理掉, 导致单例物体残留在场景中, Unity 从而报错: Some objects were not cleaned up when closing the scene.

因此在销毁的逻辑中调用单例, 必须先判断单例是否是可用的, 就像调用别人传递过来的引用参数一样, 需要先判断是否为空以及数据合法性, 这是正确的修改方式.

这样修改之后, 即使有人没有进行单例可用性判断的时候, Unity 同样会报错, 这样可以帮助我们发现问题, 及时改正.

但是很多人的修改方式是修改单例基类, 在单例的基类中判断此时是否是场景破坏的特殊情况, 如果是场景破坏, 则直接返回 null, 不再新建单例物体, 这样也确实可以让 Unity 不报错.

1
var controller = UIManager.Instance.Panel.Open<UI_Loading_Controller>() as UI_Loading_Controller

像上面那段代码, 即使是在场景破坏阶段执行, UIManager.Instanc 的值为 null, 也不会报错! 具体原因不明! 这样也就解决了前面的报错问题

但是这样的做法是在隐藏 Bug, 当有人错误的使用了单例, 没有进行可用性判断的时候, 此时单例返回的值为 null, 但是 Unity 却不会给出报错信息, 那么这就是一个隐藏 Bug, 不会给出报错信息的 Bug, 这种 Bug 才是最恐怖的, 因此一定要避免这种修改 Bug 的方式.

Try-Catch