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

—

от автора

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

У Zenject-а есть очевидно лишние (привет,


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

Мотивация

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

Все эти инсталлеры прикреплены к дочерним 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<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 регистрируется другим образом:

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

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

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

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

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

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

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

Мотивация

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

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

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

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

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

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

  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();   } }

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


ссылка на оригинал статьи https://habr.com/ru/articles/781016/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *