Как стать автором
Обновить
1065.87
OTUS
Цифровые навыки от ведущих экспертов

Архитектура игры на Unity без Zenject. Часть 2

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров5.8K
Автор статьи: Игорь Гулькин

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) квестов. Зарегистрироваться.

Теги:
Хабы:
Всего голосов 11: ↑9 и ↓2+7
Комментарии17

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS