Пул объектов и фабрика в Unity. От теории к практике
Всем привет, сегодня вместе с вами попробуем разобраться, что такое фабрика, пулы объектов и как с ними работать. Ну и напишем код, который можно будет переносить между вашими проектами.
Введение
Начнем с фабрики. В программировании фабрика — один из архитектурных паттернов в разработке, который отделяет логику создания объекта от остальной бизнес-логики.
В чем плюс фабрики?
Код становится более независимы (SRP);
Легкость в переписывании. Вытекает из предыдущего пункта, теперь, если мы захотим изменить настройки или порядок создания объекта, нам не нужно бегать по всему коду, мы можем просто зайти в один класс и внести в него изменения.
С фабрикой разобрались, теперь поговорим про пул объектов (Object pool). Еще один паттерн в проектировании вашего кода, который очень популярен в разработке игр. Использование пула объектов предлагает вам не создавать и удалять объекты заново, а переиспользовать их.
Благодаря этому раскрывается главный плюс пулов — скорость. Операции Instantiate и Destroy достаточно «дорогие» (по времени) в юнити, поэтому их частое использование приводит к снижению FPS.
В оригинальном своем понимании пулов и фабрик — это два независимых паттерна программирования, которые должны жить в проекте отдельно друг от друга. На практике, гораздо удобнее скрестить эти два паттерна в один, чтобы управления созданием, хранением и удалением объектов доверить одному паттерну (классу). Возможно, есть смысл разделять данные вещи, когда мы говорить не про GameObject или у нас нет потребности использовать оба этих паттерна вместе.
К практике
Шаг 1
Создаем папку и класс GameObjectPool — универсальное название в котором мы и скрестим создание объектов и управление ими
Шаг 2
Когда мы говорим про пул объектов, то представляем что-то обобщенное, универсальное. В разработке игр, мы захотим создавать разные пулы для разных типов объектов. Например, пули, враги, сундуки и тд. У каждого такого объекта — свой класс (MonoBehaviour) поэтому и пулы должны быть разными. Чтобы не дублировать код, одинаковыми реализациями пулов, сделаем наш пул универсальным — добавим дженериков.
public class GameObjectPool where T: MonoBehaviour { }
Теперь мы сможем создавать новые пулы с разными типами данных
Шаг 3
Идем дальше. Наш пул, при его инициализации должен получить префаб, из которого он будет создавать новые объекты, количество изначально создаваемых объектов и родителя (сделаем его по умолчанию = null, чтобы родителя можно было не указывать — нужно, если ваши объекты будут присваиваться к разным родителям).
public GameObjectPool(T prefab, int initialCount, Transform parent = null) {
_prefab = prefab;
_parent = parent;
for (int i = 0; i < initialCount; i++) {
Create();
}
}
И получается вот такой конструктор класса, в котором появился метод Create (). С помощью этого метода мы будем создавать новые экземпляры необходимых нам объектов. Давайте его реализуем.
Шаг 4
Для этого создадим приватный стек всех созданных нами элементов. Стек создадим, чтобы операция добавления и извлечения элемента была очень быстрой (O (1)) и не было ненужных алокаций (можно было сделать пул на очереди, здесь это не принципиально).
private Stack _elements = new();
И реализуем сам метод:
private void Create() {
var element = Object.Instantiate(_prefab, _parent, true);
element.gameObject.SetActive(false);
_elements.Push(element);
}
Мы не хотим, чтобы методом можно было пользоваться извне, поэтому делаем его приватным.
В самом методе — мы создаем новый объект, после этого сразу его выключаем и добавляем объект в наш созданный стек.
Таким образом, после создания нового пула у нас создается начальное кол-во объектов, которыми можно будет пользоваться.
Шаг 5
Следующим шагом сделаем метод, который будет возвращать объекты в пул.
Для этого напишем следующий метод:
public void Release(T element) {
element.gameObject.SetActive(false);
_elements.Push(element);
}
Метод получается очень простой: выключаем элемент, на случай, если мы забыли сделать это перед тем, как решили вернуть элемент в пул, и возвращаем элемент в стек.
Шаг 6
Ну и переходим к самому интересному: метод получения объекта. Что нам важно здесь учесть? Как будет вести себя наш пул, когда стек окажется пустым. В нашем случае пул будет расширяться на один элемент и продолжать работу.
(Я видел различные варианты, когда пул выдает ошибку / возвращает false / расширяется не на один элемент, а в N раз, но этот функционал, в случае чего вы сможете реализовать сами).
public T Get() {
if (_elements.Count == 0) {
Create();
}
return _elements.Pop();
}
Получаем еще один простенький метод, который возвращает нам элемент. Его немного можно оптимизировать и вместо вызова метода Create просто создавать объект. Тогда нам не придется сначала класть, а потом доставать объект из стека.
Итоги
На этом создание нашего пула с созданием объектов внутри закончено. Вы можете переносить этот класс между разными проектами, расширять его и по своему дополнять.
P.S. Существует еще реализация пула, в который передаются функции, выполняющие действия над объектом сразу после его создания, перед его выдачей и перед возвращением в пул. Я считаю, что это уже смешение логики и нагромождение. Легче и понятнее выполнять эти методы там, где вы обращаетесь к пулу.
P.P. S. Спасибо, что дочитали эту статью, вы можете задать мне вопросы в моем ТГ канале.