Небольшая предистория

19-го апреля 2024 года у меня на работе состоялось собрание программистов, где обсуждались насущные проблемы. Одной из тем обсуждения была сильно возросшая сложность UI. Не одна из существующих реализаций не удовлетворяла наши потребностям:

  • Разделение логики от представления.

  • Производительность (Избежание рефлексии).

  • Параллельная работа дизайнеров и программистов.

На той встрече мы не приняли какое либо решение по этой теме. Тем не менее вечером того же дня я сделал первый коммит решения, которое впоследствие стало полноценным производительным MVVM фреймворком для Unity - Aspid.MVVM.

Минимум об MVVM

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

MVVM - это архитектурный паттерн для разделения логики на три основных компонента:

  1. View - Это все, что видит и с чем взаимодействует пользователь. Отвечает за отображение данных. Не содержит бизнес-логику. Отправляет команды и получает обновление от ViewModel.

  2. ViewModel - Управляет состоянием, отображаемым во View. обрабатывает пользовательские действия. Выполняет бизнес-логику, связанную с представлением. Связан с View через Data Binding. Содержит команды и наблюдаемые свойства.

  3. Model - Представляет данные и бизнес-логику. Не знает о View и ViewModel. Представляет данные и бизнес-логику приложения.

Более подробней про MVVM вы можете узнать на


Особености Aspid.MVVM

Data Binding

Aspid.MVVM поддерживает 4 основных режима связывания данных между View и ViewModel:

  • OneWay – автоматическое обновление View при изменении ViewModel.

  • TwoWay – двусторонняя синхронизация View и ViewModel.

  • OneTime – однократная установка значения при инициализации.

  • OneWayToSource – обновление ViewModel при изменении View.

Режим можно легко указать:

  • Во View – прямо через инспектор Unity.

  • Во ViewModel – с помощью атрибутов, ограничивающих допустимые режимы привязки.

Привязка работает без рефлексии и без boxing/unboxing.

ViewModel

Благодаря встроенному Source Generator, можно связывать любые типы данных:

  • Без наследования от специальных базовых классов.

  • Без использования оберток или обернутых свойств.

  • Без boilerplate-кода используя атрибуты.

Commands

Мощный механизм команд:

  • Поддержка до 4 параметров — достаточно выбрать нужную сигнатуру.

  • Атрибут [RelayCommand] позволяет превратить обычный метод в команду с поддержкой CanExecute.

Observable Collections

Набор гибких ковариантных наблюдаемых коллекций:

  • ObservableList<T>

  • ObservableDictionary<TKey, TValue>

  • ObservableHasSet<T>

  • ObservableStack<T>

  • ObservableQueue<T>

Особености:

  • Легкая синхронизация между двумя зависимыми коллекциями.

  • Поддержка фильтрации и сортировки без изменения исходной коллекции.

Высокая производительность

Aspid.MVVM спроектирован с фокусом на производительность:

  • Без использования рефлексии при привязке.

  • Без boxing/unboxing при передаче значений.

  • Сведенные к минимуму аллокации памяти.

И многое другое. Подробней читайте на странице документации.


Примеры

Сложно определить наилучший пример для начала. Для тех, кто хочет сразу приступить к работе наилучшим примером будет First Steps.

Более же полноценным примером является Hello World. Пример начинается с реализации на MVP и заканчивается применением Aspid.MVVM, чтобы увидеть все преимущества фреймворка.

Hello World

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

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

using System;

public class Speaker
{
    public event Action<string> TextChanged;

    private string _text;

    public string Text
    {
        get => _text;
        private set
        {
            _text = value;
            TextChanged?.Invoke();
        }
    }
    
    public void Say(string text) =>
        Text = text;
}

1. Создайте сцену

Теперь давайте создадим новую сцену. Добавьте на нее Canvas, на Canvas добавьте пустой объект с названием "Out View MVVM", а к "Out View MVVM" — объект с компонентом TextMeshProUGUI

Добавьте на Canvas объект "Input View MVVM", а в этот объект добавьте InputField и Button.

2. Создайте OutView

Теперь давайте создадим View, которая будет заниматься выводом текста на экран.

using Aspid.MVVM;
using UnityEngine;

// Каждая View помечается атрибутом [View]
// и должна быть partial для корректной генерации кода.
// Так же, как правило, наследуется от MonoView, наследник MonoBehaviour
// для работы с MonoBinder.
[View]
public sealed partial class OutView : MonoView
{
  // [RequireBinder] — необязательный атрибут, ограничивающий
  // допустимые биндеры по типу данных (в данном случае string).
  // Поле должно называться так же, как поле во ViewModel.
  // Поля: _outText, m_outText, s_outText и outText являются эквивалентными. 
  // MonoBinder — базовый класс для всех биндеров, которые являются MonoBehaviour.
  [RequireBinder(typeof(string))]
  [SerializeField] private MonoBinder[] _outText;
}

3. Настройте OutView

Добавьте компонент OutView на объект "Out View MVVM".

На объект с TextMeshProUGUI добавьте биндер текста: Text Binder - Text через контекстное меню компонента или же любым другим способом.

