Довольно часто слышу вопросы про реализацию этого паттерна, поэтому здесь я постараюсь показать рабочий вариант исполнения.
Дисклеймер
Мне справедливо заметили, что надо указать уровень, на который рассчитана статья. У меня чуть больше 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
Идея реализации принадлежит не мне, поэтому будет правильно прикрепить ссылку на гит оригинала.