Dependency Injection в Unity3d

в 9:23, , рубрики: dependency injection, game development, unity3d, метки: ,

Добрый день, уважаемые коллеги!

Так получилось, что к моменту начала работы с Unity3D, у меня был четырехлетний опыт разработки на .NET. Три года из этих четырех я успешно применял dependency injection в нескольких крупных промышленных проектах. Этот опыт оказался для меня настолько позитивен, что я постарался привнести его и в геймдев.
Сейчас уже могу сказать, что затеял это не зря. Дочитав до конца, вы увидите пример того, как dependency injection позволяет сделать код читабельнее и проще, но в то же время гибче, а заодно еще и более пригодным для юнит-тестирования. Даже если вы впервые слышите словосочетание dependency injection — ничего страшного. Не проходите мимо! Эта статья задумана как ознакомительная, без погружения в тонкие материи.

Про dependency injection написано очень много, в том числе и на Хабре. Существует и большое количество решений для DI — так называемые DI-контейнеры. К сожалению, при ближайшем рассмотрении выяснилось, что большинство из них тяжеловесны и перегружены функционалом, так что я побоялся применять их в мобильных играх. Некоторое время я использовал Lightweight-Ioc-Container (все ссылки приведены в конце статьи), однако впоследствии отказался и от него и, каюсь, написал свой. В свое оправдание могу сказать только, что старался создать максимально простой контейнер, заточенный на применение с Unity3D и легкую расширяемость.

Пример

Итак, рассмотрим применение dependency injection на специально упрощенном примере. Предположим, мы пишем игру, в которой игрок должен лететь вперед на космическом корабле уклоняясь от метеоритов. Все, что он может делать — нажатием кнопок смещать корабль влево-вправо, чтобы избегать столкновений. Должно получиться что-то типа раннера, только на космическую тематику.
У нас уже есть класс KeyboardController, который будет сообщать нам о нажатых кнопках, и класс космического корабля SpaceShip, который умеет красиво перемещаться влево-вправо, выбрасывая при этом потоки партиклов. Свяжем все это вместе:

public class MyClass
{
    private SpaceShip spaceShip;
    private KeyboardController controller;

    public void Init()
    {
        controller = new KeyboardController();
        GameObject gameObject = GameObject.Find("/root/Ships/MySpaceShip");
        spaceShip = gameObject.GetComponent<SpaceShip>();
    }

    public void Update()
    {
        if (controller.LeftKeyPressed())
            spaceShip.MoveLeft();
        if (controller.RightKeyPressed())
            spaceShip.MoveRight();
    }
}

Код получился отличный — простой и понятный. Наша игра практически готова.

Аааа!!! Только что пришел главный дизайнер, и сказал, что концепция поменялась. Мы теперь пишем не под PC, а под планшеты и кораблем нужно управлять не кнопками, а наклонами планшета влево-вправо. А в некоторых сценах вместо полета на космическом корабле у нас будет бегущий по коридору смешной инопланетянин. И этот инопланетянин должен управляться свайпами. Это же все переделывать!!!
Или не все?
Даже если мы введем интерфейсы, чтобы уменьшить связанность, это нам ничего не даст:

public class MyClass
{
    private IControlledCharacter spaceShip;
    private IController controller;

    public void Init()
    {
        controller = new KeyboardController();
        GameObject gameObject = GameObject.Find("/root/Ships/MySpaceShip");
        spaceShip = gameObject.GetComponent<SpaceShip>();
    }

    public void Update()
    {
        if (controller.LeftCmdReceived())
            spaceShip.MoveLeft();
        if (controller.RightCmdReceived())
            spaceShip.MoveRight();
    }
}

Я даже переименовал методы LeftKeyPressed() и RightKeyPressed() в LeftCmdReceived() и RightCmdReceived(), но и это не помогло (тут должен быть грустный смайлик) В коде все равно остаются имена классов KeyboardController и SpaceShip. Нужно как-то избежать привязки к конкретным реализациям интерфейсов. Было бы круто, если бы в наш код передавались сразу же интерфейсы. Например вот так:

