Hero background image
Last updated May 2019, 10 min. read

Как проектировать архитектуру по мере роста масштабов проекта

Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

Что вы получите с этой страницы: Эффективные стратегии для архитектуры кода растущего проекта, чтобы он масштабировался аккуратно и с меньшими проблемами. По мере развития вашего проекта приходится регулярно перерабатывать и улучшать его структуру. Всегда полезно сделать шаг назад от изменений, которые вы вносите, разбить их на более мелкие элементы, чтобы упорядочить их, а затем снова собрать все вместе.

Статья написана Микаэлем Калмсом, техническим директором шведской игровой студии Fall Damage. Микаэл более 20 лет занимается разработкой и выпуском игр. После всего этого времени он по-прежнему остро интересуется тем, как архитектурировать код, чтобы проекты могли расти безопасно и эффективно.

Как проектировать архитектуру по мере роста масштабов проекта

От простого к сложному

Давайте посмотрим на некоторые примеры кода из очень простой игры в стиле Pong, которую моя команда сделала для моего Unite Berlin talk. Как вы можете видеть на изображении выше, есть две ракетки и четыре стены – сверху и снизу, слева и справа – немного игровой логики и интерфейс счета. На ракетке, а также на стенах есть простой скрипт.

Этот пример построен на нескольких ключевых принципах:

  • Один «предмет» = один префаб
  • Логика для одного «предмета» = один класс MonoBehavior
  • Приложение = сцена со взаимосвязанными префабами

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

Как проектировать архитектуру по мере роста масштабов проекта_Параметры компонентов

Экземпляры, префабы и объекты ScriptableObject

Во-первых, давайте проясним путаницу относительно различий между экземплярами, Prefabs и ScriptableObjects. Выше представлен компонент ракетки на игровом объекте ракетки игрока 1, просмотренный в инспекторе:

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

Имеет ли смысл изменять Input Axis на левой ракетке, изменяя его на экземпляре, или мне следует делать это в Prefab? Допустим, что ось ввода у игроков должна быть разной, поэтому параметр нужно менять в экземпляре. Что насчет Movement Speed Scale? Это то, что я должен изменить на экземпляре или на Prefab?

Давайте посмотрим на код, который представляет компонент ракетки.

Как проектировать архитектуру по мере роста масштабов проекта_Параметры в коде

Параметры в примере простого кода

Если мы остановимся и подумаем немного, мы поймем, что разные параметры используются по-разному в нашей программе. Мы должны изменить InputAxisName индивидуально для каждого игрока: Факторы масштабирования скорости движения и позиции должны быть общими для обоих игроков. Вот стратегия, которая может помочь вам определить, когда использовать экземпляры, префабы и ScriptableObjects:

  • Вам нужно что-то только один раз? Создайте префаб, затем инстанцируйте его.
  • Вам нужно что-то несколько раз, возможно, с некоторыми изменениями, специфичными для экземпляра? Тогда вы можете создать префаб, инстанцировать его и переопределить некоторые настройки.
  • Хотите ли вы обеспечить одинаковую настройку для нескольких экземпляров? Тогда создайте ScriptableObject и используйте данные оттуда.

Смотрите, как мы используем ScriptableObjects с нашим компонентом Paddle в следующем примере кода.

Как проектировать архитектуру по мере роста масштабов проекта_с помощью объектов ScriptableObject

Использование ScriptableObject

Поскольку мы переместили эти настройки в ScriptableObject типа PaddleData, у нас просто есть ссылка на этот PaddleData в нашем компоненте Paddle. И в итоге мы видим в инспекторе два элемента: PaddleData и два экземпляра Paddle. Вы по-прежнему можете изменить ось и пакет общих настроек, на который будет ссылаться каждая отдельная ракетка. Новая структура позволяет вам легче увидеть намерение за различными настройками.

Как проектировать архитектуру по мере роста масштабов проекта_принцип одиночной ответственности

Разделение больших классов MonoBehavior

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

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

Мы повторно используем одну и ту же переменную для двух немного разных целей. Как только мяч начинает двигаться, информация о начальной скорости теряется.

Физическая модель не только определяет движение через метод FixedUpdate(); она также включает обработку столкновений со стеной.

Глубоко внутри обратного вызова OnTriggerEnter() находится операция Destroy(). Она отвечает за уничтожение собственного объекта GameObject. В больших кодовых базах редко разрешают сущностям удалять себя; тенденция заключается в том, чтобы владельцы удаляли вещи, которые они владеют.

Здесь есть возможность разбить вещи на более мелкие части. В этих классах есть несколько различных типов ответственности – игровая логика, обработка ввода, физические симуляции, презентации и многое другое.