Сейчас компонент весь красный, что говорит о том, что биндер никуда не прикреплен. Выберете в выпадающем списке View - "Out View MVVM (OutView)", а в Id - "OutText".

Вот так выглядит валидный биндер:

Обратите внимание как изменилась View, после установки биндера:

4. Создайте InputView

Давайте создадим View, которая будет заниматься обработкой введенного текста.

using Aspid.MVVM;
using UnityEngine;

[View]
public sealed partial class InputView : MonoView
{
  // _inputText может принять только 1 биндер.
  // Этот подход удобен, когда мы точно знаем, что будет один связующий элемент.
  [RequireBinder(typeof(string))]
  [SerializeField] private MonoBinder _inputText;
        
  // _sayCommand объявлен как массив — это удобно,
  // так как позволяет указывать неограниченное количество связующих элементов.
  [RequireBinder(typeof(IRelayCommand))]
  [SerializeField] private MonoBinder[] _sayCommand;
}

5. Настройте InputView

Добавьте компонент InputView на объект "Input View MVVM":

На GameObject с TextMeshPro - InputField добавьте два связующих элемента из StarterKit:

  • InputField Binder - Command (для отправки текста с помощью Submit у InputField).

  • InputField Binder - Text (для установки введенного текста в ViewModel).

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

Настройте биндеры

На объект с Button добавьте биндер текста: Button Binder - Command из StarterKit через контекстное меню компонента или же любым другим способом и настройте его.

Обратите внимание как изменилась View:

6. Создайте SpeakerViewModel

Теперь нам необходимо связать наши View с нашей моделью. В отличие от подхода с MVP тут нам подойдет одна и таже ViewModel для разных View.

using System;
using Aspid.MVVM;

// Объяснение:
// 1. Поле _outText для OneWay привязки, чтобы получить значение из модели.
// 2. Поле _inputText для TwoWay привязки, чтобы установить начальное значение
// и далее получить значение из View.
// 3. Команда Say, использует OneTime привязку и выполняется из View.

// Каждая ViewModel помечается атрибутом [ViewModel] 
// и должна быть partial для корректной генерации кода.
[ViewModel]
public sealed partial class SpeakerViewModel : IDisposable
{ 
    // [OneWayBind] — это маркер для Source Generator,
    // который создает OneWay привязку.
    // Source Generator на основе помеченного поля
    // создает свойство «OutText», метод «SetOutText»
    // и событие «OutTextChanged» для привязки.
    // Source Generator корректно работает со следующими стилями имен:
    // «s_outText», «m_outText», «_outText», «outText».
    // Также необходимо пометить класс атрибутом [ViewModel],
    // для работы Source Generator.
    [OneWayBind] private string _outText;
        
    // [TwoWayBind] — это маркер для Source Generator,
    // который создает TwoWay привязку.
    // Генератор исходного кода на основе помеченного поля
    // создает свойство «InputText», метод «SetInputText»
    // и событие «InputTextChanged» для привязки.
    // Source Generator корректно работает со следующими стилями имен:
    // «s_inputText», «m_inputText», «_inputText», «inputText».
    // Также необходимо пометить класс атрибутом [ViewModel].
    // Для работы Source Generator.
    [TwoWayBind] private string _inputText;
        
    private readonly Speaker _speaker;
    
    public SpeakerViewModel(Speaker speaker)
    {
        _speaker = speaker;
        _outText = speaker.Text;
        _inputText = speaker.Text;
            
        // Добавьте обработчик для события TextChanged,
        // сгенерированного генератором исходного кода.
        _speaker.TextChanged += SetOutText;
    }
    
    // [RelayCommand] — это маркер для Source Generator.
    // Source Generator создает свойство «SayCommand»
    // только для чтения на основе этого метода.
    // Свойство «SayCommand» поддерживает
    // только привязку OneTime и OneWay на стороне представления,
    // поскольку оно доступно только для чтения.
    // Также необходимо поместить на класс метку ViewModelAttribute,
    // Для работы Source Generator.
    [RelayCommand]
    private void Say()
    {
        // Хотя это не о��язательно для чтения, 
        // рекомендуется считывать значение через сгенерированное свойство.
        // Для удобства работает анализатор кода, который выдаст предупреждение,
        // если будет использовано _inputText.
        _speaker.Say(InputText);
    }

    public void Dispose() =>
        _speaker.TextChanged -= SetOutText;
}

7. Создайте Bootstrap

Теперь нам необходимо соединить все 3 компонента (Model, View, ViewModel) между собой.

using System;
using UnityEngine;

public sealed class Bootstrap : MonoBehaviour
{
    [Header("Views")]
    [SerializeField] private OutView _outView;
    [SerializeField] private InputView _inputView;
        
    private Speaker _speaker;

    private void Awake()
    {
        _speaker = new Speaker();
        InitializeViews();
    }

    private void OnDestroy() => 
        DeinitializeViews();

    private void InitializeViews()
    {
        var viewModel = new SpeakerViewModel(_speaker)
            
        _outView.Initialize(viewModel);
        _inputView.Initialize(viewModel);
    }
        
