Автор статьи: Игорь Гулькин

Senior Unity Developer

Всем привет! ????

Меня зовут Игорь Гулькин, и я Unity разработчик. За свои 5 лет накопилось много опыта, поэтому в этой статье хотел бы поделиться принципами и подходами, с помощью которых можно реализовать архитектуру игры просто и гибко без фреймворка. Цель доклада, дать не просто готовое решение, а показать ход мыслей, как она выстраивается. Если вы не читали первую часть, то рекомендую сначала прочитать ее :).

Внедрение зависимостей

Как писал уже выше, проставление зависимостей вручную через инспектор имеет пару проблем:

  1. Во-первых, если мы захотим поменять скрипт KeyboardInput, например, на JoystickInput, то и в MoveController тоже придется поменять ссылку в инспекторе. А что если мы захотим поменять компонент, от которого зависят 30 других компонентов? Будем вручную переставлять все? В результате все эти “перестановки” занимают не только время, но и могут привести к неожиданным багам, так как человеческий фактор никто не отменял.

  1. Во-вторых, если мы захотим, сделать подгрузку систем через Prefab’ы (например префаб игрового интерфейса), то монобехи в этих префабах не смогут получить доступ к скриптам, которые уже были на сцене.

Таким образом, в качестве решения напрашивается инструмент, с помощью которого мы будем получать зависимости на компоненты автоматически.

Один из таких фреймворков, я думаю, что вы уже знаете и даже используете в проектах, если нет, то обязательно ознакомьтесь с Zenject’ом

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

Предлагаю начать с простого: с паттерна Service Locator

Service Locator

Идея паттерна Service Locator проста: есть некий центральный реестр, в котором хранятся ссылки на все объекты системы, которые мы можем получить через метод GetService().

Например, если мы хотим получить компонент KeyboardInput, то мы можем просто вызвать метод ServiceLocator.GetService<KeyboardInput>()

Так вот, если мы не хотим проставлять ссылки в инспекторе вручную, тогда все эти ссылки нужно хранить в неком локаторе, чтобы в процессе игры одни компоненты игры могли общаться с другими. Напишем класс-локатор:

public sealed class GameLocator : MonoBehaviour
    {
        private readonly List<object> services = new();

        public void AddService(object service)
        {
            this.services.Add(service);
        }

        public void RemoveService(object service)
        {
            this.services.Remove(service);
        }

        public T GetService<T>()
        {
            foreach (var service in this.services)
            {
                if (service is T result)
                {
                    return result;
                }
            }

            throw new Exception($"Service of type {typeof(T)} is not found!");
        }
    }

Отлично, добавим скрипт GameLocator на сцену:

Теперь, нам нужно зарегистрировать компоненты KeyboardInput, MoveController и Player в наш реестр. Для этого напишем класс GameLocatorInstaller, который и добавит все сервисы в методе Awake()

public sealed class GameLocatorInstaller : MonoBehaviour
    {
        [SerializeField]
        private GameLocator gameLocator;

        [SerializeField]
        private MonoBehaviour[] gameServices;

        private void Awake()
        {
            foreach (var service in this.gameServices)
            {
                this.gameLocator.AddService(service);
            }
        }
    }

Поясняю за класс: инсталлер содержит в себе ссылку на локатор и массив монобехов, которые будут зарегистрированы в этом локаторе. Как сказал выше регистрация сервисов происходит в Awake().

Теперь добавим скрипт GameLocatorInstaller на сцену и подключим ему сервисы:

Далее давайте немного подумаем о том, как в компонент MoveController прокинуть автоматически зависимости на компоненты KeyboardInput и Player. Прежде чем я покажу вам, как это можно сделать, вставлю небольшую ремарку.

Ремарка: Многие опытные разработчики считают, что Service Locator — это анти-паттерн, так как при его использовании все классы жестко зависят от этого посредника, тем самым призывают вместо него использовать DI фреймворки. Тут я попытаюсь защитить имя сервис-локатора и наглядно показать, что именно является анти-паттерном.

