
Небольшая предистория
19-го апреля 2024 года у меня на работе состоялось собрание программистов, где обсуждались насущные проблемы. Одной из тем обсуждения была сильно возросшая сложность UI. Не одна из существующих реализаций не удовлетворяла наши потребностям:
Разделение логики от представления.
Производительность (Избежание рефлексии).
Параллельная работа дизайнеров и программистов.
На той встрече мы не приняли какое либо решение по этой теме. Тем не менее вечером того же дня я сделал первый коммит решения, которое впоследствие стало полноценным производительным MVVM фреймворком для Unity - Aspid.MVVM.
Минимум об MVVM
В этой статье я не буду полностью рассказывать о том, что такое MVVM, а только самый минимум. Дальше в статье речь пойдет о том как работать с фреймворком, поэтому предпологается, что вы все же знакомы с данным паттерном.
MVVM - это архитектурный паттерн для разделения логики на три основных компонента:
View - Это все, что видит и с чем взаимодействует пользователь. Отвечает за отображение данных. Не содержит бизнес-логику. Отправляет команды и получает обновление от ViewModel.
ViewModel - Управляет состоянием, отображаемым во View. обрабатывает пользовательские действия. Выполняет бизнес-логику, связанную с представлением. Связан с View через Data Binding. Содержит команды и наблюдаемые свойства.
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 на русском и английском языке.