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, связанных с одним проперти модели и одной функцией действия.



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