За всю карьеру удалось поработать на множестве игровых проектов в качестве 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.