Архитектура unity проектов
За всю карьеру удалось поработать на множестве игровых проектов в качестве Lead Unity Developer. И за более чем 5 лет удалось насмотреться на разные подходы к созданию проектов.
В данной статье я собрал выжимку статей и мыслей из своего блога о подходах, которые я использую в своих проектах. А так же поделился шаблоном пустого проекта, в котором отражена самая удобоваримая архитектура проекта в unity.
Не волнуйтесь, никаких SOLID и других изотерических практик. Коротко, по делу, подкрепленное использованием на нескольких проектах.
TL;DR
Просто можете почитать readme с моего шаблона пустого проекта, там все по структуре понятно будет ❤️
Об авторе
Меня зовут Алексей и я Lead разработчик. А последние полгода помогаю компании Zeptolab улучшать проект Overcrowded.
Я самоучка. Всё, что связано с разработкой игр, я изучал самостоятельно. Все мои знания - личный опыт. Работал над 5 мобильными проектами в разных жанрах (mid-core, simulator, merge-3). А также делал несколько проектов в дополненной реальности.
Проводя много времени за ответом на вопросы в unity чатиках в Telegram, я понял что тема архитектуры очень плохо освещена. А странные best practices от unity часто не имеют ничего общего с реальными проектами.
Потому сделал свой блог в Telegram, где пишу про архитектуру проектов на unity, присоединяйтесь!
(А|а)рхитектура
Общество движется вперед путем увеличения числа операций, которые может осуществлять, не раздумывая над ними.
Архитектурные подходы (паттерны) в разработке ПО универсальны. Их можно применить к любому языку и проекту. Код, следующий этим принципам, будет +- одинаков с точки зрения масштабируемости и сложности.
Для меня архитектура проекта — это совокупность решений в организации проекта, обеспечивающие удобство работы и масштабируемость, а так же замедление роста когнитивной сложности.
И при проектировании нужно вырабатывать эти решения (правила, подходы, гайдлайны) не только для кода, но и для всего с чем проект связан:
- Именование файлов и папок, структура хранения ассетов, структура сборок
- Утилиты для разработки, тестирование, работа с системой контроля версии
- Билд проекта и его деплой, работа со сторами и выливка хотфиксов
- Мониторинг метрик, ошибок и реагирование на них
Структура проекта
Папка проекта
Перед тем как написать первую строчку кода, нужно решить очень важную проблему — быстрое захламление иерархии проекта. Чтобы последующие N-лет работать с ней без головной боли.
Обычно происходит как:
- Cоздается проект, в этой же папке создается git репозиторий.
- У вас плане backend с общей shared частью (это общий код для бэка и клиента, например модели). И вам нужно где-то хранить скрипты для копирования shared части.
- Или бэкенд лежит в одном репозитории с клиентом.
- Или CI/CD пайплайн потребует запуска кастомных скриптов.
- Или есть внешние конфиги, которые вы хотите хранить вне папки Assets, чтобы они не попали в билд (например конфигурации сборки билда).+ unity генерирует кучу папок, которые не попадают в репозиторий, но есть локально (Library, Debug, Temp, obj и прочие).+ файлы, которые генерирует Rider и VS.
- В итоге все смешалось. Что принадлежит проекту, что unity, что утилитам... Различить становится сложно
Сталкивались с таким? В общем это ведет к тому что работать с этим становится все сложнее и сложнее со временем.
Как этого избежать?
У себя на проектах я делаю такую иерархию:
+-- %project_name%
| +-- .git
| +-- %project_name%.Build
| +-- %project_name%.ExternalConfigs
| +-- %project_name%.ThirdParty
| +-- %project_name%.Keystore
| +-- %project_name%.Backend
| +-- %project_name%.Kubernetes
| +-- %project_name%.Unity
| | + -- Assets
| | | +-- _Project
Плюсы:
- Unity проект отображается в Unity Hub адекватно
- Внешние и внутренние файлы разделены
- Декларативность == низкая когнитивная сложность
Минусы:Как по мне их нет, но если вы нашли, 👨💻 в комментарии
Папка _Project
Поскольку нет единого гайдлайна для именования и структуры папок, то со временем, папка Assets тоже превращается в помойку. И любой импортированный в проект пакет ассетов может наследить в иерархии так, как ему вздумается, создавая сколько угодно своих папок в корне.
Со временем когнитивная сложность растет и поиск нужной папки отнимает все больше и больше времени.
Отдельная папка выделяет место чисто под файлы вашего проекта. Вы можете выстроить свою иерархию, в которую ни один из импортируемых ассетов не влезет.
Внутри _Project делаю такую иерархию:
+-- _Project
| +-- Art
| +-- Develop
| +-- Plugins
| +-- Resources
| +-- Scenes
| +-- прочие папки
Плюсы:
- Быстрая адаптация новых людей
- Легкая масштабируемость
- Поскольку наши файлы и файлы импортируемых ассетов лежат отдельно, обновление последних происходит без головняка и без риска задеть наши файлы
Минусы:
- Глубокая иерархия - увеличивает когнитивную нагрузку
- Требуется порядок и правила создания новых папок - чтобы со временем не стало помойки
- Некоторые папки типа StreamingAssets нельзя скопировать к себе в _Project
Сцены
Первая проблема, которую я сразу решаю на новом проекте — 4 сцены для управления состоянием проекта.
- 0.Bootstrap — точка входа и контекст проекта (аналитика, SDK сторов, платежка, конфиги, связь с сервером)
- 1.Loading — загрузка, авторизация, GDPR, проверка обновлений и прочее что нужно для старта игры.
- 2.Meta — в основном UI в котором вы тратите накопленные ресурсы на прогресс по игре
- 3.Core — основная реиграбельная часть
- 4.Empty — костыль сцена для 100% выгрузки всех ресурсов предыдущей сцены.
Из любой сцены мы можем перейти в любую. Исключение Bootstrap. В нее входим только при запуске игры. И из нее загружаем только Loading
DI
DI (Dependency Injection) — подход, который абстрагирует создание объекта, выделяя его на отдельный слой и отделяя от основного приложения.
Добавляет концепцию управления зоной использования (scope) и временем жизни объекта.
При том важно разделять:
Composition Root (CR) — паттерн, подход при котором создание объектов системы происходит в одном месте.
Выделяет 3 (RRR) фазы управления жизненным циклом объектов:
- Register — создание зависимостей
- Resolve — решение графа зависимостей
- Release — удаление, освобождение объекта
DI container (DIc 🍆) — как правило плагин, реализация-автоматизация CR.
Т.е. вместо ручного создания и решения графа зависимостей, он через рефлексию или кодогеном сам прокидывает зависимости в конструктор или поля.
Service Locator (SL) — создает, хранит и отдает по требованию любое множество зависимостей.
Может быть частью реализации CR, но тогда решать граф нужно самому.
В этом месте, при не понимании RRR, возникает куча проблем (кольцевые зависимости, классы боги) из-за чего SL называют антипаттерном.
Из этого следует:
- Реализовать DI можно без использования DIc
- DIc (Zenject, Vcontainer и др.) сильно расширяют жизненный цикл любого класса
- С DIс мы делегируем обязанность по созданию и управлению жизненным циклом объекта!
- Легковестной реализацией DI может быть SL (статический класс со словарем)
При использовании DIc или SL важно навсегда запомнить:
- Никакой логики в конструкторе. Только агрегация и композиция
- Вся логика инициализации, как отдельная стадия создания объекта, выделяется в отдельный метод
В случае несоблюдения мы рискуем обратиться к зависимости раньше, чем был решен граф, что сразу == куча головняка с порядком регистрации зависимостей.
Точка входа
Точка входа — LUCA проекта, место откуда все начинается.
А ее первостепенная и единственная задача — прозрачная инициализация проекта/сцены.
Критерии прозрачности:
- Можно легко найти в проекте/сценеОбъект всегда находится вверху иерархии сценыНа сцене объект всегда называется EntryPoint
- Сквозное единое именованиеПостфикс Scope — для RRR фазыПостфикс Flow — для фазы инициализацииПример: BattleScope, BattleFlow, MetaScope, MetaFlow
- Единый дизайн классов
- Каждый Scope наследуется от класса, предоставляющий функционал IoC контейнера LifetimeScope, MonoInstaller
- Каждый Flow начинается с метода Start, в котором происходит вся инициализация
Т.е. мы сначала создаем объекты, inject'им их в Flow, ждем вызова Start и согласно нашему порядку инициализируем.
Из интересного:
- Поскольку решение графа и инициализация разделены, то начало работы класса может быть отложено на сколько угодно по времени.
- Инициализация в Start освобождает от необходимости помнить что такое ExecutionOrderТак же защищает от рисков обратиться к внешней зависимости (плагину), которая еще не проинициализирована.
- Можно легко включать, отключать части игры, занося блоки инициализации под define
- Правило "1 точка входа = 1 сцена" избавляет от racing condition при инициализации
- Для инициализации монобехов пишется отдельный метод Init
- Start и Awake не реализуются из-за racing conditionИногда удобно прописать вызов Init() в Start(), чтобы запустить объект на другой сцене, вне основной системы
- Легко сделать примитивный перезапуск игры. Достаточно ещё раз вызвать метод Start
Define'ы проекта
В большинстве случаев, делай как удобно и норм.
Но со временем у меня сформировалось 3 подхода к добавление и использованию define’ов в проекте:
- Восходящий — define’ы и их кол-во определяется фичами, которые используются в проекте. Как бы идем снизу, от фичей.Начинаются с ENABLE или DISABLE и отключают какой-то функционал, например: DISABLE_CHEATS, DISABLE_LOGS, ENABLE_SERVER_CONFIGS и т.д.
- Нисходящий — define’ы и их кол-во определяется потребностями проекта.Начинаются с имени компании SILVERFOX, ROGAIKOPbITA и отключают/включают целую группу функций, которые должны быть доступны на том или ином окружение.
- Комбинированный — когда включение одного define’а, включает/отключает несколько фичей. Требует конфигурации глобальных предиктив (те что действуют на весь проект).К сожалению поддерживается только для проектов, где *.csproj файлы задаются пользователем. В unity эти файлы генерирует только IDE, самому unity они не нужны, т.к. файлы ищутся по всему проекту и отдаются на сборку через csc.exe.
Например вот так в своей студии я организовывал define’ы:
- SILVERFOX_RC — отключены читы, отключен fake iap магазин, отключены валидации данных, отключены все логи ниже warning, включена консоль с логами
- SILVERFOX_PROD — отключены все фичи отладки и разработки
- В случае если ни одна из предиктив не указана, проект собирается со всеми включенными отладками, читами, логами. В общем работает по максимуму на разработчика и поиск проблемы
Плюсы:
- Кол-во define’ов полностью синхронизировано с кол-вом веток и их назначением
- Есть четкое понимание когда какой define нужно использовать
- Нет проблемы с двойным отрицанием по типу !DISABLE_CHEATS
Минусы:
Как по мне их нет, но если вы нашли, 👨💻 в комментарии
Структура сборок
Сборки (Assembly definition, asmdef) — unity обертка над сборками в .NET. По факту это json в котором прописана конфигурация .NET Assembly.
Это делает сборки неотъемлемой частью архитектуры, потому что каждую сборку можно версионировать, проставлять в нее зависимости, определять отдельные define’ы.
Так же по умолчанию она поддерживает проверку на циклические зависимости.
У себя на проектах я выделяю такие сборки:
- Runtime — клиентский код
- Editor — утилиты редактора
- Tests.PlayMode — интеграционные тесты.Т.е. тесты, для которых требуется unity scripts lifecycle
- Tests.EditorMode — unit тесты
Для онлайн приложений так же выделяю:
- Transport — cпецифичная для клиента связь с сервером. Реализация подключения, обмена данными, переключение состояний
- Shared — общие с бэком модели данных, константы, конфиги и прочее.Т.е. эти сборки является моделью домена и явно отделяют отображение unity (слой Presentation) от источника данных (слой Data Source)
Выделение этих частей позволяет mock'ать клиентскую часть и запускать клиент без unity т.е. должны стоять галки No Engine References
Из интересного:
- В define constrains поддерживает отрицание. Т.е. можно выключить всю сборку для PROD define’ов
- MonoBehavior нельзя добавить как компонент на объект если в Platforms отмечен только EditorЧтобы это обойти, поставьте галку на любую платформу, под которую 100% проект никогда не будет собран (Stadia например)
- Для удобства редактирования и отслеживания изменений в git, уберите галку Use GUIDs
- csc.rsp можно положить рядом с asmdef и прописать туда дополнительные параметры компиляцииУдобно когда нужно подключить большой список define’ов или отключить надоедливые warning’и
- Можно делать точеные оптимизации или патчи IL кода через Mono.Cecil или Harmony. Так раньше был сделан Inject в VContainer
- Если сборка не изменилась, то перекомпилироваться она не будет. Таким образом, разбивая крупные сборки на более мелкие, можно снизить время компиляции проекта
Инициализация
Для правильной инициализации нужно одно:
❕Честная загрузка приложения без фейковых ожиданий и шкал.
Это меняет архитектуру объектов, так как загрузка должна быть асинхронной и последовательной.Звучит не сложно, но часто проект сталкивается с проблемами из-за невнимания к этому:
- Инициализация происходит слишком раноУ меня раньше было искажение что инициализация должна происходить до того как загрузится сцена. Такое решение может привести к неочевидным проблемам.
- Инициализация детерминированаКогда RRR и инициализация разделяются, но вызовы методов не упорядоченыИли упорядочены не прозрачно, с завязкой на порядок регистрации или BindExecutionOrder
- Логика инициализации выполняется раньше решения графаЧастая ошибка - писать весь код инициализации в Start или Awake, смешивая загрузку и инициализацию ресурсов.Так можно столкнуться с ситуацией, когда ресурс А зависит от ресурса Б, который еще не готов.
Чтобы этих проблем не было, нужно:
- Разделить фазу RRR и инициализации
- Выделить конcтруктор и метод Init(-ialize)
- Сделать сервис загрузки
- Реализовать все Initialize методы
- Запустить инициализацию, когда все компоненты движка 100% загружены
Тут лежит пример как это может быть реализовано на практике.
Это реальное тестовое задание, которое я делал пару месяцев назад меня с ним взяли в компанию, где я сейчас работаю.
Так же пример точки инициализации с одного из реальных проектов. Для понимания как это выглядит в продакшене.
Unity Empty Project Template (UEPT)
По всем описанным выше пунктам я решил сделать шаблон пустого проекта, в котором реализованы все описанные в данной статье пункты.
В разделе Installation вы сможете найти инструкции по настройке.
Пока там 10 пунктов, все из которых нужно делать ручками.
Использовать unitypackage не получилось т.к. он игнорирует пустые папки при импорте 😵💫
Заключение
Все перечисленные блоки носят лишь рекомендательный характер. Скорее всего на реальных проектах вы встретите лишь часть от описанного выше. А это значит, что есть что добавить в бэклог и сделать взаимодействие с проектом чуточку лучше.
На этом все, но если хочется почитать обо мне, моем пути в IT, опыте создания своей игровой студии, буду рад вашему присоединению в мой блог в Telegram.