Zenject: приемы и хитрости

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

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

Рассказываю о способах приготовления тех фич и тонкостей Zenject, которые за несколько лет разработки нашел полезными и постоянно применял.

d307c6c8912ef7134f7918b589b3666e.png

1. Организация сцены при регистрации префабов

Мотивация

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

С другой стороны, эти системы могут внутри себя содержать компоненты и MonoBeh-и для связи с движком, а значит должны создаваться и регистрироваться полноценные игровые объекты из префабов. Примеры: камера (-ы) (с Cinemachine), инпут (с компонентом и ссылкой на InputActions), инвентарь (трансформы привязки оружия) и экран загрузки (Curtain с твинами и CanvasGroup).

Zenject имеет методы Container.Instantiate() для спавна префабов с последующей регистрацией их компонентов и Container.Bind().FromComponentInNewPrefab(xPref) для регистрации отдельных компонентов из экземпляра префаба. Далее рассмотрим необязательные настройки для организации структурфы проекта и иерархии сцен.

Архитектурные условия

Проект построен по сервисной архитектуре, с использованием Zenject, поделен на три основные секции:

  • Infrastructure — провайдеры и сервисы, общее и вспомогательное;

  • Meta — мета-геймплей и меню, MV*-паттерны, связанное с UI;

  • (Core) Gameplay — 3C, компоненты игровых объектов.

Все сервисы и провайдеры регистрируются в нужном DI-контейнере по интерфейсу и попадают в конструкторы классов компонентов также по интерфейсу.

Реализация

Контейнер по умолчанию, ProjectContext, в котором регистрируются глобальные зависимости содержит 4 (четыре) MonoInstaller:

  1. Infrastructure и

  2. Gameplay, согласно структуре проекта,

  3. Debug для вспомогательных и дебажных компонентов и систем и отдельный

  4. GameStateMachine для регистрации стейт-машины игры (рассмотрим далее отдельно).

b6a9dffa0eb08977b2bf893f54a5804b.png

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

ProjectConext со ссылками на отдельные инсталлеры и GameplayInstaller

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 регистрируется другим образом:

  1. Создается обычный GameObject, помещается под transform инсталлера (this.transform).

  2. Задается аккуратное имя "Player Input".

  3. Из него получается компонент 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 и необходимостью заполнения словаря. Возникают следующие очевидные проблемы:

  1. Стейт-машина перегружена зависимостями.

  2. Этими зависимостями при выполнении своих обязанностей она не пользуется, а только раздает их состояниям.

  3. Огромный громоздкий конструктор.

Очевиден и первый шаг к решению. Раз явно присутствует логика создания состояний, можно применить паттерн фабрики и вынести эту часть ответственностей из самой 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 и зависимости уровней

Мотивация

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

  1. Как запустить, остановить, передать сообщение в стороннюю систему (например, FlowCanvas).

  2. Как сообщить уровню и системам, расположенным на нем, что фаза загрузки завершена и определить момент начала их работы.

  3. Как передать зависимости из контейнера, тем системам, которые предустановлены на уровне и с контейнером не связаны.

  4. Как отследить переключение (загрузку) уровней, и своевременно обновлять данные и зависимости.

Возникает потребность в локальном для уровня сервис-локаторе и сервисе, отслеживающем состояние уровня и запускающем его. Zenject позволяет регистрировать зависимости в суб-контейнерах SceneContext, ограниченных рамками сцены (уровня). Можно сказать, что для уровня он является composition root объектом, и значит требуемая иерархия сцены и взаимосвязи выглядят так:

6f1e97c7bbe4557297c22835008052d5.png

Реализация

Рассмотрим здесь 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 следующая:

  1. Глобальный InfrastructureInstaller регистрирует LevelProgressServiceResolver и LevelProgressService в Project-контейнере.

  2. LevelProgressServiceResolver при этом помечается для копирования в суб-контейнеры (SceneContext).

  3. LevelProgressServiceResolver инициализируется согласно IInitializable, но пока значением null. Происходит это 2 раза, для сцен Bootstrap и Meta, которые не являются геймплейными уровнями (не имеют Watcher).

  4. При загрузке геймплейного уровня GameplayInstaller регистрирует экземпляр (BindInstance) LevelProgressWatcher в Scene-контейнере.

  5. Вызывается метод Construct у LevelProgressWatcher, передающий ему необходимые зависимости.

  6. У LevelProgressServiceResolver вызывается сначала конструктор, получающий зависимости на Service и Watcher, а затем —

  7. метод Initialize и инициализация Service в методе InitForLevel экземпляром LevelProgressWatcher.

  8. GSM переходит в GameloopState.

  9. На входе (метод 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();
  }
}

Референсы и контакты

© Habrahabr.ru