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

Зачем это вообще нужно?

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

Вольный пересказ Википедии

MVVM — архитектурный паттерн, отделяющий разработку пользовательского интерфейса от разработки бизнес логики.

MVVM состоит из 3, но не совсем, частей:

  1. Model (модель), где происходит логики игры: прокачиваются способности, умирают враги, повышается уровень и тд. Так как здесь речь про работу с UI, о модели я буду говорить как о всем, что не пользовательский интерфейс. Там может быть все что угодно от god object до ECS.

  2. View (вью) с визуальной частью. Это как раз слой UI: слайдеры, прогресс бары, менюшки, а также кнопки, в которые может жать пользователь

  3. View model (вью модель) выступает связующим звеном между моделью и вью. Этот слой собирает данные из модели для показа, а также вызывает изменения модели.

  4. Binder (биндер). Слой, помогающий связать вью и вью модель в автоматическом режиме. Почему‑то совершенно игнорируется вики на русском языке, хотя, если верить английской версии сайта, это самый важный компонент паттерна, а сам паттерн можно называть model‑view‑binder.

View model

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

public interface IViewModel : IDisposable
{
}

public abstract class BaseViewModel : IViewModel
{
   public virtual void Dispose()
   {
   }
}

View

Здесь все немножко сложнее. Для начала я введу дополнительное слово — экран. Экран — это цельный объект, в котором может быть много разных UI элементов. Например, окно прокачки персонажа — экран. Инвентарь — экран. Полоска прогресса производственного генератора — не экран, а вот окно с генератором и его настройками — экран. Экран сам руководит своими UI элементами: может их показывать, скрывать, менять у них настройки и тд. Связывать в мью моделью мы будем именно экраны.

При первом приближении должно получиться что‑то такое:

public abstract class BaseScreen : MonoBehaviour
{
   public abstract void Show();
   public abstract void Close();

   public virtual void Dispose()
   {
   }
}

Сейчас нет никакой возможности связать вью и вью модель, а так же нет никакого бинда, хотя английская википедия настаивает, что это важно. Так что надо во view добавить немного функционала. После дополнения получится такой вариант:

public abstract class BaseScreen : MonoBehaviour
{
   public abstract Type ModelType { get; }

   public abstract void Show();
   public abstract void Close();
   public abstract void Bind(object model);

   public virtual void Dispose()
   {
   }
}

public abstract class AbstractScreen<TModel> : BaseScreen where TModel:IViewModel
{
   public override Type ModelType => typeof(TModel);
   protected TModel _model;

   public override void Show()
   {
       gameObject.SetActive(true);
   }

   public override void Close()
   {
       gameObject.SetActive(false);
   }

   public override void Bind(object model)
   {
       if (model is TModel)
           Bind((TModel) model);
   }

   public void Bind(TModel model)
   {
       _model = model;
       OnBind(model);
   }

   protected abstract void OnBind(TModel model);
}

Теперь есть связь экрана с его вью моделью. Можно независимо готовить данные для каждого экрана и настраивать сами экраны.

Вот пример экрана и его вью модели:

public class ConcreteScreen : AbstractScreen<ConcreteViewModel>
{
   [SerializeField] private Text _health;
   [SerializeField] private Button _someButton;
   private ConcreteViewModel _model1;

   private void Start()
   {
       _someButton.onClick.AddListener(AddHealth);
   }

   protected override void OnBind(ConcreteViewModel model)
   {
       _model1 = model;
       _health.text = model.Health.ToString();
   }

   private void AddHealth()
   {
       _model1.DoSomething();
   }
}

public class ConcreteViewModel : BaseViewModel
{
   private readonly IGameData _gameData;
   public int Health => _gameData.Health;
   public ConcreteViewModel(IGameData gameData)
   {
       _gameData = gameData;
   }
   public void DoSomething()
   {
       _gameData.AddHealth();
   }
}

Binder

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

public class UiManager : MonoBehaviour
{
   private IEnumerable<BaseScreen> _screens;
   private Dictionary<Type, BaseScreen> _screensMap;
   private Dictionary<Type, BaseScreen> _shownScreens;
  
   public void Init(IEnumerable<BaseScreen> screens)
   {
       foreach (var screen in _screens)
       {
           screen.gameObject.SetActive(false);
       }
       _screensMap = _screens.ToDictionary(e => e.ModelType, e => e);
   }

   public void BindAndShow<TModel>(TModel model) where TModel : IViewModel
   {
       if (_screensMap.TryGetValue(typeof(TModel), out var screen))
       {
           screen.Bind(model);
           screen.Show();
           _shownScreens.Add(typeof(TModel), screen);
       }
   }

   public void Hide<TModel>() where TModel : IViewModel
   {
       if (_shownScreens.TryGetValue(typeof(TModel), out var screen))
       {
           screen.Dispose();
           screen.Close();
           _shownScreens.Remove(typeof(TModel));
       }
   }
}

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

Абстрактный пример, призванный показать зоны ответственности

Допустим, мы подходим к сундуку и хотим посмотреть, что у него внутри. Сундук в нашей системе считается частью модели. При клике на сундук он у UIManager вызывает метод BindAndShow, в который передает только что созданную вью модель для экрана. Теперь мы видим экран с содержимым сундука.

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

P.S

Идея реализации принадлежит не мне, поэтому будет правильно прикрепить ссылку на гит оригинала.

Комментарии (8)


  1. splatt
    00.00.0000 00:00

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

    Слишком условное деление. Что если я хочу сделать экран в экране? Что если нужны модальные окна? Что делать с элементами вроде статичного меню которое всегда вверху или внизу? Что если в одном случае UI элемент является экраном, а в другом встроен внутрь меню?

    Что за интерфейсIGameData ? В статье нигде не упоминается.

    Зачем нужен не дженерик Bind(object model); и для чего вообще нужен BaseScreen который вроде как имеет в себе концепцию модели, но ничего с ней не делает? Выглядит как лишний уровень абстракции.

    В чем вообще смысл всей этой свзяки с моделью через ViewModel, если по сути вызовы логики типа AddHealth() проксируются через аж 3 класса в нужный?

    Как это все работает с юнит, интеграционным тестированием? C# не позволяет делать mocks для абстрактных классов, чем обусловлен выбор их а не в пользу интерфейсов? Какие элементы из MVVM планируется покрывать тестами?

    Проблема с кастомными реализациями MVC, MVP, MVVP и прочими паттернами в Unity и не только, заключается в том что структура копируется без оглядки на смысл. Если делать столько слоёв абстракции, то нужно понимать, зачем они нужны.

    Например, в Xamarin.Forms структура MVVM нужна для (и предоставляет) множество полезных фич, таких как Two-Way Binding. В вашем примере ничего этого нету.

    Ответ, "потому-что так написано в англоязычной википедии по запросу MVVM" не является на мной взгляд достаточным.

    Как по мне, выбор любых паттернов или фрейморков очень сильно зависит от нужд проекта. Не существует серебряной пули, которая бы отлично подходила всем. В 3D игре с экраном настроек и главным меню, требования к структуре UI совсем другие, чем в сложном 2D ui-driven проекте с сотней различных экранов.

    Для игр с большим колиеством UI элементов, я советую начать с изучения Dependency Injection и IoC, эти концепции на мой взгляд довольно хорошо сочетается с ECS.


    1. Ipashelovo Автор
      00.00.0000 00:00

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

      IGameData выступает в роли модели, из которой можно брать кактие-то данные

      Не дженерик Bind не нужен, согласен

      Смысл в разделении. Если здесь это вызов в лоб, то в более-менее крупных экранах во вью модели будет происходит подготовка данных для обращения к модели, какие-то события аналитики и тд.

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

      На дальнейшее отвечать уже не надо, полагаю


  1. alexshipin
    00.00.0000 00:00
    +1

    MVVM состоит из 3, но не совсем, частей:

    Паттерн MVVM как раз и состоит из 3 частей (M - model, V - view, VM - view model), чего для самого паттерна достаточно, любые Binder, Controller и прочее - это уже дополнительные классы/модули, которые расширяют функционал MVVM.

     Binder (биндер). Слой, помогающий связать вью и вью модель в автоматическом режиме. Почему-то совершенно игнорируется вики на русском языке, хотя, если верить английской версии сайта, это самый важный компонент паттерна, а сам паттерн можно называть model-view-binder.

    Сам паттерн назвать Model-View-Binder? А смысл тогда использовать MVVM? Ответ весьма лаконичен: непонимание того, что есть Binder, не говорит о том, что мы используем другой паттерн, мы остаёмся также в пределах одного паттерна, только дополняем его функционал, делаем код более читаемым, разбиваем логику на части (легче исправлять небольшие файлы, чем один, но на "миллионы" строк)

    ---

    В общем и целом, я согласен с @splatt, так как вы просто переписали Wikipedia, не понимая того, как это будет работать в отличных от ваших условиях и для чего это использовать.

    Абстрактные примеры для того и абстрактные, что они дают самое поверхностное представление, но не заявляют, что так правильно и/или так надо делать всегда и везде.


    1. eonyanov
      00.00.0000 00:00

      Как насчет написать свою статью, если разбираетесь? Просто я на хабре, вроде бы, не видел еще ни одной статьи по MVVM на юнити, которую бы не захейтили.


      1. playermet
        00.00.0000 00:00

        А чем MVVM на Unity принципиально отличается от всех остальных MVVM? На каждый движок и фреймворк с UI отдельный туториал писать?


        1. eonyanov
          00.00.0000 00:00

          Тем, что юнити не заточен под этот фреймворк в том плане, что все его UI элементы это отдельные геймобжекты, у которых свои цикл создания и инициализации.


      1. bustedbunny
        00.00.0000 00:00

        Я делал свою реализацию MVVM с биндами и локализацией. Статья если что не о MVVM, а скорее просто презентация того что я делал.

        https://habr.com/ru/post/717286/

        И это тоже не первый MVVM фреймворк. Ранее я на харбе видел ещё одну реализацию, но мне она не понравилась т.к. для биндов там требовалось создавать новые кастомные контролы для UI Toolkit.


  1. MaxNekra
    00.00.0000 00:00

    Очень слабо раскрыта тема MVVM. Сама система MVVM намного больше и шире.

    На мой взгляд Microsoft сделали эталонную реализацию MVVM. Если интересно, можете попробовать поработать на Microsoft WPF, для более глубокого понимания MVVM и работы с этим подходом. Так же Microsoft держит свои исходники в открытом доступе, можете всегда поковыряться и посмотреть реализацию.

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

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