🎮💉 Инъекция зависимостей в Unity для самых маленьких
Ваш проект Unity утопает в зависимостях, где изменение одного класса ломает всю игру? Внедрение зависимостей решает эту проблему, создавая гибкий и упрощенный подход к разработке. В этой статье мы подробно расскажем, как это работает.
Что такое внедрение зависимостей
В мире разработки игр создание захватывающих и хорошо организованных игр является конечной целью. Однако по мере роста сложности проектов управление зависимостями и обеспечение гибкости кода может стать сложной задачей. Вот тут-то и вступает в дело внедрение зависимостей (Dependency Injection, сокр. DI) .
В этой статье мы погрузимся в мир внедрения зависимостей в Unity, исследуем его концепции, преимущества и практическую реализацию. К концу статьи вы будете иметь четкое представление о том, как внедрение зависимостей может помочь вам в процессе разработки игр.
Данная статья является переводом другой статьи. С оригиналом вы можете ознакомиться по ссылке. В оригинальную статью в процессе перевода были внесены некоторые правки, а материал расширен для лучшего раскрытия темы.
Понимание концепции внедрения зависимостей
По своей сути внедрение зависимостей заключается в переносе ответственности за создание и предоставление зависимостей из класса на внешний источник. Вместо того чтобы класс создавал свои собственные зависимости, он получает их извне. Это не только снижает тесную связь между компонентами, но и упрощает тестирование, повторное использование кода и его последующую поддержку.
В C# внедрение зависимостей может быть достигнуто с помощью техники, называемой «внедрение в конструктор». Давайте разберем основные концепции:
Зависимость — это объект, от которого зависит класс для выполнения своих функций. Например, если вы создаете игру, персонаж может зависеть от оружия для атаки врагов. В программном обеспечении зависимости могут быть чем угодно: от источников данных до других классов или служб.
Внедрение — это процесс предоставления этих зависимостей классу извне. Вместо того чтобы класс создавал свои собственные зависимости, они предоставляются извне.
Особенности DI в Unity
В Unity вы не можете напрямую создавать экземпляры классов, унаследованных от MonoBehaviour с помощью ключевого слова new, поскольку MonoBehaviour — это особый тип, которым Unity управляет изнутри. Поэтому здесь нельзя использовать конструкторы, так как Unity просто не предоставляет к ним доступ.
Этот код выдаст предупреждение «You are trying to create a MonoBehaviour using the new keyword» (Вы пытаетесь создать MonoBehaviour, используя ключевое слово new), и это не будет работать.
Для создания объекта необходимо использовать функцию Instantiate()вместо ключевого слова new. Однако Instantiate() принимает только некоторые параметры, такие как положение, вращение и родительский объект. Мы не можем передать здесь другую ссылку, поэтому мы не можем использовать внедрение зависимостей по умолчанию в Unity.
Другой вариант создания экземпляра класса, унаследованного от MonoBehaviour, является вызов метода AddComponent<ИмяВашегоКласса>(). Этот метод позволяет добавить компонент (а наследники MonoBehaviour являются наследниками класса Component, с которыми и работает данный метод) на уже существующий игровой объект, тем самым создавая экземпляр класса. В этом случае мы тоже не имеем доступа к конструктору класса, так как жизненным циклом этого объекта управляет сам Unity.
Мы поговорим о решении проблемы недоступности конструктора в классах-наследниках MonoBehaviour позже.
В контексте разработки игр Unity сама по себе может помочь нам с процессом внедрения зависимостей, но для начала нам следует разобраться с такой концепцией, как «Инверсия управления (Inversion of Control, сокр. IoC)».
Inversion of Control
IoC относится к идее, что управление потоком программы инвертируется или передается фреймворку или контейнеру, а не контролируется самим кодом приложения.
Например, старший разработчик поручает свою работу младшему разработчику и ничего не делает. Это действительно форма инверсии управления. Это реальный пример того, как концепция инверсии управления может применяться и за пределами разработки программного обеспечения.
Для понимания принципа работы инъекции зависимостей необходимо понять принцип работы конструкторов.
Конструктор — это специальный метод, который вызывается при создании объекта (экземпляра класса). Он используется для инициализации переменных экземпляра класса.
Конструкторы вызываются автоматически при создании нового экземпляра класса и помогают убедиться, что объект правильно инициализирован и готов к использованию.
Изучение внедрения зависимостей (DI) в классическом C# и Unity
Давайте рассмотрим наглядный пример, чтобы понять внедрение зависимостей (DI) как в классическом программировании на C#, так и в Unity.
Допустим, вы создаете простое консольное приложение, в котором у вас есть класс Character, зависящий от класса Weapon. Вот как вы можете реализовать DI без Unity:
- Определение интерфейсов: Создайте интерфейсы, которые определяют поведение оружия, например IWeapon.
- Реализация DI: Спроектируйте класс Character для принятия различных реализаций оружия через внедрение конструктора.
- Собираем все вместе: В вашей основной программе создайте экземпляры оружия и персонажа с выбранным оружием. Персонаж может атаковать, используя внедренное оружие.
В коде это может выглядеть как-то так. Определим интерфейс для оружия с методом атаки и создадим две реализации: меч и лук.
Далее добавим класс персонажа, который в конструкторе будет получать зависимость в виде объекта, который реализует интерфейс IWeapon.
Далее в методе Main создадим экземпляр класса Character, куда передадим экземпляр класса Sword, реализующий интерфейс IWeapon.
Так и будет выглядеть примитивная инъекция зависимостей. Персонаж получает оружие откуда-то извне, а не создает его самостоятельно.
В Unity классы-наследники MonoBehaviour не используют традиционные конструкторы так же, как стандартные классы C#. Это накладывает ограничения на реализацию Dependency Injection в Unity по сравнению с классическим программированием на C#. Вместо внедрения конструктора Unity использует другие методы для достижения DI, наиболее простым из которых является присвоение полям атрибута [SerializeField] полю, которое позволяет выставлять его значение из инспектора прямо в Unity.
Пример реализации инъекции зависимости через интерфейс Unity
Представьте, что у вас есть простая игра, в которой игрок собирает монеты, чтобы набрать очки. Вы хотите реализовать систему подсчета очков с использованием DI. Вот простой способ, как это можно сделать:
Начните с определения интерфейса, например, IScoreService, который содержит в себе методы AddScore() и GetScore().
Теперь создайте класс, реализующий этот интерфейс:
Теперь можно переходить к инъекции зависимостей. В данном случае мы будем использовать атрибут [SerializeField], который позволит отобразить поле во вкладке Inspector в Unity.
В редакторе Unity во вкладке Inspector прикрепите скрипт Player к GameObject вашего игрока. Затем вам нужно создать GameObject с прикрепленным скриптом ScoreService. Этот GameObjectбудет служить вашим контейнером DI. Назначьте объект ScoreService полю _scoreServiceв скрипте Player с помощью Inspector.
При такой настройке каждый раз, когда игрок собирает монету, скрипт Playerдобавляет 10 очков к счету, используя внедренный экземпляр scoreService. Это пример самой простой реализации DI в Unity. Тем не менее, подобный подход использовать не рекомендуется. Более того, он имеет ряд существенных ограничений, которые делают его использование крайне неудобным на проектах хотя бы среднего масштаба.
Рассмотрим же другой подход к реализации инъекции зависимостей.
Инъекция зависимостей через код
Если вы хотите внедрить зависимости через код вместо использования редактора Unity, вы можете использовать один из следующих подходов: внедрение в метод, свойство или поле. Каждый из них подразумевает создание необходимых экземпляров классов-зависимостей в вашем коде и передачу их соответствующим компонентам при их создании.
Измените скрипт Player следующим способом: выберите один из трех предложенных вариантов внедрения зависимостей.
Создайте скрипт CodeInjectionExample, унаследованный от MonoBehaviour. В методе Start() создайте экземпляр класса ScoreService. Создайте игровой объект с именем PlayerObject и добавьте к нему компонент Player, описанный ранее. Затем вы можете внедрить scoreService любым из трех доступных способов.
Такой подход к внедрению зависимостей является более гибким, чем использование редактора Unity. Во-первых, нам открывается возможность внедрять зависимости в процессе игры, а не до ее начала. Во-вторых, теперь нам совсем не нужно, чтобы классы-зависимости наследовались от MonoBehaviour, ведь нет необходимости перетаскивать их в соответствующие поля в инспекторе. Зависимость может быть обычным классом, экземпляр которого создается в коде и там же внедряется в нужный объект.
Тем не менее, этот способ не идеален, ведь мы становимся зависимы от жизненного цикла игровых объектов в Unity, так как занимаемся внедрением зависимостей самостоятельно. Более того, использование публичных полей и свойств нарушает принцип инкапсуляции из концепции объектно-ориентированного программирования.
Следующим вашим шагом в использовании внедрения зависимостей станет использование специальных DI-контейнеров.
Инъекция зависимостей с помощью контейнеров
DI-контейнеры помогают упростить этот процесс, автоматически обрабатывая создание, инициализацию и передачу зависимостей. Один из популярных DI-контейнеров для Unity — это Zenject. Он позволяет избавиться от необходимости вручную связывать зависимости и делает код более читаемым и гибким. Мы рассмотрим использование контейнеров на его примере.
Здесь не будет подробного гайда по использованию Zenject, его установке и настройке, ведь это достаточно обширная тема для отдельной статьи, мы лишь приведем пример его использования в коде. С подробными инструкциями по использованию Zenject вы можете ознакомиться на странице фреймворка на GitHub.
Итак, вернемся к нашему примеру с интерфейсов IScoreService и его реализацией ScoreService, вот их код:
Далее следует создать класс GameInstaller, унаследованный от класса MonoInstaller. В нем вам необходимо переопределить метод InstallBindings(), внутри которого будет размещена логика регистрации зависимостей:
Разберем по шагам, что именно происходит в методе InstallBindings:
- Bind<IScoreService>(): указывает, что мы регистрируем интерфейс IScoreService.
- To<ScoreService>(): привязывает интерфейс к реализации ScoreService.
- AsSingle(): гарантирует, что будет создан один экземпляр ScoreServiceдля всего проекта.
Этот класс необходимо прикрепить к игровому объекту, размещенному на сцене.
Теперь класс Playerбудет выглядеть следующим образом:
В данном примере зависимость передается в метод Construct, который Zenject вызывает автоматически и передает в него указанную зависимость (в данном случае конкретную реализацию – класс ScoreService). Теперь нам не нужно думать, как и когда создавать и передавать зависимости классам, которые в них нуждаются, ведь это берет на себя DI-контейнер. Атрибут [Inject] сообщает контейнеру, что зависимости необходимо передать в указанный метод. Используя DI-контейнеры, мы можем быть уверены, что к моменту начала игры все необходимые зависимости будут переданы всем объектам, которые в них нуждаются, и мы не получим никаких ошибок, связанных, например, с тем, что какие-то сервисы не успели инициализироваться вовремя.
Заключение
Внедряя зависимости извне, а не создавая их внутри классов, инъекция зависимостей способствует слабой связанности между компонентами, упрощает тестирование и позволяет легко вносить изменения и расширения для ваших систем.
Внедрение зависимостей может показаться сложной концепцией на первый взгляд, но ее преимущества для ваших проектов Unity неоспоримы. Используя DI, вы можете создавать более чистый, модульный и тестируемый код, что в конечном итоге приведет к более плавному и приятному процессу разработки. Так что освободитесь от цепочек жестко закодированных зависимостей и добавьте гибкости в свои проекты Unity с помощью DI!
Тонна похожего мега-контента в нашем канале: