Zenject: приемы и хитрости
В мой джентельменский набор разработчика входят Zenject, Addressables и DOTween, значительно облегчающие разработку любого проекта на длинной дистанции.
У Zenject-а есть очевидно лишние (привет, Signals) и запутанные модули и возможности. Зачастую, чтобы сделать все красиво, приходится хорошенько покопаться в устройстве DI-контейнера.
Рассказываю о способах приготовления тех фич и тонкостей Zenject, которые за несколько лет разработки нашел полезными и постоянно применял.
1. Организация сцены при регистрации префабов
Мотивация
Некоторые системы геймплейного уровня используются многими геймплейными же компонентами игровых объектов и другими системами. А значит, желательно зарегистрировать их в DI контейнере, для удобной раздачи зависимостей.
С другой стороны, эти системы могут внутри себя содержать компоненты и MonoBeh-и для связи с движком, а значит должны создаваться и регистрироваться полноценные игровые объекты из префабов. Примеры: камера (-ы) (с Cinemachine), инпут (с компонентом и ссылкой на InputActions), инвентарь (трансформы привязки оружия) и экран загрузки (Curtain с твинами и CanvasGroup).
Zenject имеет методы Container.Instantiate
для спавна префабов с последующей регистрацией их компонентов и Container.Bind
для регистрации отдельных компонентов из экземпляра префаба. Далее рассмотрим необязательные настройки для организации структурфы проекта и иерархии сцен.
Архитектурные условия
Проект построен по сервисной архитектуре, с использованием Zenject, поделен на три основные секции:
Infrastructure
— провайдеры и сервисы, общее и вспомогательное;Meta
— мета-геймплей и меню, MV*-паттерны, связанное с UI;(Core)
Gameplay
— 3C, компоненты игровых объектов.
Все сервисы и провайдеры регистрируются в нужном DI-контейнере по интерфейсу и попадают в конструкторы классов компонентов также по интерфейсу.
Реализация
Контейнер по умолчанию, ProjectContext, в котором регистрируются глобальные зависимости содержит 4 (четыре) MonoInstaller:
Infrastructure
иGameplay
, согласно структуре проекта,Debug
для вспомогательных и дебажных компонентов и систем и отдельныйGameStateMachine
для регистрации стейт-машины игры (рассмотрим далее отдельно).
Все эти инсталлеры прикреплены к дочерним GameObject-ам ProjectContext-а для облегчения визуального считывания в иерархии. Префабы, необходимые каждому из инсталлеров привязываются в инспекторе. Очевидно, что все GameObject-ы и их компоненты, создаваемые инсталлерами должны складываться дочерними объектами под соответствующие порождающие инсталлеры, а не одной кучей в DontDestroy
и/или ProjectContext.
ProjectConext со ссылками на отдельные инсталлеры и GameplayInstaller
В инфраструктурном инсталлере из префаба создается CurtainService
, который помещается под соответствующей группой в иерархии:
public class InfrastructureInstaller : MonoInstaller
{
[SerializeField] private GameObject curtainServicePrefab;
public override void InstallBindings()
{
...
BindServices();
BindFactories();
}
private void BindServices()
{
...
Container.BindInterfacesAndSelfTo()
.FromComponentInNewPrefab(curtainServicePrefab)
.WithGameObjectName("Curtain")
.UnderTransformGroup("Infrastructure")
.AsSingle().NonLazy();
}
private void BindFactories() { ... }
}
Используется метод UnderTransformGroup
(или UnderTransform
), а для аккуратного переименования копии — WithGameObjectName
.
GameplayInstaller
выглядит следующим образом:
public class GameplayInstaller: MonoInstaller
{
...
private Camera _mainCamera;
public override void InstallBindings()
{
BindCameraService();
BindInputService();
BindInventory();
}
...
}
И InputService
регистрируется другим образом:
Создается обычный GameObject, помещается под transform инсталлера (
this.transform
).Задается аккуратное имя
"Player Input"
.Из него получается компонент
PlayerInput
, который используется далее как аргумент при регистрации сервиса InputService:
private void BindInputService()
{
var playerInput = Instantiate(playerInputPrefab, this.transform)
.GetComponent();
playerInput.gameObject.name = "Player Input";
var inputProvider = _playerVirtualCamera
.GetComponent();
Container.BindInterfacesAndSelfTo()
.AsSingle()
.WithArguments(playerInput, inputProvider)
.NonLazy();
}
2. Аргументы конструктора при регистрации
Мотивация
При регистрации зависимостей, имеющих конструкторы (не MonoBeh, обычные C#-классы) Zenject постарается самостоятельно разрешить их зависимости, по правилам хорошего тона указанные как аргументы таких конструкторов.
Иногда возникает необходимость передать в качестве зависимостей что-то извне контейнера, например компонент из инстанциированного объекта. Использование и ручной вызов дополнительного метода инициализации или реализация Zenject-интерфейса IInitializable
будут размывать логику конструктора.
Реализация
Zenject предоставляет дополнительный метод для регистрации WithArguments
, позволяющий передать дополнительные аргументы (зависимости) непосредственно в конструктор. Пример использования этого метода был приведен в предыдущем разделе, для передачи компонентов PlayerInput
и CinemachineInputProvider
в конструктор InputService
.
Однако, конструктор InputService
выглядит следующим образом:
public InputService(
PlayerInput playerInput,
CinemachineInputProvider inputProvider,
ILoggingService logger)
{
_loggingService = logger;
_playerInput = playerInput;
_controls = new PlayerControls();
_inputProvider = inputProvider;
}
В него так же передается (разумеется, по интерфейсу) LoggingService
, регистрирующийся ранее в контейнере.
Zenject возьмет явно указанные зависимости из переданных аргументов, а оставшиеся постарается разрешить из DI-контейнера. Поэтому нет необходимости дописывать вспомогательные методы для пост-конструирования / инициализации объектов или регистрировать в контейнере (тем более в ProjectContext) ситуативные одноразовые компоненты.
3. Циклические зависимости и фабрики
Мотивация
Классы, агрегирующие несколько компонентов или управляющие некоторой композицией или системой, зачастую вынуждены иметь большое количество зависимостей. И ввиду того, что эти зависимости они потребляют не самостоятельно, а больше передают подопечным объектам, сложно говорить о разделении ответственностей и вынесении зависимостей в отдельные классы.
Применение Zenject у таких (являющихся или похожих на) composition root объектов ведет к нежелательному соседству bind и resolve операций, к потенциальному смешиванию фаз контейнера, и даже циклическим зависимостям.
Рассмотрим пример со стейт-машиной игры. В используемой архитектуре весь жизненный цикл игры поделен на состояния (инициализация, загрузка контента, загрузка данных, меню, загрузка уровня, подготовка уровня, геймплей …). Каждое отдельное состояние управляет необходимыми системами и сервисами на отдельном этапе игры и содержит зависимости на эти системы и сервисы. Стейт-машина GameStateMachine
, управляющая этими состояниями отвечала и за их создание, инициализацию и передачу зависимостей. Множество состояний покрывает все этапы игры, поэтому сумма зависимостей состояний стремится к сумме всех систем и сервисов проекта и ведет к взрыву конструктора стейт-машины.
В классе GameStateMachine
определен словарь вида тип-экземпляр, использующийся для доступа к текущим состояниям GSM: состояние каждого типа = уникальная фаза игры. Определен обобщенный метод Enter
для входа в состояние и реализован интерфейс IInitializable
от Zenject, отправляющий GSM в начальное состояние BootStrapState
:
public class GameStateMachine : IInitializable
{
private readonly ILoggingService _logger;
private readonly Dictionary _states;
private IExitableState _currentState;
public GameStateMachine(
SceneLoader sceneLoader,
ILoggingService loggingService,
IStaticDataService staticDataService,
IPersistentDataService persistentDataService,
ISaveLoadService saveLoadService,
IEconomyService economyService,
IUIFactory uiFactory,
IHeroFactory heroFactory,
IStageFactory stageFactory,
IEnemyFactory enemyFactory
)
{
_logger = loggingService;
_states = new Dictionary
{
[typeof(BootstrapState)] = new BootstrapState(
this,
staticDataService),
[typeof(LoadProgressState)] = new LoadProgressState(
this,
persistentDataService,
saveLoadService,
economyService),
[typeof(LoadMetaState)] = new LoadMetaState(
this,
uiFactory,
sceneLoader),
[typeof(LoadLevelState)] = new LoadLevelState(
this,
sceneLoader,
uiFactory,
heroFactory,
stageFactory),
[typeof(GameLoopState)] = new GameLoopState(
this,
heroFactory,
enemyFactory)
};
}
public void Initialize() => Enter();
}
GSM в своем конструкторе получает все необходимые зависимости, сохраняет для собственных нужд единственный LoggingService
сервис, а остальные зависимости передает конкретным состояниям, экземплярами которых заполняется словарь _states
. И только после завершения стадии конструирования и получения зависимостей происходит этап инициализации — переход в начальное состояние.
Ручной вызов конструкторов состояний продиктован логикой GSM и необходимостью заполнения словаря. Возникают следующие очевидные проблемы:
Стейт-машина перегружена зависимостями.
Этими зависимостями при выполнении своих обязанностей она не пользуется, а только раздает их состояниям.
Огромный громоздкий конструктор.
Очевиден и первый шаг к решению. Раз явно присутствует логика создания состояний, можно применить паттерн фабрики и вынести эту часть ответственностей из самой GSM.
Реализация
Создадим отдельный инсталлер и зарегистрируем состояния в контейнере. По условиям архитектуры каждый экземпляр — уникальный (AsSingle
):
public class StateMachineInstaller : MonoInstaller
{
public override void InstallBindings()
{
Container.Bind().AsSingle().NonLazy();
Container.Bind().AsSingle().NonLazy();
Container.Bind().AsSingle().NonLazy();
Container.Bind().AsSingle().NonLazy();
Container.Bind().AsSingle().NonLazy();
Container
.BindInterfacesAndSelfTo()
.AsSingle(); //GameStateMachine entry point is Initialize
}
}
Реализуем StateFactory
. Передаем в нее DI-контейнер как зависимость — для фабрик и composition root объектов это допустимо. В методе CreateState
нужное состояние получается из контейнера:
public class StateFactory
{
private readonly DiContainer _container;
public StateFactory(DiContainer container) =>
_container = container;
public T CreateState() where T : IExitableState =>
_container.Resolve();
}
}
Далее регистрируем фабрику по интерфейсу в InfrastructureInstaller
и упрощаем реализацию стейт машины:
public class GameStateMachine : IInitializable
{
private readonly StateFactory _stateFactory;
private readonly ILoggingService _logger;
private Dictionary _states;
private IExitableState _currentState;
public GameStateMachine(StateFactory stateFactory, ILoggingService loggingService)
{
_stateFactory = stateFactory;
_logger = loggingService;
}
public void Initialize()
{
_states = new Dictionary
{
[typeof(BootstrapState)] = _stateFactory
.CreateState(),
[typeof(LoadProgressState)] = _stateFactory
.CreateState(),
[typeof(LoadMetaState)] = _stateFactory
.CreateState(),
[typeof(LoadLevelState)] = _stateFactory
.CreateState(),
[typeof(GameLoopState)] = _stateFactory
.CreateState()
};
Enter();
}
}
Теперь стейт-машина получает ровно 2 (две) зависимости, которые напрямую и использует, а создание состояний и получение ими собственных зависимостей происходит в отдельной фабрике.
Все описанные проблемы стейт-машины не перенесены и спрятаны в фабрике, а решены с помощью нее: конструктор фабрики не взрывается от зависимостей, а все необходимое разрешается самим контейнером. Упрощается расширение проекта и добавление новых состояний.
Отдельно отметим, что внедрение фабрики как посредника снимает вопрос циклических зависимостей и упорядочивает процесс регистрации и разрешения зависимостей.
Обобщенные методы и рефлексия
Обобщенные методы и словари тип-экземпляр удобно использовать для кеширования и быстрого доступа к уникальным компонентам. Например, компоненты-расширения для Cinemachine
:
public class CameraService : ICameraService, IInitializable
{
private readonly Dictionary<
Type,
CinemachineExtension> _cinemachineExtensions = new();
public void Initialize()
{
foreach (var extension in _playerVirtualCamera
.GetComponents())
_cinemachineExtensions[extension.GetType()] = extension;
}
[CanBeNull]
public T GetCinemachineExtension() where T :CinemachineExtension =>
(T)_cinemachineExtensions[typeof(T)];
}
4. SceneContext и зависимости уровней
Мотивация
Системы и компоненты, встроенные в движок и от сторонних разработчиков зачастую имеют специфическую реализацию и жизненный цикл. Поэтому при внедрении собственной архитектуры в пограничных местах всегда возникают проблемы интеграции:
Как запустить, остановить, передать сообщение в стороннюю систему (например, FlowCanvas).
Как сообщить уровню и системам, расположенным на нем, что фаза загрузки завершена и определить момент начала их работы.
Как передать зависимости из контейнера, тем системам, которые предустановлены на уровне и с контейнером не связаны.
Как отследить переключение (загрузку) уровней, и своевременно обновлять данные и зависимости.
Возникает потребность в локальном для уровня сервис-локаторе и сервисе, отслеживающем состояние уровня и запускающем его. Zenject позволяет регистрировать зависимости в суб-контейнерах SceneContext
, ограниченных рамками сцены (уровня). Можно сказать, что для уровня он является composition root объектом, и значит требуемая иерархия сцены и взаимосвязи выглядят так:
Реализация
Рассмотрим здесь LevelProgressWatcher
и связанный с ним сервис, позволяющие определить точку и время запуска систем уровня и являющиеся фасадом для объектов сцены. Сервис-локатор будет рассмотрен отдельно, в контексте работы с FlowCanvas.
LevelProgressWatcher
— фасад уровня. Его задача — после загрузки уровня
связаться с состоянием или GSM и предоставить метод для запуска уровня. Поэтому выглядит он следующим образом:
public class LevelProgressWatcher : SerializedMonoBehaviour
{
private GameStateMachine _gameStateMachine;
private ILoggingService _loggingService;
[Inject]
private void Construct(GameStateMachine gameStateMachine,ILoggingService loggingService)
{
_gameStateMachine = gameStateMachine;
_loggingService = loggingService;
}
public void RunLevel()
{
_loggingService.LogMessage($"level ran", this);
}
}
Далее он может обрасти ссылками на объекты и компоненты сцены, с которыми начнет работать в методе RunLevel
. В нем же может появиться CompleteLevel
, запускающий поток информации в обратном направлении — от уровня к GSM / GameloopState
.
Важно отметить, что получение зависимостей происходит в отдельном методе Construct
с атрибутом Inject
, так как MonoBeh-и не имеют конструкторов.
На каждом уровне, сцене расположен свой LevelProgressWatcher
, поэтому регистрируется они на каждой сцене отдельным инсталлером, в SceneContext
:
public class GameplayInstaller : MonoInstaller
{
[SerializeField]
private LevelProgressWatcher levelProgressWatcher;
public override void InstallBindings()
{
Container.BindInstance(levelProgressWatcher);
}
}
В ProjectContext
в InfrastructureInstaller
регистрируется сервис:
public class LevelProgressService : ILevelProgressService
{
public LevelProgressWatcher LevelProgressWatcher { get; set; }
public void InitForLevel(LevelProgressWatcher levelController) =>
LevelProgressWatcher = levelController;
}
Service
и Watcher
связываются через дополнительный Resolver
:
public class LevelProgressServiceResolver : IInitializable, IDisposable
{
private readonly ILevelProgressService _levelProgressService;
private readonly LevelProgressWatcher _levelProgressWatcher;
public LevelProgressServiceResolver(
ILevelProgressService levelProgressService,
[Inject(Source = InjectSources.Local, Optional = true)]
LevelProgressWatcher levelProgressWatcher)
{
_levelProgressService = levelProgressService;
_levelProgressWatcher = levelProgressWatcher;
}
public void Initialize() =>
_levelProgressService.InitForLevel(_levelProgressWatcher);
public void Dispose() =>
_levelProgressService.InitForLevel(null);
}
Регистрируется он также в InfrastractureInstaller
, но с дополнительной инструкцией:
Container
.BindInterfacesAndSelfTo()
.AsSingle()
.CopyIntoDirectSubContainers();
Container
.BindInterfacesAndSelfTo()
.AsSingle()
.NonLazy();
Использование
Последовательность передачи сообщений в тройке Service-Watcher-Resolver следующая:
Глобальный
InfrastructureInstaller
регистрируетLevelProgressServiceResolver
иLevelProgressService
в Project-контейнере.LevelProgressServiceResolver
при этом помечается для копирования в суб-контейнеры (SceneContext).LevelProgressServiceResolver
инициализируется согласноIInitializable
, но пока значениемnull
. Происходит это 2 раза, для сцен Bootstrap и Meta, которые не являются геймплейными уровнями (не имеют Watcher).При загрузке геймплейного уровня GameplayInstaller регистрирует экземпляр (BindInstance) LevelProgressWatcher в Scene-контейнере.
Вызывается метод
Construct
уLevelProgressWatcher
, передающий ему необходимые зависимости.У
LevelProgressServiceResolver
вызывается сначала конструктор, получающий зависимости на Service и Watcher, а затем —метод
Initialize
и инициализация Service в методеInitForLevel
экземпляромLevelProgressWatcher
.GSM переходит в
GameloopState
.На входе (метод
Enter
) в состояние у локального Watcher, доступного через глобальный сервис вызывается методRunLevel
.
Использование сервиса в GameloopState
:
public class GameLoopState : IState
{
private readonly GameStateMachine _stateMachine;
private readonly IEnemyFactory _enemyFactory;
private readonly IHeroFactory _heroFactory;
private readonly ILevelProgressService _levelProgressService;
public GameLoopState(
GameStateMachine gameStateMachine,
IHeroFactory heroFactory,
IEnemyFactory enemyFactory,
ILevelProgressService levelProgressService)
{
_stateMachine = gameStateMachine;
_heroFactory = heroFactory;
_enemyFactory = enemyFactory;
_levelProgressService = levelProgressService;
_levelProgressService = levelProgressService;
}
public void Enter()
{
_levelProgressService.LevelProgressWatcher.RunLevel();
}
public void Exit()
{
_enemyFactory.CleanUp();
_heroFactory.CleanUp();
}
}