На кой черт я выбираю HMVС. Разбираем подходы к организации архитектуры при разработке игр на Unity
Всем привет. Давненько я ничего не писал технического на DTF (хотя есть вопрос - на кой черт я вообще это делаю). В этот раз я решил немного покопать архитектуру проектов при разработке игр на Unity и пройтись по самым часто встречаемым мной подходам. Ну и конечно же рассказать, зачем я такой мазохист и пришел к любимому мной HMVС (HMVP).
Пы-сы. Все что здесь описано - субъективное мнение. Каждому нужно учитывать специфику разработки и проекта в целом, а вообще, лучшая архитектура та, которой нет и сочетание разных подходов в удобном и эффективном для команды стиле :D
Монобехи и КОП
Начнем с самого базового подхода который используют в основном новички. Я не хочу сказать, что этот подход плох, просто по большей части разработчики привыкли мыслить в рамках ООП (объектно-ориентированного программирования) и правильное использование КОП (компонентно-ориентированного программирования) требует несколько другого склада мыслей. Ну и в плюс ко всему приложим то, что юнитевская реализация КОП на основе монобехов не выглядит идеальной.
Базово реализация КОП выглядит следующим образом:
Итак, базовый подход подразумевает, что вся игра будет построена на GameObject с компонентами MonoBehaviour что позволяет вам разделять различные подсистемы на маленькие кусочки и выстраивать из этого игру.
Однако дьявол кроется в деталях. Такой подход приводит к сложностям масштабирования, в особенности на больших проектах, излишним связям, проблемам рефлекскии под капотом Unity и сильной привязке к Unity API - что в дальнейшем может породить проблемы, в особенности если вы хотите дублировать код на клиент и сервер.
Что же мы можем с этим сделать? Правильно. Начать внедрять различные архитектурные подходы в наш проект.
Синлтооооны
Первое и самое ужасное что может прийти на ум - упаси боже синглтон для менеджмента на нашей сцене. По своей сути - синглтон это объект, содержащийся на сцене или во всем проекте в единственном экземпляре, который нужен для связки и управления разделенными системами в нашей игре.
Простой пример синглтона, который можно видеть часто у Junior-разработчиков:
Если же на небольших проектах это не приведет к излишним проблемам, то чем больше будет проект - тем хуже это будет контролировать. Не говоря об утечках памяти, проблемы с построением тестов, организацией многопоточности, километровых скриптах (часто видел такое у начинающих разработчиках) и упаси боже напрямую прокинутых в класс-одиночку ссылок на объекты - в качестве ошибок которыми изобилуют наши классы начинающих разработчиков. Подробнее про паттерн и его плюсы и минусы можно почитать здесь.
Немного о том, как обычно выглядит организация Singleton (и далеко не самая правильная):
Итак, как понять что Singleton это зло?
- Когда он связывает всю логику в вашей игре и контролирует все подряд;
- Когда в него напрямую прокидывается куча ссылок;
- Когда его размер становится огромным;
- Когда вы уже отстрелили себе ногу при отладке или же менеджменте памяти, в особенности если Singleton является GameObject-ом;
Когда все же можно и сделать Singleton?
- Маленькие проекты;
- Когда менеджмент памяти не вызывает проблем, а вместо проброса прямых ссылок вы управляете событиями;
- Для небольших систем - к примеру для управления аудио или же в качестве endpoint-обретки для систем аналитики (некий билдер или фабрика для систем аналитики);
DI-Контейнеры и охреневший Zenject
Ох-ох, как же многие топят за Zenject и внедрение зависимостей с использованием DI-контейнера. А по факту многие используют этот огромный фреймворк как обычный Singleton.
Как обычно я видел это на проектах:
По своей сути и вкратце - DI-контейнер нужен для того, чтобы помещать в него ссылки и резолвить зависимости в конечных объектах. Подробнее об этом можно почитать здесь.
Простейший пример из того же Zenject:
Этот подход хорош, но только тогда, пока не начинают возникать сложности:
- По сути DI-Container это тот же самый Singleton, но улучшенный, что создает привязку к самому контейнеру, как правило;
- Очень часто создаются километровые классы-инсталлеры, которые занимаются биндингом зависимостей (пусть даже и на интерфейсах);
- Сложность для понимания у новичков за счет большего разделения ответственности, хотя и хорошо масштабируемый подход в дальнейшем;
- Сложная отладка благодаря контейнерам и вездесущим биндингам;
- Очень легко превратить обычный DI-контейнер в Service Locator;
Естественно, в правильных руках DI-контейнеры это хороший способ организации кода, однако и уровень подготовки должен быть высокий, чтобы все не скатилось в вакханалию.
MVC в чистом виде
Почему именно в чистом? Потому что это достаточно просто для понимания. Для организации проекта у нас есть контроллер, модель и вьюха. Однако сколько существует MVC, столько и существует его подвидов - MVP, MVVM и пр.
Но пока мы остановимся на базовом и доступном для всех примере:
Плюсы очевидны - мы разделяем контроль (пользователский ввод) от данных и от представления (то что пользователь видит на экране). Связь как правило строится на событиях и инициализируется в контейнере приложения. Это отбрасывает необходимость в большой связности и по сути мы всегда общаемся лишь событиями. Подробный пример можно взять здесь.
Однако здесь есть некоторые минусы:
- При масштабировании проекта растет наш установочный класс-приложения (тот же контейнер);
- Горизонтальная система расположения триады MVC создает огромное количество различных классов, слабо связанных друг с другом;
- Если отделить триады от установщика приложения - сложнее контролировать связи между объектами - это может выступать как плюсом, так и минусом.
MVC в контейнерах
Еще один возможный сценарий - связать нашу триаду MVC (или любой другой M**) в контейнер DI. Таким образом мы сможем лучше контролировать связи между приложениями, однако очень легко превратить все в Service Locator.
Подход различается в том, что вместо связи контроллеров событиями - мы резолвим наши контроллеры через контейнер, а дальше уже работаем с событиями. Однако здесь вытекают все те же проблемы, что и при обычном использовании DI-контейнера, но при этом накладывается повышенная сложность вхождения и большее количество создаваемых классов, однако мы разделяем представление, модели и контроллеры.
HMVC / HMVP
Здесь я хотел бы остановится подольше, поскольку я, как излюбленный мазохист сильно полюбил этот подход. Он заключается в том, что мы создаем древовидное разделение наших M-V-C, что дает ряд преимуществ не смотря на сильно возрастающую кодовую базу.
Итак, рассмотрим схему взаимодействия, которую чаще всего использую я:
Как это работает?
Изначально мы создаем пустую сцену с GameInstaller - который будет подгружать контейнеры для каждой сцены отдельно. Сам класс GameInstaller хранит глобальные (верхнеуровневые) триады, которые как правило отвечают за крупные системы (к примеру, работа с аудио) и хранит общие эвенты на весь жизненный цикл игры.
Далее GameInstaller загружает нужный нам контейнер сцены, который инициализирует верхнеуровневые триады внутри себя (к примеру, общий контроллер игрока), а тот в свою очередь по необходимости будет инициализировать внутри себя дочерние контроллеры (к примеру, контроллер пушки). И так длится по нисходящей. Все общение между ветками происходит исключительно через события и реактивные поля (поля, хранящие в себе некое значение, на изменение которого подписываются отдельные члены ветки).
Звучит сложно, однако на деле все намного проще, такой подход позволяет нам легко разделять все триады, при этом сохранять адекватную связь между её дочерними элементами через контекст. Инициализация каждого презентера начинается с того, что он получает в себя контекст с событиями от родителя.
Простой пример:
Выглядит объемно. Однако я вижу несколько преимуществ в таком подходе:
- Сцены проекта можно загружать практически моментально и инициализировать наши объекты, в том числе и View - по требованию по мере загрузки нашего древа. Если нам не нужно загружать View с настройками или магазином игры до отправки события - мы не храним ничего кроме события;
- Жесткая структурированность и изоляция отдельных триад;
- Слабая связность за счет событий;
- Реактивность за счет событий;
- Достаточно легкая отладка по веткам триад, нежели через контейнеры;
Как и несколько недостатков:
- Если вам нужно прокинуть событие по древу в 20 триад - это будет достаточно долгая затея, однако подход подразумевает хорошее изначальное проектирование;
- Большая кодовая база для проекта, хоть и хорошо структурированная;
- Если вам понадобится связывать ветки между собой - для вас это может стать отличным челленджем по прокидыванию эвентов через десяток классов.
В целом HMVC/HMVP нужен для хорошо организованных проектов с высокой изоляцией подсистем, высоким требованиям к работе с памятью и ресурсами игры. Однако для того, чтобы освоится в проекте - возможно придется потратить несколько больше времени, чем при других подходах.
Итого
Каждый подход к организации проекта имеет место быть. Все зависит лишь от цели проектирования. Если вам нужна жесткая архитектура и быстрая работа по памяти и предполагается быстрая и динамическая работа с ресурсами - берите под мышку HMVC. Если вам нужно быстро прототипировать проект без заморочек - пишите все на синглтонах.
немного оффтопа. А какие есть опенсоурсные проекты на unity где можно было бы посмотреть нормальную архитектуру и прочие интересности?
пока только про Gigaya слышал, правда еще не щупал
В опенсорс сложно найти большие проекты, где можно было бы наглядно увидеть примеры думаю
Эх, значит опять собирать знания по крупицам)
Утёкшие сорсы Гвинта посмотри)
Это, мягко говоря, не совсем так)
Да сейчас по другому, забыл упомянуть
Интересно было бы посмотреть на большие проекты автора этой статьи, что бы понять в каких случаях нужно заморачиваться с этим.
Работали и на больших проектах, просто нужно сильно внимательно относится к коду, дисциплинирует хорошо 😀
Приветствую! А не нужны ли новые "голоса"? СМогу ВСЁ!
Приветствую! Я — Олег Исаев, актёр, чтец, диктор. Предлагаю свой голос к сотрудничеству!
Почти 60 лет.
Москва-Пермь (сейчас в Перми). У микрофона — 30 лет, тысячи аудиокниг, тонны рекламы, кино, видео и так далее.
Работаю дома, имею полный тракт и полный Интернет.
Почти круглосуточно.
Демо прикрепил.
Готов к тесту!
Если дадите электроадрес - пришлю демо!
Мой адрес - [email protected]
Олег Исаев
Хм... Как мне кажется HMVC/HMVP не хватает какого-нибудь "глобального" хранилища ивентов и данных для них. Вроде как в React. В таком случае это решило бы проблемы и связывания компонентов и прокидывания ивентов.
Вообще это можно сделать при помощи контейнера событий, опять же тогда возникает проблема, когда ты зависишь от хранилища.
Event bus что-ли?
Как много веселых ребят, и все делают велосипед (с)
Я только предположил. Потому в C# не умею. Последний раз в школе на нём что то писал. Ну и сегодня кстати Unity скачал посмотреть, вдохновившись DTF'ерами)
Все эти паттерны - они вполне универсальным и от языка не зависят практически :)
Ну справедливости ради, на языках, без использования библиотек/фреймворков мы (ладно я) не так часто пишем. А в тех, которые я использую, для этого есть хорошие встроенные инструменты.
Он самый. Но его можно немного модифицировать и сделать что то вроде EventStack изолированного для веток hmvc
Очень круто! Интересно. Мы тоже почти пришли к таком решению.
Сначала мы делали MVC без C. Чисто с MV. Из-за этого было кучу проблем типо что есть Model и что есть View. Потом все таки взяли MVP.
Сейчас так и живем MVP с Zenject и с unit тестами для Model и Presenter.
В интерфейсе все хорошо, а в геймплее чот не зашло и пока не придумали как это заюзать.
Типа спеллы, перки, пушки и т. д. как архитектуру выстраивать c помощью этого? У вас с этим все хорошо? То есть MVP в геймплее
Ну есть определённые сложности да в этом, пока решаем на уровне отдельного презентера-системы которая уже отрабатывает перки.
Привожу проекты к виду примерно MVVM, максимально сохраняю компонентную систему. Можно делать с DI, можно без. Тут скорее зависит от способности команды полноценно использовать DI - для Unity проектов это не столь важно как в вебе. Zenject не советую использовать без опыта, требуется много времени на изучение, очень легко скатиться к лапше, божественным структурам, и вообще собрать все грабли. Поглядывал на другие вещи, что попроще, но уже забыл названия)
View - компоненты отображения, коим может быть и стандартный Image и какая-нить панелька с ресурсами. Иерархия с другими View(может содеражать в себе), но у каждого объекта только одна связь с VM. Отображение данных полученных от VM без изменения. Ограничивать можно через интерфейс.
VM - это модель UI, по сути тот же Presenter и управление логикой как в Model, но по отношению к UI. К примеру, это окна, попапы, ну или целый UI магазина. Иерархичен по отношению к другим VM, взаимодействует с другими частями, вызывает методы в Model, но не имеет доступа к его данным(только управление). Передаёт данные в View, получает. Иногда напрямую, иногда как посредник передаёт данные во View. Здесь так же разделение между данными в Model и передаваемыми во View - для Prediction, уборки сетевой задержки и т.п.
Model - логика игры. Тут важно - не стоит пытаться делать все объекты не MonoBehaviour. Если объект на сцене - часть непосредственной логики игры, в т.ч. физики, то так и должно быть. Если надо ту же логику повторить на сервере - тащите как есть и делайте свою реализацию MB на сервере, либо делите через partial(лучше), ну или наследованием и контенерами данных(хуже). Это так же позволяет использовать Photon и другую логику непосредственно игрового процесса на сервере(т.е. с апдейтами или их имитацией).
Так ещё несколько отличительных, часто:
1) нужен Update - это или View или VM.
2) нужен FixedUpdate - Model.
3) Model в любой реализации должен компилится без View и VM. В идеале, Model в новых версиях отделять в отдельную сборку(-и).
Касательно загрузки. Управление загрузкой осуществляется из M и VM. Тут только иногда требуются какие-то общее ожидание загрузки и для M, и для VM, делая эту загрузку независимо друг от друга(для Model обычно ожидания ответа от сервера логики, а для VM сканиявание и загрузка ассетов). C использованием Task это наконец-то получается организовать правильно и легко читаемо, без костылей типа сервис-локатора, синглтонов и т.п..
Хороший пример. MVVM часто используется на играх где много UI.
Можно посмотреть на пример вашей реализации данного подхода. Будет очень полезно для новичков типо меня :)
Примеры есть и в интернете. По ним даже проще - не будет лишнего кода и мусора)
А вот если будут вопросы по конкретной реализации - обращайся.
Объясните как андроид девелоперу. Что вообще в данном случае выполняет вью? Предположим, в модели есть обработка физики, контроллер ввода пользователя, запуск анимаций и т.д. Т.е. всё, что влияет на непосредственно на ход игры. Что делает View? Только UI? т.е. для большинства геймплейных объектов VVM не нужны? Или View и VM как раз и выполняют обработку ввода и запуск анимаций? А то куда ни зайди - везде срачи по поводу того, нужны ли MV* паттерны в юнити
Весь рендер, включая UI элементы, рендер моделей и т.д. По мне так, это всё то, что непосредственно занимается выводом на экран. Но и как более сложная вещь, т.е. может быть просто выводом картинки, а может быть выводом счётчика с анимацией.
Управление этим всем - VM, непосредственно(т.е. прямые ссылки на все компоненты V есть в VM).
Запуск анимаций точно влияет на ход игры? Скорее всего в таком случае надо делить анимацию и действие. Банальный пример - в UI начисление денег можно произвести(VM) после анимации полёта ресурса(V) в каунтер(V). Но начислить деньги в M по ответу сервера. Так же легко реализовать prediction, никак не меняя логику M. Примерно так же с анимацией. При реализации анимации, которая влияет на физику игры, к примеру в игре с катящимся шаром, анимация попадает в M. Но не вывод графики, в т.ч. эффекты, прочее управление ими. Иногда для этого выстраиваю параллельную анимацию для графики.
Если игра содержит много GUI, либо сильно отличный от базового рендер, либо который требует сложного управления, то лучше с MVVM) Промежуточные варианты для проектов попроще - MVC/MVP. Для любого проекта не на пару выходных стоит отделять M от всего остального.
Опечатка:
Пользователь при выполнении какого-либо действия обращается к контроллеру (который может быть монобехом, а может и не быть), тот в свою очередь обновляет данные в классе-модели, на обновление которой подписана [[[[модель]]] и она то и отображает все на экран
Модель надо заменить на представление
Очень странная схема. Почему в Model идёт ViewData? В MVP данные Model не должны модифицироваться извне. Данные Model должны отображаться(передаваться в P, использоваться в V), но не должны модифицироваться извне(напрямую). Ну и иерархии тут нет - это одноуровневая реализация схемы.
По тексту выходит что-то вроде контейнеров с древовидным доступом. Почти как DI контейнеры(контексты), но без DI. Получается что-то вроде частичной реализации DI. Но это не HMVP, может даже не MVP(т.к. где тут model не ясно). Надеюсь, не Context)
В примере нет модели, контекст это конструктор по сути
"Достаточно легкая отладка по веткам триад, нежели через контейнеры;"
А что не так с контейнерами и отладкой?
Если контейнеры самописные проблем нет. Я тут скорее задевал Zenject и ту кашу, которую он срет в отладчик
Это в моменты инициализации имеется ввиду?
В том числе. Сам контейнер очень перегружен и без должного обращения с ним опасно скатывается в сервис локатор, вместо нормального DI.
Да уж) В командах без практики и хорошего понимания структуры вообще нельзя давать разработчикам работать с контейнерами.
Нельзя просто взять и в лоб сделать правильно, по наитию, как бывает с другими вещами. Контейнеры в принципе требуют предварительного проектирования, которое с новым инструментом отходит куда-то на задний план и всё сводится к "достать нужный объект - уже победа")
Как-то кашеобразно выглядит, поток данных не упорядочен, рандомно мечется между объектами через события…это не слабая связанность, это хаос. Есть хорошие примеры использования событийного подхода без потери организации слоев, вариации гексагональной и чистой архитектуры с реактивностью, например.
Вспоминаю свой первый учебный проект по ООП, где рыцарь при подборе предмета получал указатель на него, а при использовании уничтожал объект)) Только по итогу из-за необходимости писать быстро, много и не по своему плану, это привело к каше в понимании программы (хотя вроде ничего не падало, но и не факт, что всё освобождалось). За материал спасибо!
@Andrey Apanasik вроде неплохой материал, в сети не выведут, но может хоть репостиком помочь?
Очень интересно как работает LoadView(). Каждый Presenter внутри себя порождает триаду ниже по иерархии. Если модель через конструктор порождается, то как грузятся вьюхи? Есть отдельный объект, который занимается подгрузкой View?
LoadView это метод класса BasePresenter от которого наследуются все презентеры
Ну вот вопрос больше в том, как передается ссылка на дочерний View? Или откуда она берется?
View - это MonoBehaviour класс, который вероятно висит на игровом объекте на сцене или префабе хотя бы.
Ну то есть с базовым MVP понятно. Есть некий класс-Installer, который формирует первую триаду. И вероятно этот Installer сам MonoBehaviour на сцене и знает ссылку на View для базовой триады.
А вот, как оно дальше вниз по иерархии работает? Ну то есть откуда BasePresenter знает о ссылке на View?
PS. Мне интересно разобраться, как оно работает. Буду благодарен за развернутый ответ.
LoadView возвращает в коллбеке ссылку на префаб
Ну точнее не на сам префаб, а на вью которая на нём висит