В данной статье будет рассмотрен модульный подход при проектировании и дальнейшей реализации игры на движке Unity. Описаны основные плюсы, минусы и проблемы с которыми приходилось сталкиваться.
Под термином «Модульный подход» имеется ввиду организация ПО, которая использует внутри себя независимые, подключаемые, конечные сборки которые можно разрабатывать параллельно, менять на ходу и достигать разного поведения ПО в зависимости от конфигурации.
Структура модулей
Важно определить для начала что из себя представляет модуль, какая структура у него, какие части системы за что отвечают и как должны быть использованы.
Модуль является относительно независимой сборкой не зависящей от проекта. Имеет возможность быть использованным в совершенно разных проектах при правильном конфигурировании и наличии в проекте общего ядра. Обязательными условиями реализации модуля является наличие след. частей:
Infrastructure сборка
Данная сборка содержит в себе модели и контракты которые могут быть использованы другими сборками. Важно понимать что данная часть модуля обязана не иметь ссылок на реализации конкретных фич. В идеальном случае инфраструктура может ссылаться только на ядро проекта.
Структура сборки выглядит след. образом:
- Entities — сущности используемые внутри модуля.
- Messaging — модели запросов/сигналов. О них можно будет прочесть далее.
- Contracts — место для хранения интерфейсов.
Важно помнить о том, что рекомендовано минимизировать использование ссылок между инфраструктурными сборками.
Сборка с фичей
Конкретная реализация фичи. Может использовать внутри себя любой из архитектурных паттернов но с поправкой на то, что система обязана быть модульной.
Внутренняя архитектура может иметь следующий вид:
- Entities — сущности используемые внутри модуля.
- Installers — классы для регистрации контрактов для DI.
- Services — бизнес слой.
- Managers — задача менеджера вытащить нужные данные из сервисов, создать ViewEntity и вернуть ViewManager'у.
- ViewManagers — Получает ViewEntity от Manager, создает нужные View, пробрасывает необходимые данные.
- View — Отображает данные которые были переданы от ViewManager.
Реализация модульного подхода
Для реализации данного подхода может понадобиться не менее двух механизмов. Нужен подход разделения кода на сборки и DI фреймворк. В данном примере используются механизмы Assembly Definitions Files и Zenject.
Использование вышеупомянутых конкретных механизмов не является обязательным. Главное — понимать для чего они были использованы. Можно заменить Zenject любым DI фреймворком IoC контейнером или еще чем либо, а Assembly Definitions Files — любой другой системой позволяющей объединять код в сборки или же просто делать его независимым (К примеру можно использовать разные репозитории под разные модули, которые можно подключать как пекеджи, сабмодули гита или что либо другое).
Особенностью модульного подхода является то, что нет явных ссылок из сборки одной фичи на другую, за исключением ссылок на инфраструктурные сборки в которых могут храниться модели. Взаимодействие между модулями реализовано с помощью обертки над сигналами из фреймворка Zenject'a. Обертка позволяет отправлять сигналы и запросы на разные модули. Стоит отметить, что под сигналом имеется ввиду какое-либо уведомление текущим модулем других модулей, а под запросом — запрос на другой модуль который может возвращать данные.
Сигналы
Сигнал — механизм уведомления системы о каких-то изменениях. И проще всего их разобрать на практике.
Допустим у нас есть 2 модуля. Foo и Foo2. Модуль Foo2 должен среагировать на какое-то изменение модуля Foo. Что бы избавиться от зависимости модулей, реализуется 2 сигнала. Один сигнал внутри Foo модуля, который будет информировать систему о изменении состояния, а второй сигнал — внутри модуля Foo2. На этот сигнал будет реагировать Foo2 модуль. Маршрутизация сигнала OnFooSignal в OnFoo2Signal будет находиться в роутинг модуле.
Схематично это будет выглядеть так:
Запросы
Запросы позволяют решать проблемы связи получения/передачи данных одним модулем от другого (других).
Рассмотрим похожий пример, который был приведен выше для сигналов.
Предположим у нас есть 2 модуля. Foo и Foo2. Модулю Foo нужны какие-то данные от модуля Foo2. При этом модуль Foo ничего не должен знать о модуле Foo2. По сути это проблему можно было бы решить с помощью дополнительных сигналов, но решение с запросами выглядит проще и красивей.
Схематично будет выглядеть так:
Связь между модулями
Для того, что бы минимизировать ссылки между модулями с фичами (включая ссылки Infrastructure-Infrastructure) было принято решение написать обертку над сигналами, которые предоставлены фреймворком Zenject и создать модуль, задачей которого был бы роутинг разных сигналов и маппинг данных.
P.S. По сути у этого модуля есть ссылки на все Infrastructure сборки что не есть хорошо. Но эту проблему решить можно через IoC.
Пример взаимодействия модулей
Допустим есть два модуля. LoginModule и RewardModule. RewardModule должен дать награду пользователю после ФБ логина.
namespace RewardModule.src.Infrastructure.Messaging.Signals
{
public class OnLoginSignal : SignalBase
{
public bool IsFirstLogin { get; set; }
}
}
namespace RewardModule.src.Infrastructure.Messaging.RequestResponse.Produce
{
public class GainRewardRequest : EventBusRequest<ProduceResponse>
{
public bool IsFirstLogin { get; set; }
}
}
namespace MessagingModule.src.Feature.Proxy
{
public class LoginModuleProxy
{
[Inject]
private IEventBus eventBus;
public override async void Subscribe()
{
eventBus.Subscribe<OnLoginSignal>((loginSignal) =>
{
var request = new GainRewardRequest()
{
IsFirstLogin = loginSignal.IsFirstLogin;
}
var result = await eventBus.FireRequestAsync<GainRewardRequest, GainRewardResponse>(request);
var analyticsEvent = new OnAnalyticsShouldBeTracked()
{
AnalyticsPayload = new Dictionary<string, string>
{
{
"IsFirstLogin", "false"
},
},
};
eventBus.Fire<OnAnalyticsShouldBeTrackedSignal>(analyticsEvent);
});
В примере выше ссылок между модулями прямых нет. Но они связаны через MessagingModule. Очень важно помнить о том, что в маршрутизации не должно быть ничего, кроме как маршрутизация сигналов/запросов и их маппинг.
Подмена реализаций
Используя модульный подход и паттерн Feature toggle можно добиться удивительных результатов по влиянию на приложение. Имея определенную конфигурацию на сервере можно манипулировать включением-отключением разных модулей на старте приложения, их изменением во время игры.
Достигается это путем того, что во время биндинга модулей в Zenject(по сути в контейнер) проверяются флаги доступности модуля и исходя из этого модуль или биндится в контейнер или нет. Для того, что бы добиться изменения поведения во время игровой сессии (допустим нужно поменять механику во время игровой сессии. Есть модуль Солитер и модуль Косынки. И для 50 процентов юзеров должен работать модуль косынки) был разработан механизм который при переходе с одной сцены на другую очищал определенный контейнер модуля и биндил новые зависимости.
Работал по след. принципу: если фича была включена, а потом во время сессии были отключена — нужно было бы очистить контейнер. Если фича была включена — нужно внести в контейнер все изменения. Важно это делать на «пустой» сцене, что бы не нарушить целостность данных и связей. Удалось реализовать такое поведение, но как продакшн фича не рекомендуется использовать подобный функционал, потому как он влечет за собой больший риск что-либо сломать.
Ниже предоставлен псевдокод базового класса, наследники которого обязаны заниматься регистрацией чего-либо в контейнере.
public abstract class GlobalInstallerBase<TGlobalInstaller, TModuleInstaller> : MonoInstaller<TGlobalInstaller>
where TGlobalInstaller : MonoInstaller<TGlobalInstaller>
where TModuleInstaller : Installer
{
protected abstract string SubContainerName { get; }
protected abstract bool IsFeatureEnabled { get; }
public override void InstallBindings()
{
if (!IsFeatureEnabled)
{
return;
}
var subcontainer = Container.CreateSubContainer();
subcontainer.Install<TModuleInstaller>();
Container.Bind<DiContainer>()
.WithId(SubContainerName)
.FromInstance(subcontainer)
.AsCached();
}
protected virtual void SubContainerCleaner(DiContainer subContainer)
{
subContainer.UnbindAll();
}
protected virtual DiContainer SubContainerInstanceGetter(InjectContext containerContext)
{
return containerContext.Container.ResolveId<DiContainer>(SubContainerName);
}
}
Пример примитивного модуля
Рассмотрим на простом примере как может быть реализован модуль.
Допустим, необходимо реализовать модуль, который будет ограничивать движение камеры что бы пользователь не мог ее увести за «границу» экрана.
Модуль будет содержать сборку Infrastructure с сигналом, который будет уведомлять о том, что камера попыталась выйти за границу экрана систему.
Feature — реализация фичи. Здесь будет логика проверки того, вышла ли камера за допустимые пределы, уведомление других модулей об этом и т.д.
- BorderConfig — сущность, описывающая границы экрана.
- BorderViewEntity — сущность для передачи в ViewManager и View.
- BoundingBoxManager — получает BorderConfig от сервайса, создает BorderViewEntity.
- BoundingBoxViewManager — наследник MonoBehaviour'a. Может использоваться как компонент камеры, проверять ее позицию и ограничивать ее при движении.
- BoundingBoxView — компонент, который будет рисовать «границу» на экране пользователя.
Заключение
- Модульность не является панацеей. Требует более ответственного написания кода, определять что именно должно быть модулем, уметь все это разделять.
- При разработке модуля разработчик обязан придерживаться архитектуры и понимать как лучше настроить связи между сборками если это необходимо.
- При неверном использовании сигналов и реквестов через шину событий можно легко реализовать EventHell, система может стать более запутанной, чем при монолитном подходе.
- Порог вхождения разработчика в проект — выше, чем при использовании подхода с монолитами. Но, в то же время, если использовать рекомендации и придерживаться правил — весь проект будет использовать одну и ту же архитектуру.
- Разработчики смогут быстро и легко разбираться в других фичах.
- Каждый из модулей может быть безопасно отключен и может быть автономен.
- Еще одним плюсом-минусом модульного подхода является то, что архитектура конкретной фичи может быть реализована как угодно. К примеру, один модуль может быть написан используя MVC, а второй — ECS.
- Так же подобный подход позволил устанавливать требования к разным командам, которые могли разрабатывать параллельно фичи и модули.
- Дал возможность использовать аутсорс команды для написания модулей, времени для которых не хватало у внутренних команд.