最近在学习Unity,接触到了《码路旅人》的课程项目,该项目从UI和设计框架出发进行构建,在学习项目的过程中,我发现该项目有使用到单例模式,所以想着根据这个项目讲讲我对单例模式的看法

哦,需要先说明一下,我并非才得知单例模式的概念,我在学校的时候做的是Web全栈开发,而后端用的是基于Java的Spring框架,所以单例模式的概念还是大致知道的,但毕竟项目经验比较少,

关于单例模式

我查到的单例模式是这么讲的:

单例模式确保一个类在任何情况下都只有一个实例,并提供一个全局访问点来获取该实例

确实相当官方,不过意思确实相当明确,在通常情况下,我们在创建一个类的时候,可以创建理论上无限多的实例,例如:

StealItem.cs
1
2
3
4
public class StealItem
{
// ...
}
StealManager.cs
1
2
3
4
5
6
7
8
9
10
11
12
public class StealManager
{
[SerializeField] private StealItem _item1;
[SerializeField] private StealItem _item2;
[SerializeField] private StealItem _item3;
[SerializeField] private StealItem _item4;
// ...

// 理论上可以无限创建下去(这里只做演示用,只是为了说明通常情况下一个类的实例可以生成
// 多个实例,实际项目中这种情况一般就直接使用数组或者List了,而且实际项目中一般都是
// 在多个类中分别实例化而不会集中在一个类里)
}

而在单例模式的情况下,显然是不允许的,至于为什么不允许,后面会提到,我们先着手实现单例模式

实现单例模式

我们已经了解到了单例模式的一些特点,通过这些特点,我们可以思考一下,单例模式应该如何实现呢?

单例模式的实现思路

刚才我们提到,单例模式的类在任何情况下只能允许有一个实例,所以我们需要围绕一个问题进行思考:

如何在代码层面避免一个类多次被实例化

通常情况下,我们需要通过 new 关键字实例化一个类,想通了这点,思路就分为了两点:

  • 有没有办法让 new 关键字只能实例化一次?
  • 有没有办法避免在使用单例模式时避免 new 的使用?

