Архитектура unity проектов

Мнение Midjourney на тему "Архитектура unity проектов"
Мнение Midjourney на тему "Архитектура 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, присоединяйтесь!

(А|а)рхитектура

Общество движется вперед путем увеличения числа операций, которые может осуществлять, не раздумывая над ними.

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

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

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

  • Именование файлов и папок, структура хранения ассетов, структура сборок
  • Утилиты для разработки, тестирование, работа с системой контроля версии
  • Билд проекта и его деплой, работа со сторами и выливка хотфиксов
  • Мониторинг метрик, ошибок и реагирование на них

Структура проекта

Архитектура 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

Сцены

Архитектура unity проектов

Первая проблема, которую я сразу решаю на новом проекте — 4 сцены для управления состоянием проекта.

  • 0.Bootstrap — точка входа и контекст проекта (аналитика, SDK сторов, платежка, конфиги, связь с сервером)
  • 1.Loading — загрузка, авторизация, GDPR, проверка обновлений и прочее что нужно для старта игры.
  • 2.Meta — в основном UI в котором вы тратите накопленные ресурсы на прогресс по игре
  • 3.Core — основная реиграбельная часть
  • 4.Empty — костыль сцена для 100% выгрузки всех ресурсов предыдущей сцены.

Из любой сцены мы можем перейти в любую. Исключение Bootstrap. В нее входим только при запуске игры. И из нее загружаем только Loading

DI

Картинка из книги Dependency Injection Principles, Practices, and Patterns by Steven van Deursen and Mark Seemann
Картинка из книги Dependency Injection Principles, Practices, and Patterns by Steven van Deursen and Mark Seemann

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 важно навсегда запомнить:

  • Никакой логики в конструкторе. Только агрегация и композиция
  • Вся логика инициализации, как отдельная стадия создания объекта, выделяется в отдельный метод

В случае несоблюдения мы рискуем обратиться к зависимости раньше, чем был решен граф, что сразу == куча головняка с порядком регистрации зависимостей.

Точка входа

Архитектура unity проектов

Точка входа — 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.

1313
27 комментариев

Команда Юнити на момент 3.5 версии была гениями. Можно обойтись без всего вышеперечисленного.

Даже без гита. У юнити отличное решение для слияния работы разных людей. Конечно нужны правила, но они нужны и с работой в Гите только вот гит переодически глючит. Сколько раз сталкивался в командах, сначала орут про гит, потом через неделю начинают друг другу слать проект целиком потому что "с шитом что то случилось".

Людям сделали оболочку что бы делать игры, но нет. Один добавляет чтение текстов из Экселя, другой пишет перевод картинок в уровень и левелдизайнит в Фотошопе, третий добавляет json для сохранений , как буд то фалы сохранения будет читать человек.

Открываешь cv, ёп твою мать, чего там только нет, какие то сторонние доттвины, и ещё хуева туча названий, а портфолио без слёз не взглянешь.

И вот очередная статья.

Коммерции это всё не нужно. Это нужно прогерам которым нехуй делать.

Так что ребята не бойтесь, учитесь делать продукты, игры, сделаете качественно без тормозов и глюков, рынок это оценит. Конечно оценят бизнесмены, а не Лиды у которых из преимущества только то что они пришли раньше.

В общем как то так.

1
1
Ответить

Про git не понял вообще о чем речь. Лучшего средства для синхронизации работы просто нет. Есть аналоги, но они тупо уступают по функциональности и распространенности.

Чтение текстов из excel (google sheets) - стандарт. Это самый удобный способ создавать/модифицировать/добавлять конфиги.
Перевод картинок в уровень - забавно, но может быть полезно для оптимизации создания большого объема контента. На практике не сталкивался
Файлы для сохранение читает человек - программист и команда поддержки, что помогает пользователям восстановить их прогресс или выдать компенсацию.

CV - да, потому что это используется повсеместно. Умеешь с этим работать? - не придется с тобой нянчится

Коммерции это все нужно, потому что в итоге это экономит время и нервы людей.

Ну и как ты хоть чему-то научишься без контента, кто тебе намек хотя бы даст как сделать без тормозов и глюков?
Моя статья не об этом, это скорее в целом на подумать.

Единственный пока кому нехуй делать - это я, кто отвечает на этот коммент в 2 ночи, хотя стоило просто проигнорировать.

Про лидов, не злись, работай и учись, когда-нибудь либо игра твоя взлетит, либо будешь руководить разработкой AAA игры.

4
Ответить

Большое спасибо за материал. Очень интересно и качественно, такого мало

1
Ответить

На хабре это Вы с этой статьей публиковались? Некоторое время назад

Ответить

Да, публиковался)
Просто решил попробовать эту платформу)

Ответить