Pure.DI — это генератор исходного кода C# для создания композиций объектов в парадигме чистого DI. С версии 2.1.53 в нем появились новые возможности, которые будут полезны разработчикам игр на Unity. Предлагается познакомиться с ними на этом примере.

Основной сценарий использования Pure.DI — это генерация частичного класса на языке C#. Такой класс содержит один и или несколько свойств/методов, каждый из которых предоставляет композицию объектов. Так, в примере ниже показана настройка Pure.DI для создания частичного класса с именем Composition. Экземпляр такого класса дает возможность получить композицию объектов с корнем типа Service, как в строке 9 в примере ниже:

using Pure.DI;

DI.Setup("Composition")
    .Root<Service>("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<ClockService>()
        .RootArg<Clock>("clock", "arg")
        .Bind().To(ctx =>
        {
            ctx.Inject("arg", out Clock clock);
            ctx.BuildUp(clock);
            return clock;
        })
        .Root<Clock>("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<T>(), которая позволяет создать метод для внедрения зависимостей для уже созданного объекта. Теперь настройка 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<ClockService>()
        .Builder<Clock>();
}

В 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<Clock>() без аргументов привела к созданию в классе 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<Clock>("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<T>() вы можете найти в репозитории проекта Pure.DI:

Спасибо, что уделили время моей статье! Буду рад услышать ваше мнение и предложения ... и не только от разработчиков на Unity!