Паттерны проектирования в Unity
- О чём эта тема
- Проверенные решения типовых проблем игрового кода: внедрение и инверсия зависимостей, фабрика, пул объектов и наблюдатель — с реализациями на C# и особенностями Unity.
- Аннотация
- Конспект начинается с понятий зависимости и её внедрения: классический пример с персонажем и оружием через интерфейс и конструктор, затем — почему в Unity этот путь не работает напрямую (запрет new для MonoBehaviour) и как внедряют зависимости через поля инспектора. Формулируется принцип инверсии зависимостей и идея инверсии контроля. Вторая часть — три рабочих паттерна: фабрика (централизованное создание объектов), пул объектов (переиспользование вместо Instantiate/Destroy — с симулятором, показывающим выигрыш) и наблюдатель (события и подписчики: делегаты, event и UnityEvent).
- Пререквизиты
- A07 — классы, методы, наследование, жизненный цикл (OnEnable/OnDisable); A08 — Instantiate, Destroy, префабы, SerializeField. Понятие интерфейса вводится здесь.
- Мотивация
- Скрипты из A08–A09 работают, но растущий проект начинает «слипаться»: скрипт меню знает про шар, шар — про надпись, всё ссылается на всё. Замена одного элемента тянет правки в десятке мест, а частые Instantiate/Destroy снарядов просаживают частоту кадров. Паттерны — это накопленные индустрией ответы именно на такие проблемы; четыре из них покрывают большинство нужд студенческого проекта.
1. Что такое паттерн проектирования
Паттерны проектирования — проверенные временем решения общих проблем, возникающих при разработке программного обеспечения. Они помогают создавать более структурированный, понятный и поддерживаемый код. Паттерн — не библиотека и не готовый класс, а схема решения, которую каждый раз реализуют под свою задачу.
2. Зависимости и их внедрение
Два ключевых понятия. Зависимость (dependency) — объект, от которого зависит функциональность класса: персонаж зависит от оружия, чтобы атаковать врагов; зависимостями могут быть источники данных, другие классы, сервисы. Внедрение (injection) — процесс предоставления классу его зависимостей извне, вместо того чтобы класс создавал их сам.
Сравним. Без внедрения класс жёстко привязан к конкретному оружию:
public class Character { private Sword sword = new Sword(); // зависимость создаётся внутри public void Attack() { sword.Swing(); } }
Проблема: чтобы заменить меч на лук, придётся переписывать Character. Решение — зависеть не от конкретного класса, а от интерфейса: контракта, который описывает, что делает объект, но не как. Зависимость передаётся через конструктор — специальный метод, вызываемый при создании объекта и инициализирующий его поля:
public interface IWeapon { void Attack(); } public class Sword : IWeapon { public void Attack() => Console.WriteLine("Swing sword!"); } public class Bow : IWeapon { public void Attack() => Console.WriteLine("Shoot arrow!"); } public class Character { private IWeapon weapon; public Character(IWeapon weapon) // внедрение через конструктор { this.weapon = weapon; } public void Attack() => weapon.Attack(); } // сборка: оружие выбирается снаружи IWeapon sword = new Sword(); Character character = new Character(sword); character.Attack();
2.1. Особенность Unity: new запрещён
Напрямую перенести этот приём в Unity нельзя: объекты MonoBehaviour не создаются через new — этим типом классов Unity управляет сама, и конструкторы здесь не используются. Попытка new Player() для MonoBehaviour вызовет предупреждение движка. Объекты создаются через Instantiate(), но он принимает только префаб, позицию, поворот и родителя — передать через него ссылки-зависимости нельзя.
Поэтому в Unity зависимости внедряют иначе — чаще всего через поля, назначаемые в инспекторе. Пример: система очков для игры со сбором монет.
public interface IScoreManager { void AddScore(int points); int GetScore(); } // реализация — компонент на отдельном объекте-«контейнере» public class ScoreManager : MonoBehaviour, IScoreManager { private int score; public void AddScore(int points) => score += points; public int GetScore() => score; } public class Player : MonoBehaviour { // зависимость назначается в инспекторе перетаскиванием объекта со ScoreManager [SerializeField] private ScoreManager scoreManager; private void OnTriggerEnter(Collider other) { if (other.CompareTag("Coin")) { scoreManager.AddScore(10); Destroy(other.gameObject); } } }
2.2. Инверсия зависимостей и инверсия контроля
Принцип инверсии зависимостей: модули высокого уровня не должны зависеть от модулей низкого уровня — оба должны зависеть от абстракций; абстракции не должны зависеть от деталей — детали зависят от абстракций. В примере выше модуль высокого уровня — Character (логика: атаковать), модули низкого уровня — Sword, Bow (детали), абстракция — IWeapon. Character теперь зависит только от IWeapon, а конкретное оружие подставляется извне — это и есть внедрение зависимостей.
Рядом стоит идея инверсии контроля (IoC): управление потоком программы передаётся фреймворку или контейнеру, а не коду приложения. Житейский пример: старший разработчик раздаёт задачи младшим и контролирует выполнение — контроль «перевёрнут». Unity сама выступает таким фреймворком: она вызывает методы жизненного цикла ваших скриптов и помогает управлять зависимостями через инспектор.
3. Фабрика
Идея: собрать все способы создания объектов в отдельном классе; фабрика может отвечать и за уничтожение. Если фабрик несколько — они наследуются от общего базового класса, как и создаваемые ими «продукты», чтобы работать с ними единообразно. В играх фабрика полезна для создания персонажей, врагов, предметов, эффектов; для централизованного контроля памяти; в связке с пулом объектов она превращается в «завод по переработке».
Простая фабрика врагов из префаба:
using UnityEngine; public interface ISpawnable { void Initialize(object config); } public class Enemy : MonoBehaviour, ISpawnable { public void Initialize(object config) { // настройка врага по config } } public class PrefabFactory : MonoBehaviour { [SerializeField] private GameObject enemyPrefab; // создаёт объект в сцене и возвращает компонент ISpawnable public ISpawnable CreateEnemy(Vector3 position, object config = null) { var go = Instantiate(enemyPrefab, position, Quaternion.identity); var spawnable = go.GetComponent<ISpawnable>(); spawnable?.Initialize(config); return spawnable; } // уничтожение через фабрику (можно заменить на возврат в пул) public void Destroy(GameObject go) { Destroy(go); } }
Использование: компонент-спаунер получает ссылку на PrefabFactory и вызывает CreateEnemy(). Все места создания врагов сходятся в одну точку — менять префаб, вести учёт или добавить пул можно, не трогая спаунеры.
4. Пул объектов (Object Pool)
Идея: частые Instantiate и Destroy резко просаживают производительность. Пул решает проблему так: нужное количество экземпляров создаётся один раз при старте и хранится в неактивном состоянии; когда объект нужен — он активируется, когда больше не нужен — деактивируется и возвращается в пул вместо уничтожения. Если объектов не хватило, пул можно расширить, переиспользовать малозаметный активный объект или просто пропустить создание (для необязательных эффектов).
Классический простой пул пуль на списке:
using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { [SerializeField] private GameObject bulletPrefab; [SerializeField] private int poolSize = 20; private List<GameObject> pool = new List<GameObject>(); void Awake() { // создаём пули один раз при старте for (int i = 0; i < poolSize; i++) { var bullet = Instantiate(bulletPrefab); bullet.SetActive(false); pool.Add(bullet); } } public GameObject GetBullet(Vector3 position, Quaternion rotation) { foreach (var bullet in pool) { if (!bullet.activeInHierarchy) { bullet.transform.SetPositionAndRotation(position, rotation); bullet.SetActive(true); return bullet; } } // если пул пуст — создаём дополнительную пулю var newBullet = Instantiate(bulletPrefab, position, rotation); pool.Add(newBullet); return newBullet; } public void ReturnBullet(GameObject bullet) { bullet.SetActive(false); } }
Вариант на списке подходит для небольшого числа объектов (до сотен): при выдаче перебираются все элементы, и на больших пулах поиск первого неактивного становится медленным.
Каждая клетка — снаряд. Стреляйте одиночными или очередью: с выключенным пулом каждый выстрел — новый Instantiate (красная вспышка) и Destroy через секунду; с включённым — снаряды переиспользуются (розовые — активные, серые — ждут в пуле). Сравните счётчик Instantiate в обоих режимах после десятка очередей.
4.1. Встроенный пул Unity
Начиная с Unity 2021 есть встроенная реализация — UnityEngine.Pool.ObjectPool<T>. Жизненный цикл объектов (создание, активация, деактивация, уничтожение) задаётся лямбда-функциями при инициализации; Unity сама контролирует размер пула и не создаёт объекты сверх maxSize, а операции выполняются без ручного поиска:
using UnityEngine; using UnityEngine.Pool; public class Bullet : MonoBehaviour { private ObjectPool<Bullet> pool; public void SetPool(ObjectPool<Bullet> poolRef) { pool = poolRef; } void OnCollisionEnter(Collision collision) { pool.Release(this); // возврат пули в пул после столкновения } } public class BulletSpawner : MonoBehaviour { [SerializeField] private Bullet bulletPrefab; private ObjectPool<Bullet> bulletPool; void Awake() { bulletPool = new ObjectPool<Bullet>( createFunc: () => { var b = Instantiate(bulletPrefab); b.SetPool(bulletPool); return b; }, actionOnGet: b => b.gameObject.SetActive(true), actionOnRelease: b => b.gameObject.SetActive(false), actionOnDestroy: b => Destroy(b.gameObject), defaultCapacity: 20, maxSize: 100 ); } public void Fire(Vector3 pos, Quaternion rot) { var bullet = bulletPool.Get(); bullet.transform.SetPositionAndRotation(pos, rot); } }
Практические советы: ссылки на префабы и фабрики храните в инспекторе; комбинация «фабрика + пул» почти всегда улучшает производительность при частом создании снарядов, частиц и врагов; для сложных объектов фабрика может собирать объект по частям — создавать корневой GameObject и добавлять компоненты.
5. Наблюдатель
Идея: организовать систему уведомлений — когда в игре происходит событие (враг погиб, игрок стал невидимым), другие объекты автоматически получают сигнал и реагируют: обновляют счёт, проигрывают анимацию, создают эффект. Паттерн разделяет (decouple) объекты: они не вызывают методы друг друга напрямую. В C# и Unity есть несколько инструментов: делегаты и event, Action/EventHandler, UnityEvent.
Издатель события — способность невидимости игрока:
using UnityEngine; public class PlayerManagment : MonoBehaviour { // делегат делает «вид функции» типом данных: какие подписчики допустимы public delegate void Invisibility(); // событие этого типа; на него подписываются другие скрипты public static event Invisibility OnActivated; private bool _isActive = true; void Update() { if (_isActive && Input.GetKeyDown(KeyCode.Space)) { OnActivated?.Invoke(); // «?.» — сигнал уходит, только если есть подписчики _isActive = false; } } }
Подписчик — враг, который при активации невидимости теряет игрока из виду:
using UnityEngine; public class Enemy : MonoBehaviour { private bool isFollowing; private void OnEnable() { PlayerManagment.OnActivated += StopFollowing; // подписка на событие } private void OnDisable() { PlayerManagment.OnActivated -= StopFollowing; // отписка — обязательно } private void StopFollowing() { isFollowing = false; // дальше — своя реакция: искать в кустах, стрелять наугад… } }
Подписчики могут быть совершенно разными: один враг начинает проверять кусты, другой — стрелять в разные стороны, а скрипт способности запускает таймер перезарядки невидимости. Издатель об этом не знает ничего — в этом и сила паттерна. Схожие реакции врагов при этом стоит вынести в один общий скрипт.
Вариант без кода подписки — UnityEvent: реакции подключаются прямо в инспекторе, как OnClick() у кнопки в A08. Подходит для визуальных эффектов, звуков и UI, где гибкость важнее производительности:
using UnityEngine; using UnityEngine.Events; public class EnemyWithUnityEvent : MonoBehaviour { [SerializeField] private UnityEvent onDeath; // настраивается в инспекторе void Die() { // ...логика смерти onDeath.Invoke(); // вызывает все методы, привязанные в инспекторе } }
Контрольные вопросы
-
Зависимость — объект, от которого зависит функциональность класса (персонажу нужно оружие). Внедрение — предоставление зависимостей классу извне (через конструктор или поле), вместо создания их внутри класса.
-
Модули высокого уровня не должны зависеть от модулей низкого уровня — оба зависят от абстракций; абстракции не зависят от деталей. Character (логика) зависит от интерфейса IWeapon (абстракция), а не от Sword или Bow (детали); конкретное оружие подставляется извне.
-
MonoBehaviour нельзя создавать через new — этими объектами управляет Unity, конструкторы не используются. Instantiate принимает только префаб, позицию, поворот и родителя, ссылки передать нельзя. Поэтому зависимости внедряются через сериализуемые поля в инспекторе (или иные механизмы вроде ScriptableObject).
-
Разбросанное по проекту создание объектов: все способы создания (и, при желании, уничтожения) собираются в одном классе. Смена префаба, учёт объектов или добавление пула делаются в одной точке, не затрагивая остальной код.
-
Экземпляры создаются один раз при старте и хранятся неактивными; вместо Instantiate объект активируется из пула, вместо Destroy — деактивируется и возвращается. Нужен при частом создании однотипных объектов: пуль, частиц, врагов — частые Instantiate/Destroy просаживают производительность.
-
Нет ручного поиска первого неактивного объекта (операции выполняются без перебора), жизненный цикл задаётся лямбда-функциями, размер контролируется параметрами defaultCapacity и maxSize. Самодельный пул на List замедляется с ростом числа объектов.
-
Проверяет событие на наличие подписчиков: если ни один метод не подписан, событие равно null и вызов не происходит — без проверки был бы NullReferenceException.
-
Пара гарантирует, что объект подписан ровно тогда, когда активен. Без отписки уничтоженный объект остаётся в списке подписчиков статического события — утечки и MissingReferenceException. Каждой «+=» — парная «−=».
Источники
- Гамма, Э. Приёмы объектно-ориентированного проектирования. Паттерны проектирования / Э. Гамма, Р. Хелм, Р. Джонсон, Дж. Влиссидес ; пер. с англ. — Санкт-Петербург : Питер, 2020. — 368 с.
- Nystrom, R. Game Programming Patterns / R. Nystrom. — [Электронный ресурс]. — URL: https://gameprogrammingpatterns.com (дата обращения: 08.07.2026).
- ObjectPool<T0> // Unity Scripting API : [сайт]. — URL: https://docs.unity3d.com/ScriptReference/Pool.ObjectPool_1.html (дата обращения: 08.07.2026).
- UnityEvent // Unity Scripting API : [сайт]. — URL: https://docs.unity3d.com/ScriptReference/Events.UnityEvent.html (дата обращения: 08.07.2026).