[Программирование, Проектирование и рефакторинг, Разработка игр] Как мы пришли к реактивному связыванию в Unity3D

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
23-Окт-2020 15:31


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

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

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_razrabotka_igr (Разработка игр), #_reaktivnoe_programmirovanie (реактивное программирование), #_reactive_programming, #_reaktivnost (реактивность), #_gamedev, #_razrabotka_igr (разработка игр), #_unity3d, #_blog_kompanii_pixonic (
Блог компании Pixonic
)
, #_programmirovanie (
Программирование
)
, #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
)
, #_razrabotka_igr (
Разработка игр
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 19:33
Часовой пояс: UTC + 5