Довольно часто слышу вопросы про реализацию этого паттерна, поэтому здесь я постараюсь показать рабочий вариант исполнения.
Дисклеймер
Мне справедливо заметили, что надо указать уровень, на который рассчитана статья. У меня чуть больше 2 лет коммерческого опыта, а представленная реализация - единственная, с которой я работал над несколькими айдлерами. Поэтому статья рассчитана на тех, кто хочет поработать с паттерном, но не может найти реализаций в открытом доступе. И я нисколько не претендую на абсолютность решения.
Зачем это вообще нужно?
В современных играх есть огромное количество UI элементов, которые надо показывать игроку. При этом у них обычно есть какие‑то взаимодействия между собой, одни элементы должны показываться поверх других, а иногда должна происходить замена одних элементов на другие. Чтобы предотвратить превращение процесса работы с UI в боль пользуются различными архитектурными паттернами, позволяющими упорядочить происходящее и снизить требования к личной оперативной памяти разработчика.
Вольный пересказ Википедии
MVVM — архитектурный паттерн, отделяющий разработку пользовательского интерфейса от разработки бизнес логики.
MVVM состоит из 3, но не совсем, частей:
Model (модель), где происходит логики игры: прокачиваются способности, умирают враги, повышается уровень и тд. Так как здесь речь про работу с UI, о модели я буду говорить как о всем, что не пользовательский интерфейс. Там может быть все что угодно от god object до ECS.
View (вью) с визуальной частью. Это как раз слой UI: слайдеры, прогресс бары, менюшки, а также кнопки, в которые может жать пользователь
View model (вью модель) выступает связующим звеном между моделью и вью. Этот слой собирает данные из модели для показа, а также вызывает изменения модели.
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
Идея реализации принадлежит не мне, поэтому будет правильно прикрепить ссылку на гит оригинала.
