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!

Комментарии (13)


  1. Tr0sT
    29.01.2025 13:15

    Вот это:

    void Start()
    {
        // Injects dependencies
        Composition.Shared.BuildUp(this);
    }
    

    как будто бы не очень удобно - хочется чтобы был какой-то factory метод для инстаншиэйта, который бы инициализировал все монобэхи на префабе. типа _composition.Instantiate(prefab);

    и внутри было что-то типа

    public GameObject Instantiate(GameObject go)
    {
        var result = GameObject.Instantiate(go);
        foreach (var innerMonoBeh in GetAllInnerMonobehs(go))
        {
            BuildUp(innerMonoBeh);
        }
        return result;
    }
    


    1. Tr0sT
      29.01.2025 13:15

      и ещё лучше инжектить не через проперти, а через метод, типа

      public class Clock : MonoBehaviour|
      {
          private IClockService _clockService = null!;
      
          [Dependency]
          public void Inject(IClockService clockService)
          {
              _clockService = clockService;
          }
      }
      


      1. NikolayPyanikov Автор
        29.01.2025 13:15

        Да, так работает тоже


    1. NikolayPyanikov Автор
      29.01.2025 13:15

      BuildUp(Clock clock) принимает аргументом экземпляр конкретного типа (не базового). И в зависимости от этого типа выполняет внедрение набора зависимостей.

      Вы можете использовать несколько Builder<>() что бы создать несколько перегрузок метода BuildUp(...). Но это всегда будет определенный набор, который соответствует набору изBuilder<>(). Это сделано потому что сгенерировать BuildUp() для всех типов объектов на этапе компиляции невозможно. Этим Pure.DI отличается от классическим библиотек DI. Pure.DI на этапе компиляции должен знать какие проверки выполнять и какие методы BuildUp(...)сгенерировать.


      1. Tr0sT
        29.01.2025 13:15

        foreach (var innerMonoBeh in GetAllInnerMonobehs(go))
            {
                // вот тут бы тогда кодогенерить switch на все поддерживаемые монобехи,
                // для которых builder зарегистрирован 
               switch (innerMonoBeh)
                {
                    case Clock clock:
                           BuildUp(clock);
                          break;
                     default: break;
                }
         }
        


        1. NikolayPyanikov Автор
          29.01.2025 13:15

          Да, так будет работать. Но этот код нужно написать вручную


        1. NikolayPyanikov Автор
          29.01.2025 13:15

          Не совсем понял зачем нужен этот метод, если его можно заменить на:

          Composition.Shared.BuildUp(obj);

          Если вдруг не будет нужной настройки, то не будет метода "BuildUp" и будет ошибка компиляции. Все разумно и безопасно


          1. Tr0sT
            29.01.2025 13:15

            с методом Start, который вызывает инициализацию класса, вы как-будто бы нарушаете свой принцип - отсутствие зависимости на фреймворк DI.

            По-моему стрёмно, когда каждый монобех сам себя инициализирует - ваш Composition.Shared превращается в какой-то сервис локатор в таком случае. С инстаншиэйтом через фабрики (как сделано во всех di фреймворках на юнити) - можно перейти на pure.di просто изменив атрибут с [Inject] на [Dependency] и не нужно ничего дописывать (ну кроме фабрики).


            1. Tr0sT
              29.01.2025 13:15

              ещё без Start (а с инстаншиэйтом только через фабрику) можно уйти от .Builder()

              просто все монобехи у которых есть [Dependency] дописывать в switch. Хотя не очень понятно, что мешает и сейчас генерить BuildUp для них автоматически...


              1. NikolayPyanikov Автор
                29.01.2025 13:15

                Спасибо за идею! Подумаю как это можно реализовать


  1. SadOcean
    29.01.2025 13:15

    То есть отличие от других контейнеров это кодогенерация?
    Если это самоподнимаемый объект, и мы используем инициализацию в Start и имеем его как зависимость, не проще ли тогда просто использовать его как сервис локатор?

    public IClockService ClockService { private get; set; }
    void Start()
    { // Injects dependencies
    ClockService = Composition.Shared.Resolve<IClockService>();
    }

    Еще хочется отметить, что по сути система инициализации через префабы и Scriptable Objects - это де факто встроенная в Unity DI система.
    Можно просто использовать SO как сервисы (или адаптеры сервисов). У них нет своего поведения в рамках ЖЦ юнити, но поведение в смысле методов они вполне могут иметь.

    Вообще жаль, что у юнити нет каких нибудь хаков на создание объектов, чтобы можно было снаружи подключить инъекции без модификации объекта или метода инициализации


    1. NikolayPyanikov Автор
      29.01.2025 13:15

      Вообще жаль, что у юнити нет каких нибудь хаков на создание объектов, чтобы можно было снаружи подключить инъекции без модификации объекта или метода инициализации

      Полностью с вами согласен

       не проще ли тогда просто использовать его как сервис локатор

      Как сервис локатор работать не будет, Pure.DI - антипод сервис локатору. Только зарегистрированные корни можно получать, так как только для них есть код создания.

      То есть отличие от других контейнеров это кодогенерация?

      Это отличие порождает множество других. Есть свои плюсы и минусы.


      1. SadOcean
        29.01.2025 13:15

        Насколько Я понимаю, сервис локатор отличается от DI контейнером только направлением связи (и, из-за этого, сложностью.

        DI поставляет зависимости снаружи, благодаря чему объект в идеале не знает про DI, соответственно не тащит зависимостей. Конечно в идеальном случае с конструктором. Если есть атрибуты и инъекция в метод/поле - это уже небольшая связь. Тут объект знает только про зависимость.

        Сервис локатор - это точка доступа, у которой объект сам запрашивает нужные ему типы. Получается что объект знает про зависимость и сам сервис локатор.

        То есть фактически DI содержит в себе сервис локатор и функции у них те же - возврат экземпляра зависимости

        Зависимость и ее тип тоже настраивается снаружи (обычно это новый инстанс, единственный инстанс по типу синглтона или какие то специальные инстансы со специальным ЖЦ, например кешируемые)