
Небольшая предистория
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 на русском и английском языке.