    private void DeinitializeViews()
    {
        // Вы можете использовать методы расширения 
        // для деинициализации представления и освобождения ViewModel.
        _outView.DeinitializeView()?.DisposeViewModel();
        _inputView.DeinitializeView()?.DisposeViewModel();
            
        // Ручной способ деинициализации View и освобождения ViewModel:
        // var viewModel = _outView.ViewModel;
        // _outView.Deinitialize();
        //
        // if (viewModel is IDisposable disposable) 
        //     disposable.Dispose();
    }
}

В данном случаи мы можем передать один и тот же инстанс ViewModel к каждой View, но так же можно передать каждой View свой собственный инстанс ViewModel.

8. Настройте Bootstrap

Создайте пустой GameObject на сцене, добавьте на него компонент Bootstrap и укажите все ссылки.

9. Запустите и проверьте

Hello World 2.0

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

Теперь нам говорят, что требуется выводить текст моментально при изменение текста в InputField. Для этого нам понадобиться новое поведение и поэтому нужна новая ViewModel.

1. Создайте MomentSpeakerViewModel

using System;
using Aspid.MVVM;

// Объяснение:
// 1. Поле _outText для OneWay привязки, чтобы получить значение из модели.
// 2. Поле _inputText для TwoWay привязки, чтобы установить начальное значение
// и далее получить значение из View.
// 3. Когда InputText изменяется, мы немедленно передаем его
// в модель через метод OnInputTextChanged.
[ViewModel]
public sealed partial class MomentSpeakerViewModel : IDisposable
{
    [OneWayBind] private string _outText;
    [TwoWayBind] private string _inputText;
    
    private readonly Speaker _speaker;
    
    public MomentSpeakerViewModel(Speaker speaker)
    {
        _speaker = speaker;
        _outText = speaker.Text;
        _inputText = speaker.Text;
            
        // Добавьте обработчик для события TextChanged,
        // сгенерированного генератором исходного кода.
        _speaker.TextChanged += SetOutText;
    }
        
    // Для каждого сгенерированного связанного свойства можно реализовать
    // два частичных метода:
    // Вызов перед изменением:
    // partial void On(Property name)Changing(string oldValue, string newValue)
    // Вызов после изменения:
    // partial void On(Property name)Changed(string newValue)
    partial void OnInputTextChanged(string newValue) =>
        _speaker.Say(newValue);
        
    public void Dispose() =>
        _speaker.TextChanged -= SetOutText;
}

2. Донастройте InputView

Теперь надо чуть изменить настройки у InputView. Нам достаточно всего лишь модифицировать саму View на сцене добавив биндер для кнопки, который будет скрывать кнопку, если нужного поля во ViewModel нет.

GameObject Binder - Visible By Bind выключает компонент, если связывание по данному полю не произошло.

3. Доработайте Bootstrap

По новым требованиям View для ввода текста может быть как с нажатием по кнопке, так и моментальным. Поэтому напишем Bootstrap, который будет переключать View для ввода текста.

using System;
using UnityEngine;

public sealed class Bootstrap : MonoBehaviour
{
    [Header("Views")]
    [SerializeField] private OutView _outView;
    [SerializeField] private InputView _inputView;
        
    [Header("ViewModel")]
    [SerializeField] private InputViewModelType _inputViewModelType;
 
    private Speaker _speaker;

    private void OnValidate()
    {
        if (!Application.isPlaying) return;
        if (!_outView || !_inputView || _speaker is null) return;
            
        DeinitializeViews();
        InitializeViews();
    }

    private void Awake()
    {
        _speaker = new Speaker();
        InitializeViews();
    }

    private void OnDestroy() => 
        DeinitializeViews();

    private void InitializeViews()
    {
        var viewModel = GetViewModel();
            
        _outView.Initialize(viewModel);
        _inputView.Initialize(viewModel);
    }
        
    private void DeinitializeViews()
    {
        // Вы можете использовать методы расширения
        // для деинициализации представления и освобождения ViewModel.
        _outView.DeinitializeView()?.DisposeViewModel();
        _inputView.DeinitializeView()?.DisposeViewModel();
            
        // Ручной способ деинициализации View и освобождения ViewModel:
        // var viewModel = _outSpeakerView.ViewModel;
        // _outSpeakerView.Deinitialize();
        //
        // if (viewModel is IDisposable disposable) 
        //     disposable.Dispose();
    }

    private IViewModel GetViewModel() => _inputViewModelType switch
    {
        InputViewModelType.Command => new SpeakerViewModel(_speaker),
        InputViewModelType.Moment => new MomentSpeakerViewModel(_speaker),
        _ => throw new ArgumentOutOfRangeException()
    };
        
    private enum InputViewModelType
    {
        Moment,
        Command,
    }
}

4. Запустите и проверьте


Итого

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

Фреймворк доступен в Asset Store для покупки.

Полностью бесплатно доступен на GitHub, если у вас нет возможности/желания купить.

Подробную документацию можно прочитать на GitBook на русском и английском языке.