Pure.DI в Unity

2fc89907481ee80c322dc5e39dff51f9.png

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!

© Habrahabr.ru