大家好,我叫 Oles Dibrivniy,是Keiki的一名 Unity 开发人员。我们在 EdTech 和 GameDev 的交叉点上创建产品——用于儿童发展的应用程序和网络产品。现在我们的产品有超过四百万的用户。
如果有人在面试时问你在游戏开发中使用的编程模式,你首先应该提到的是 ObjectPool。它基于一个基本原则:在完成其任务后,对象不会被删除,而是被移动到一个单独的环境中,并且可以被检索和重用。
这种模式直接影响用户对应用程序的看法,这就是它至关重要的原因。它应该是99%的游戏项目优化的基础。
这篇文章可能与编程大师无关,但对于初学者来说反之亦然。在这里我将通过例子来解释为什么这个模式是必不可少的。
首先我们分析一下没有ObjectPool的项目案例,从设置入手。我们有一个相对简单的场景,主角和两个敌人投掷火球:
让我们考虑一下火球本身会是什么样子。田野
Rigidbody2D
和_speed
将负责火球的运动;这OnTriggerEnter2D
方法将在碰撞后起作用——带有 FireBall 组件的对象将被销毁: public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5
6 public void FlyInDirection(Vector2 flyDirection) 7 { 8 _rigidbody.velocity = flyDirection * _speed; 9 } 10
11 private void OnTriggerEnter2D(Collider2D other) 12 { 13 Destroy(gameObject); 14 } 15 }
敌人看起来也很简单:
public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBall; 4 [SerializeField] private Transform _castPoint; 5
6 public void Cast() 7 { 8 FireBall fireBall = Instantiate(_fireBall, _castPoint.position, Quaternion.identity); 9 fireBall.FlyInDirection(transform.right); 10 } 11 }
你可以打电话给
Cast
从项目或动画事件中的任何点开始的方法。我们有以下流程:敌人制造了一个火球并朝他注视的方向发射。火球在到达途中的第一个障碍物时被摧毁。这种方法看似最优,但却是最糟糕的选择之一。让我们逐点分析一切:
1. 设备的CPU和内存使用效率低下。
Instantiate
和Destroy
是昂贵的操作:每隔几秒调用它们会导致延迟和滞后。在使用智能手机等小工具时,这一点尤其明显,因为在这些小工具中,保存每个字节的内存都是必要的。您可以打开 Unity Profiler 并在 Hierarchy 窗口的搜索字段中键入 Instantiate,以查看这些操作对游戏来说有多“痛苦”。会有以下内容: 当然,我已经将每个敌人一次制造的火球数量增加到 500 个,以最大限度地提高戏剧效果。然而,很容易想象项目具有更频繁地创建和删除的对象——尤其是在处理 UI 元素或粒子时。
这些过程在运行时不断发生。在舞台上生成数百个对象后,由于持续的内存分配,玩家可能会遇到明显的帧数下降。
2. 你创造的一切都必须被销毁和清理。这
Destroy
方法从场景中移除游戏对象,并将其发送到垃圾收集器。您无法控制收集器何时或如何处理它。顺便一提, Destroy
很鬼鬼祟祟的。它删除了游戏对象本身,但它的组件可以继续单独存在。如果另一个对象链接到此组件,则可能会发生这种情况。例如,它可能是对某个事件的订阅。3.代码控制。不同的类负责创建和销毁一个对象,项目中可以有几十个。查找创建或删除的内容和位置有时并不是一项微不足道的任务——我现在对控制层次结构中的对象保持沉默。
定义问题后,让我们继续解决问题。正如我前面提到的,ObjectPool 模式操作的原理很简单:对象完成工作后,它不会被删除,而是隐藏在“池”中。可以从中检索和重用该对象:
一个实体将负责创建、重用和销毁一个对象——我们称它为
ObjectPool
.我们可以让它看起来像这样来处理火球: public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4
5 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 6
7 public FireBall GetFireBall() 8 { 9 FireBall fireBall; 10 if (_freeFireBalls.Count > 0 ) 11 { 12 fireBall = _freeFireBalls[ 0 ]; 13 _freeFireBalls. Remove(fireBall);
14 } 15 else
16 { 17 fireBall = Instantiate(_fireBallPrefab, transform); 18 } 19 return fireBall; 20 } 21
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 _freeFireBalls.Add(fireBall); 25 } 26 }
这
_freeFireBalls
列表出现在这段代码中。我们将在那里存储已经完成工作的创建的火球。敌人现在看起来像这样: public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private ObjectPool _objectPool; 4 [SerializeField] private Transform _castPoint; 5
6 public void Cast() 7 { 8 FireBall fireBall = _objectPool.GetFireBall(); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }
问题的根源:我们怎样才能把火球放回池子里?我们不能依靠敌人;他不知道火球什么时候会被摧毁。我们也不想提供关于火球的知识
ObjectPool
,因为它会创建不必要的连接。我希望你注意到我做了
ReturnFireBall
方法私有。因此,我们将使用一种基本的 C# 模式 — 观察者及其实现 —事件。火球现在看起来像这样: public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5
6 public event Action<FireBall> Destroyed; 7
8 public void FlyInDirection(Vector2 flyDirection) 9 { 10 _rigidbody.velocity = flyDirection * _speed; 11 } 12
13 private void OnTriggerEnter2D(Collider2D other) 14 { 15 Destroyed?.Invoke(this); 16 } 17 }
ObjectPool
将订阅Destroyed
将对象传递给世界后的事件: public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 5
6 public FireBall GetFireBall() 7 { 8 FireBall fireBall; 9 if (_freeFireBalls.Count > 0 ) 10 { 11 fireBall = _freeFireBalls[ 0 ]; 12 _freeFireBalls. Remove(fireBall);
13 } 14 else
15 { 16 fireBall = Instantiate(_fireBallPrefab, transform); 17 } 18 fireBall.Destroyed += ReturnFireBall; 19 return fireBall; 20 } 21
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 fireBall.Destroyed -= ReturnFireBall; 25 _freeFireBalls.Add(fireBall); 26 } 27 }
它仍然是将对象池挂在对象上
Enemy
组件——恭喜!垃圾收集器将休息,直到您移动到另一个场景或关闭游戏。我们在其基本实现中有 ObjectPool,但我们可以改进它。这
FireBall
不是我们将在对象池中运行的唯一对象类型。你必须单独写一个ObjectPool
每个都可以与其他类型一起工作。这将扩展代码库并降低代码的可读性。所以,让我们使用仿制品和仿制药。我们运行的每个对象
ObjectPool
必须绑定到特定类型。它们必须独立于特定的实现进行处理。保持池将处理的对象的基本继承层次结构是必不可少的。他们将继承自MonoBehaviour
, 至少。让我们使用 IPoolable 接口: public interface IPoolable 2 { 3 GameObject GameObject { get ; } 4 event Action<IPoolable> Destroyed; 5 void Reset (); 6 }
我们继承
FireBall
从中: public class FireBall : MonoBehaviour, IPoolable 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5 public GameObject GameObject => gameObject; 6
7 public event Action<IPoolable> Destroyed; 8
9 private void OnTriggerEnter2D(Collider2D other) 10 { 11 Reset (); 12 } 13 public void Reset () 14 { 15 Destroyed?.Invoke(this); 16 } 17
18 public void FlyInDirection(Vector2 flyDirection) 19 { 20 _rigidbody.velocity = flyDirection * _speed; 21 } 22 }
最后的任务是教
ObjectPool
与任何对象一起工作。 IPoolable 是不够的,因为 Instantiate 只能创建对象的副本gameObject
财产。我们将使用Component
;每个这样的对象都继承自此类。 MonoBehaviour
也继承自它。结果,我们将得到以下内容ObjectPool
: public class ObjectPool<T> where T : Component, IPoolable 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly Transform _container; 5 private readonly T _prefab; 6
7 public ObjectPool(T prefab) 8 { 9 _freeObjects = new List <IPoolable>(); 10 _container = new GameObject().transform; 11 _container. name = prefab.GameObject. name ; 12 _prefab = prefab; 13 } 14
15 public IPoolable GetFreeObject() 16 { 17 IPoolable poolable; 18 if (_freeObjects.Count > 0 ) 19 { 20 poolable = _freeObjects[ 0 ] as T; 21 _freeObjects. RemoveAt(0);
22 } 23 else
24 { 25 poolable = Object.Instantiate(_prefab, _container); 26 } 27 poolable.GameObject.SetActive(true); 28 poolable.Destroyed += ReturnToPool; 29 return poolable; 30 } 31
32 private void ReturnToPool(IPoolable poolable) 33 { 34 _freeObjects.Add(poolable); 35 poolable.Destroyed -= ReturnToPool; 36 poolable.GameObject.SetActive(false); 37 poolable.GameObject.transform.SetParent(_container); 38 } 39 }
现在我们可以创建一个
ObjectPool
对于任何满足继承条件的对象Component
和 IPoolable。我删除了ObjectPool
继承自MonoBehaviour
,从而减少我们将挂在对象上的组件数量。有一个问题需要解决。多个敌人可以同时出现在舞台上,并且每个敌人都会产生相同的火球。如果他们都经历同样的事情就好了
ObjectPool
— 永远不会有太多的资源节省!让一个类请求特定的对象类型也很方便。这样的类将接管对象的生成、控制和最终处置。此选项是管理每个ObjectPool
单独在项目中是具有挑战性的。完成基本要求
ObjectPool
,我们需要在场景加载时生成一定数量的对象。为避免任何资源消耗问题,我们将包含一个加载窗口。让我们现在开始实施吧。我们将创建一个额外的实体
PoolTask
在最终执行ObjectPool
.此类将控制从一个预制件创建的对象的工作: public class PoolTask 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly List <IPoolable> _objectsInUse; 5 private readonly Transform _container; 6
7 public PoolTask(Transform container) 8 { 9 _container = container; 10 _objectsInUse = new List <IPoolable>(); 11 _freeObjects = new List <IPoolable>(); 12 } 13
14 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 15 { 16 for (var i = 0 ; i < count; i++) 17 { 18 var poolable = Object.Instantiate(prefab, _container); 19 _freeObjects.Add(poolable); 20 } 21 } 22
23 public T GetFreeObject<T>(T prefab) where T : Component, IPoolable 24 { 25 T poolable; 26 if (_freeObjects.Count > 0 ) 27 { 28 poolable = _freeObjects[ 0 ] as T; 29 _freeObjects. RemoveAt(0);
30 } 31 else
32 { 33 poolable = Object.Instantiate(prefab, _container); 34 } 35 poolable.Destroyed += ReturnToPool; 36 poolable.GameObject.SetActive(true); 37 _objectsInUse.Add(poolable); 38 return poolable; 39 } 40
41 public void ReturnAllObjectsToPool() 42 { 43 foreach (var poolable in _objectsInUse) 44 poolable. Reset (); 45 } 46
47 public void Dispose() 48 { 49 foreach (var poolable in _objectsInUse) 50 Object.Destroy(poolable.GameObject); 51
52 foreach (var poolable in _freeObjects) 53 Object.Destroy(poolable.GameObject); 54 } 55
56 private void ReturnToPool(IPoolable poolable) 57 { 58 _objectsInUse. Remove(poolable);
59 _freeObjects.Add(poolable); 60 poolable.Destroyed -= ReturnToPool; 61 poolable.GameObject.SetActive(false); 62 poolable.GameObject.transform.SetParent(_container); 63 } 64 }
1. 跟踪我们释放到世界中的对象,以便在必要时销毁它们或在特定时刻将它们放回池中;
2.生成预定数量的免费对象。
最后,让我们创建一个 ObjectPool,它将满足我们所有的需求,并完全接管对象的控制和生成:
public class ObjectPool 2 { 3 private static ObjectPool _instance; 4 public static ObjectPool Instance => _instance ??= new ObjectPool(); 5
6 private readonly Dictionary<Component, PoolTask> _activePoolTasks; 7 private readonly Transform _container; 8
9 private ObjectPool() 10 { 11 _activePoolTasks = new Dictionary<Component, PoolTask>(); 12 _container = new GameObject().transform; 13 _container. name = nameof(ObjectPool); 14 } 15
16 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 17 { 18 if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 19 AddTaskToPool(prefab, out poolTask); 20
21 poolTask.CreateFreeObjects(prefab, count); 22 } 23
24 public T GetObject<T>(T prefab) where T : Component, IPoolable 25 { 26 if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 27 AddTaskToPool(prefab, out poolTask); 28 return poolTask.GetFreeObject(prefab); 29 } 30
31 public void Dispose() 32 { 33 foreach (var poolTask in _activePoolTasks.Values) 34 poolTask.Dispose(); 35 } 36
37 private void AddTaskToPool<T>(T prefab, out PoolTask poolTask) where T : Component, IPoolable 38 { 39 var taskContainer = new GameObject 40 { 41 name = $ "{prefab.name}_pool" , 42 transform = 43 { 44 parent = _container 45 } 46 }; 47 poolTask = new PoolTask(taskContainer.transform); 48 _activePoolTasks.Add(prefab, poolTask); 49 } 50 }
Singleton 的使用可能会引起您的注意——这只是一个示例。您可以自定义项目中的用法——它可以运行
ObjectPool
通过构造函数或通过 Zenject 注入。我们有我们的最终版本
Cast
中的方法Enemy
: public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _prefab; 4 [SerializeField] private Transform _castPoint; 5
6 public void Cast() 7 { 8 FireBall fireBall = ObjectPool.Instance.GetObject(_prefab); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }
因为使用泛型,我们得到了一个我们需要立即处理的类型的对象。
ObjectPool
将根据预制件对内部任务进行分组——如果有多个FireBall
组件,池将正确处理它们并为您提供正确的组件。这种方法将有助于为游戏场景生成任何对象。但是,在使用 UI 元素时要小心:在具有不同的父变换之间移动对象时
localScale
, 这localScale
对象本身会发生变化。如果您的项目中有自适应 UI,使用画布组件进行转换将改变它们的localScale
取决于扩展名。我建议你做这个简单的操作: poolable.GameObject.transform.localScale = Vector2. on e ;
您可以在其他选项中使用 3 个脚本:
ObjectPool
, PoolTask
, 和IPoolable
.所以请随意将它们添加到您的项目中并 100% 使用对象池模式!