Pull to refresh

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

Level of difficultyMedium
Reading time9 min
Views19K
Мнение 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, присоединяйтесь!

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

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

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

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

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

  • Именование файлов и папок, структура хранения ассетов, структура сборок

  • Утилиты для разработки, тестирование, работа с системой контроля версии

  • Билд проекта и его деплой, работа со сторами и выливка хотфиксов

  • Мониторинг метрик, ошибок и реагирование на них

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

Папка проекта

Перед тем как написать первую строчку кода, нужно решить очень важную проблему — быстрое захламление иерархии проекта. Чтобы последующие 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

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

  1. Никакой логики в конструкторе. Только агрегация и композиция

  2. Вся логика инициализации, как отдельная стадия создания объекта, выделяется в отдельный метод

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

Точка входа

Точка входа — 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’ов в проекте:

  1. Восходящий — define’ы и их кол-во определяется фичами, которые используются в проекте. Как бы идем снизу, от фичей.
    Начинаются с ENABLE или DISABLE и отключают какой-то функционал, например: DISABLE_CHEATS, DISABLE_LOGS, ENABLE_SERVER_CONFIGS и т.д.

  2. Нисходящий — define’ы и их кол-во определяется потребностями проекта.
    Начинаются с имени компании SILVERFOX, ROGAIKOPbITA и отключают/включают целую группу функций, которые должны быть доступны на том или ином окружение.

  3. Комбинированный — когда включение одного 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.

Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments18

Articles