company_banner

Как мы пришли к реактивному связыванию в Unity3D

    image

    Сегодня я расскажу о том, как некоторые проекты в Pixonic пришли к тому, что для всего мирового фронтэнда уже давно стало нормой, — к реактивному связыванию.

    Подавляющее большинство наших проектов пишется на Unity 3D. И, если у других клиентских технологий с реактивщиной всё неплохо (MVVM, Qt, миллионы JS-фреймворков), и воспринимается она как должное, в Unity каких-либо встроенных или общепринятых средств связывания нет.

    У кого-то к этому моменту наверняка созрел вопрос: «А зачем? Мы такое не используем и неплохо живём».

    Причины были. Точнее, были проблемы, одним из решений которых могло стать использование такого подхода. В результате оно им стало. А подробности под катом.

    Сначала о проекте, проблемы которого и потребовали такого решения. Конечно же, речь о War Robots — гигантском проекте с множеством различных команд разработки, поддержки, маркетинга и т. д. Нас сейчас интересуют только две из них: команда клиентских программистов и команда пользовательского интерфейса. Далее для простоты будем называть их «код» и «вёрстка». Так уж сложилось, что проектированием и вёрсткой UI у нас занимаются одни люди, а «оживлением» всего этого — другие. Это логично, и на своём опыте я встречал немало подобных примеров организации команд.

    Мы заметили, что при растущем потоке фичей на проекте взаимодействие кода и вёрстки становится местом взаимных блокировок и «узким горлышком». Программисты ждут готовых виджетов для работы, верстальщики — каких-то доработок от кода. Да много всего происходило при этом взаимодействии. Словом, иногда это превращалось в хаос и прокрастинацию.

    Сейчас поясню. Взгляните на классический простой пример виджета — особенно на метод RefreshData. Остальной бойлерплейт я просто добавил для правдоподобия, и он не стоит особого внимания.

    public class PlayerProfileWidget : WidgetBehaviour
    {
      [SerializeField] private Text nickname;
      [SerializeField] private Image avatar;
      [SerializeField] private Text level;
      [SerializeField] private GameObject hasUpgradeMark;
      [SerializeField] private Button upgradeButton;
    
      public void Initialize(ProfileService profileService)
      {
     	RefreshData(profileService.Player);
    
     	upgradeButton.onClick
        	.Subscribe(profileService.UpgradePlayer)
        	.DisposeWith(Lifetime);
    
     	profileService.PlayerUpgraded
        	.Subscribe(RefreshData)
        	.DisposeWith(Lifetime);
      }
    
      private void RefreshData(in PlayerModel player)
      {
     	nickname.text = player.Id;
     	avatar.overrideSprite = Resources.Load<Sprite>($"Avatars/{player.Avatar}_Small");
     	level.text = player.Level.ToString();
     	hasUpgradeMark.SetActive(player.HasUpgrade);
      }
    }

    Это пример статического связывания «сверху вниз». В компонент верхнего (по иерархии) GameObject’а вы линкуете компоненты соответствующих типов нижних объектов. Тут всё предельно просто, но не очень гибко.

    Функциональность виджетов расширяется постоянно с приходом новых фичей. Давайте представим. Вокруг аватара теперь должна быть рамка, вид которой зависит от уровня игрока. Окей, добавим ссылку на Image рамки и будем туда погружать соответствующий уровню спрайт, затем добавим настройку соответствия уровня и рамки и отдадим все это вёрстке. Готово.

    Прошёл месяц. Теперь в виджете игрока появляется иконка клана, если он состоит в таковом. А ещё нужно прописать звание, которое он там имеет. И никнейм нужно покрасить в зелёный цвет, если есть апгрейд. Вдобавок, мы теперь используем TextMeshPro. А ещё…

    Ну, вы поняли. Кода становится всё больше, он становится сложнее и сложнее, обрастая различными условиями.

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

    У инженеров всё просто: если видишь проблему, пытаешься её решить. Вот мы и пытались. В результате пришли к идее, что нужно сужать фронт соприкосновения двух команд. А реактивные паттерны сужают этот фронт до одной точки — того, что обычно называют View Model. Для нас она выступает в роли контракта между кодом и вёрсткой. Когда я перейду к деталям, станет ясен смысл контракта, и почему он не блокирует параллельную работу двух команд.

    На тот момент, когда мы только задумались обо всём этом, существовало несколько сторонних решений. Мы смотрели в сторону Unity Weld, Peppermint Data Binding, DisplayFab. У всех были свои плюсы и минусы. Но один из фатальных для нас недостатков был общим — слабая для наших целей производительность. На простых интерфейсах они, может, и нормально работают, но к тому моменту нам сложности интерфейсов избежать не удалось.

    Поскольку задача не представлялась запредельно сложной, да ещё и релевантный опыт имелся, было решено реализовать систему реактивного связывания внутри студии.

    Задачи были такие:

    • Производительность. Сам механизм распространения изменений должен быть быстрым. Ещё желательно уменьшить нагрузку на GC, чтобы можно было использовать это всё даже в геймплее, где совсем не рады фризам.
    • Удобный авторинг. Это нужно для того, чтобы с системой могли работать ребята из команды UI.
    • Удобный API.
    • Расширяемость.


    «Сверху вниз», или общее описание


    Задача понятна, цели ясны. Начнём с «контракта» — ViewModel. Его должен уметь формировать любой человек, а значит, реализовать ViewModel нужно максимально просто. По сути это просто набор свойств, которые определяют текущее состояние отображения.

    Для простоты набор типов свойств со значениями мы максимально ограничили до bool, int, float и string. Это было продиктовано сразу несколькими соображениями:

    • Сериализация этих типов в Unity не требует никаких усилий;
    • Это подмножество типов, которыми пользуется и бизнес-логика, и отображение. К примеру, вам не нужен тип Sprite в бизнес-логике, так же как и пользовательский тип PlayerModel в чистом виде сложно прикрутить в отображении, даже если он у вас прекрасно сериализуется;
    • Подобные ограничения делают проще реализацию, особенно когда вам нужно писать код для авторинга системы и инструменты редактирования.

    Все свойства активны и сообщают подписчикам об изменениях своих значений. Не всегда эти значения есть — бывают просто события в бизнес-логике, которые нужно как-то визуализировать. На этот случай есть тип свойства без значения — event.

    Без коллекций, конечно же, в интерфейсах тоже никак не обойтись. Поэтому есть и тип свойства collection. Коллекция оповещает подписчиков о любом изменении своего состава. Элементы коллекции — это тоже ViewModel определённой структуры или схемы. Эта схема тоже описывается в контракте при редактировании.

    В редакторе ViewModel выглядит следующим образом:



    Стоит обратить внимание, что свойства можно редактировать прямо в инспекторе и «на лету». Это позволяет посмотреть, как будет вести себя виджет (или окно, или сцена, или что угодно) в рантайме даже без кода, что на практике очень удобно.

    Если ViewModel — верх нашей системы связывания, то низ — так называемые аппликаторы. Это конечные подписчики свойств ViewModel, которые как раз и делают всю работу:

    • Включают/выключают GameObject или отдельные компоненты по изменению значения булевого свойства;
    • Меняют текст в поле зависимости от значения строкового свойства;
    • Запускают аниматор, меняют его параметры;
    • Подставляют нужный спрайт из коллекции по индексу или строковому ключу.

    На этом я остановлюсь, так как количество вариантов применения ограничено только фантазией и спектром задач, которые вы решаете.

    Вот так выглядят некоторые аппликаторы в редакторе:




    Для большей гибкости между свойствами и аппликаторами можно использовать адаптеры. Это сущности для преобразования свойств перед применениями. Их тоже много разных:

    • Логические — например, когда вам нужно инвертировать булевое свойство или выдавать true или false в зависимости от значения другого типа (хочу золотую рамку, когда уровень выше 15).
    • Арифметические. Тут без комментариев.
    • Операции над коллекциями: инвертировать, взять только часть коллекции, сортировать по ключу и многое другое.

    Опять же, различных вариантов адаптеров может быть великое множество, так что не буду продолжать.





    На деле же, пусть общее количество различных аппликаторов и адаптеров большое, базовый, используемый повсеместно набор весьма ограничен. Человеку, работающему с контентом, нужно предварительно изучить этот набор, что несколько увеличивает время обучения. Впрочем, нужно один раз уделить этому время, чтобы далее здесь не возникало больших проблем. Тем более, что у нас есть кукбук и документация на этот счёт.

    Когда вёрстке чего-то нехватает, программисты дописывают необходимые компоненты. При этом подавляющая часть аппликаторов и адаптеров универсальна и активно используется повторно. Отдельно стоит заметить, что у нас всё же есть аппликаторы, работающие на рефлексии через UnityEvent. Они применимы в случаях, когда нужный аппликатор ещё не реализован или его реализация нецелесообразна.

    Работы команде вёрстки это, несомненно, добавляет. Но в нашем случае они даже рады той степени свободы и независимости от программистов, которую получают. И если со стороны вёрстки работы прибавилось, то со стороны кода теперь всё намного проще.

    Вернёмся к примеру с PlayerProfileWidget. Вот так он теперь выглядит в нашем гипотетическом проекте в виде презентера, ведь тут больше не нужен Widget в виде компонента, и мы можем всё получить из ViewModel вместо линковки всего напрямую:

    public class PlayerProfilePresenter : Presenter
    {
      private readonly IMutableProperty<string> _playerId;
      private readonly IMutableProperty<string> _playerAvatar;
      private readonly IMutableProperty<int> _playerLevel;
      private readonly IMutableProperty<bool> _playerHasUpgrade;
    
      public PlayerProfilePresenter(ProfileService profileService, IViewModel viewModel)
      {
     	_playerId = viewModel.GetString("player/id");
     	_playerAvatar = viewModel.GetString("player/avatar");
     	_playerLevel = viewModel.GetInteger("player/level");
     	_playerHasUpgrade = viewModel.GetBoolean("player/has-upgrade");
    
     	RefreshData(profileService.Player);
    
     	viewModel.GetEvent("player/upgrade")
        	.Subscribe(profileService.UpgradePlayer)
        	.DisposeWith(Lifetime);
    
     	profileService.PlayerUpgraded
        	.Subscribe(RefreshData)
        	.DisposeWith(Lifetime);
      }
    
      private void RefreshData(in PlayerModel player)
      {
     	_playerId.Value = player.Id;
     	_playerAvatar.Value = player.Avatar;
     	_playerLevel.Value = player.Level;
     	_playerHasUpgrade.Value = player.HasUpgrade;
      }
    }

    В конструкторе можно увидеть получение кодом свойств из ViewModel. Да, в этом коде для простоты опущены проверки, но существуют методы, которые кинут исключение, если не найдут нужного свойства. Кроме того, у нас есть несколько инструментов, которые дают довольно сильную гарантию присутствия нужных полей. Они основаны на валидации ассетов, о которой можно почитать тут.

    Я не буду вдаваться в детали реализации, так как это займёт ещё очень много текста и вашего времени. Если будет общественный запрос, то лучше будет это оформить отдельной статьёй. Скажу только, что реализация не сильно отличается от того же Rx, только всё немного проще.

    В таблице приведены результаты бенчмарка, в котором происходит создание 500 форм с InputField, Text и Button, связанных с одним проперти модели и одной функцией действия.



    В качестве вывода могу сообщить, что озвученные выше цели были достигнуты. Сравнительные бенчмарки показывают выигрыш как по памяти, так и по времени относительно упомянутых вариантов. По мере вникания команды вёрстки и людей из других отделов, которые занимаются контентом, трений и блокировок становится всё меньше. Эффективность и качество кода возросли, и теперь многие вещи не требуют вмешательства программистов.
    Pixonic
    Разрабатываем и издаем игры с 2009 года

    Комментарии 9

      0

      А вы изначально смотрели на UniRx, и он чем-то не подошёл, или сразу стали пилить своё?

        0

        Смотрели. Если кратко, то в UniRx нет динамических ViewModel, из-за чего авторинг блокируется программистами

        0
        Если бы не строковые константы и ограничение уже по базовым типам. Последнее ещё можно «простить», но вот строковые константы без хотя бы гарантированной обработки ошибок никак нельзя. Хотя тем же страдает и сама Unity, но уподобляться их частой практике 10-летней давности не стоит.
        Планируете дорабатывать именно по базовому функционалу(типы, валидация, переменные/перечисления вместо строк), удобству?

        Сравнение с другими было бы ок, если бы так же написали о их возможностях, а они, вроде как, в разы больше. Это не плохо, просто и быстро — тоже хороший результат. Но хотелось бы знать чем пожертвовали или чего можно больше получить в сравнении. Такие же таблицы можно найти к любому сериализатору на своей страничке — каждый показывает, что уж его то велосипед лучше других, умалчивая о некоторых незначительных нюансах.
          +1

          Строго говоря константы там не строковые, ключи имеют тип PropertyName обычно их значениня уносятся в константы. Хотя есть желание отказаться от этого в пользу обычных строк. Но я так понимаю, вы не можете «простить» именно «динамичность» вьюмодели? Если так, то дискуссия рискует перетечь в плоскость очердного обсуждения «статика vs динамика». Мы и сами туда часто скатывамся во внутрннних обсуждениях. Но тут был упор именно на то, что набор свойств без особых усилий сформировать может человек, не пишущий код.
          Валидация – это хорошо, и мы добавляем проверки, особенно в критичных частях. Но это касается и первого куска кода, который с линковкой компонент в поля, так что тут описанный подход ничем особенным не выделяется.


          Дорабатывать планируем. Будет расширение разнообразия типов (очень в этом поможет SerializeReference) и прочие плюшки. А валидация (и даже некоторая кодогенерация) уже частично реализованы.


          По функциональности мы покрыли всё, что есть у других участников сравнения, за исключениеем каких-то специфичных вещей, вытекающих из особенностей рализации.

            0
            Здесь я вижу именно частое использование строк, часто даже не вынесенных в константы. К примеру:
            _playerId = viewModel.GetString("player/id");
            То же в инспекторе.
            Проблемы могут быть не видны, если есть всеобъемлющая валидация, но работать с этим будет не удобно, даже при не большом объёме. Я вот как-то не верю, что проблем нет или почти нет вообще, т.к. ни разу не было ни 1 проекта, где несколько раз из-за таких констант что-то не работало.
            PS: а code не отработал что-то…

            И я тут не о типизации. Но могу и о ней пару слов кинуть. Во вьюхах компонентов как бы «динамика» вполне ок. А вот в логике самой схемы всё таки лучше чистая статика. При любой большой командной работе динамика плодит ошибки, не самые простые для разбора, ухудшает скорость чтения. Опытные программисты могут писать и без таких ошибок, но только пока у них есть понимание каждого элемента кода, условно, пока оперативки хватает. За то динамика импонирует простотой и скоростью перетекания мысли в код, что на тех же формочках даже важнее, да и ошибиться там сложнее. Но если в результате система становится сложной, то проявляется боль, которой нет в статике.
          +2
          1) Очень важная и нужная фича — уметь сериализовывать ViewModel и в случае ошибки отправлять состояние интерфейса в качестве багрепорта. Если ещё не сделали — сделайте, много крови и слёз сэкономите.
          2) Адресация по строкам — первородное зло в самой своей чистой форме. Пути для избавления — автогенерящиеся енумы, кастомные контролы на этапе редактирования делающие связывание через рефлекшен, а на этапе выполнения выгребающие данные из автоматически создаваемого массива по индексам.
            +1

            1) Согласен. Есть такой план, даже расширенный – уметь ещё и десериализовать ViewModel. Очень пригодится для автотестов интерфйса. Автотест загружает префаб, пихает в нго условный джсон и смотрит, что получилось. Огромный плюс в том, что при реализации такого тестирования можно максимально абстрагироваться от кода самого проекта.
            2) Да, возможно это зло. Но почти за два года использования связанных с этим проблем возникло исчезающе мало. Я подумываю, что тут можно сделать, но меня останавливает нежелание чинить то, что не сломано.

              0
              1) Хммм… Как интересно. У меня модель может сериализоваться и десериализоваться, но идея использовать это в автотестах мне в голову не пришла. Впрочем, в отличии от вас, у меня никогда не было лишних одного-программиста и одного QA инженера для того чтобы покрыть автотестами не только базовую функциональность, но и все интерфейсы. Я это использовал для другого. У меня модель меняется транзактно и поля обновляются не по вызову Refresh, этот вызов частенько оказывается забыт когда происходят какие-то сложные манипуляции с моделью, а по концу транзакции. Событие раздаётся только изменившимся полям. Если при этой раздаче где-то происходит эксепшен, очень удобно сформировать автоматический багрепорт в который включается состояние модели до начала транзакции и произведённые изменения. А десериализация позволяет ошибку воспроизвести чуть ли не автоматически. ЧТо важно, это избавляет от необходимоти выяснять у реального игрока на проде — чего он там вчера такого нажал, что приложение рухнуло.
                +1

                Думаю, что к этому мы ещё придём.
                А для транзактных изменений у нас тоже есть инструменты, но используются они явно. Код в статье слишком упрощён, я не стал там показыать всё. Делается как-то так


                using (viewModel.Lock())
                {
                    // тут мы меняем свойста
                } // тут срабатывают все затронутые диспатчеры
                

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое