【性能优化】在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/