public class MyClass
{
    public IControlledCharacter SpaceShip { get; set; }

    public IController Controller { get; set; }
              
    public void Update()
    {
        if (Controller.LeftCmdReceived())
            SpaceShip.MoveLeft();
        if (Controller.RightCmdReceived())
            SpaceShip.MoveRight();
    }
}

Хм, гляди-ка! Наш класс стал короче и читабельнее! Исчезли строчки связанные с поиском объекта в дереве сцены и получением его компонента. Но с другой стороны, эти строчки же должны где-то присутствовать? Нельзя же их так просто взять и выкинуть? Получается, мы упростили наш класс, чтобы усложнить код, который его использует?

Не совсем так. Нужные нам объекты в свойства нашего класса можно передавать автоматически — «инжектить». Это может сделать за нас DI-контейнер!

Но для этого нам придется ему немножечко помочь:
1. Четко обозначить зависимости нашего класса. В приведенном выше примере мы делаем это при помощи свойств с атрибутами [Dependency]:

public class MyClass
{
    [Dependency]
    public IControlledCharacter SpaceShip { private get; set; }

    [Dependency]
    public IController Controller { private get; set; }
              
    public void Update()
    {
        if (Controller.LeftCmdReceived())
            SpaceShip.MoveLeft();
        if (Controller.RightCmdReceived())
            SpaceShip.MoveRight();
    }
}

2. Мы должны создать контейнер и сказать ему, откуда брать объекты для этих зависимостей — сконфигурировать его:

var container = new Container();
container.RegisterType<MyClass>();
container.RegisterType<IController, KeyboardController>();
container.RegisterSceneObject<IControlledCharacter>("/root/Ships/MySpaceShip");

Теперь заставим контейнер собрать нужный нам объект:

MyClass obj = container.Resolve<MyClass>();

В obj будут проставлены все необходимые зависимости.

Как это работает?

Что происходит, когда мы просим контейнер предоставить объект типа MyClass?
Контейнер ищет запрашиваемый тип среди зарегистрированных. В нашем случае класс MyClass зарегистрирован в контейнере при помощи RegisterType(), что означает — по запросу контейнер должен создать новый объект этого типа.
После создания нового объекта MyClass, контейнер проверяет — есть ли у него зависимости? Если зависимостей нет, контейнер вернет созданный объект. Но в нашем примере зависимостей целых две и контейнер пытается разрешить их точно так же, как и вызов пользователем Resolve<>().

Одна из зависимостей — зависимость типа IController. RegisterType<IController, KeyboardController>() подсказывает контейнеру, что при запросе объекта IController нужно создать новый объект типа KeyboardController (и конечно же разрешить его зависимости, если они есть).
Где взять объект для второй зависимости IControlledCharacter, мы сообщили контейнеру при помощи RegisterSceneObject("/root/Ships/MySpaceShips"). Тут от контейнера не требуется ничего создавать. Достаточно найти game object по пути в дереве сцены, а у него — выбрать компонент, реализующий указанный интерфейс.

Что еще умеет наш DI-контейнер? Много всякого. Например еще он поддерживает синглтоны. В приведеном выше примере каждый, кто запросит объект IController, получит свою копию KeyboardController. Мы могли бы зарегистрировать KeyboardController как синглтон, и тогда все обратившиеся получали бы ссылку на один и тот же объект. Мы могли бы даже создать объект сами, при помощи 'new', а потом передать его контейнеру, чтобы он раздавал объект страждущим. Это полезно, когда синглтон требует какой-то нетривиальной инициализации.