Вот способы такого разбиения:

  • Общая игровая логика, обработка ввода, моделирование физики и представление данных могут быть назначены MonoBehavior, объектам ScriptableObject или базовым классам C#.
  • Если нужно сделать параметры доступными в Inspector, то можно использовать MonoBehavior или ScriptableObject.
  • Обработчики событий движка или управление существованием объекта GameObject обязательно должны находиться в MonoBehavior.

Я думаю, что для многих игр стоит извлечь как можно больше кода из MonoBehaviors. Один из способов сделать это – использовать ScriptableObjects, и уже есть несколько отличных ресурсов по этому методу.

Как проектировать архитектуру по мере роста масштабов проекта_переход с monobehavior на классы C#

От MonoBehavior к обычным классам C#

Перемещение MonoBehaviors в обычные классы C# – это еще один метод, который стоит рассмотреть, но каковы преимущества этого?

Обычные классы C# имеют лучшие языковые возможности, чем собственные объекты Unity, для разбивки кода на небольшие, составные части. И обычный код C# можно использовать с нативными кодовыми базами .NET вне Unity.

С другой стороны, если использовать обычные классы C#, то редактор не воспримет объекты, не сможет нативно отобразить их в инспекторе и так далее.

С помощью этого метода вы хотите разделить логику по типу ответственности. Если взглянуть на наш пример, то мы вынесли простую физическую модель в класс C#, который назвали BallSimulation. Единственная задача, которую он должен выполнять, – это интеграция физики и реакция, когда мяч что-то касается.

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

Если мы посмотрим на реорганизованную версию выше, одно значительное изменение, которое мы видим, заключается в том, что операция Destroy() больше не скрыта на многих уровнях. На данный момент в MoneBehavior осталось всего несколько четких областей ответственности.

Есть еще много вещей, которые мы можем сделать с этим. Если взглянуть на алгоритм обновления положения в FixedUpdate(), то станет понятно, что он должен отправить информацию о положении и затем вернуть новое положение. Симуляция мяча на самом деле не владеет местоположением мяча; она выполняет симуляцию на основе местоположения мяча, которое предоставляется, а затем возвращает результат.

Как проектировать архитектуру по мере роста масштабов проекта_с помощью интерфейсов

Пользовательский интерфейс

Если мы используем интерфейсы, то, возможно, мы сможем поделиться частью этого MonoBehavior мяча с симуляцией, только теми частями, которые ей нужны (см. изображение выше).

Давайте снова посмотрим на код. Класс Ball создает простой интерфейс. Класс LocalPositionAdapter делает возможным передать ссылку на объект Ball другому классу. Мы не передаем весь объект Ball, только аспект LocalPositionAdapter.

BallLogic также должен сообщить Ball, когда пришло время уничтожить GameObject. Вместо того чтобы вернуть флаг, Ball может делегировать его BallLogic. Именно за это и отвечает последняя выделенная строка в переработанном коде. Это дает нам аккуратный дизайн: много шаблонной логики, но каждый класс имеет узко определенную цель.

Используя эти принципы, можно легко сохранять аккуратность структуры проекта даже силами одного человека.

Как проектировать архитектуру по мере роста масштабов проекта_архитектура ПО

Архитектура ПО

Давайте рассмотрим решения архитектуры программного обеспечения для немного больших проектов. Если использовать пример игры с мячом, как только мы начнем вводить более специфические классы в код – BallLogic, BallSimulation и т.д. – мы должны быть в состоянии построить иерархию:

MonoBehaviours должны знать обо всем остальном, потому что они просто оборачивают всю эту другую логику, но игровые элементы симуляции не обязательно должны знать, как работает логика. Они должны просто выполнять свою задачу. Иногда логика подает сигналы в симуляцию, и симуляция реагирует соответственно.

Полезно обрабатывать ввод в отдельном, самодостаточном месте. Здесь будет происходить генерация событий ввода и их передача логическим алгоритмам. Что бы ни происходило дальше, это зависит от симуляции.

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

Логика и представление

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

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

Исключительно информационные и вспомогательные классы

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

Кроме того, бывают полезны классы, которые не имеют никаких данных, но содержат функции, цель которых — выполнять операции с предоставляемыми им объектами.

Статические методы

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

Этот подход касается области функционального программирования. Базовая единица здесь такова: вы отправляете данные функции, а функция возвращает результат или меняет один из внешних параметров. Попробуйте этот подход; вы можете обнаружить, что у вас будет меньше ошибок по сравнению с классическим объектно-ориентированным программированием.

