MVVM в .NET MAUI с CommunityToolkit.Mvvm: практическое руководство 2026
Полное руководство по MVVM в .NET MAUI 9 с CommunityToolkit.Mvvm 8.4: атрибуты [ObservableProperty] и [RelayCommand], валидация форм, DI, навигация Shell и тестирование ViewModel без MAUI-хоста. Примеры кода на C# 13.
MVVM в .NET MAUI с CommunityToolkit.Mvvm, это архитектурный шаблон, при котором XAML-разметка привязывается к ViewModel через свойства и команды, а библиотека CommunityToolkit.Mvvm убирает шаблонный код за счёт source-генераторов. Атрибуты [ObservableProperty] и [RelayCommand] превращают обычное поле в свойство с INotifyPropertyChanged, а метод в ICommand. Честно говоря, в 2026 это де-факто стандарт для новых MAUI-проектов: меньше кода, лучшая тестируемость, полная совместимость с NativeAOT.
CommunityToolkit.Mvvm 8.4 использует Roslyn source generators. Код INotifyPropertyChanged генерируется во время компиляции, без reflection и без потерь производительности.
Атрибут [ObservableProperty] заменяет около 15 строк boilerplate одной строкой; [RelayCommand] устраняет ручную реализацию ICommand.
ViewModel наследуется от ObservableObject или ObservableValidator. Второй добавляет встроенную валидацию через DataAnnotations.
Регистрируйте ViewModel как Transient в MauiProgram.cs, а сервисы как Singleton. Это устраняет утечки памяти при переходе между страницами Shell.
MVVM и MVU, это разные парадигмы. MVVM основана на data binding, MVU на иммутабельном состоянии. В .NET MAUI 9 официально поддерживается только MVVM.
Для тестирования ViewModel вынесите навигацию и Preferences в интерфейсы, тогда unit-тесты не требуют MAUI-хоста.
Что такое MVVM в .NET MAUI и зачем он нужен
MVVM (Model-View-ViewModel), это шаблон разделения UI-разметки и логики, при котором View (XAML-страница) ничего не знает о том, откуда берутся данные, а ViewModel не знает о существовании конкретных элементов управления. Связь обеспечивается через data binding: свойства ViewModel уведомляют View об изменениях через интерфейс INotifyPropertyChanged, а действия пользователя оборачиваются в объекты ICommand.
Без MVVM код-бихайнд страницы быстро превращается в тысячестрочного монстра, где логика загрузки данных, навигация и валидация перемешаны с обработчиками кликов. Я шипил три MAUI-приложения в продакшн, и в двух из них первая версия писалась «по-быстрому» без MVVM. Обе переписывались через полгода, когда добавили модульные тесты и подключили второго разработчика.
Разделение через ViewModel даёт три вещи. Во-первых, страница подставляется в тесты без Android/iOS-хоста. Во-вторых, одна и та же ViewModel переиспользуется в разных представлениях (например, десктоп-версия WinUI). В-третьих, код становится читаемым: каждый файл делает одну вещь.
В .NET MAUI MVVM не встроен на уровне фреймворка (в отличие от Compose Multiplatform, где такой паттерн навязывается state-hoisting). Вам нужна библиотека, которая генерирует boilerplate, и с 2023 года стандартом стал CommunityToolkit.Mvvm от Microsoft.
Установка CommunityToolkit.Mvvm и настройка проекта
Актуальная версия на июль 2026, это CommunityToolkit.Mvvm 8.4.0. Требует .NET 8 или новее и поддерживает NativeAOT из коробки. Установка выполняется одной командой:
Пакет добавляет source-генераторы, которые работают на этапе компиляции: никаких runtime-зависимостей, никакой reflection. Это критично для NativeAOT. Если вы читали руководство по оптимизации NativeAOT в .NET MAUI, то знаете, что reflection-based MVVM-фреймворки типа Prism или ReactiveUI требуют дополнительной настройки trimmer'а. CommunityToolkit.Mvvm свободен от этой проблемы.
После установки убедитесь, что в .csproj включена генерация nullable-типов и последняя языковая версия. Это влияет на работу генераторов:
Основная боль ручного MVVM, это писать set { field = value; OnPropertyChanged(); } для каждого свойства. Атрибут [ObservableProperty] решает это. Вы объявляете поле, а генератор создаёт публичное свойство с уведомлениями.
using CommunityToolkit.Mvvm.ComponentModel;
public partial class LoginViewModel : ObservableObject
{
[ObservableProperty]
private string _email = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
private string _password = string.Empty;
[ObservableProperty]
private bool _isBusy;
}
После компиляции генератор создаёт свойства Email, Password, IsBusy с вызовом OnPropertyChanged в сеттере. Атрибут [NotifyCanExecuteChangedFor] связывает свойство с командой: при изменении Password у команды LoginCommand будет пересчитан CanExecute. Раньше это писалось руками через ((RelayCommand)LoginCommand).NotifyCanExecuteChanged().
Дополнительные атрибуты, которыми я пользуюсь постоянно: [NotifyPropertyChangedFor(nameof(FullName))] уведомляет о вычислимом свойстве, когда меняется зависимое; [NotifyDataErrorInfo] обеспечивает интеграцию с ObservableValidator для формы регистрации.
RelayCommand и асинхронные команды
Второй генератор, [RelayCommand], превращает обычный метод в ICommand. Для асинхронных операций (сетевые запросы, работа с базой данных) поддерживается Task-возвращающий метод, причём генератор автоматически оборачивает его в AsyncRelayCommand с корректной обработкой исключений.
Ключевой момент: параметр CanExecute ссылается на метод, а не на свойство. Метод вызывается каждый раз при NotifyCanExecuteChanged. По умолчанию [RelayCommand] для асинхронных методов включает защиту от повторных нажатий: если команда уже выполняется, второй тап игнорируется. Это отключается через [RelayCommand(AllowConcurrentExecutions = true)], но в 99% случаев поведение по умолчанию, это то, что нужно.
ObservableObject или ObservableValidator
ViewModel наследуется от одного из двух базовых классов CommunityToolkit.Mvvm. ObservableObject, это минимальный вариант: он реализует INotifyPropertyChanged и INotifyPropertyChanging. Используйте его для ViewModel без валидации, например для главного экрана, где данные приходят с сервера и уже проверены.
ObservableValidator добавляет поверх INotifyDataErrorInfo и интеграцию с System.ComponentModel.DataAnnotations. Это лучший выбор для форм: атрибуты [Required], [EmailAddress], [MinLength] навешиваются прямо на свойства, и ошибки автоматически появляются в UI при использовании ValidationSummary-контрола или триггеров DataTrigger.
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
public partial class RegistrationViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "Email обязателен")]
[EmailAddress(ErrorMessage = "Некорректный email")]
[NotifyDataErrorInfo]
private string _email = string.Empty;
[ObservableProperty]
[Required]
[MinLength(8, ErrorMessage = "Минимум 8 символов")]
[NotifyDataErrorInfo]
private string _password = string.Empty;
[RelayCommand]
private void Register()
{
ValidateAllProperties();
if (HasErrors) return;
// отправка данных
}
}
Метод ValidateAllProperties() запускает проверку всех атрибутов сразу, что полезно перед сабмитом. Отдельные свойства проверяются автоматически при каждом изменении благодаря [NotifyDataErrorInfo]. Свойство HasErrors удобно биндить к IsEnabled кнопки «Отправить»:
MAUI использует стандартный Microsoft.Extensions.DependencyInjection. Регистрируйте ViewModel и страницы в MauiProgram.cs, тогда конструкторы автоматически получат зависимости.
Почему ViewModel Transient, а не Singleton? В Shell-приложении страницы создаются заново при каждой навигации; если ViewModel будет Singleton, она будет держать ссылки на старые View, что даёт классическую утечку памяти на Android. Я разбирал эту ловушку подробнее в руководстве по маршрутам и параметрам .NET MAUI Shell, там же показан жизненный цикл страниц.
Установка BindingContext делается в конструкторе страницы:
public partial class LoginPage : ContentPage
{
public LoginPage(LoginViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
Навигация из ViewModel в Shell
Прямой вызов Shell.Current.GoToAsync() из ViewModel, это плохой стиль. Тест не сможет замокать статический Shell.Current. Правильный путь, это интерфейс INavigationService, регистрируемый через DI. Реализация тонкая:
public interface INavigationService
{
Task GoToAsync(string route, IDictionary<string, object>? parameters = null);
Task GoBackAsync();
}
public class ShellNavigationService : INavigationService
{
public Task GoToAsync(string route, IDictionary<string, object>? parameters = null)
=> parameters is null
? Shell.Current.GoToAsync(route)
: Shell.Current.GoToAsync(route, parameters);
public Task GoBackAsync() => Shell.Current.GoToAsync("..");
}
Для передачи параметров между страницами используйте атрибут [QueryProperty] на ViewModel-приёмнике. Это официальный механизм Shell-навигации в .NET MAUI, работающий с deep links и восстановлением состояния. В связке с сервисом навигации выглядит так:
[QueryProperty(nameof(ProductId), "id")]
public partial class ProductDetailViewModel : ObservableObject
{
[ObservableProperty] private int _productId;
partial void OnProductIdChanged(int value) => _ = LoadProductAsync(value);
}
Метод partial void OnPropertyNameChanged, это ещё одна фишка source-генератора. Если вы объявите такой partial-метод в том же классе, он будет вызван после изменения свойства. Это заменяет ручную подписку на PropertyChanged.
Тестирование ViewModel без MAUI-хоста
Главная выгода MVVM, это тесты. ViewModel не наследуется от MAUI-типов (за исключением ObservableObject, который живёт в отдельной сборке CommunityToolkit.Mvvm), поэтому её можно инстанцировать в обычном xUnit-проекте без Android-эмулятора. В одном из проектов я нашёл на этом реальный баг в проверке пароля буквально за два часа работы над тестами, и это спасло релиз.
public class LoginViewModelTests
{
[Fact]
public async Task LoginCommand_WhenAuthSucceeds_NavigatesToHome()
{
// Arrange
var auth = Substitute.For<IAuthService>();
auth.SignInAsync("[email protected]", "password123")
.Returns(new AuthResult { Success = true });
var nav = Substitute.For<INavigationService>();
var vm = new LoginViewModel(auth, nav)
{
Email = "[email protected]",
Password = "password123"
};
// Act
await vm.LoginCommand.ExecuteAsync(null);
// Assert
await nav.Received(1).GoToAsync("//home");
}
[Fact]
public void LoginCommand_CannotExecute_WhenPasswordTooShort()
{
var vm = new LoginViewModel(
Substitute.For<IAuthService>(),
Substitute.For<INavigationService>())
{
Email = "[email protected]",
Password = "123"
};
Assert.False(vm.LoginCommand.CanExecute(null));
}
}
Ключевые моменты: используйте ExecuteAsync вместо синхронного Execute для AsyncRelayCommand, иначе тест закончится до завершения задачи. Мокаем INavigationService и IAuthService через NSubstitute (или Moq). Никаких Xamarin.UITest или Appium для проверки бизнес-логики не нужно, это отдельный слой E2E-тестов, о котором я расскажу отдельно.
Чем MVVM отличается от MVU
MVU (Model-View-Update), это альтернативная парадигма, знакомая по Elm, SwiftUI и Jetpack Compose. Основная разница: состояние иммутабельно, а UI перерисовывается функцией view(state) при каждом обновлении. В .NET MAUI 9 официальной поддержки MVU нет, хотя есть экспериментальный MauiReactor от сообщества. Ниже сравнение:
Критерий
MVVM + CommunityToolkit
MVU (MauiReactor / SwiftUI-style)
Официальная поддержка
Да, стандарт де-факто
Нет, community-проект
Разметка
XAML (или C# markup)
Только C#
Тестирование ViewModel
Простое, POCO-стиль
Через snapshot-тесты state
Кривая обучения
Низкая для WPF/Xamarin разработчиков
Высокая, immutable-мышление
Производительность
Только изменённые свойства
Diff всего дерева (может быть дороже)
Инструменты дизайнеров
XAML Hot Reload, Preview
Ограничены C#-only tooling
В production я всегда выбираю MVVM: инструменты Visual Studio 2026 и Rider поддерживают XAML-биндинги с автокомплитом, дизайнеры справляются с ним лучше, а source-генераторы CommunityToolkit убирают почти весь boilerplate. MVU оставьте для случаев, где UI полностью декларативный и меняется постоянно (визуальные редакторы, игровые интерфейсы). Для 95% бизнес-приложений MVVM, это правильный ответ.
Часто задаваемые вопросы
Нужен ли CommunityToolkit.Mvvm, или можно писать MVVM вручную?
Можно вручную, но не нужно. Ручной MVVM требует около 15 строк boilerplate на каждое свойство. CommunityToolkit.Mvvm генерирует тот же код на этапе компиляции, без runtime-накладных, с полной поддержкой NativeAOT. В новых проектах .NET MAUI 9 это стандартный выбор Microsoft.
Как связать команду с кнопкой в XAML?
Используйте атрибут [RelayCommand] на методе ViewModel. Генератор создаст свойство ИмяМетодаCommand. В XAML привяжите его через Command="{Binding LoginCommand}". Для передачи параметра используйте CommandParameter="{Binding ItemId}" и сигнатуру [RelayCommand] private void Delete(int id).
Работает ли CommunityToolkit.Mvvm с NativeAOT?
Да. Начиная с версии 8.2 все генераторы полностью совместимы с NativeAOT и trimming. Никакой ручной настройки TrimmerRootDescriptor не требуется, в отличие от reflection-based фреймворков вроде Prism или классического ReactiveUI.
Как передать данные между двумя ViewModel?
Три способа, по мере предпочтения: параметры Shell-навигации через [QueryProperty] для передачи ID или коротких значений; общий сервис-Singleton, зарегистрированный в DI, для общего состояния сессии; и WeakReferenceMessenger из CommunityToolkit для событий типа «пользователь вышел».
Почему свойства не обновляются в UI?
Три частые причины: класс ViewModel не помечен partial (генератор молчит), поле начинается не с подчёркивания или строчной буквы (генератор пропускает), либо BindingContext у страницы не установлен. Проверьте вывод компилятора, source-генераторы выдают предупреждения о некорректных атрибутах.