Pull to refresh

Особенности внедрения зависимостей в Unity3D

Reading time 6 min
Views 25K
Привет, Хабр!

При ознакомлении с разработкой под Unity3D, для меня, пришедшего из мира Java и PHP, довольно необычным стал подход к внедрению зависимостей на данной платформе. В основном, конечно, это связано с недоступностью конструкторов MonoBehaviour и ScriptableObject, создание объектов вне доступа разработчика, а также наличие редактора, в котором можно конфигурировать каждый объект индивидуально или целыми префабами (при этом оставляя возможность один из экземпляров префаба изменить на своё усмотрение в процессе создания сцены).

Constructor Injection


Конструкторы — это хорошо, но ровно до тех пор, пока вы не начнёте использовать MonoBehaviour или ScriptableObject, экземпляры которых допустимо создавать только через готовые фабрики, подразумевающие использование конструктора без параметров.

Для первого случая, фабрикой будет являться метод GameObject.AddComponent(T), где T — это тип создаваемого компонента. Существовать наследник MonoBehaviour просто так, в отрыве от конкретного игрового объекта не может. Для него инъекция зависимости через конструктор или его аналог невозможна. Чего мы, естественно, делать не хотим. Значит, сюда зависимости внедрять будем другими способами.

Для ScriptableObject фабрикой является метод ScriptableObject.CreateInstance(T), где T, опять же, тип объекта. Но ситуация ничем принципиально не отличается от MonoBehaviour — использовать собственный конструктор не получится.

Но, так как наследники этого класса являются самостоятельными сущностями и спокойно существуют без привязки к конкретному игровому объекту, иногда для обхода конструктора ScriptableObject используется инициализирующий метод, в который передаются все необходимые параметры. Для удобства метод CreateInstance переопределяется для каждого наследника ScriptableObject с указанием параметров псведо-конструктора, дабы при каждом создании экземпляра не стучаться руками в Init. Но, как мы с вами понимаем, это сильно усложняет реализацию и использование класса: внутри него требуется проверка (везде, где только можно) на инициализированность, а во-вне есть риск, что где-то экземпляр будет создан без инициализации, через стандартный CreateInstance. По этим двум причинам, я стараюсь избегать такого использования метода Init.

Property Injection


А вот инъекция зависимостей через публичные свойства объекта — это уже в стиле Unity3D. Именно так работает инспектор с установкой параметров для компонентов префабов и объектов сцены. Для скалярных параметров всё довольно просто — достаточно прописать значения каждого свойства объекта в соответствующем поле формы инспектора. А вот со ссылками на объекты всё менее однозначно.

Ссылка на префаб или собственный компонент


Небольшая оговорка: под «собственным», я подразумеваю компонент, навешанный на тот же игровой объект, что и скрипт, в который мы внедряем зависимость.

Особенность этого варианта в том, что ссылка на собственный компонент или префаб может быть сохранена в префаб объекта и легко использоваться в будущем. Например, мы можем создать N генераторов (spawners) монстров, указав каждому монстра, который он должен генерировать, после чего сохранить эти генераторы в префабы и использовать повторно сколько угодно раз без каких бы то ни было трудностей. Изменение зависимости (т.е. выбранного монстра) происходит легко — в любом используемом на сцене генераторе мы заменяем ссылку на объект монстра, сохраняем префаб и всё, на всех уровнях он изменён. Вот они, прелести внедрения зависимостей!

Ссылка на внешний компонент


Здесь трудность состоит в том, что в префаб объекта нельзя сохранить ссылку на объект, находящегося на сцене, что вполне логично, так как префаб — независимая от сцены сущность. И вариантов решения этой проблемы несколько:
  • Ручное внедрение;
  • «Запекание» зависимостей;
  • Глобальный доступ;
  • Внедрение через порождающий объект;
  • Глобально доступное событие создания;
  • IoC контейнеры.


Ручное внедрение

Как и в случае с созданием префабов, мы просто руками указываем зависимости каждому объекту в инспекторе. Этот вариант работает, если таких объектов с зависимостями у нас не много. Но когда их много (или сцен большое количество), то этот вариант нам вряд ли подойдёт. Да и при смене зависимости, нам придётся всё переделывать заново, так что это скорее анти-паттерн, на фоне которого даже какой-нибудь одиночка (singleton) выглядит куда красивее.

«Запекание» зависимостей

