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 с Toolkit.Mvvm (2026)

Обновлено: 5 июля 2026

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 из коробки. Установка выполняется одной командой:

dotnet add package CommunityToolkit.Mvvm --version 8.4.0

Пакет добавляет source-генераторы, которые работают на этапе компиляции: никаких runtime-зависимостей, никакой reflection. Это критично для NativeAOT. Если вы читали руководство по оптимизации NativeAOT в .NET MAUI, то знаете, что reflection-based MVVM-фреймворки типа Prism или ReactiveUI требуют дополнительной настройки trimmer'а. CommunityToolkit.Mvvm свободен от этой проблемы.

После установки убедитесь, что в .csproj включена генерация nullable-типов и последняя языковая версия. Это влияет на работу генераторов:

<PropertyGroup>
  <TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst;net9.0-windows10.0.19041.0</TargetFrameworks>
  <Nullable>enable</Nullable>
  <LangVersion>latest</LangVersion>
  <UseMaui>true</UseMaui>
</PropertyGroup>

Как работает [ObservableProperty]

Основная боль ручного 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().

В XAML привязка выглядит стандартно:

<Entry Text="{Binding Email}" Placeholder="Email" />
<Entry Text="{Binding Password}" IsPassword="True" />
<Button Text="Войти" Command="{Binding LoginCommand}" />
<ActivityIndicator IsRunning="{Binding IsBusy}" />

Дополнительные атрибуты, которыми я пользуюсь постоянно: [NotifyPropertyChangedFor(nameof(FullName))] уведомляет о вычислимом свойстве, когда меняется зависимое; [NotifyDataErrorInfo] обеспечивает интеграцию с ObservableValidator для формы регистрации.

RelayCommand и асинхронные команды

Второй генератор, [RelayCommand], превращает обычный метод в ICommand. Для асинхронных операций (сетевые запросы, работа с базой данных) поддерживается Task-возвращающий метод, причём генератор автоматически оборачивает его в AsyncRelayCommand с корректной обработкой исключений.

using CommunityToolkit.Mvvm.Input;

public partial class LoginViewModel : ObservableObject
{
    private readonly IAuthService _authService;
    private readonly INavigationService _navigation;

    public LoginViewModel(IAuthService authService, INavigationService navigation)
    {
        _authService = authService;
        _navigation = navigation;
    }

    [ObservableProperty] private string _email = string.Empty;
    [ObservableProperty] private string _password = string.Empty;
    [ObservableProperty] private bool _isBusy;

    [RelayCommand(CanExecute = nameof(CanLogin))]
    private async Task LoginAsync()
    {
        IsBusy = true;
        try
        {
            var result = await _authService.SignInAsync(Email, Password);
            if (result.Success)
                await _navigation.GoToAsync("//home");
            else
                await Shell.Current.DisplayAlert("Ошибка", result.Error, "OK");
        }
        finally
        {
            IsBusy = false;
        }
    }

    private bool CanLogin() =>
        !IsBusy && !string.IsNullOrWhiteSpace(Email) && Password.Length >= 8;
}

Ключевой момент: параметр 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 кнопки «Отправить»:

<Button Text="Зарегистрироваться"
        Command="{Binding RegisterCommand}"
        IsEnabled="{Binding HasErrors, Converter={StaticResource InverseBool}}" />

Регистрация ViewModel через Dependency Injection

MAUI использует стандартный Microsoft.Extensions.DependencyInjection. Регистрируйте ViewModel и страницы в MauiProgram.cs, тогда конструкторы автоматически получат зависимости.

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
        });

    // Сервисы: Singleton
    builder.Services.AddSingleton<IAuthService, AuthService>();
    builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
    builder.Services.AddSingleton<HttpClient>();

    // ViewModel: Transient
    builder.Services.AddTransient<LoginViewModel>();
    builder.Services.AddTransient<RegistrationViewModel>();
    builder.Services.AddTransient<HomeViewModel>();

    // Страницы: Transient
    builder.Services.AddTransient<LoginPage>();
    builder.Services.AddTransient<HomePage>();

    return builder.Build();
}

Почему ViewModel Transient, а не Singleton? В Shell-приложении страницы создаются заново при каждой навигации; если ViewModel будет Singleton, она будет держать ссылки на старые View, что даёт классическую утечку памяти на Android. Я разбирал эту ловушку подробнее в руководстве по маршрутам и параметрам .NET MAUI Shell, там же показан жизненный цикл страниц.

Установка BindingContext делается в конструкторе страницы:

public partial class LoginPage : ContentPage
{
    public LoginPage(LoginViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

Прямой вызов 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 + CommunityToolkitMVU (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-генераторы выдают предупреждения о некорректных атрибутах.

Marcus Chen
Об авторе Marcus Chen

Senior mobile architect with a decade of cross-platform experience. Spent the last five years going deep on .NET MAUI in production.