【性能优化】在Unity中结合三种设计模式使用对象池

本文简介:结合三种设计模式(工厂模式+对象池+单例模式),带你上手对象池在Unity中的使用。

架构代码在文末

简单介绍一下对象池

对象池是什么:对象池是常用的设计模式之一。它可以减少从头创建每个对象的系统开销。
其核心是:在使用对象前先进行预热,使用的时候从池内拿出来,使用结束后反回池里,而不是销毁。在Unity中的具体实现中,它把Destroy”改”为DeActive了。
应用场景:对象池常用在需要大量或者多次反复实例且需要销毁的物体上。

比如,做弹幕游戏的时候就可以使用对象池优化。如果不使用对象池,那么弹幕需要反复实例化、反复销毁。这种开销是很大的。

此外,提前在加载界面的时候预热,也就是表明,在游戏进程中如果有大量实例对象的情况,这能一定程度上减少资源动态加载的性能消耗。

对象池的主要优点,便是避免了对象频繁实例化和频繁销毁,而且一定程度上减少了动态加载的性能消耗。

呐,这里问一下你,galgame需要对象池吗?不着急,在文末有回答。

对象池的具体实现(上)

在具体实现对象池前,我们先要清楚,对象池需要实现什么功能。

对象池需要实现的最基础功能:

预热(Prewarm):在使用对象前先进行预热。在对象池的生命周期内,此步骤仅执行一次。在这个步骤中,实例化对象。

取出(Request):从对象池中取出对象。在这个步骤中,一般不实例化对象。只有在池空的情况下,Request才会再次实例化对象。

归还(Return):将对象归还于对象池。

于是我们可以如下写下对象池的接口:

1
2
3
4
5
6
public interface IPool<T>
{
void Prewarm(int num);
T Request();
void Return(T member);
}

这里使用了泛型T,可以支持不同的游戏对象。

别急,先别急着写对象池的具体实现。在具体实现之前,我们来了解一下工厂这一设计模式。

工厂模式(工厂方法模式)

本教程中,使用的是工厂方法模式。

工厂方法模式是什么:是一种创建型设计模式, 其在父类中提供一个创建对象的方法, 允许子类决定实例化对象的类型。

工厂方法模式的本质:将获取对象的过程抽象化。你可以重写获取对象的规则。

工厂方法模式的应用有很多,我这里不一一展开,具体可以看这个网站,这个网站讲得很好:https://refactoringguru.cn/design-patterns/factory-method

使用工厂方法模式,可以降低代码的耦合度。这里,对象池的创建对象这个操作,是拜托工厂进行创建的。这样,我们创建对象的方法就可以和对象池分开,方便以后代码的重写、重构等等。

工厂模式用在对象池实例对象的步骤里。(Prewarm&空池情况下的Request)

如果你还是有点不明白的话,那也没有关系。这个好,结合对象池用,用就完事了!用着用着你就明白ta的好哩。

(用上手了可以看情况,试试看把工厂方法模式升级成抽象工厂模式)

工厂模式的实现

说了这么多,工厂的本质就是创建东西,所以只要有一个创建方法就足够了。

工厂的接口:

1
2
3
4
public interface IFactory<T>
{
T Create();//create一个泛型T类物品
}

Unity中,接下来你可以这么写,将工厂变成ScriptableObject:

1
2
3
4
public abstract class FactotySO<T> : ScriptableObject, IFactory<T>
{
public abstract T Create();
}

Tips:

ScriptableObject是Unity五种数据储存方法之一。

ScriptableObject好处都有啥?谁说对了就给ta(bushi

ScriptableObject并不可以作为组件挂载在物体上,但可以以Asset形式储存。它的好处有很多,可以被序列化等。一般用来储存开发数据。这里不过多展开,对于ScriptableObject的用法大家可以查一下看看。

工厂模式的使用案例

这里就以我的弹幕游戏作为一个案例。

这是我的小弹幕工厂;)

这里我重写了Create方法,写了我小弹幕的生成规则:它将随机返回一个Bullet数组里的小弹幕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "newBulletFactory", menuName = "Factory/BulletFactory")]
public class BulletFactory : FactotySO<Bullet>
{
public Bullet[] bt;

public override Bullet Create()
{
int index = Random.Range(0, bt.Length);
Bullet instance = Instantiate(bt[index]);
return instance;
}
}

如果要单用的话,你可以这样写

1
2
3
4
5
public BulletFactory bf;
GameObject temp = bf.Create();
//下面是伪代码
temp.transform.position = xxx;
temp.xxxxxx;

结合对象池用的话,需要在对象池里实现。

对象池的具体实现(下)

我们来具体实现一下,这里我用队列来作为对象池的基底。当然,用栈区别也不是特别大。栈(Stack)/队列(Queue)的性能消耗都是比较低的。

(具体区别还是要看具体项目啦。比如在我的项目里,我希望随机返回弹幕,那么用队列比较合适。栈会有点小问题)

先想想,我们需要啥:

我们需要队列来引用/访问对象。

我们需要用工厂来创建对象。

有一个bool,保证prewarm只执行了一次。

1
2
3
protected readonly Queue<T> Available = new Queue<T>();//先进后出的队列
public abstract IFactory<T> Factory { get; set; }
protected bool HasBeenPrewarmed { get; set; }

来写函数:

拜托工厂创建对象

1
2
3
4
protected virtual T Create()
{
return Factory.Create();
}

预热池子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//预热池子
//此方法可以随时调用,但池子的生命周期中只能调用一次(一个生命周期调用一次)
//num是预热数
public virtual void Prewarm(int num)
{
if (HasBeenPrewarmed)
{
Debug.LogWarning($"Pool {name} has already been prewarmed.");
return;
}
for (int i = 0; i < num; i++)
{
//Available.Push(Create());
Available.Enqueue(Create());
}
HasBeenPrewarmed = true;
}

请求对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//请求
public virtual T Request()
{
//return Available.Count > 0 ? Available.Pop() : Create();//pop或创建
return Available.Count > 0 ? Available.Dequeue() : Create();//Dequeue或创建
}

//返回一个IEnumerator对象(可枚举对象)
public virtual IEnumerable<T> Request(int num = 1)
{
List<T> members = new List<T>(num);
for (int i = 0; i < num; i++)
{
members.Add(Request());
}
return members;
}

归还对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//归还
public virtual void Return(T member)
{
//Available.Push(member);
Available.Enqueue(member);
}


//归还一个可枚举对象(一系列物品)
public virtual void Return(IEnumerable<T> members)
{
foreach (T member in members)
{
Return(member);
}
}

当池子生命周期结束的时候:

清空队列,并且池子可以再次被预热。

1
2
3
4
5
public virtual void OnDisable()
{
Available.Clear();
HasBeenPrewarmed = false;
}

我使用对象池的时候,发现了一些值得注意的事情。我们对象池的Return是即时进行的。Destroy是异步的。所以Return和Destroy的具体使用感可能会有略微差异。我本来想试试看写模拟Destroy,下一帧执行return。我当时的想法是,使用协程(不错 我半吊子水平暴露了)

来看一下我的哈皮代码(反面案例不要参考):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public override void Return(T member)
{
//模仿destroy 下一帧执行return
//StartCoroutine(returnWait(member));//然而需要继承mono
member.transform.SetParent(PoolRoot.transform);
member.gameObject.SetActive(false);
base.Return(member);
}
//IEnumerator returnWait(T member) {
// yield return null;
// member.transform.SetParent(PoolRoot.transform);
// member.gameObject.SetActive(false);
// base.Return(member);
//}

开销加倍 超级加倍 而且还需要继承Mono才能用IEnumerator。此外,协程也是有一定开销的 我当时写完就觉得我事憨憨(

我们对象池差不多完成了。这里参考github上的一个开源项目(https://github.com/UnityTechnologies/open-project-1/wiki),多加一笔,整理一下我们生成的游戏对象。

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
75
76
//using System.Collections;
using UnityEngine;

//该脚本将池子安排给一个场景中的父级对象
//用于整理子物品
public abstract class ComponentPoolSO<T> : PoolSO<T> where T : Component
{
private Transform _poolRoot;
private Transform PoolRoot
{
get
{
if (_poolRoot == null)
{
_poolRoot = new GameObject(name).transform;
_poolRoot.SetParent(_parent);
}
return _poolRoot;
}
}

private Transform _parent;



//pool在场景中的父级对象
//可以考虑分配给一个DontDestroyOnLoad对象
public void SetParent(Transform t)
{
_parent = t;
PoolRoot.SetParent(_parent);
}

public override T Request()
{
T member = base.Request();
member.gameObject.SetActive(true);
return member;
}

public override void Return(T member)
{
//模仿destroy 下一帧执行return
//StartCoroutine(returnWait(member));//然而需要继承mono
member.transform.SetParent(PoolRoot.transform);
member.gameObject.SetActive(false);
base.Return(member);
}
//IEnumerator returnWait(T member) {
// yield return null;
// member.transform.SetParent(PoolRoot.transform);
// member.gameObject.SetActive(false);
// base.Return(member);
//}

protected override T Create()
{
T newMember = base.Create();
newMember.transform.SetParent(PoolRoot.transform);
newMember.gameObject.SetActive(false);
return newMember;
}

public override void OnDisable()
{
base.OnDisable();
if (_poolRoot != null)
{
#if UNITY_EDITOR
DestroyImmediate(_poolRoot.gameObject);
#else
Destroy(_poolRoot.gameObject);
#endif
}
}
}

对象池的使用案例(上)

把文末的架构脚本放到你的项目中,并且写下下面这一个脚本。它需要在视口上分配一个工厂。

右键通过菜单创建一个工厂(记得实现一下具体的工厂,可以参考上面写的对象池的使用案例),再把需要创建的对象拖入工厂中,再把工厂拖入对象池的引用中就ok哩。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "newBulletPool", menuName = "Pool/BulletPool")]
public class bulletPoolSO : ComponentPoolSO<Bullet>
{
[SerializeField]
BulletFactory factory;
public override IFactory<Bullet> Factory
{
get => factory;
set => factory = value as BulletFactory;
}
}

如果要单用对象池的话,你可以这样写

1
2
3
4
5
6
7
8
9
10
11
使用:
public bulletPoolSO weaponPool;
weapon wpt = weaponPool.Request();
---
预热:
public int initialSize;
Start: weaponPool.Prewarm(initialSize);
---
归还:
public bulletPoolSO wpp;
wpp.Return(this);

单用对象池容易出问题,多个对象需要引用单个对象池的情况也比较不好处理。

所以先别急着用,我们来康康单例模式。

单例模式(懒狗模式)

单例模式:是一种创建型设计模式, 让你能够保证一个类只有一个实例, 并提供一个访问该实例的全局节点。

单例模式的定义中就包含了它的本质了。在我还没接触设计模式的时候,最喜欢用的变量就是全局静态变量(不错,我就是自爆卡车),因为非常懒,好用事真的好用,绿皮也是真的绿皮。我一开始用设计模式的时候,最初用的设计模式便是单例模式。

在unity中的单例模式,一般搭配Don’tDestroyOnload属性用。可以在切换场景的时候不被销毁。

单例模式好用之处就在于,其他脚本可以直接通过:class名.实例名的方法访问脚本中的方法和变量。这跟静态方法有点类似。但是,静态方法一般是不能访问类内部的变量的。如果有一个数值,很多组件都会进行访问,那么这个数值最好就放在单例模式里。

单例模式有很多优点,但它的缺点在某些情况下,也是致命的(https://refactoringguru.cn/design-patterns/singleton)。

x违反了_单一职责原则_。 该模式同时解决了两个问题。

x单例模式可能掩盖不良设计, 比如程序各组件之间相互了解过多等。

x该模式在多线程环境下需要进行特殊处理, 避免多个线程多次创建单例对象。

x单例的客户端代码单元测试可能会比较困难, 因为许多测试框架以基于继承的方式创建模拟对象。 由于单例类的构造函数是私有的, 而且绝大部分语言无法重写静态方法, 所以你需要想出仔细考虑模拟单例的方法。 要么干脆不编写测试代码, 或者不使用单例模式。

康完单例模式后,你可以选择不用,单用对象池结合工厂。如果想用的话,可以继续看下去。

单例模式在Unity中的实现

首先需要有一个唯一实例

1
2
3
4
public static 类名 instance;
Awake(){
instance= this;
}

此外,需要保证在切换场景、重新载入场景的时候,不会重复实例化。

1
2
3
4
5
6
7
8
9
if (instance == null)
{
instance = this;
}
else {
if (instance != this) {
Destroy(gameObject);
}
}

最后,挂载DontDestroyOnLoad属性

1
DontDestroyOnLoad(gameObject);

完整代码(以GameManager为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Collections;
using System.Collections.Generic;
using UnityEngine;


public class GameManager : MonoBehaviour
{

public static GameManager instance;
private void Awake()
{

if (instance == null)
{
instance = this;
}
else {
if (instance != this) {
Destroy(gameObject);
}
}
DontDestroyOnLoad(gameObject);
}
}

对象池的使用案例(下)

结合单例模式,我们可以如下使用对象池:

首先,在GM中预热(并且把对象池最初的引用放在GM中,GM挂载在场景中的对象上):

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;


public class GameManager : MonoBehaviour
{

public static GameManager instance;

//pool
public bulletPoolSO btPool;

[SerializeField]
int initialSize = 20;


private void Awake()
{

if (instance == null)
{
instance = this;
}
else {
if (instance != this) {
Destroy(gameObject);
}
}
DontDestroyOnLoad(gameObject);
}

void Start()
{
btPool.Prewarm(initialSize);
btPool.SetParent(this.transform);
}

}

我一般将归还方法就写在需要生产的对象的脚本内部:

1
2
3
4
5
6
7
8
9
10
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bullet : MonoBehaviour
{
void returnthis() {
GameManager.instance.btPool.Return(this);
}
}

我们可以在GM里声明一个接口,Bullet继承这个接口:

1
2
3
interface kejiaohu {
void jiaohu();
}

Bullet继承了这个接口,就方便别的脚本里调用这个方法

1
2
3
4
5
6
7
public class Bullet : MonoBehaviour,kejiaohu{
public virtual void jiaohu()
{
GameManager.instance.btPool.Return(this);
}

}

请求对象只需要一句简单的

1
GameManager.instance.btPool.Request();

就可以了。

接下来,我们简单实现一个反复取出和入池的具体案例。

视频(手把手教你在Unity内上手使用对象池&遇到的一些小BUG与需要注意的点等):https://www.bilibili.com/video/BV1kb4y197Jc/

E. N. D.

回到文初,关于galgame需不需要对象池:

众所周知,老婆饼里没有老婆,一堆对象的游戏里当然用不上对象池(逃

今日份双关(1/1)

如果不需要频繁创建和销毁对象,一般用不上对象池。对象池在动态加载资源上的优化也是比较有限的。小体量的游戏,按需使用就好啦。

作者水平非常有限,是菜菜子,如果有比较好的建议,欢迎与我交流。

架构:https://github.com/Perhacept/objectPoolTutorialSourceScripts/tree/main/core

案例:https://github.com/Perhacept/objectPoolTutorialSourceScripts/tree/main/Example

视频:https://www.bilibili.com/video/BV1kb4y197Jc/