Этот вариант, как и в случае с ручным внедрением, работает только с зависимостями уже созданных на сцене объектов. Нам надо добавить скрипт редактора, который будет проходить по всем объектам сцены определённого типа и подсовывать им указанный компонент в публичное свойство. То есть, запекать зависимости в объектах (небольшое дополнение — все поля компонента, которые были изменены в редакторе таким способом, должны быть отмечены как dirty (гряные?) с помощью метода EditorUtility.SetDirty, иначе они будут сбрасываться в исходное значение при запуске игры).

Глобальный доступ

По глобальному доступу, думаю, подробно расписывать не придётся (все мы так делали, когда начинали программировать) — к объекту, используемому в качестве зависимости обращаемся через публичное статическое свойство или метод для получения ссылки на экземпляр объекта. Назвать это гордыми словами «внедрение зависимости» можно с большой натяжкой, но ведь иногда, на практике, приходится пользоваться и анти-паттернами, не так ли?

Порождающий объект

Если наши объекты являются следствием работы некоей порождающей сущности (а так или иначе она присутствует, если игровые объекты создаются в процессе жизни приложения — вы ведь где-то вызываете GameObject.Instantiate), то именно через этот объект можно передать зависимости всех «детей». То есть, зависимость указывается в публичном поле порождающего объекта и передаётся в порождённые сущности. Единственная загвоздка в том, что если он создаёт разные объекты, то эта зависимость либо должна быть указана в абстрактном классе порождаемых объектов, либо, если зависимость индивидуальна, придётся дополнительно описывать правила инициализации того или иного объекта.

Глобальное событие создания объекта

C# прекрасен такой простой но классной вещью, как события. Которые, в том числе, можно сделать статическими, то есть, вывести в глобальную область видимости (степень глобальности можно ограничить одним пространством имён с помощью модификатора доступа internal).

Для реализации такого способа внедрения зависимостей, нам понадобится выделить в объектах, которых она внедряется, статическое событие, вызываемое в методе Awake и на которое будет подписываться некий слушатель. А затем задавать этим объектам определённые параметры. Этот способ является очень и очень спорным (в общем-то, как и любые глобально доступные объекты), так как за наличием слушателя придётся следить программисту. К счастью, Unity3D извещает разработчика о пустом публичном свойстве у MonoBehaviour посредством сообщения в консоли, но вот на IDE тут полагаться не придётся. Да и следить за всеми этими инициализаторами будет тяжело. Записывает этот пункт в грязные анти-паттерны.

IoC контейнеры

По моей практике, этот способ является во всех известных мне языках, пожалуй, самым популярным.

Что представляет собой контейнер? Это список соответствий интерфейса и конкретного объекта, реализующего этот интерфейс. Однако, так как мы работаем с объектам, создаваемыми самой платформой, а не нами, в конфигураторе контейнера придётся реализовывать поиск конкретных компонентов на сцене или, опять же, указывать их через инспектор (то есть, контейнер должен быть компонентом некоего объекта на сцене). Далее, в скриптах, где используется внедрение зависимости, из контейнера вытаскивается ссылка на нужный объект.

Главная сложность здесь — Unity3D не дружит с интерфейсами (в пространстве имён UnityEngine, например, интерфейс всего один и тот для сериализации). И искать компоненты по интерфейсам он не умеет. Так же, как не умеет показывать в инспекторе поля, чьи типы заданы интерфейсами. Что и понятно, так как там ожидается наследник класса Component, а интерфейса, который подразумевал бы его, нет. Но в интернете существует множество примеров, как реализовать поиск компонентов по интерфейсу, благодаря чему, если мы готовы пожертвовать возможностью использовать инспектор (или готовы написать расширение для него), можно воспользоваться IoC контейнерами.

Простой, но за счёт этого понятный пример IoC контейнера можно посмотреть вот здесь (реализация не моя). К сожалению, он не включает пример поиска объектов по интерфейсам.

Послесловие


Внедрение зависимостей в Unity3D несколько нестандартно из-за особенностей платформы. Кстати, во время написания этой статьи, я немало мониторил интернет насчёт готовых решений по теме и пришёл к выводу, что стоит в ближайшее время найти свободную минутку и заморочиться с написанием небольшой библиотеки для тех же IoC контейнеров с полноценным конфигуратором, адаптированном под Unity3D, так как готовых решений не много. Надеюсь, что в скором времени код окажется в публичном доступе на GitHub, а на Хабрахабре появится ещё одна статья от меня.

Спасибо за внимание :)

PS: буду очень благодарен за указание на ошибки в тексте в личку.
Tags:
Hubs:
+7
Comments 3
Comments Comments 3

Articles