文章目录
目录
前言
欢迎来到设计模式的第2节🥳,这次要讲的是对象池模式。在《游戏编程模式》中,对象池模式(并不属于 GoF 设计模式)被划分为优化型模式,帮助我们避免不必要的内存分配。
官方示例项目的下载地址在这里。
此外,《Level up your code with design patterns and SOLID》已被翻译为中文,现已上传到 Github ,个人翻译。本人水平有限,若有错误还请指正😭,如果可以的话,请帮我点个小星星吧!🥹
管理游戏场景中众多对象的生命周期是实现最佳性能的关键。虽然 C# 的自动内存管理系统通过垃圾收集器提供了便利,但当对象频繁创建和销毁时,这一特性也可能引入明显的卡顿或峰值。
另外,使用对象池模式也可以避免内存的碎片化。虽然 Unity 使用的 Mono 具有垃圾自动回收机制,不像 C++ 中需要我们控制内存的释放,但是,无论是 C# 中的值类型还是引用类型,都会在栈堆中分配一小片内存,所以我们还是要注意内存管理,避免内存泄漏和内存碎片化。
对象池可以通过减少 CPU 运行重复创建和销毁调用所需的处理能力来提供性能优化。通过对象池,现有的游戏对象可以被反复重用。《游戏编程模式》对其的描述是:“使用固定的对象池重用对象,取代单独地分配和释放对象,以此来达到提升性能和优化内存使用的目的”。
对象池的关键功能是提前创建对象并将其存储在池中,而不是按需创建和销毁对象。当需要对象时,从池中取出并使用。当不再需要时,将其返回池中而不是销毁。
对象被使用后会被停用并返回池中,避免了销毁的开销。理想情况下,我们应该在不易察觉的时刻(例如在加载屏幕期间)初始化对象池,以防止卡顿。应用对象池模式时,对于内存管理器,我们仅分配一大块内存直到游戏结束才释放它,对于内存池的使用者,我们可以按照自己的意愿来分配和释放对象。
示例项目
使用鼠标瞄准,点击鼠标左键开火
Unity 通过 UnityEngine.Pool 命名空间包含了一个内置的对象池功能。该命名空间在 Unity 2021 LTS 及更高版本中可用,便于管理对象池,自动化处理对象生命周期和池大小控制等方面。但是,为了更好的理解该模式的底层原理,我们还是应该自己构建一个简单的对象池系统。
直接来看代码吧,以下是未使用 UnityEngine.Pool 的对象池,包含两个 MonoBehaviour 脚本:
- 一个是 ObjectPool,持有一组可以从中抽取的游戏对象:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.ObjectPool
{
public class ObjectPool : MonoBehaviour
{
// 初始克隆对象的数量
[SerializeField] private uint initPoolSize;
public uint InitPoolSize => initPoolSize;
// PooledObject 预制体
[SerializeField] private PooledObject objectToPool;
// 使用栈存储池化对象
private Stack<PooledObject> stack;
private void Start()
{
SetupPool();
}
// 创建对象池(在不明显的延迟时调用)
private void SetupPool()
{
// 缺少 objectToPool 预制体字段
if (objectToPool == null)
{
return;
}
stack = new Stack<PooledObject>();
// 填充对象池
PooledObject instance = null;
for (int i = 0; i < initPoolSize; i++)
{
instance = Instantiate(objectToPool);
instance.Pool = this;
instance.gameObject.SetActive(false);
stack.Push(instance);
}
}
// 从对象池中返回第一个激活的 GameObject
public PooledObject GetPooledObject()
{
// 缺少 objectToPool 字段
if (objectToPool == null)
{
return null;
}
// 如果对象池不够大,实例化额外的 PooledObjects
if (stack.Count == 0)
{
PooledObject newInstance = Instantiate(objectToPool);
newInstance.Pool = this;
return newInstance;
}
// 否则,只需从列表中获取下一个
PooledObject nextInstance = stack.Pop();
nextInstance.gameObject.SetActive(true);
return nextInstance;
}
// 将 GameObject 返回到对象池
public void ReturnToPool(PooledObject pooledObject)
{
stack.Push(pooledObject);
pooledObject.gameObject.SetActive(false);
}
}
}
在 ObjectPool 中,我们需要设置对象池的大小以及要储存在对象池中的 PooledOject 预制件,以及形成池的集合,这里使用的是栈。
这里要注意,由于 GetPooledObject 中使用 Instantiate 方法没有指定旋转和位移,所以在客户端调用时要记得设置 PooledObject 的旋转和位置。
- 另一个是 PoolObject,添加到预制件上,帮助每个克隆项保持对池的引用:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.ObjectPool
{
public class PooledObject : MonoBehaviour
{
private ObjectPool pool;
public ObjectPool Pool { get => pool; set => pool = value; }
public void Release()
{
pool.ReturnToPool(this);
}
}
}
通过调用 Release 禁用游戏对象,并将其返回到池中。
当我们发射子弹后,在几秒后禁用子弹的游戏对象,将其返回到池中,就像下图所示:
我们看起来像是发射了很多子弹,但实际上只是禁用了它们并进行了回收。
UnityEngine.Pool
另外来看看使用 UnityEngine.Pool 命令空间的代码,这样就不需要我们自己创建 PooledObject 类和 ObjectPool 类了。
using UnityEngine.Pool;
public class RevisedGun : MonoBehaviour
{
// 基于栈的 ObjectPool,适用于 Unity 2021 及更高版本
private IObjectPool<RevisedProjectile> objectPool;
// 如果尝试返回已存在于池中的项目,则抛出异常
[SerializeField] private bool collectionCheck = true;
// 额外选项,用于控制池的容量和最大大小
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
private void Awake()
{
objectPool = new ObjectPool<RevisedProjectile>(
CreateProjectile,
OnGetFromPool,
OnReleaseToPool,
OnDestroyPooledObject,
collectionCheck,
defaultCapacity,
maxSize
);
}
// 在创建项目以填充对象池时调用
private RevisedProjectile CreateProjectile()
{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;
return projectileInstance;
}
// 在将项目返回到对象池时调用
private void OnReleaseToPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(false);
}
// 在从对象池中检索下一个项目时调用
private void OnGetFromPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(true);
}
// 在超过池中最大项目数时调用(即销毁池中的对象)
private void OnDestroyPooledObject(RevisedProjectile pooledObject)
{
Destroy(pooledObject.gameObject);
}
private void FixedUpdate()
{
// 更新逻辑
}
}
ObjectPool 构造函数现在包括了一些有用的功能,可以在以下情况下设置逻辑:
- 首次创建池化项目以填充池时
- 从池中取出项目时
- 将项目返回池中时
- 销毁池化对象时(例如,如果达到最大限制)
必须定义一些相应的方法传递给构造函数,也就是上面代码中的 CreateProjectile()、OnReleaseToPool()、OnGetFromPool() 和 OnDestroyPooledObject()方法,方法签名没有特定要求。
注意,内置的 ObjectPool 还包括默认池大小和最大池大小的选项。超过最大池大小的项目会触发自我销毁操作,保持内存使用在可控范围内。
其余的代码可以查看 Unity 官方项目。
补充
我们总是听到各种池,比如内存池、线程池等等,那么,什么是池呢?以下内容来自维基百科。
在计算机科学中,池是保存在内存中以供使用的资源集合,而不是使用时获取的内存或之后释放的内存。在此上下文中,资源可以引用系统资源(如文件句柄)(位于进程外部)或内部资源(如对象)。池客户端从池中请求资源,并对返回的资源执行所需的作。当客户端使用完资源时,该资源将返回到池中,而不是释放和丢失。
在资源获取相关成本高、资源请求率高以及同时使用的资源总计数较低的情况下,资源池可以显著缩短响应时间。当延迟是一个问题时,池化也很有用,因为池提供了获取资源所需的可预测时间,因为资源已经被获取。这些好处主要适用于需要系统调用的系统资源或需要网络通信的远程资源,例如数据库连接、套接字连接、线程和内存分配。池化对于计算成本高昂的数据也很有用,尤其是字体或位图等大型图形对象,本质上充当数据缓存或记忆化技术。
简单的说,池就是在内存中预先分配好的资源,然后在程序需要时重复使用这些资源,从而减少重复分配和释放资源带来的额外开销。
Unity 其实也使用了对象池,就比如 ParticleSystem:
粒子系统通常用于表现一些瞬间的视觉效果(如爆炸、火花、烟雾等),这些效果往往需要频繁地被创建和销毁。如果每次都调用 Instantiate 和 Destroy 来管理粒子系统对象,会带来较大的性能开销和内存碎片问题。所以,Unity 使用了对象池来管理粒子系统。
总结
对象池模式是一种设计模式,也是一种优化性能的常用策略,它通过预先分配和重复使用对象,减少频繁创建和销毁带来的性能开销,提升游戏性能并降低垃圾回收的影响,同时需要权衡池的大小和管理复杂性。
优缺点
优点
- 减少垃圾回收的开销:通过重用对象而不是反复创建和销毁,可以减少垃圾回收的需求,减少内存分配次数。在运行时降低 GC 频率,这有助于防止性能的突然波动或卡顿。
- 性能提升:提前初始化对象并在需要时激活对于某些节奏快的游戏(如射击游戏)有助于提高性能。
- 优化初始化:通过在非关键时机分散创建对象,可以优化资源和应用程序的启动时间。
缺点
- 增加复杂度:对象池需要更多的管理。必须正确地初始化和释放对象,否则会引发错误或 bug。
- 内存使用:虽然对象池可以减少垃圾回收,但它们会导致更高的静态内存使用量。对象池会在内存中存储一组预定义数量的对象,即使不使用它们也是如此。需要根据项目需求平衡池的大小。
- 更多管理:确定对象池的最佳大小可能非常困难。池太小会导致频繁的分配,而池太大会造成对已分配内存的低使用率。
对象池模式与享元模式的区别
对象池模式与享元模式虽然都管理着一系列可重用对象,但它们“重用”的含义和目的有所不同。
享元模式的重用强调“共享使用”,即多个使用者在整个生命周期中共享同一个对象实例,这样可以避免在不同上下文中创建多个相同的对象,从而节省内存空间;而对象池模式则强调“重复使用”,在任意时刻,一个池中的对象只会被一个使用者独占,使用完毕后对象会被归还到池中,等待下次借用,以此减少频繁创建和销毁对象带来的时间和资源开销。
换句话说,享元模式主要是为了解决内存浪费问题,通过共享不可变或不易改变的内部状态来节省空间,而对象池模式则侧重于性能优化,避免对象重复创建和销毁所产生的额外开销,每个对象在使用期间是独占的,使用后再归还以供重复利用。
什么时候使用对象池?
- 当我们需要频繁地创建和销毁对象时。
- 地创建和销毁对象时。
- 每个对象封装着获取代价昂贵且可重用的资源,如数据库、网络的连接。
改进
当我们在实际项目中部署对象池时,可以考虑以下改进:
- 将其设为静态或单例:如果需要从多个来源生成池化对象,考虑将对象池设为静态并可以重用。这使它在应用程序的任何地方都可以被访问,但无法使用检视面板。或者,也可以将对象池模式与单例模式结合,使其在全局可访问,从而简化使用。
- 使用字典来管理多个池:如果有许多不同的预制件想要池化,可以将它们存储在不同的池中,并用键值对保存,这样我们就知道该从哪个池中获取对象(预制件的 InstanceID 可用作唯一键)。
- 有创意地移除未使用的游戏对象:高效利用对象池的关键在于“隐藏”未使用的对象并将其返回池中。利用任何机会来停用对象(例如,它们离屏幕后或被爆炸效果遮蔽时,等等)。
- 检查错误:避免将已在池中的对象再次释放。当使用 ObjectPool 创建一个实例时,可以将 collectionCheck 参数设置为 true。如果尝试将已在池中的对象返回池中,那么在 Editor 中会抛出异常。
- 设置最大尺寸/上限:大量的池化对象会占用内存。通过在 ObjectPool 构造函数 的 maxSize 参数来限制池大小。
引用
书籍
Robert Nystrom.(2016).《游戏编程模式》.人民邮电出版社
Unity.(2023).《Level up your code with design patterns and SOLID》. Unity E-Book