Тут дорогой читатель подозрительно прищурится и спросит — а не оверинжиниринг ли это? Зачем городить такие огороды, когда есть старый-добрый рецепт синглтона с «public static T Instance {get;}»? Отвечаю — по двум причинам:
1. Обращение к статическому синглтону скрыто в коде, и с первого взгляда бывает невозможно сказать — обращается ли наш класс к синглтону или нет. В случае же использования dependency injection через свойства все ясно как божий день. Все зависимости видны в интерфейсе класса и помечены атрибутами Dependency. У нас, в добавок к этому, coding convention требует, чтобы все зависимости класса были сгруппированы вместе и шли сразу после приватных переменных, но до конструкторов.
2. Написать юнит-тесты для класса, который обращается к традиционному синглтону, вообще задача нетривиальная. В случае применения DI-контейнера наша жизнь сильно упрощается. Нужно только сделать, чтобы класс обращался к синглтону через интерфейс, а в контейнере зарегистрировать соответствующий мок. Вообще это относится не только к синглтонам. Вот пример юнит-теста для нашего класса:

var controller = new Mock<IController>();
controller.Setup(c => c.LeftCmdReceived()).Returns(true);

var spaceShip = new Mock<IControlledCharacter>();
                     
var container = new Container();
container.RegisterType<MyClass>();
container.RegisterInstance<IController>(controller.Object);
container.RegisterInstance<IControlledCharacter>(spaceShip.Object);

var myClass = container.Resolve<MyClass>();
myClass.Update();

spaceShip.Verify(s => s.MoveLeft(), Times.Once());
spaceShip.Verify(s => s.MoveRight(), Times.Never());

Для написания этого теста я использовал Moq. Тут мы создаем два мока — один для IController и другой для IControlledCharacter. Для IController задаем поведение — метод LeftCmdReceived() при вызове должен вернуть true. Оба мока регистрируем в контейнере. Затем получаем из него объект MyClass (обе зависимости которого будут теперь нашими моками) и вызываем у него Update(). После чего проверяем, что метод MoveLeft() был вызван один раз, а MoveRight() — ни одного.
Да, конечно, моки можно было воткнуть в MyClass «ручками», без всякого контейнера. Однако, напомню, пример специально упрощен. Представьте, что нужно протестировать не один класс, а набор объектов, которые должны работать в связке. В этом случае мы подменим в контейнере моками только отдельные сущности, которые ну никак не пригодны для тестирования — например классы, которые лезут в БД или сеть.

Сухой остаток

1. Обратившись к контейнеру, мы получим уже собранный объект со всеми его зависимостями. А так же зависимостями его зависимостей, зависимостями зависимостей его зависимостей и т.д.
2. Зависимости класса очень четко выделены в коде, что сильно повышает читабельность. Достаточно одного взгляда, чтобы понять, с какими сущностями класс взаимодействует. Читабельность, на мой взгляд, очень важное качество кода, если вообще не самое важное. Легко читать -> легко модифицировать -> меньше вероятность внесения багов -> код живет дольше -> разработка движется быстрее и стоит дешевле
3. Сам код упрощается. Даже в нашем тривиальном примере удалось избавиться от поиска объекта в дереве сцены. А сколько таких однотипных кусков кода раскидано в реальных проектах? Класс стал более сфокусирован на своем основном функционале
4. Появляется дополнительная гибкость — изменить настройку контейнера легко. Все изменения, отвечающие за связывание ваших классов между собой, локализованы в одном месте
5. Из этой гибкости (и применения интерфейсов для уменьшения связанности) проистекает легкость юнит-тестирования ваших классов
6. И последнее. Бонус для тех, кто терпеливо дочитал до этого места. Мы неожиданно получили дополнительную метрику качества кода. Если ваш класс имеет больше N зависимостей — значит с ним что-то не так. Возможно, он перегружен и стоит разделить его функционал между несколькими классами. N подставьте сами

Вы конечно догадались, что поиск зависимости в дереве сцены — это и есть то, ради чего я затеял написание собственного DI-контейнера. Контейнер получился очень простым. Взять его исходники и демонстрационный проект можно тут: dl.dropboxusercontent.com/u/1025553/UnityDI.rar
Прошу ознакомится и раскритиковать.

Так же в статье упоминались:
Lightweight-Ioc-Container
Moq

Автор: guriyar

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js