Что такое внедрение зависимостей
В мире разработки игр создание захватывающих и хорошо организованных игр является конечной целью. Однако по мере роста сложности проектов управление зависимостями и обеспечение гибкости кода может стать сложной задачей. Вот тут-то и вступает в дело внедрение зависимостей (Dependency Injection, сокр. DI) .
В этой статье мы погрузимся в мир внедрения зависимостей в Unity, исследуем его концепции, преимущества и практическую реализацию. К концу статьи вы будете иметь четкое представление о том, как внедрение зависимостей может помочь вам в процессе разработки игр.
Данная статья является переводом другой статьи. С оригиналом вы можете ознакомиться по ссылке. В оригинальную статью в процессе перевода были внесены некоторые правки, а материал расширен для лучшего раскрытия темы.
Понимание концепции внедрения зависимостей
По своей сути внедрение зависимостей заключается в переносе ответственности за создание и предоставление зависимостей из класса на внешний источник. Вместо того чтобы класс создавал свои собственные зависимости, он получает их извне. Это не только снижает тесную связь между компонентами, но и упрощает тестирование, повторное использование кода и его последующую поддержку.
В C# внедрение зависимостей может быть достигнуто с помощью техники, называемой «внедрение в конструктор». Давайте разберем основные концепции:
Зависимость — это объект, от которого зависит класс для выполнения своих функций. Например, если вы создаете игру, персонаж может зависеть от оружия для атаки врагов. В программном обеспечении зависимости могут быть чем угодно: от источников данных до других классов или служб.
Внедрение — это процесс предоставления этих зависимостей классу извне. Вместо того чтобы класс создавал свои собственные зависимости, они предоставляются извне.
Особенности DI в Unity
В Unity вы не можете напрямую создавать экземпляры классов, унаследованных от MonoBehaviour
с помощью ключевого слова new, поскольку MonoBehaviour
— это особый тип, которым Unity управляет изнутри. Поэтому здесь нельзя использовать конструкторы, так как Unity просто не предоставляет к ним доступ.
public class Player : MonoBehaviour // Класс-наследник MonoBehaviour
{
public int name;
}
public class User : MonoBehaviour
{
Player p1 = new Player(); // Так сделать не получится
}
Этот код выдаст предупреждение «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
для принятия различных реализаций оружия через внедрение конструктора. - Собираем все вместе: В вашей основной программе создайте экземпляры оружия и персонажа с выбранным оружием. Персонаж может атаковать, используя внедренное оружие.
В коде это может выглядеть как-то так. Определим интерфейс для оружия с методом атаки и создадим две реализации: меч и лук.
public interface IWeapon
{
void Attack();
}
public class Sword : IWeapon
{
public void Attack()
{
Console.WriteLine("Удар мечом!");
}
}
public class Bow : IWeapon
{
public void Attack()
{
Console.WriteLine("Выстрел из лука!");
}
}
Далее добавим класс персонажа, который в конструкторе будет получать зависимость в виде объекта, который реализует интерфейс IWeapon
.
public class Character
{
private IWeapon weapon;
public Character(IWeapon weapon)
{
this.weapon = weapon;
}
public void AttackEnemy()
{
weapon.Attack();
}
}
Далее в методе Main
создадим экземпляр класса Character
, куда передадим экземпляр класса Sword
, реализующий интерфейс IWeapon
.
class Program
{
static void Main(string[] args)
{
IWeapon sword = new Sword();
Character character = new Character(sword);
character.AttackEnemy();
}
}
Так и будет выглядеть примитивная инъекция зависимостей. Персонаж получает оружие откуда-то извне, а не создает его самостоятельно.
В Unity классы-наследники MonoBehaviour не используют традиционные конструкторы так же, как стандартные классы C#. Это накладывает ограничения на реализацию Dependency Injection в Unity по сравнению с классическим программированием на C#. Вместо внедрения конструктора Unity использует другие методы для достижения DI, наиболее простым из которых является присвоение полям атрибута [SerializeField]
полю, которое позволяет выставлять его значение из инспектора прямо в Unity.
Пример реализации инъекции зависимости через интерфейс Unity
Представьте, что у вас есть простая игра, в которой игрок собирает монеты, чтобы набрать очки. Вы хотите реализовать систему подсчета очков с использованием DI. Вот простой способ, как это можно сделать:
Начните с определения интерфейса, например, IScoreService
, который содержит в себе методы AddScore()
и GetScore()
.
public interface IScoreService
{
void AddScore(int points);
int GetScore();
}
Теперь создайте класс, реализующий этот интерфейс:
public class ScoreService : IScoreService
{
private int _score;
public void AddScore(int points)
{
_score += points;
}
public int GetScore()
{
return _score;
}
}
Теперь можно переходить к инъекции зависимостей. В данном случае мы будем использовать атрибут [SerializeField]
, который позволит отобразить поле во вкладке Inspector в Unity.
using UnityEngine;
public class Player : MonoBehaviour
{
private const string CoinTag = “Coin”;
[SerializeField] private IScoreService _scoreService;
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag(CoinTag))
{
_scoreService.AddScore(10);
Destroy(other.gameObject);
}
}
}
В редакторе Unity во вкладке Inspector прикрепите скрипт Player
к GameObject
вашего игрока. Затем вам нужно создать GameObject с прикрепленным скриптом ScoreService
. Этот GameObject
будет служить вашим контейнером DI. Назначьте объект ScoreService полю _scoreService
в скрипте Player
с помощью Inspector.
При такой настройке каждый раз, когда игрок собирает монету, скрипт Player
добавляет 10 очков к счету, используя внедренный экземпляр scoreService
. Это пример самой простой реализации DI в Unity. Тем не менее, подобный подход использовать не рекомендуется. Более того, он имеет ряд существенных ограничений, которые делают его использование крайне неудобным на проектах хотя бы среднего масштаба.
Рассмотрим же другой подход к реализации инъекции зависимостей.
Инъекция зависимостей через код
Если вы хотите внедрить зависимости через код вместо использования редактора Unity, вы можете использовать один из следующих подходов: внедрение в метод, свойство или поле. Каждый из них подразумевает создание необходимых экземпляров классов-зависимостей в вашем коде и передачу их соответствующим компонентам при их создании.
Измените скрипт Player следующим способом: выберите один из трех предложенных вариантов внедрения зависимостей.
using UnityEngine;
public class Player : MonoBehaviour
{
private const string CoinTag = “Coin”;
// Вариант 1 для инъекции в поле
public IScoreService ScoreServiceField;
// Вариант 2 для инъекции в свойство
public IScoreService ScoreServiceProperty { get; set; }
// Вариант 3 для инъекции в метод
private IScoreService _scoreService;
public void Construct(IScoreService scoreService)
{
_scoreService = scoreService;
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag(CoinTag))
{
// Здесь будет обращение к экземпляру IScoreService
Destroy(other.gameObject);
}
}
}
Создайте скрипт CodeInjectionExample
, унаследованный от MonoBehaviour. В методе Start()
создайте экземпляр класса ScoreService
. Создайте игровой объект с именем PlayerObject
и добавьте к нему компонент Player
, описанный ранее. Затем вы можете внедрить scoreService
любым из трех доступных способов.
using UnityEngine;
public class CodeInjectionExample : MonoBehaviour
{
private void Start()
{
IScoreService scoreService = new ScoreService();
// Создание игрового объекта и присвоение ему скрипта Player
GameObject playerGameObject = new GameObject("PlayerObject");
playerGameObject.AddComponent<Player>();
// Внедрение зависимости с помощью инъекции в поле
player.ScoreServiceField = scoreService;
// Внедрение зависимости с помощью инъекции в свойство
player.ScoreServiceProperty = scoreService;
// Внедрение зависимости с помощью инъекции в метод
player.Construct(scoreService);
}
}
Такой подход к внедрению зависимостей является более гибким, чем использование редактора Unity. Во-первых, нам открывается возможность внедрять зависимости в процессе игры, а не до ее начала. Во-вторых, теперь нам совсем не нужно, чтобы классы-зависимости наследовались от MonoBehaviour, ведь нет необходимости перетаскивать их в соответствующие поля в инспекторе. Зависимость может быть обычным классом, экземпляр которого создается в коде и там же внедряется в нужный объект.
Тем не менее, этот способ не идеален, ведь мы становимся зависимы от жизненного цикла игровых объектов в Unity, так как занимаемся внедрением зависимостей самостоятельно. Более того, использование публичных полей и свойств нарушает принцип инкапсуляции из концепции объектно-ориентированного программирования.
Следующим вашим шагом в использовании внедрения зависимостей станет использование специальных DI-контейнеров.
Инъекция зависимостей с помощью контейнеров
DI-контейнеры помогают упростить этот процесс, автоматически обрабатывая создание, инициализацию и передачу зависимостей. Один из популярных DI-контейнеров для Unity — это Zenject. Он позволяет избавиться от необходимости вручную связывать зависимости и делает код более читаемым и гибким. Мы рассмотрим использование контейнеров на его примере.
Здесь не будет подробного гайда по использованию Zenject, его установке и настройке, ведь это достаточно обширная тема для отдельной статьи, мы лишь приведем пример его использования в коде. С подробными инструкциями по использованию Zenject вы можете ознакомиться на странице фреймворка на GitHub.
Итак, вернемся к нашему примеру с интерфейсов IScoreService
и его реализацией ScoreService
, вот их код:
public interface IScoreService
{
void AddScore(int points);
int GetScore();
}
public class ScoreService : IScoreService
{
private int _score;
public void AddScore(int points)
{
_score += points;
}
public int GetScore()
{
return _score;
}
}
Далее следует создать класс GameInstaller
, унаследованный от класса MonoInstaller
. В нем вам необходимо переопределить метод InstallBindings()
, внутри которого будет размещена логика регистрации зависимостей:
using Zenject;
using UnityEngine;
public class GameInstaller : MonoInstaller
{
public override void InstallBindings()
{
// Регистрация зависимостей
Container.Bind<IScoreService>().To<ScoreService>().AsSingle();
}
}
Разберем по шагам, что именно происходит в методе InstallBindings
:
Bind<IScoreService>()
: указывает, что мы регистрируем интерфейсIScoreService
.To<ScoreService>()
: привязывает интерфейс к реализацииScoreService
.AsSingle()
: гарантирует, что будет создан один экземплярScoreService
для всего проекта.
Этот класс необходимо прикрепить к игровому объекту, размещенному на сцене.
Теперь класс Player
будет выглядеть следующим образом:
using Zenject;
using UnityEngine;
public class Player : MonoBehaviour
{
private const string CoinTag = "Coin";
private IScoreService _scoreService;
[Inject] // Указывает Zenject, что зависимость нужно передать сюда
private void Construct(IScoreService scoreService)
{
_scoreService = scoreService;
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag(CoinTag))
{
_scoreService.AddScore(10);
Destroy(other.gameObject);
}
}
}
В данном примере зависимость передается в метод Construct
, который Zenject вызывает автоматически и передает в него указанную зависимость (в данном случае конкретную реализацию – класс ScoreService
). Теперь нам не нужно думать, как и когда создавать и передавать зависимости классам, которые в них нуждаются, ведь это берет на себя DI-контейнер. Атрибут [Inject]
сообщает контейнеру, что зависимости необходимо передать в указанный метод. Используя DI-контейнеры, мы можем быть уверены, что к моменту начала игры все необходимые зависимости будут переданы всем объектам, которые в них нуждаются, и мы не получим никаких ошибок, связанных, например, с тем, что какие-то сервисы не успели инициализироваться вовремя.
Заключение
Внедряя зависимости извне, а не создавая их внутри классов, инъекция зависимостей способствует слабой связанности между компонентами, упрощает тестирование и позволяет легко вносить изменения и расширения для ваших систем.
Внедрение зависимостей может показаться сложной концепцией на первый взгляд, но ее преимущества для ваших проектов Unity неоспоримы. Используя DI, вы можете создавать более чистый, модульный и тестируемый код, что в конечном итоге приведет к более плавному и приятному процессу разработки. Так что освободитесь от цепочек жестко закодированных зависимостей и добавьте гибкости в свои проекты Unity с помощью DI!
Какие проблемы вы встречали при внедрении Dependency Injection в свои проекты? Расскажите о своих находках и решениях.