Pure.DI в Unity

Pure.DI — это генератор исходного кода C# для создания композиций объектов в парадигме чистого DI. С версии 2.1.53 в нем появились новые возможности, которые будут полезны разработчикам игр на Unity. Предлагается познакомиться с ними на этом примере.
Основной сценарий использования Pure.DI — это генерация частичного класса на языке C#. Такой класс содержит один и или несколько свойств/методов, каждый из которых предоставляет композицию объектов. Так, в примере ниже показана настройка Pure.DI для создания частичного класса с именем Composition
. Экземпляр такого класса дает возможность получить композицию объектов с корнем типа Service
, как в строке 9 в примере ниже:
using Pure.DI;
DI.Setup("Composition")
.Root("MyService");
var composition = new Composition();
// var service = new Service(new Dependency())
var service = composition.MyService;
class Dependency;
class Service(Dependency dependency);
Код свойства MyService
в классе Composition
выглядит просто:
public Service MyService
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
return new Service(new Dependency());
}
}
Этот код создает композицию из двух объектов, выполняя внедрение зависимости типа Dependency
в конструктор Service
. Внедрение зависимостей через конструктор — рекомендуемый способ, так как другие способы (через поля, свойства или методы) на какой-то момент оставляют объект не готовым к использованию. Это момент — между выполнением конструктора и «финальной инициализацией» объекта. Можно по ошибке забыть выполнить все необходимые манипуляции с экземпляром после его создания и до его использования, или же сделать эти действия в неверном порядке.
Теперь несколько слов о Unity. Unity — это игровой движок, разработанный компанией Unity Technologies. Он используется для создания интерактивных 2D и 3D приложений– игр или симуляторов. Unity поддерживает множество платформ, включая Windows, macOS, Android, iOS, виртуальную и дополненную реальность. В Unity MonoBehaviour
— это основа для создания сценариев, игровых персонажей, анимаций, искусственного интеллекта и других элементов игры. В терминах языка C#, MonoBehaviour
— это базовый класс, который предоставляет различные методы и свойства для управления поведением объекта в игре. Помимо MonoBehaviour
в Unity еще есть ScriptableObject
— это специальный базовый класс, который позволяет создавать свои типы для настроек игры, параметров персонажей, таблиц врагов и т. д. ScriptableObject
отличается от MonoBehaviour
тем, что ScriptableObject
не имеет поведения и лишь хранит данные.
Ниже приведен пример сценария Unity для отображения аналоговых часов:
using UnityEngine;
public class Clock : MonoBehaviour
{
const float HoursToDegrees = -30f;
const float MinutesToDegrees = -6f;
const float SecondsToDegrees = -6f;
[SerializeField]
private Transform hoursPivot;
[SerializeField]
private Transform minutesPivot;
[SerializeField]
private Transform secondsPivot;
void Update()
{
var now = DateTime.Now.TimeOfDay;
hoursPivot.localRotation = Quaternion
.Euler(0f, 0f, HoursToDegrees * (float)now.TotalHours);
minutesPivot.localRotation = Quaternion
.Euler(0f, 0f, MinutesToDegrees * (float)now.TotalMinutes);
secondsPivot.localRotation = Quaternion
.Euler(0f, 0f, SecondsToDegrees * (float)now.TotalSeconds);
}
}
В этом примере при выполнении метода Update()
сценарий Clock
поворачивает стрелки аналоговых часов на угол соответствующий текущему времени. Метод Update()
вызывается инфраструктурой Unity при отображении каждого кадра.
Сценарии могут быть сложными и иметь много кода. В какой-то момент хорошим решением может быть перенос кода сценариев в другие классы. Сценарий аналоговых часов Clock
простой. Но для примера перенесем логику определения текущего времени из метода Update()
в новый класс ClockService
. Тогда сценарий Clock
может выглядеть примерно так:
using Pure.DI;
using UnityEngine;
public class Clock : MonoBehaviour
{
...
ClockService _clockService
void Update()
{
var now = _clockService.Now.TimeOfDay;
hoursPivot.localRotation = Quaternion
.Euler(0f, 0f, HoursToDegrees * (float)now.TotalHours);
...
}
}
В строке 8 появилось поле _clockService
. Перед использованием его нужно обязательно проинициализировать. Можно просто создать новый экземпляр типа ClockService
в строке 8. Когда таких полей появляется больше, а приложение становится сложным, то для уменьшения связанности кода рекомендуется применить DI и внедрить все требуемые зависимости извне. К сожалению, в Unity нет возможности внедрить зависимости в сценарии через конструктор, так как созданием объектов сценариев занимается инфраструктура. Следовательно, внедрить зависимостей в объекты-наследники MonoBehaviour
или ScriptableObject
можно только через поля, свойства и/или методы. Поэтому, когда разработчики игр на Unity начали использовать Pure.DI, был предложен следующий вариант настройки:
using Pure.DI;
using static Pure.DI.Lifetime;
partial class Composition
{
public static readonly Composition Shared = new();
void Setup() => DI.Setup()
.Bind().As(Singleton).To()
.RootArg("clock", "arg")
.Bind().To(ctx =>
{
ctx.Inject("arg", out Clock clock);
ctx.BuildUp(clock);
return clock;
})
.Root("BuildUp");
}
Настройка выше делает следующее:
В строке 6 создается публичный статический объект
Shared
типаComposition
, он будет выполнять «финальную инициализацию» объектов наследниковMonoBehaviour
иScriptableObject
В строке 10 определяется аргумент корня композиции типа
Clock
с тегомarg
Строки с 11 по 16 определяют фабрику, которая будет «достраивать» объект типа
Clock
, полученный из аргументастрока 13 сохраняет значения аргумента (из строки 10) с тегом
arg
в локальную переменнуюclock
строка 14 предписывает внедрить зависимости через поля, свойства или методы в объект, который хранится в переменной
clock
, т.е. достроить его
Строка 17 нужна для создания метода с именем
"BuildUp"
, фактически это корень композиции с аргументов типаClock
, который возвращает готовую к использованию композицию объектов
Для того чтобы можно было внедрить зависимость в объект типа Clock
, его закрытое поле _clockService
было преобразовано в свойство ClockService
абстрактного типа IClockService
с публичной операцией присвоения (в строке 6 примера ниже):
public class Clock : MonoBehaviour
{
...
[Ordinal(0)]
public IClockService ClockService { private get; set; }
void Start()
{
// Injects dependencies
Composition.Shared.BuildUp(this);
}
void Update()
{
var now = _clockService.Now.TimeOfDay;
hoursPivot.localRotation = Quaternion
.Euler(0f, 0f, HoursToDegrees * (float)now.TotalHours);
...
}
}
Использование абстрактного типа для свойства опционально и призвано уменьшить связанность кода.
Обратите внимание, что атрибут Ordinal
(строка 5), перед свойством ClockService
, нужен для того, чтобы Pure.DI мог понять, какие поля, свойства или методы принимают участие во внедрении зависимостей. Был добавлен метод Start()
для инициализации текущего сценария, в нём вызов метода BuildUp(this)
(строка 11) выполняет внедрение зависимостей. Метод Start()
вызывается инфраструктурой Unity в момент, когда сценарий «включается», и идеально подходит для задач, которые должны быть выполнены до начала основного игрового процесса, например, загрузки ресурсов или установки начальных значений полей и свойств. Это как раз наш случай, так как метод Start()
внедряет зависимость в свойство ClockService
.
Абстракция для сервиса выглядит так:
interface IClockService
{
DateTime Now { get; }
}
А сам сервис так:
class ClockService : IClockService
{
public DateTime Now => DateTime.Now;
}
Подход представленный выше — вполне рабочий. Существенным его недостатком является количество настроек Pure.DI — 8 строк кода. Поэтому начиная с версии 2.1.53 в Pure.DI появилась дополнительная настройка Builder
, которая позволяет создать метод для внедрения зависимостей для уже созданного объекта. Теперь настройка Pure.DI для сценариев Unity выглядит проще и компактнее:
using Pure.DI;
using static Pure.DI.Lifetime;
internal partial class Composition
{
public static readonly Composition Shared = new();
private void Setup() => DI.Setup()
.Bind().As(Singleton).To()
.Builder();
}
В API Pure.DI дополнительно был добавлен атрибут Dependency
, который по своей сути мало чем отличается от атрибута Ordinal
. Название Dependency
выглядит более уместно. Финальный вариант для Unity сцены Clock
выглядит так:
using Pure.DI;
using UnityEngine;
public class Clock : MonoBehaviour
{
const float HoursToDegrees = -30f;
const float MinutesToDegrees = -6f;
const float SecondsToDegrees = -6f;
[SerializeField]
private Transform hoursPivot;
[SerializeField]
private Transform minutesPivot;
[SerializeField]
private Transform secondsPivot;
[Dependency]
public IClockService ClockService { private get; set; }
void Start()
{
// Injects dependencies
Composition.Shared.BuildUp(this);
}
void Update()
{
var now = ClockService.Now.TimeOfDay;
hoursPivot.localRotation = Quaternion
.Euler(0f, 0f, HoursToDegrees * (float)now.TotalHours);
minutesPivot.localRotation = Quaternion
.Euler(0f, 0f, MinutesToDegrees * (float)now.TotalMinutes);
secondsPivot.localRotation = Quaternion
.Euler(0f, 0f, SecondsToDegrees * (float)now.TotalSeconds);
}
}
Настройка Builder
без аргументов привела к созданию в классе Composition
метода с именем BuildUp
:
[CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public Clock BuildUp(Clock buildingInstance)
{
if (buildingInstance is null)
throw new ArgumentNullException(nameof(buildingInstance));
if (_clockService is null)
lock (_lock)
if (_clockService is null)
_clockService = new ClockService();
buildingInstance.ClockService = _clockService;
return buildingInstance;
}
Название метода можно переопределить в первом аргументе, например
Builder
.("BuildUpClock")
Из фрагмента кода выше можно сделать вывод, что Pure.DI имеет ряд преимуществ перед классическими библиотеками контейнеров DI:
Не влияет на производительность, потребление памяти и не добавляет побочных эффектов при создании композиции объектов
Во время выполнения работает только код создания композиций объектов, вся логика анализа графа объектов, конструкторов, полей, свойств и методов происходит во время компиляции
Во время компиляции Pure.DI уведомляет разработчика об отсутствующих или циклических зависимостях, о случаях, когда некоторые зависимости не подходят для внедрения, и других ошибках, что исключает проблемы во время выполнения
Pure.DI не добавляет зависимости на библиотеки
Для запуска примера Unity
Склонируйте репозиторий Pure.DI
Убедитесь что у вас установлен Unity Hub. Если нет, скачайте и установите его: перейдите на официальный сайт Unity и нажмите кнопку «Download Unity Hub», запустите скачанный файл и следуйте инструкциям на экране для установки Unity Hub
Установите Unity Editor версии 6000.0.35f1 или новее через Unity Hub: откройте Unity Hub, перейдите на вкладку «Installs» и нажмите кнопку «Add», выберите нужную версию Unity Editor и дополнительные модули, затем нажмите «Next» и «Install»
Нажмите «Projects» и выберите «Add» и «Add project from disk», найдите проект «samples/UnityApp» на диске в директории склонированного репозитория и добавьте его
Более общие примеры сценариев использования настройки Builder
вы можете найти в репозитории проекта Pure.DI:
Спасибо, что уделили время моей статье! Буду рад услышать ваше мнение и предложения … и не только от разработчиков на Unity!