Разделение объектов

Вы также можете разъединить объекты, вставив между ними связующую логику. Вернемся к примеру с пинг-понгом: как взаимодействуют логика шарика и система представления счета? Передает ли логика шарика системе представления счета информацию в случае, когда с шариком что-то происходит? Опрашивает ли логика счета логику шарика? Им нужно будет как-то общаться друг с другом.

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

Хороший способ отделить логику от презентации по мере роста вашей игры - это использовать шину сообщений. Ключевой принцип заключается в том, что ни отправитель, ни получатель не знают о существовании друг друга, но оба знают о существовании системы или шины сообщений. Система представления должна получать от шины сообщения о событиях, меняющих счет. Игровая логика будет отправлять в систему сообщений события, меняющие счет игрока. Хорошее место для начала, если вы хотите отделить системы, - это использовать UnityEvents - или написать свои; тогда у вас могут быть отдельные шины для отдельных целей.

Загрузка сцен

Перестаньте использовать LoadSceneMode.Single и вместо этого используйте LoadSceneMode.Additive.

Используйте явные выгрузки, когда хотите выгрузить сцену - рано или поздно вам нужно будет сохранить несколько объектов во время перехода между сценами.

Также перестаньте использовать DontDestroyOnLoad. Он лишает вас контроля над временем жизни объекта. Кстати, если загружать объекты с помощью LoadSceneMode.Additive, то вам вообще не понадобится DontDestroyOnLoad — Поместите ваши долгоживущие объекты в специальную долгоживущую сцену.

Чистое и управляемое завершение работы

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

Сделайте ваше приложение способным освобождать практически все ресурсы перед выходом из приложения. Если возможно, глобальные переменные не должны быть назначены, и никакие GameObjects не должны быть помечены как DontDestroyOnLoad.

Когда у вас есть определенный порядок для того, как вы завершаете работу, вам будет легче обнаруживать ошибки и находить утечки ресурсов. Кроме того, это позволит редактору избавляться от лишнего мусора при выходе из режима игры. Unity не выполняет полной перезагрузки домена при выходе из режима Play. Если у вас есть чистое завершение работы, то менее вероятно, что редактор или какой-либо вид скриптового режима редактирования будут показывать странное поведение после того, как вы запустили свою игру в редакторе.

Улучшение качества слияния файлов сцен

Вы можете сделать это, используя систему контроля версий, такую как Git, Perforce или Plastic. Храните все ассеты в виде текста, выводите объекты из сцены путем преобразования их в префабы. Наконец, разделите файлы сцен на несколько меньших сцен, но будьте готовы к тому, что это может потребовать дополнительного инструмента.

Автоматизация процессов тестирования кода

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

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

Начните с написания тестов для вашего кода. Особенно стоит отметить, что, выводя код из классов MonoBehaviour в регулярные классы, вы упрощаете себе возможность использования платформы юнит-тестирования для разработки тестов как логики, так и симуляции. Это не имеет смысла везде, но это делает ваш код доступным для других программистов позже.

Автоматизация процессов тестирования контента

Тестирование - это не только тестирование кода. Тестировать нужно и контент. Если у вас есть создатели контента в вашей команде, вам всем будет лучше, если у них будет стандартизированный способ быстро проверять контент, который они создают.

Логика тестирования - например, проверка Prefab или проверка данных, которые они ввели через пользовательский редактор - должна быть легко доступна создателям контента. Если они могут просто нажать кнопку в редакторе и получить быструю проверку, они вскоре научатся ценить, что это экономит им время.

Следующий шаг после этого - настроить Unity Test Runner, чтобы вы могли автоматически повторно тестировать вещи на регулярной основе. Мы рекомендуем настраивать ее как часть системы сборки, чтобы она также проводила все ваши тесты. Хорошей практикой является настройка уведомлений, чтобы, когда возникает проблема, ваши коллеги получали уведомление в Slack или по электронной почте.

Создание автоматизированных алгоритмов прохождения

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

В нашем случае мы настроили около 10 игровых клиентов на одном компьютере с минимальными настройками детализации и позволили всем им работать. Мы наблюдали за вылетами, а затем изучали логи. Каждое падение клиента избавляло нас от необходимости тратить время на самостоятельное прохождение игры или привлечение тестеров к поиску багов. Это означало, что когда мы действительно тестировали игру сами и с другими игроками, мы могли сосредоточиться на том, интересна ли игра, где находятся визуальные глюки и так далее.

Как проектировать архитектуру по мере роста масштабов проекта | Минимизация технических недоработок | Unity