Пул объектов и фабрика в Unity. От теории к практике

в 9:15, , рубрики: object pool, архитектура, практика, проектирование, разработка игр, теория, фабрика объектов

Всем привет, сегодня вместе с вами попробуем разобраться, что такое фабрика, пулы объектов и как с ними работать. Ну и напишем код, который можно будет переносить между вашими проектами.

Введение

Начнем с фабрики. В программировании фабрика - один из архитектурных паттернов в разработке, который отделяет логику создания объекта от остальной бизнес-логики.
В чем плюс фабрики?

  • Код становится более независимы (SRP);

  • Легкость в переписывании. Вытекает из предыдущего пункта, теперь, если мы захотим изменить настройки или порядок создания объекта, нам не нужно бегать по всему коду, мы можем просто зайти в один класс и внести в него изменения.

С фабрикой разобрались, теперь поговорим про пул объектов (Object pool). Еще один паттерн в проектировании вашего кода, который очень популярен в разработке игр. Использование пула объектов предлагает вам не создавать и удалять объекты заново, а переиспользовать их.
Благодаря этому раскрывается главный плюс пулов - скорость. Операции Instantiate и Destroy достаточно "дорогие" (по времени) в юнити, поэтому их частое использование приводит к снижению FPS.

В оригинальном своем понимании пулов и фабрик - это два независимых паттерна программирования, которые должны жить в проекте отдельно друг от друга. На практике, гораздо удобнее скрестить эти два паттерна в один, чтобы управления созданием, хранением и удалением объектов доверить одному паттерну (классу). Возможно, есть смысл разделять данные вещи, когда мы говорить не про GameObject или у нас нет потребности использовать оба этих паттерна вместе.


К практике

Шаг 1

Создаем папку и класс GameObjectPool - универсальное название в котором мы и скрестим создание объектов и управление ими

Шаг 2

Когда мы говорим про пул объектов, то представляем что-то обобщенное, универсальное. В разработке игр, мы захотим создавать разные пулы для разных типов объектов. Например, пули, враги, сундуки и тд. У каждого такого объекта - свой класс (MonoBehaviour) поэтому и пулы должны быть разными. Чтобы не дублировать код, одинаковыми реализациями пулов, сделаем наш пул универсальным - добавим дженериков.

public class GameObjectPool<T> 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<T> _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. Спасибо, что дочитали эту статью, вы можете задать мне вопросы в моем ТГ канале.

Автор: savelus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js