На кой черт я выбираю HMVС. Разбираем подходы к организации архитектуры при разработке игр на Unity

Всем привет. Давненько я ничего не писал технического на DTF (хотя есть вопрос - на кой черт я вообще это делаю). В этот раз я решил немного покопать архитектуру проектов при разработке игр на Unity и пройтись по самым часто встречаемым мной подходам. Ну и конечно же рассказать, зачем я такой мазохист и пришел к любимому мной HMVС (HMVP).

Пы-сы. Все что здесь описано - субъективное мнение. Каждому нужно учитывать специфику разработки и проекта в целом, а вообще, лучшая архитектура та, которой нет и сочетание разных подходов в удобном и эффективном для команды стиле :D

На кой черт я выбираю HMVС. Разбираем подходы к организации архитектуры при разработке игр на Unity

Монобехи и КОП

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

Базово реализация КОП выглядит следующим образом:

<i>Каждый Awake(),  Start() и Update() по своей сути общается с движком через рефлексию и приколы в духе SendMessage()</i>
Каждый Awake(),  Start() и Update() по своей сути общается с движком через рефлексию и приколы в духе SendMessage()

Итак, базовый подход подразумевает, что вся игра будет построена на GameObject с компонентами MonoBehaviour что позволяет вам разделять различные подсистемы на маленькие кусочки и выстраивать из этого игру.

// Базово все классы выглядят как в туториалах Unity public class Weapon : MonoBehaviour { private Awake() { } }

Однако дьявол кроется в деталях. Такой подход приводит к сложностям масштабирования, в особенности на больших проектах, излишним связям, проблемам рефлекскии под капотом Unity и сильной привязке к Unity API - что в дальнейшем может породить проблемы, в особенности если вы хотите дублировать код на клиент и сервер.

Что же мы можем с этим сделать? Правильно. Начать внедрять различные архитектурные подходы в наш проект.

Синлтооооны

Первое и самое ужасное что может прийти на ум - упаси боже синглтон для менеджмента на нашей сцене. По своей сути - синглтон это объект, содержащийся на сцене или во всем проекте в единственном экземпляре, который нужен для связки и управления разделенными системами в нашей игре.

Простой пример синглтона, который можно видеть часто у Junior-разработчиков:

public class MySingleton { private static MySingleton _instance; private Someconfig _config; private MySingleton(Someconfig config){ _config = config; } public static MySingleton Instance(Someconfig config){ if(_instance == null) _instance = new MySingleton(config); return _instance; } public void DoSome() { // Do Some Shit } }

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

Немного о том, как обычно выглядит организация Singleton (и далеко не самая правильная):

<i>Singleton объединяющий взаимодействие объектов - не очень хороший Singleton, в особенности когда мы пихаем внутрь него прямые ссылки. Можно конечно улучшить это дело при помощи отправки событий на объекты, но все же.</i>
Singleton объединяющий взаимодействие объектов - не очень хороший Singleton, в особенности когда мы пихаем внутрь него прямые ссылки. Можно конечно улучшить это дело при помощи отправки событий на объекты, но все же.

Итак, как понять что Singleton это зло?

  • Когда он связывает всю логику в вашей игре и контролирует все подряд;
  • Когда в него напрямую прокидывается куча ссылок;
  • Когда его размер становится огромным;
  • Когда вы уже отстрелили себе ногу при отладке или же менеджменте памяти, в особенности если Singleton является GameObject-ом;

Когда все же можно и сделать Singleton?

  • Маленькие проекты;
  • Когда менеджмент памяти не вызывает проблем, а вместо проброса прямых ссылок вы управляете событиями;
  • Для небольших систем - к примеру для управления аудио или же в качестве endpoint-обретки для систем аналитики (некий билдер или фабрика для систем аналитики);

DI-Контейнеры и охреневший Zenject

Ох-ох, как же многие топят за Zenject и внедрение зависимостей с использованием DI-контейнера. А по факту многие используют этот огромный фреймворк как обычный Singleton.

Как обычно я видел это на проектах:

<i>Контейнер выступает в качестве связующего звена для нахождения зависимостей или (упаси боже) для запуска сервисов</i>
Контейнер выступает в качестве связующего звена для нахождения зависимостей или (упаси боже) для запуска сервисов

По своей сути и вкратце - DI-контейнер нужен для того, чтобы помещать в него ссылки и резолвить зависимости в конечных объектах. Подробнее об этом можно почитать здесь.

Простейший пример из того же Zenject:

public class TestInstaller : MonoInstaller { public override void InstallBindings() { Container.Bind<string>().FromInstance("Hello World!"); Container.Bind<Greeter>().AsSingle().NonLazy(); } } public class Greeter { public Greeter(string message) { Debug.Log(message); } }

Этот подход хорош, но только тогда, пока не начинают возникать сложности:

  • По сути DI-Container это тот же самый Singleton, но улучшенный, что создает привязку к самому контейнеру, как правило;
  • Очень часто создаются километровые классы-инсталлеры, которые занимаются биндингом зависимостей (пусть даже и на интерфейсах);
  • Сложность для понимания у новичков за счет большего разделения ответственности, хотя и хорошо масштабируемый подход в дальнейшем;
  • Сложная отладка благодаря контейнерам и вездесущим биндингам;
  • Очень легко превратить обычный DI-контейнер в Service Locator;

Естественно, в правильных руках DI-контейнеры это хороший способ организации кода, однако и уровень подготовки должен быть высокий, чтобы все не скатилось в вакханалию.

MVC в чистом виде

Почему именно в чистом? Потому что это достаточно просто для понимания. Для организации проекта у нас есть контроллер, модель и вьюха. Однако сколько существует MVC, столько и существует его подвидов - MVP, MVVM и пр.

Но пока мы остановимся на базовом и доступном для всех примере:

<i>Пользователь при выполнении какого-либо действия обращается к контроллеру (который может быть монобехом, а может и не быть), тот в свою очередь обновляет данные в классе-модели, на обновление которой подписана модель и она то и отображает все на экран</i>
Пользователь при выполнении какого-либо действия обращается к контроллеру (который может быть монобехом, а может и не быть), тот в свою очередь обновляет данные в классе-модели, на обновление которой подписана модель и она то и отображает все на экран

Плюсы очевидны - мы разделяем контроль (пользователский ввод) от данных и от представления (то что пользователь видит на экране). Связь как правило строится на событиях и инициализируется в контейнере приложения. Это отбрасывает необходимость в большой связности и по сути мы всегда общаемся лишь событиями. Подробный пример можно взять здесь.

Однако здесь есть некоторые минусы:

  • При масштабировании проекта растет наш установочный класс-приложения (тот же контейнер);
  • Горизонтальная система расположения триады MVC создает огромное количество различных классов, слабо связанных друг с другом;
  • Если отделить триады от установщика приложения - сложнее контролировать связи между объектами - это может выступать как плюсом, так и минусом.

MVC в контейнерах

Еще один возможный сценарий - связать нашу триаду MVC (или любой другой M**) в контейнер DI. Таким образом мы сможем лучше контролировать связи между приложениями, однако очень легко превратить все в Service Locator.

<i>Вместо резолва системы - мы резолвим только её часть, а дальше общаемся событиями.</i>
Вместо резолва системы - мы резолвим только её часть, а дальше общаемся событиями.

Подход различается в том, что вместо связи контроллеров событиями - мы резолвим наши контроллеры через контейнер, а дальше уже работаем с событиями. Однако здесь вытекают все те же проблемы, что и при обычном использовании DI-контейнера, но при этом накладывается повышенная сложность вхождения и большее количество создаваемых классов, однако мы разделяем представление, модели и контроллеры.

HMVC / HMVP

Здесь я хотел бы остановится подольше, поскольку я, как излюбленный мазохист сильно полюбил этот подход. Он заключается в том, что мы создаем древовидное разделение наших M-V-C, что дает ряд преимуществ не смотря на сильно возрастающую кодовую базу.

Итак, рассмотрим схему взаимодействия, которую чаще всего использую я:

<i>Почему Presenter, а не Controller? Потому что мы создаем пассивную модель и общаемся сразу и с представлением и с моделью.</i>
Почему Presenter, а не Controller? Потому что мы создаем пассивную модель и общаемся сразу и с представлением и с моделью.

Как это работает?

Изначально мы создаем пустую сцену с GameInstaller - который будет подгружать контейнеры для каждой сцены отдельно. Сам класс GameInstaller хранит глобальные (верхнеуровневые) триады, которые как правило отвечают за крупные системы (к примеру, работа с аудио) и хранит общие эвенты на весь жизненный цикл игры.

Далее GameInstaller загружает нужный нам контейнер сцены, который инициализирует верхнеуровневые триады внутри себя (к примеру, общий контроллер игрока), а тот в свою очередь по необходимости будет инициализировать внутри себя дочерние контроллеры (к примеру, контроллер пушки). И так длится по нисходящей. Все общение между ветками происходит исключительно через события и реактивные поля (поля, хранящие в себе некое значение, на изменение которого подписываются отдельные члены ветки).

Звучит сложно, однако на деле все намного проще, такой подход позволяет нам легко разделять все триады, при этом сохранять адекватную связь между её дочерними элементами через контекст. Инициализация каждого презентера начинается с того, что он получает в себя контекст с событиями от родителя.

Простой пример:

class MyPresenter { public struct Context { public IGameEvent<float> SomeEvent; public IReactiveField<float> SomeField; } private Context _ctx; private MyView _myView; private MyChildPresenter _myChildPresenter; public void SetContext(Context ctx) { _ctx = ctx; // Load Child Controllers _myChildPresenter = new MyChildPresenter(); _myChildPresenter.SetContext(new MyChildPresenter.Context { SomeEvent = _ctx.SomeEvent }); // Load View _myView = LoadView<MyView>(); _myView.SetContext(new MyChildPresenter.Context { SomeField = _ctx.SomeField }); } } class MyChildPresenter { public struct Context { public IGameEvent<float> SomeEvent; } private Context _ctx; public void SetContext(Context ctx) { _ctx = ctx; _ctx.SomeEvent.AddListener(val => { // Do Some Shit }); } } class MyView : MonoBehaviour { public struct Context { public IReactiveField<float> SomeField; } private Context _ctx; public void SetContext(Context ctx) { _ctx = ctx; _ctx.SomeField.AddListener(val => { // Do Some Shit }); } }

Выглядит объемно. Однако я вижу несколько преимуществ в таком подходе:

  • Сцены проекта можно загружать практически моментально и инициализировать наши объекты, в том числе и View - по требованию по мере загрузки нашего древа. Если нам не нужно загружать View с настройками или магазином игры до отправки события - мы не храним ничего кроме события;
  • Жесткая структурированность и изоляция отдельных триад;
  • Слабая связность за счет событий;
  • Реактивность за счет событий;
  • Достаточно легкая отладка по веткам триад, нежели через контейнеры;

Как и несколько недостатков:

  • Если вам нужно прокинуть событие по древу в 20 триад - это будет достаточно долгая затея, однако подход подразумевает хорошее изначальное проектирование;
  • Большая кодовая база для проекта, хоть и хорошо структурированная;
  • Если вам понадобится связывать ветки между собой - для вас это может стать отличным челленджем по прокидыванию эвентов через десяток классов.

В целом HMVC/HMVP нужен для хорошо организованных проектов с высокой изоляцией подсистем, высоким требованиям к работе с памятью и ресурсами игры. Однако для того, чтобы освоится в проекте - возможно придется потратить несколько больше времени, чем при других подходах.

Итого

Каждый подход к организации проекта имеет место быть. Все зависит лишь от цели проектирования. Если вам нужна жесткая архитектура и быстрая работа по памяти и предполагается быстрая и динамическая работа с ресурсами - берите под мышку HMVC. Если вам нужно быстро прототипировать проект без заморочек - пишите все на синглтонах.

4848
42 комментария

немного оффтопа. А какие есть опенсоурсные проекты на unity где можно было бы посмотреть нормальную архитектуру и прочие интересности?
пока только про Gigaya слышал, правда еще не щупал

3
Ответить

В опенсорс сложно найти большие проекты, где можно было бы наглядно увидеть примеры думаю

3
Ответить

Утёкшие сорсы Гвинта посмотри)

1
Ответить

Каждый Awake(), Start() и Update() по своей сути общается с движком через рефлексию и приколы в духе SendMessage()

Это, мягко говоря, не совсем так)

3
Ответить

Да сейчас по другому, забыл упомянуть

1
Ответить

Интересно было бы посмотреть на большие проекты автора этой статьи, что бы понять в каких случаях нужно заморачиваться с этим.

3
Ответить

Работали и на больших проектах, просто нужно сильно внимательно относится к коду, дисциплинирует хорошо 😀

Ответить