Как мы пришли к реактивному связыванию в Unity3D
Сегодня я расскажу о том, как некоторые проекты в 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($"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 _playerId;
private readonly IMutableProperty _playerAvatar;
private readonly IMutableProperty _playerLevel;
private readonly IMutableProperty _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, связанных с одним проперти модели и одной функцией действия.
В качестве вывода могу сообщить, что озвученные выше цели были достигнуты. Сравнительные бенчмарки показывают выигрыш как по памяти, так и по времени относительно упомянутых вариантов. По мере вникания команды вёрстки и людей из других отделов, которые занимаются контентом, трений и блокировок становится всё меньше. Эффективность и качество кода возросли, и теперь многие вещи не требуют вмешательства программистов.