显然,第一种思路是无法实现的,因为在所有主流编程语言中,尽管很多的编程语言(例如C++、C#等)运算符可以被重载,但关键字是没有办法被重载的,所以这种思路可以放弃了

第二种思路可不可行,我们需要进一步思考。

首先我们应该通过什么方式才能避免使用 new 关键字实例化类呢?

我们可以想想看,new 关键字实例化对象的本质是什么?答案是:调用类的构造函数(构造方法),想到这个就很简单了,我们可以将类的构造函数私有化,这样一来,在外部就没有办法通过 new 关键字实例化类了(因为构造函数本质也是函数,所以在私有化构造函数后,外部就没办法调用到构造函数了,new 关键字的访问权限取决于它所在类的访问权限)

到这里,我们已经可以实现禁用 new 关键字实例化类的途径了。常规情况下针对禁用外部实例化,做到这一点就已经足够了。

关于单例模式需要补充的点

其实还有一些我们需要考虑到的点:在有的语言或场景下,(施工中…)

但对于单例模式的实现,仅仅是这样肯定是不够的,因为显然有一个新的问题出现了:

既然没有办法从外部实例化对象,那么单例模式唯一的那个实例应该怎么实例化,以及从外部获取呢?

唯一实例的实例化其实非常简单:在类的内部实例化就可以了,但需要注意的是:该实例不能是成员变量,即需要用 static 约束为静态,这是为什么?这就涉及到面向对象的基础了,还记得成员变量从外部怎么获取吗?对,需要通过 new 一个实例进行获取,这与我们前面提到的单例模式的实现是冲突的,因此这唯一的“实例”必须是静态变量

当然,为了保证代码的健壮性,我们对于这个静态实例通常会进行私有化处理,然后暴露一个get方法,使得可以在外部访问这个单例。

好了,到这里已经把单例模式的实现思路整理完成了,接下来,我们开始着手实现它

通过代码实现单例模式

我们先以上文中的 StealItem 为例

首先,我们先将StealItem的构造函数私有化:(演示代码里只私有化了空白构造函数,实际项目里需要把所有构造函数私有化,不过既然单例模式需要避免从外部实例化,那么其实最好的方式是去掉其它所有构造函数,反正也用不上了)

StealItem.cs
1
2
3
4
5
6
7
8
9
10
public class StealItem
{
// ...

private StealItem()
{

}
}

接下来,我们在类的内部创建一个静态实例并且暴露一个 get 让外部可以访问:

StealItem.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 如果要在方法外初始化,那么需要继承new()
public class StealItem : class, new()
{
private static StealItem _item;

public static StealItem Item =>
{
get
{
if (_item = null)
{
_item = new StealItem();
}
return _item;
}
};

private StealItem()
{

}
}

做到这里,关于 StealItem 的单例化改造已经完成了。当然,对于常规的.NET项目,你还需要针对线程安全做一些修改,这里就不赘述了

不过,在实际项目中,为了代码的健壮性、可维护性和可扩展性,最好的方式当然是创建一个通用的单例化类,让所有需要使用单例模式的类直接继承这个类:

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
public class Singleton<T> where T : class, new()
{
private static T _instance;

// 关于线程安全的问题,这里就先忽略了,但如果是在常规.NET项目中,一定要确保线程安全
public static T Instance =>
{
get
{
if (_instance is null)
{
_instance = new T();
}
return _instance;
}
};

// 这里使用protected的原因是为了让子类继承的时候能调用
protected Singleton()
{

}
}
StealItem.cs
1
2
3
4
5
6
7
8
9
public class StealItem: Singleton<StealItem>
{
// ...

public void Exclude()
{
// ...
}
}

在外部调用的时候,只需要通过类调用实例和内部的方法就可以了:

Manager.cs
1
2
3
4
5
6
7
public class StealManager
{
public void ExcludeStealItem()
{
StealItem.Instance.Exclude();
}
}

Unity C#实现单例模式的方法

请注意,Unity C#实现单例模式的思路虽然和上述一致,但在具体的实现方法上会与常规.NET C#项目有所区别。

首先我们先回顾一下上面的通用单例类的实现:

Singleton.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton<T> where T : class, new()
{
private static T _instance;

public static T Instance =>
{
get
{
if (_instance is null)
{
_instance = new T();
}
return _instance;
}
};

protected Singleton()
{

}
}

为什么我们不能把这段代码直接用在Unity C#项目当中呢?这里就得说一下常规C#项目和Unity C#项目的区别了:(不同点很多,我后续可能会写一篇文章专门说一下,这里先简短说明一下)

常规C#项目的生命周期与Unity不一致,而且常规C#的实例通常是普通对象,而Unity的则是 GameObject,正因此,单例类必须是 Mono Behaviour。此外Unity由于是单线程引擎,因此不需要考虑线程安全的问题。

所以,在Unity C#中,单例类的实现通常是这样的:

Singleton.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;

public static T Instance => _instance;

protected virtual void Awake()
{
if (_instance is null)
{
_instance = this as T;
}
if (_instance != this)
{
Destroy(gameObject);
}
}
}
  1. 因为单例类在Unity中通常还是需要服务于脚本,所以它需要继承 MonoBehaviour
Singleton.cs
1
public class Singleton<T> where T : class, new() {}
Unity C#
1
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour {}

并且你还会发现,Unity C#不需要继承 classnew(),这是因为在Unity中我们直接继承 MonoBehaviour,同时Unity负责创建实例(包括我们创建单例)的模式并不需要使用 new 关键字。(因为不需要在引用时实例化单例)

  1. 在Unity C#中,需要考虑到Unity引擎生命周期管理,我们并不能直接将构造函数私有化,而我们初始化实例的时机就从直接私有化构造函数和在引用时声明变成了在Unity生命周期函数 Awake() 中了:
常规C#
1
2
3
4
5
6
7
8
9
10
11
12
// ...
private static _instance;

public static Instance => {
get
{
// ...在这里写实例化的代码
}
};

protected Singleton() {}
// ...
Unity C#
1
2
3
4
5
6
7
8
9
10
11
// ...
private static _instance;

public static Instance => _instance;

// 方便被子类重写(override)
protected virtual void Awake()
{
// ...在这里写实例化的代码
}
// ...
  1. 由于上述的变化,以及Unity生命周期的影响,Unity C#创建单例可以使用在常规C#项目中不能使用的 this as T 了,而且,还记得之前提到的常规C#中需要考虑线程的问题吗,而在Unity中,考虑实例唯一性的时候可以通过 Destroy() 函数销毁可能多余的实例,因为在Unity中,实例的本质还是 GameObejct
常规C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
public static T Instance =>
{
get
{
// 注意线程安全
if (_instance is null)
{
_instance = new T();
}
return _instance;
}
};

protected Singleton() {}
// ...
Unity C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
public static Instance => _instance;

protected virtual void Awake()
{
if (_instance is null)
{
_instance = this as T;
}
if (_instance != this)
{
Destroy(gameObject);
}
}
// ...

到这里,我们已经完成了 Singleton “Unity化改造”

最后,我们来对比一下常规C#项目和Unity C#项目对于 Singleton.cs 的实现:

Normal C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton<T> where T : class, new()
{
private static T _instance;

public static T Instance =>
{
get
{
if (_instance is null)
{
_instance = new T();
}
return _instance;
};
};

protected Singleton()
{

}
}
Unity C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;

public static T Instance => _instance;

protected virtual void Awake()
{
if (_instance is null)
{
_instance = this as T;
}
if (_instance != this)
{
Destroy(gameObject);
}
}
}

为什么需要单例模式?

是的,说到这里,我们只讲了单例模式的实现,但为什么在实际开发中,时常需要单例模式呢?

1. 作为管理器的时候,需要保持数据的一致性

在项目开发的过程中,我们会遇到对于一个类来说总共只需要一份数据的时候,比如管理器(Manager),举个例子,我自己在学习《码路旅人》项目的时候,需要实现一个游戏模式管理器(GameMode Manager)并挂载到场景中某个GameObject中(一般都是创建一个空GameObject并明明为对应的脚本名)作为管理器:

GameModeManager.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
37
38
39
40
41
42
43
public class GameModeManager : Singleton<GameModeManager>
{
public GameMode currentGameMode;

[SerializeField] private GameMode defaultMode = GameMode.Explore;

protected override void Awake()
{
base.Awake();
currentGameMode = defaultMode;
}

private void Start()
{
// 广播当前游戏模式
ApplyMode(currentGameMode);
}

/// <summary>
/// 外部请求入口
/// </summary>
/// <param name="newMode"></param>
public void RequestChangeGameMode(GameMode newMode)
{
if (Instance != this) return;

if (!CanSwitchMode(newMode)) return;

ApplyMode(newMode);
}

public bool CanSwitchMode(GameMode newMode)
{
if (currentGameMode == GameMode.Battle) return false;
return true;
}

private void ApplyMode(GameMode newMode)
{
currentGameMode = newMode;
EventBus.Publish(new GameModeChangedEvent(currentGameMode));
}
}

这时我并不希望有多个管理器,而是唯一的,并且我希望全局在使用的时候都“共享”这一份管理器数据,这就需要使用到单例模式了

2. 控制资源的开销,降低性能成本

有的类可能比较“重”,在创建和销毁其实例的时候往往相当消耗资源,所以需要通过单例模式降低性能开销

3. 对独占性资源的处理

在有些时候,本身一些资源就是“独占”的,所以需要单例模式确保在同一时间内。只有一个“入口”操作这些独占性的资源

4. 全局状态的管理

对于一些配置类,我们希望在全局使用它。这时候我们当然不希望它具有多个实例,也不需要多个实例,这时就适合使用单例模式了。

总结

单例模式是一种很常见的模式,在开发中经常会用到,但要注意,单例模式虽然方便,但滥用单例模式会使得代码难以维护,所以,谨慎使用