За всю карьеру удалось поработать на множестве игровых проектов в качестве Lead Unity Developer. И за более чем 5 лет удалось насмотреться на разные подходы к созданию проектов.
В данной статье я собрал выжимку статей и мыслей из своего блога о подходах, которые я использую в своих проектах. А так же поделился шаблоном пустого проекта, в котором отражена
Просто можете почитать 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 проект отображается в Unity Hub адекватно Внешние и внутренние файлы разделены Декларативность == низкая когнитивная сложность Минусы: Поскольку нет единого гайдлайна для именования и структуры папок, то со временем, папка Assets тоже превращается в помойку. И любой импортированный в проект пакет ассетов может наследить в иерархии так, как ему вздумается, создавая сколько угодно своих папок в корне. Со временем когнитивная сложность растет и поиск нужной папки отнимает все больше и больше времени. Отдельная папка выделяет место чисто под файлы вашего проекта. Вы можете выстроить свою иерархию, в которую ни один из импортируемых ассетов не влезет. Внутри Первая проблема, которую я сразу решаю на новом проекте — 4 сцены для управления состоянием проекта. Из любой сцены мы можем перейти в любую. Исключение 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 проекта, место откуда все начинается. А ее первостепенная и единственная задача — прозрачная инициализация проекта/сцены. Критерии прозрачности: Можно легко найти в проекте/сцене Объект всегда находится вверху иерархии сцены На сцене объект всегда называется Сквозное единое именование Постфикс Scope — для RRR фазы Постфикс Flow — для фазы инициализации Пример: BattleScope, BattleFlow, MetaScope, MetaFlow Единый дизайн классов Каждый Scope наследуется от класса, предоставляющий функционал IoC контейнера LifetimeScope, MonoInstaller Каждый Flow начинается с метода Start, в котором происходит вся инициализация Т.е. мы сначала создаем объекты, inject’им их в Flow, ждем вызова Start и согласно нашему порядку инициализируем. Из интересного: Поскольку решение графа и инициализация разделены, то начало работы класса может быть отложено на сколько угодно по времени. Инициализация в Start освобождает от необходимости помнить что такое Можно легко включать, отключать части игры, занося блоки инициализации под define Правило «1 точка входа = 1 сцена» избавляет от racing condition при инициализации Для инициализации монобехов пишется отдельный метод Init Start и Awake не реализуются из-за racing condition Легко сделать примитивный перезапуск игры. Достаточно ещё раз вызвать метод Start В большинстве случаев, делай как удобно и норм. Но со временем у меня сформировалось 3 подхода к добавление и использованию define’ов в проекте: Восходящий — define’ы и их кол-во определяется фичами, которые используются в проекте. Как бы идем снизу, от фичей. Нисходящий — define’ы и их кол-во определяется потребностями проекта. Комбинированный — когда включение одного define’а, включает/отключает несколько фичей. Требует конфигурации глобальных предиктив (те что действуют на весь проект). Например вот так в своей студии я организовывал define’ы: В случае если ни одна из предиктив не указана, проект собирается со всеми включенными отладками, читами, логами. В общем работает по максимуму на разработчика и поиск проблемы Плюсы: Кол-во define’ов полностью синхронизировано с кол-вом веток и их назначением Есть четкое понимание когда какой define нужно использовать Нет проблемы с двойным отрицанием по типу !DISABLE_CHEATS Минусы: Как по мне их нет, но если вы нашли, ?? в комментарии Сборки (Assembly definition, asmdef) — unity обертка над сборками в .NET. По факту это json в котором прописана конфигурация .NET Assembly. Это делает сборки неотъемлемой частью архитектуры, потому что каждую сборку можно версионировать, проставлять в нее зависимости, определять отдельные define’ы. Так же по умолчанию она поддерживает проверку на циклические зависимости. У себя на проектах я выделяю такие сборки: Т.е. тесты, для которых требуется unity scripts lifecycle Для онлайн приложений так же выделяю: Т.е. эти сборки является моделью домена и явно отделяют отображение unity (слой Presentation) от источника данных (слой Data Source) Выделение этих частей позволяет mock‘ать клиентскую часть и запускать клиент без unity т.е. должны стоять галки No Engine References Из интересного: В define constrains поддерживает отрицание. Т.е. можно выключить всю сборку для PROD define’ов Для удобства редактирования и отслеживания изменений в git, уберите галку Use GUIDs Можно делать точеные оптимизации или патчи IL кода через Mono.Cecil или Harmony. Так раньше был сделан Inject в VContainer Если сборка не изменилась, то перекомпилироваться она не будет. Таким образом, разбивая крупные сборки на более мелкие, можно снизить время компиляции проекта Для правильной инициализации нужно одно: ❕Честная загрузка приложения без фейковых ожиданий и шкал. Это меняет архитектуру объектов, так как загрузка должна быть асинхронной и последовательной. Звучит не сложно, но часто проект сталкивается с проблемами из-за невнимания к этому: Инициализация происходит слишком рано Инициализация детерминирована Логика инициализации выполняется раньше решения графа Так можно столкнуться с ситуацией, когда ресурс А зависит от ресурса Б, который еще не готов. Чтобы этих проблем не было, нужно: Разделить фазу RRR и инициализации Выделить конcтруктор и метод Init(-ialize) Сделать сервис загрузки Реализовать все Initialize методы Запустить инициализацию, когда все компоненты движка 100% загружены Тут лежит пример как это может быть реализовано на практике. Это реальное тестовое задание, которое я делал пару месяцев назад меня с ним взяли в компанию, где я сейчас работаю. Так же пример точки инициализации с одного из реальных проектов. Для понимания как это выглядит в продакшене. По всем описанным выше пунктам я решил сделать шаблон пустого проекта, в котором реализованы все описанные в данной статье пункты. В разделе Installation вы сможете найти инструкции по настройке. Пока там 10 пунктов, все из которых нужно делать ручками. Использовать unitypackage не получилось т.к. он игнорирует пустые папки при импорте ?? Все перечисленные блоки носят лишь рекомендательный характер. Скорее всего на реальных проектах вы встретите лишь часть от описанного выше. А это значит, что есть что добавить в бэклог и сделать взаимодействие с проектом чуточку лучше. На этом все, но если хочется почитать обо мне, моем пути в IT, опыте создания своей игровой студии, буду рад вашему присоединению в мой блог в Telegram.Об авторе
(А|а)рхитектура
Структура проекта

Папка проекта
+ 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
Как по мне их нет, но если вы нашли, ?? в комментарииПапка _Project
0.Bootstrap — точка входа и контекст проекта (аналитика, SDK сторов, платежка, конфиги, связь с сервером)1.Loading — загрузка, авторизация, GDPR, проверка обновлений и прочее что нужно для старта игры.2.Meta — в основном UI в котором вы тратите накопленные ресурсы на прогресс по игре3.Core — основная реиграбельная часть4.Empty — костыль сцена для 100% выгрузки всех ресурсов предыдущей сцены.Bootstrap. В нее входим только при запуске игры. И из нее загружаем только LoadingDI

Точка входа

EntryPoint
ExecutionOrderТак же защищает от рисков обратиться к внешней зависимости (плагину), которая еще не проинициализирована.
Иногда удобно прописать вызов Init() в Start(), чтобы запустить объект на другой сцене, вне основной системыDefine’ы проекта
Начинаются с ENABLE или DISABLE и отключают какой-то функционал, например: DISABLE_CHEATS, DISABLE_LOGS, ENABLE_SERVER_CONFIGS и т.д.
Начинаются с имени компании SILVERFOX, ROGAIKOPbITA и отключают/включают целую группу функций, которые должны быть доступны на том или ином окружение.
К сожалению поддерживается только для проектов, где *.csproj файлы задаются пользователем. В unity эти файлы генерирует только IDE, самому unity они не нужны, т.к. файлы ищутся по всему проекту и отдаются на сборку через csc.exe.
SILVERFOX_RC — отключены читы, отключен fake iap магазин, отключены валидации данных, отключены все логи ниже warning, включена консоль с логамиSILVERFOX_PROD — отключены все фичи отладки и разработки
Структура сборок

Runtime — клиентский кодEditor — утилиты редактораTests.PlayMode — интеграционные тесты.
Tests.EditorMode — unit тесты
Transport — cпецифичная для клиента связь с сервером. Реализация подключения, обмена данными, переключение состоянийShared — общие с бэком модели данных, константы, конфиги и прочее.
MonoBehavior нельзя добавить как компонент на объект если в Platforms отмечен только Editor
Чтобы это обойти, поставьте галку на любую платформу, под которую 100% проект никогда не будет собран (Stadia например)csc.rsp можно положить рядом с asmdef и прописать туда дополнительные параметры компиляции
Удобно когда нужно подключить большой список define’ов или отключить надоедливые warning’иИнициализация
У меня раньше было искажение что инициализация должна происходить до того как загрузится сцена. Такое решение может привести к неочевидным проблемам.
Когда RRR и инициализация разделяются, но вызовы методов не упорядочены
Или упорядочены не прозрачно, с завязкой на порядок регистрации или BindExecutionOrder
Частая ошибка — писать весь код инициализации в Start или Awake, смешивая загрузку и инициализацию ресурсов.
Unity Empty Project Template (UEPT)
Заключение
ссылка на оригинал статьи https://habr.com/ru/articles/769044/
Добавить комментарий