Смотрите, если мы в классе MoveController сделаем private поле с сервис-локатором и через него будем обращаться к KeyboardInput и Player, то это будет анти-паттерн. При таком подходе класс MoveController получает дополнительную зависимость на GameLocator и ответственность за получение нужных сервисов, с которыми он должен взаимодействовать. К сожалению такие классы сложно переиспользовать в других проектах, поскольку там нет никакого GameLocator. Еще отмечу что процесс тестирования классов, прошитых таким реестром становится более кривой, так как помимо заглушек нужно создавать и экземпляр сервис-локатора в тесте. Это не приговор, но лучше так не делать, если есть возможность избежать этого анти-паттерна в вашей архитектуре.

Ниже привел пример кода с анти-паттерном:

public sealed class MoveController : MonoBehaviour, 
        IStartGameListener,
        IFinishGameListener
    {
        [SerializeField]
        private GameLocator gameLocator;
    
        void IStartGameListener.OnStartGame()
        {
            this.gameLocator.GetService<KeyboardInput>().OnMove += this.OnMove;
        }

        void IFinishGameListener.OnFinishGame()
        {
            this.gameLocator.GetService<KeyboardInput>().OnMove -= this.OnMove;
        }

        private void OnMove(Vector3 direction)
        {
            this.gameLocator.GetService<Player>().Move(direction);
        }
    }

Теперь мои мысли, как можно сделать правильно, используя по-прежнему сервис-локатор. Вместо того, чтобы создавать в классе MoveController private поле с GameLocator, мы сделаем public метод Construct(), в который передадим ссылки на компоненты Player и KeyboardInput в качестве аргументов. А еще лучше передать туда интерфейсы IPlayer и IMoveInput:

public sealed class MoveController : MonoBehaviour, 
        IStartGameListener,
        IFinishGameListener
    {
        private IMoveInput input;

        private IPlayer player;

        public void Construct(IMoveInput input, IPlayer player)
        {
            this.input = input;
            this.player = player;
        }

             	//TODO Rest code:
    }

Хорошо, от зависимости на сервис-локатор ушли, возникает вопрос: кто должен вызвать метод Construct() у нашего контроллера. Ответ: будет другой класс, который возьмет сервисы из реестра и вызовет их у контроллера.

 public sealed class GameAssembler : MonoBehaviour
    {
        [SerializeField]
        private GameLocator gameLocator;
    
        [Space]
        [SerializeField]
        private MoveController moveController;
    
        private void Start()
        {
            this.ConstructMoveController();
        }

        private void ConstructMoveController()
        {
            var keyboardInput = this.gameLocator.GetService<IMoveInput>();
            var player = this.gameLocator.GetService<IPlayer>();
            this.moveController.Construct(keyboardInput, player);
        }
    }

С моей точки зрения, идея паттерна Dependency заключается в том, что мы поручаем ответственность за разрешение зависимостей отдельному классу, а то, КАК он будет это делать — это уже разновидность реализации паттерна

Конкретно в этом случае разрешением зависимостей будет заниматься класс GameAssembler, который делает это явно, обращаясь к сервис локатору.

Ремарка: Обычно механизм внедрения зависимостей делается через Reflection. Алгоритм проходится по всем компонентам системы, смотрит структуру каждого экземпляра класса и по определенным правилам понимает, откуда и в каком формате передавать ссылки на другие компоненты. Подход через Reflection удобен, поскольку позволяет не писать бойлерплейт вручную, как это написано у меня выше в классе GameAssembler, но с другой стороны:

1. Наши классы имеют зависимость на атрибут Inject, поскольку эта аннотация ставится над полями и методами и импортируется из другого namespace.

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

Поэтому там, где не проходит автоматическое разрешение зависимостей, делаем внедрение ручками через ServiceLocator. Не забывайте, что если у вас есть ServiceLocator, то вы всегда можете сделать DI надстройку.

Итак, добавляю скрипт GameAssembler на сцену и проверяю, что все работает:

Таким образом, мы избавились от проставления зависимостей вручную через инспектор. Теперь внедрение зависимости идет в отдельном классе GameAssembler, в котором мы явно описываем получение ссылок через GameLocator.

В целом данное решение универсально. Если вы хотите автоматизировать разрешение зависимостей, пожалуйста, рекомендую ознакомиться с моим репозиторием ServiceLocator, в котором есть пример механизма DI через Reflection.

Таким образом наша гейм-система состоит из:

  • Есть механизм состояний игры GameMachine.

  • Есть центральный реестр GameLocator.

  • Есть GameObservableInstaller, который регистрирует подписчиков.

  • Есть GameLocatorInstaller, который регистрирует сервисы.

  • Есть GameAssembler, который внедряет зависимости в компоненты игры.

В целом все сделано по SOLID, только такая архитектура выглядит раздутой и сложной. Давайте упрощать и уходить от монобехов. 

Продолжение следует…

В завершение статьи хочу пригласить вас на бесплатные вебинары, которые я проведу в рамках курса Unity Game Developer. Professional на платформе OTUS

  • 30 марта. Бесплатный урок: Model-View-Adapter. На уроке изучим паттерн Model-View-Adapter в упрощенном варианте без пользовательского ввода на примере виджета монет игрока. Зарегистрироваться.

  • 13 апреля. Бесплатный урок: Presentation Model. На уроке изучим паттерн Presentation Model на примере попапа (pop-up) магазина. Зарегистрироваться.

  • 20 апреля. Бесплатный урок: Model-View-Presenter. На уроке изучим паттерн Model-View-Presenter на примере попапа (pop-up) квестов. Зарегистрироваться.

Комментарии (9)


  1. Tr0sT
    00.00.0000 00:00

    Я уже запутался в сущностях. Раз уж сервис-локатор всё-равно используется, то MoveController проще выглядит c таким методом.

    public void Construct(GameLocator gameLocator)
    {
    	this.input = gameLocator.GetService<IMoveInput>();
    	this.player = gameLocator.GetService<IPlayer>();
    }
    

    Так можно избавиться от GameAssembler, который будет только бухнуть со временем (или их будет много разных?).

    Да и сервис-локатор можно взять статический https://github.com/Leopotam/globals/blob/master/src/Service.cs , убрав GameLocatorInstaller. Всё-таки сервисы, которые юзаются через сервис-локатор, обычно знают, что они единичные, и регистрировать самим себя - нагляднее при чтении их кода.

    Но это всё актуально, только если идти честным путём сервис-локатора, а не как промежуточный этап к самописному DI :)


    1. StarKRE
      00.00.0000 00:00

      Привет @Tr0sT

      public void Construct(GameLocator gameLocator)
      {
      this.input = gameLocator.GetService<IMoveInput>();
      this.player = gameLocator.GetService<IPlayer>();
      }

      Лучше так не делать в средних и крупных проектах, так как это анти-паттерн. При таком стиле написания классов, у тебя вся логика в проекте будет зависеть от GameLocator, и ты не сможешь нормально переиспользовать эти классы в других проектах)

      От ассемблера пока не рекомендую избавляться, потому что в третьей части (спойлер) мы объединим классы GameObservableInstaller, GameLocatorInstaller и GameAssembler в один монобех-инсталлер, который будет заниматься созданием экземпляров с бизнес-логикой, регистрацией сервисов и лисенеров + прокидыванием в них зависимостей. И получиться так, что для каждой фичи игры будет свой инсталлер, похожий на MonoInstaller в Zenject. Например, для фичи апгрейдов будет UpgradesInstaller, для фичи квестов QuestInstaller и так далее. В результате на сцене останутся монобехи-инсталлеры и игровые объекты.

      По поводу статического сервис-локатора — лучше сделать такой центральный реестр на уровне контекста приложения, а не на уровне игровой сцены. Ты можешь честно идти путем сервис-локатора, а потом поверх него написать алгоритм для DI через Reflection


  1. redHurt69
    00.00.0000 00:00

    Получается какая-то мешанина из ответственностей. И игрок, и клавиатура, и контроллер движения теперь вообще сервисы. Получается "прокинь что хочешь куда хочешь и будь что будет". А сервис локатор только упрощает такое поведение.
    Почему бы тогда не прокинуть контроллер в игрока или ввод в игрока или игрока сразу в ввод? Раз уж они все сервисы.
    Весь контроль управления зиждется на том, что мы постараемся не забыть что в проекте главнее)


    1. StarKRE
      00.00.0000 00:00

      @redHurt69Привет!

      Спасибо за обратную связь)

      Игрок, клавиатура и контроллер движения, как были компонентами в системе со своими ответственностями и бизнес-логикой, так и остались. Просто в проекте нам нужно сделать механизм, с помощью которого мы будем получать эти объекты в системе. Но с точки зрения сервис-локатора, они все сервисы, не потому что они делают какую-то "сервисную логику", а потому что просто такая конвенция между разработчиками

      Если режет глаз, то можно назвать класс-реестр GameContext и вызывать GameContext.GetInstance<T>()


      1. redHurt69
        00.00.0000 00:00

        Их "ответственность" по сути контролируется только за счет того, что мы "считаем" контроллер главным.
        Что мешает нам или другому программисту в следующей фиче сделать зависимость игрока на контроллер или клавиатуру? Только очередная какая-то негласная конвенция и ориентацией на волшебные слова в названиях классов - "ну все что называется контроллер это типа важнее"
        Данная архитектура всего лишь упрощает доступность данных, а не решает вопрос "как мне правильно написать следующую фичу". А в плане доступности данных юнити на шаг впереди этого сервис локатора. Можно с тем же успехом просто находить зависимости через FindObjectOfType<T>


    1. StarKRE
      00.00.0000 00:00

      Вот тут не очень понял проблемы, но звучит прикольно ????:

      1.Получается, "прокинь что хочешь куда хочешь и будь что будет"

      2.Весь контроль управления зиждется на том, что мы постараемся не забыть что в проекте главнее)


      1. redHurt69
        00.00.0000 00:00

        По сути все о том же, что и комментом выше


  1. ASobolevskiy
    00.00.0000 00:00

    От ассемблера пока не рекомендую избавляться, потому что в третьей части (спойлер) мы объединим классы GameObservableInstaller, GameLocatorInstaller и GameAssembler в один монобех-инсталлер, который будет заниматься созданием экземпляров с бизнес-логикой, регистрацией сервисов и лисенеров + прокидыванием в них зависимостей. 

    Вот тут лично мне кажется, что принцип единственной ответственности будет сильно нарушаться - слишком много причин для изменений получается (если следовать определению единственной ответственности, предложенному господином Мартином), поправьте меня, если я ошибаюсь. Почему нельзя оставить так как есть?


  1. SadOcean
    00.00.0000 00:00

    Вы в целом правильно описали проблему, которая приписывается сервис локатору.
    Но получается забавно - пытаясь избавиться от DI, вы переизобрели DI
    По сути если унифицировать класс assembler - это и будет DI
    Например можно сделать его универсальным через рефлексию или рефлексию + кодогенерацию, чтобы не писать много однотипного кода.

    Еще комментарии:
    - Сервис локатор можно сделать ScriptableObject со списком префабов-сервисов, которые он инициализирует лениво при запросе. Так его будет проще внедрять.
    - В юнити в принципе можно использоват ScriptableObject + сериализацию (SerializeField) как встроенный DI. Хорошо работает для общих сервисов, настроек вьюшек и многих других целей
    - Можно оптимизировать локатор (впрочем не уверен, для маленького списка сервисов список должен быть быстрее, нужно тестировать)

    public sealed class ServiceLocator {
    
      private readonly Dictionary<Type, object> services = new Dictionary<Type, object>();
      public void AddService(object service)
      {
          this.services.Add(service.GetType(), service);
      }
      
      public void RemoveService(object service)
      {
          this.services.Remove(service.GetType());
      }
      
      public T GetService<T>()
      {
          if (services.TryGetValue(typeof(T), out var service)) {
              return (T)service;
          }
      
          throw new Exception($"Service of type {typeof(T)} is not found!");
      }
    }


    - Так же можно обобщить его через Generic интерфейсы - сделать специальный интерфейс Construct<T> и проверять из ассемблера, поддерживает ли класс один из списка.
    И прописать списочком зависимости (в этом случае нужно будет писать множество конструкторов для каждого типа данных, по сути это скорее внедрение через поля) либо множественный Construct<A, B>, Construct<A, B, C>, Construct<A, B, C, D> (в этом случае можно будет написать нормальные конструкторы вплоть до 5 параметров, но чтобы нормально их внедрить, все равно нужна рефлексия, чтобы найти в проекте все классы, которые поддерживают варианты конструкторов и использовать только нужные из списка, потому что все варианты проверить нереально)
    Правда статическую компиляцию всего этого добра обеспечить довольно трудно, но можно попытаться через лямбды.