В мой джентельменский набор разработчика входят Zenject, Addressables и DOTween, значительно облегчающие разработку любого проекта на длинной дистанции.
У Zenject-а есть очевидно лишние (привет, Signals) и запутанные модули и возможности. Зачастую, чтобы сделать все красиво, приходится хорошенько покопаться в устройстве DI-контейнера.
Рассказываю о способах приготовления тех фич и тонкостей Zenject, которые за несколько лет разработки нашел полезными и постоянно применял.

1. Организация сцены при регистрации префабов
Мотивация
Некоторые системы геймплейного уровня используются многими геймплейными же компонентами игровых объектов и другими системами. А значит, желательно зарегистрировать их в DI контейнере, для удобной раздачи зависимостей.
С другой стороны, эти системы могут внутри себя содержать компоненты и MonoBeh-и для связи с движком, а значит должны создаваться и регистрироваться полноценные игровые объекты из префабов. Примеры: камера(-ы) (с Cinemachine), инпут (с компонентом и ссылкой на InputActions), инвентарь (трансформы привязки оружия) и экран загрузки (Curtain с твинами и CanvasGroup).
Zenject имеет методы Container.Instantiate<X>() для спавна префабов с последующей регистрацией их компонентов и Container.Bind<X>().FromComponentInNewPrefab(xPref) для регистрации отдельных компонентов из экземпляра префаба. Далее рассмотрим необязательные настройки для организации структурфы проекта и иерархии сцен.
Архитектурные условия
Проект построен по сервисной архитектуре, с использованием Zenject, поделен на три основные секции:
Infrastructure– провайдеры и сервисы, общее и вспомогательное;Meta– мета-геймплей и меню, MV*-паттерны, связанное с UI;(Core)
Gameplay– 3C, компоненты игровых объектов.
Все сервисы и провайдеры регистрируются в нужном DI-контейнере по интерфейсу и попадают в конструкторы классов компонентов также по интерфейсу.
Реализация
Контейнер по умолчанию, ProjectContext, в котором регистрируются глобальные зависимости содержит 4 (четыре) MonoInstaller:
InfrastructureиGameplay, согласно структуре проекта,Debugдля вспомогательных и дебажных компонентов и систем и отдельныйGameStateMachineдля регистрации стейт-машины игры (рассмотрим далее отдельно).

Все эти инсталлеры прикреплены к дочерним GameObject-ам ProjectContext-а для облегчения визуального считывания в иерархии. Префабы, необходимые каждому из инсталлеров привязываются в инспекторе. Очевидно, что все GameObject-ы и их компоненты, создаваемые инсталлерами должны складываться дочерними объектами под соответствующие порождающие инсталлеры, а не одной кучей в DontDestroy и/или ProjectContext.

В инфраструктурном инсталлере из префаба создается CurtainService, который помещается под соответствующей группой в иерархии:
public class InfrastructureInstaller : MonoInstaller { [SerializeField] private GameObject curtainServicePrefab; public override void InstallBindings() { ... BindServices(); BindFactories(); } private void BindServices() { ... Container.BindInterfacesAndSelfTo<CurtainService>() .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>(); playerInput.gameObject.name = "Player Input"; var inputProvider = _playerVirtualCamera .GetComponent<CinemachineInputProvider>(); Container.BindInterfacesAndSelfTo<InputService>() .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<Type, IExitableState> _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<Type, IExitableState> { [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<BootstrapState>(); }
GSM в своем конструкторе получает все необходимые зависимости, сохраняет для собственных нужд единственный LoggingService сервис, а остальные зависимости передает конкретным состояниям, экземплярами которых заполняется словарь _states. И только после завершения стадии конструирования и получения зависимостей происходит этап инициализации – переход в начальное состояние.
Ручной вызов конструкторов состояний продиктован логикой GSM и необходимостью заполнения словаря. Возникают следующие очевидные проблемы:
Стейт-машина перегружена зависимостями.
Этими зависимостями при выполнении своих обязанностей она не пользуется, а только раздает их состояниям.
Огромный громоздкий конструктор.
Очевиден и первый шаг к решению. Раз явно присутствует логика создания состояний, можно применить паттерн фабрики и вынести эту часть ответственностей из самой GSM.
Реализация
Создадим отдельный инсталлер и зарегистрируем состояния в контейнере. По условиям архитектуры каждый экземпляр – уникальный (AsSingle):
public class StateMachineInstaller : MonoInstaller { public override void InstallBindings() { Container.Bind<BootstrapState>().AsSingle().NonLazy(); Container.Bind<LoadProgressState>().AsSingle().NonLazy(); Container.Bind<LoadMetaState>().AsSingle().NonLazy(); Container.Bind<LoadLevelState>().AsSingle().NonLazy(); Container.Bind<GameLoopState>().AsSingle().NonLazy(); Container .BindInterfacesAndSelfTo<GameStateMachine>() .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<T>() where T : IExitableState => _container.Resolve<T>(); } }
Далее регистрируем фабрику по интерфейсу в InfrastructureInstaller и упрощаем реализацию стейт машины:
public class GameStateMachine : IInitializable { private readonly StateFactory _stateFactory; private readonly ILoggingService _logger; private Dictionary<Type, IExitableState> _states; private IExitableState _currentState; public GameStateMachine(StateFactory stateFactory, ILoggingService loggingService) { _stateFactory = stateFactory; _logger = loggingService; } public void Initialize() { _states = new Dictionary<Type, IExitableState> { [typeof(BootstrapState)] = _stateFactory .CreateState<BootstrapState>(), [typeof(LoadProgressState)] = _stateFactory .CreateState<LoadProgressState>(), [typeof(LoadMetaState)] = _stateFactory .CreateState<LoadMetaState>(), [typeof(LoadLevelState)] = _stateFactory .CreateState<LoadLevelState>(), [typeof(GameLoopState)] = _stateFactory .CreateState<GameLoopState>() }; Enter<BootstrapState>(); } }
Теперь стейт-машина получает ровно 2 (две) зависимости, которые напрямую и использует, а создание состояний и получение ими собственных зависимостей происходит в отдельной фабрике.
Все описанные проблемы стейт-машины не перенесены и спрятаны в фабрике, а решены с помощью нее: конструктор фабрики не взрывается от зависимостей, а все необходимое разрешается самим контейнером. Упрощается расширение проекта и добавление новых состояний.
Отдельно отметим, что внедрение фабрики как посредника снимает вопрос циклических зависимостей и упорядочивает процесс регистрации и разрешения зависимостей.
Обобщенные методы и рефлексия
Обобщенные методы и словари тип-экземпляр удобно использовать для кеширования и быстрого доступа к уникальным компонентам. Например, компоненты-расширения для Cinemachine:
public class CameraService : ICameraService, IInitializable { private readonly Dictionary< Type, CinemachineExtension> _cinemachineExtensions = new(); public void Initialize() { foreach (var extension in _playerVirtualCamera .GetComponents<CinemachineExtension>()) _cinemachineExtensions[extension.GetType()] = extension; } [CanBeNull] public T GetCinemachineExtension<T>() 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<LevelProgressServiceResolver>() .AsSingle() .CopyIntoDirectSubContainers(); Container .BindInterfacesAndSelfTo<LevelProgressService>() .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(); } }
Референсы и контакты
LinkedIn: Roman Molotov
Telegram: @thunderomancer
