MVVM Community Toolkit у .NET MAUI 10: [ObservableProperty], [RelayCommand] та partial properties

Покроковий гайд з MVVM Community Toolkit 8.4+ у .NET MAUI 10: source generators, partial properties для AOT, [RelayCommand] з CancellationToken, ObservableValidator та WeakReferenceMessenger замість застарілого MessagingCenter.

MVVM Toolkit .NET MAUI 10 — [RelayCommand]

Якщо ви хоч раз писали ViewModel вручну, з усіма цими RaisePropertyChanged(nameof(...)) та власними Command-обгортками, то ви розумієте, чому MVVM Community Toolkit став де-факто стандартом для .NET MAUI 10. Десятки рядків boilerplate просто зникають — source generators роблять усю рутину за вас, лишаючи у ViewModel лише декларативні атрибути.

У цьому гайді ми пройдемо повний стек можливостей пакета CommunityToolkit.Mvvm 8.4+: від базового [ObservableProperty] на partial-властивостях до WeakReferenceMessenger, який нарешті ховає у могилу застарілий MessagingCenter.

Чесно кажучи, я бачив занадто багато поверхневих статей про toolkit. Тут буде інакше — орієнтуємося на реальні сценарії: як писати AOT-сумісні ViewModel, як правильно обробляти асинхронні команди з CancellationToken, та (найголовніше) як уникати найпоширеніших пасток.

Чому MVVM Community Toolkit, а не власна реалізація?

У .NET MAUI є вбудований BindableObject, але для ViewModel він непридатний. Чому? Бо тягне залежність від MAUI прямо у вашу бізнес-логіку — а це останнє, чого ви хочете. CommunityToolkit.Mvvm — це чиста .NET-бібліотека без UI-залежностей, що дозволяє переносити ViewModel між WPF, WinUI, Avalonia та MAUI без жодної зміни.

Версія 8.4+ принесла головну новинку, на яку чекали роками — підтримку partial properties. Це робить код повністю сумісним з Native AOT, що критично для .NET MAUI 10: AOT-компіляція суттєво зменшує розмір додатка та прискорює холодний старт (а на iOS це взагалі обов'язкова вимога).

Встановлення та базове налаштування

Почнемо з простого. Додайте NuGet-пакет до вашого MAUI-проєкту:

dotnet add package CommunityToolkit.Mvvm --version 8.4.2

Для використання partial properties переконайтеся, що у вашому .csproj вказано LangVersion щонайменше 13.0 (це включено за замовчуванням для .NET 10):

<PropertyGroup>
  <TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
  <LangVersion>latest</LangVersion>
  <Nullable>enable</Nullable>
</PropertyGroup>

ObservableObject — ваш новий базовий клас

Усі ваші ViewModel мають успадковуватись від ObservableObject. Цей клас реалізує INotifyPropertyChanged та INotifyPropertyChanging, надаючи методи SetProperty, OnPropertyChanged та OnPropertyChanging. І один важливий нюанс: будь-який клас, що використовує source generators MVVM Toolkit, обов'язково має бути позначений як partial. Забудете — компілятор одразу про це нагадає.

using CommunityToolkit.Mvvm.ComponentModel;

namespace MyMauiApp.ViewModels;

public partial class MainPageViewModel : ObservableObject
{
    // властивості та команди тут
}

[ObservableProperty] на partial-властивостях (новий рекомендований спосіб)

До версії 8.4 атрибут [ObservableProperty] застосовувався до приватних полів. Старий підхід досі працює, але — і це важливо — не сумісний з AOT у WinRT-сценаріях та видає попередження MVVMTK0045. Сучасний підхід — partial-властивості:

using CommunityToolkit.Mvvm.ComponentModel;

public partial class UserProfileViewModel : ObservableObject
{
    [ObservableProperty]
    public partial string? FullName { get; set; }

    [ObservableProperty]
    public partial int Age { get; set; }

    [ObservableProperty]
    public partial bool IsLoading { get; set; }
}

Source generator автоматично створить:

  • Backing-поле для збереження значення
  • Гетер та сетер з викликом OnPropertyChanging та OnPropertyChanged
  • Часткові методи OnFullNameChanging і OnFullNameChanged, які ви можете реалізувати для кастомної логіки

Реакція на зміну властивості

public partial class UserProfileViewModel : ObservableObject
{
    [ObservableProperty]
    public partial string? Email { get; set; }

    partial void OnEmailChanged(string? value)
    {
        // викликається після зміни значення
        Console.WriteLine($"Email змінено на: {value}");
    }

    partial void OnEmailChanging(string? oldValue, string? newValue)
    {
        // викликається до зміни значення
    }
}

Залежні властивості: [NotifyPropertyChangedFor]

Якщо одна властивість обчислюється на основі іншої, вкажіть це декларативно:

public partial class OrderViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TotalPrice))]
    public partial decimal UnitPrice { get; set; }

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TotalPrice))]
    public partial int Quantity { get; set; }

    public decimal TotalPrice => UnitPrice * Quantity;
}

Тепер при зміні UnitPrice або Quantity UI отримає сповіщення і про зміну TotalPrice. Жодних ручних викликів — усе підхоплюється автоматично.

[RelayCommand] — команди без болю

Атрибут [RelayCommand] застосовується до методів і генерує властивість типу IRelayCommand (для синхронних) або IAsyncRelayCommand (для асинхронних). До назви методу автоматично додається суфікс Command — запам'ятайте це, бо саме за цим іменем ви будете прив'язуватись з XAML.

Синхронна команда

using CommunityToolkit.Mvvm.Input;

public partial class CounterViewModel : ObservableObject
{
    [ObservableProperty]
    public partial int Count { get; set; }

    [RelayCommand]
    private void Increment()
    {
        Count++;
    }
}

У XAML команда доступна за іменем IncrementCommand:

<Button Text="+1" Command="{Binding IncrementCommand}" />

Асинхронна команда з CancellationToken

А ось це — один з найпотужніших сценаріїв, який чомусь часто пропускають. Ви передаєте CancellationToken як параметр методу, і toolkit автоматично надає його та керує життєвим циклом:

[RelayCommand]
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
    IsLoading = true;
    try
    {
        var items = await _apiClient.GetItemsAsync(cancellationToken);
        Items = new ObservableCollection<Item>(items);
    }
    catch (OperationCanceledException)
    {
        // користувач скасував операцію
    }
    finally
    {
        IsLoading = false;
    }
}

Toolkit згенерує властивість LoadDataCancelCommand, яку можна прив'язати до кнопки скасування. Жодного власного CancellationTokenSource — усе вже зроблено за вас:

<Button Text="Завантажити" Command="{Binding LoadDataCommand}" />
<Button Text="Скасувати" Command="{Binding LoadDataCancelCommand}"
        IsVisible="{Binding LoadDataCommand.IsRunning}" />

Команда з параметром

[RelayCommand]
private async Task DeleteItemAsync(Item item)
{
    bool confirmed = await Shell.Current.DisplayAlert(
        "Підтвердження", $"Видалити '{item.Name}'?", "Так", "Ні");

    if (confirmed)
    {
        Items.Remove(item);
        await _repository.DeleteAsync(item.Id);
    }
}

CanExecute та [NotifyCanExecuteChangedFor]

Контроль доступності команди прив'язується до властивості декларативно — без жодного ручного RaiseCanExecuteChanged():

public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    public partial string? Username { get; set; }

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    public partial string? Password { get; set; }

    private bool CanLogin() =>
        !string.IsNullOrWhiteSpace(Username) &&
        !string.IsNullOrWhiteSpace(Password);

    [RelayCommand(CanExecute = nameof(CanLogin))]
    private async Task LoginAsync()
    {
        // логіка автентифікації
    }
}

Кнопка "Увійти" буде автоматично деактивована, доки користувач не введе обидва поля. Просто і красиво.

Контроль одночасного виконання: AllowConcurrentExecutions

За замовчуванням AsyncRelayCommand блокує повторні виклики, доки попередня операція не завершилась. Це ідеально для запобігання подвійному натисканню (а ми всі знаємо, скільки багів виникало саме через це). Якщо ж вам справді потрібен паралельний запуск:

[RelayCommand(AllowConcurrentExecutions = true)]
private async Task RefreshAsync()
{
    await _repository.SyncAsync();
}

ObservableValidator: валідація через атрибути

Замість писати власний код валідації (з усіма його болями та незавершеними edge-cases), успадкуйтеся від ObservableValidator та використовуйте старі добрі System.ComponentModel.DataAnnotations:

using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;

public partial class RegistrationViewModel : ObservableValidator
{
    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Email обов'язковий")]
    [EmailAddress(ErrorMessage = "Некоректний email")]
    public partial string? Email { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Пароль обов'язковий")]
    [MinLength(8, ErrorMessage = "Мінімум 8 символів")]
    public partial string? Password { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Range(18, 120, ErrorMessage = "Вік від 18 до 120")]
    public partial int Age { get; set; }

    [RelayCommand]
    private void Submit()
    {
        ValidateAllProperties();
        if (HasErrors)
        {
            return;
        }
        // надсилання форми
    }
}

У XAML ви можете відобразити помилки через HasErrors та GetErrors():

<Entry Text="{Binding Email, Mode=TwoWay}" Placeholder="Email" />
<Label Text="{Binding Email, Converter={StaticResource ErrorMessageConverter}}"
       TextColor="Red" FontSize="12" />

WeakReferenceMessenger: прощавай, MessagingCenter

Починаючи з .NET 7, MessagingCenter у .NET MAUI офіційно застарілий. І це чудова новина — він був джерелом постійних витоків пам'яті, якщо ви забували відписатися. Натомість використовуйте WeakReferenceMessenger з MVVM Toolkit. Він автоматично уникає витоків завдяки слабким посиланням, тож навіть якщо ви забули про Unregister, GC рано чи пізно прибере ваш об'єкт.

Крок 1. Визначте повідомлення

using CommunityToolkit.Mvvm.Messaging.Messages;

public sealed class UserLoggedInMessage : ValueChangedMessage<UserDto>
{
    public UserLoggedInMessage(UserDto user) : base(user) { }
}

Крок 2. Підпишіться на повідомлення

using CommunityToolkit.Mvvm.Messaging;

public partial class ShellViewModel : ObservableObject, IRecipient<UserLoggedInMessage>
{
    public ShellViewModel()
    {
        WeakReferenceMessenger.Default.Register(this);
    }

    public void Receive(UserLoggedInMessage message)
    {
        UserDto user = message.Value;
        // оновлюємо UI
        WelcomeText = $"Вітаємо, {user.Name}!";
    }

    [ObservableProperty]
    public partial string? WelcomeText { get; set; }
}

Крок 3. Надішліть повідомлення

[RelayCommand]
private async Task LoginAsync()
{
    var user = await _authService.SignInAsync(Username, Password);
    WeakReferenceMessenger.Default.Send(new UserLoggedInMessage(user));
    await Shell.Current.GoToAsync("//main");
}

Важливо: попри слабкі посилання, завжди викликайте WeakReferenceMessenger.Default.UnregisterAll(this) у методі Dispose або при виході зі сторінки. Це найкраща практика, і вона економить нерви при діагностиці дивних поведінок.

Реальний приклад: екран списку завдань

А тепер зведемо все разом. Ось повний ViewModel списку завдань з валідацією додавання, обробкою помилок та оновленням з потягуванням (pull-to-refresh):

using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;

public partial class TodoListViewModel : ObservableValidator
{
    private readonly ITodoRepository _repository;

    public TodoListViewModel(ITodoRepository repository)
    {
        _repository = repository;
        Items = new ObservableCollection<TodoItem>();
    }

    [ObservableProperty]
    public partial ObservableCollection<TodoItem> Items { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Введіть текст")]
    [MinLength(3, ErrorMessage = "Мінімум 3 символи")]
    [NotifyCanExecuteChangedFor(nameof(AddItemCommand))]
    public partial string? NewItemTitle { get; set; }

    [ObservableProperty]
    public partial bool IsRefreshing { get; set; }

    [ObservableProperty]
    public partial string? ErrorMessage { get; set; }

    private bool CanAddItem() =>
        !string.IsNullOrWhiteSpace(NewItemTitle) && !HasErrors;

    [RelayCommand(CanExecute = nameof(CanAddItem))]
    private async Task AddItemAsync()
    {
        ValidateAllProperties();
        if (HasErrors) return;

        var item = new TodoItem { Title = NewItemTitle!, IsDone = false };
        await _repository.AddAsync(item);
        Items.Add(item);
        NewItemTitle = string.Empty;
    }

    [RelayCommand]
    private async Task RefreshAsync(CancellationToken ct)
    {
        try
        {
            IsRefreshing = true;
            ErrorMessage = null;
            var items = await _repository.GetAllAsync(ct);
            Items.Clear();
            foreach (var i in items) Items.Add(i);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            ErrorMessage = "Не вдалося завантажити список";
        }
        finally
        {
            IsRefreshing = false;
        }
    }

    [RelayCommand]
    private async Task ToggleAsync(TodoItem item)
    {
        item.IsDone = !item.IsDone;
        await _repository.UpdateAsync(item);
    }
}

Зверніть увагу: тут немає жодного ручного RaisePropertyChanged, жодного власного ICommand. Усе генерується. І, якщо чесно, після такого коду повертатися до старого синтаксису просто болісно.

Найкращі практики та поширені пастки

1. Завжди використовуйте partial-властивості замість полів

Для нових ViewModel не використовуйте старий синтаксис з private string _name;. Це генерує AOT-несумісний код та видає попередження. Перенесення з полів автоматизовано через code fixer у Visual Studio — буквально один клік на лампочці.

2. Не складайте складну логіку у setter

Якщо вам потрібна логіка при зміні значення — реалізуйте часткові методи OnXxxChanged або OnXxxChanging. Але уникайте бізнес-логіки там. Setter має лише оновлювати стан, нічого більше.

3. Скасування довгих операцій

Завжди приймайте CancellationToken у асинхронних командах. Це дозволить toolkit згенерувати CancelCommand та коректно зупиняти операції при навігації між сторінками (а на мобільних це частий сценарій).

4. Використовуйте конструктор для DI, а не статичні сервіси

Реєструйте ViewModel у контейнері DI у MauiProgram.cs:

builder.Services.AddTransient<TodoListViewModel>();
builder.Services.AddSingleton<ITodoRepository, SqliteTodoRepository>();

5. Завжди працюйте з UI на головному потоці

При обробці повідомлень з фонового потоку оновлюйте властивості через MainThread.BeginInvokeOnMainThread:

public void Receive(DataUpdatedMessage message)
{
    MainThread.BeginInvokeOnMainThread(() =>
    {
        Items.Add(message.Value);
    });
}

6. Ізолюйте ViewModel від MAUI-залежностей

Для діалогів та навігації використовуйте абстракції (IDialogService, INavigationService), які можна підмінити при тестуванні. ViewModel не має знати про Shell або DisplayAlert напряму — інакше юніт-тести перетворяться на пекло.

7. Тестування ViewModel

Завдяки відсутності залежностей від MAUI, ViewModel легко покривається unit-тестами:

[Fact]
public async Task AddItem_AddsToCollection_WhenValid()
{
    var repo = Substitute.For<ITodoRepository>();
    var vm = new TodoListViewModel(repo);
    vm.NewItemTitle = "Купити молоко";

    await vm.AddItemCommand.ExecuteAsync(null);

    Assert.Single(vm.Items);
    Assert.Equal(string.Empty, vm.NewItemTitle);
}

Часті питання (FAQ)

Чи потрібно встановлювати окремий пакет для .NET MAUI?

Ні. CommunityToolkit.Mvvm — це чиста .NET-бібліотека без UI-залежностей. Вона працює однаково в MAUI, WPF, WinUI та Avalonia. Не плутайте її з CommunityToolkit.Maui — той містить UI-контроли та поведінки специфічно для MAUI. Це різні пакети, хоч і живуть під однаковим брендом.

У чому різниця між RelayCommand з Toolkit та Command з .NET MAUI?

Вбудований Command з MAUI підтримує лише синхронне виконання та не має властивостей IsRunning, CanBeCanceled, ExecutionTask. RelayCommand та AsyncRelayCommand з toolkit пропонують повноцінну підтримку асинхронності, скасування та автоматичне керування станом кнопки. Плюс — використання toolkit-команд робить ViewModel портативним між фреймворками.

Чому виникає помилка "The class must be marked as partial"?

Source generators додають згенерований код до того ж класу через partial-визначення. Будь-який клас, що використовує атрибути [ObservableProperty], [RelayCommand] або [NotifyDataErrorInfo], має бути позначений ключовим словом partial. Без нього компілятор просто не зможе об'єднати ваш код зі згенерованим.

Як міксувати старий синтаксис з полями та новий з partial-властивостями?

Можна, але не рекомендовано. Старий синтаксис з [ObservableProperty] private string _name; досі працює, але видає попередження MVVMTK0045 у AOT-сценаріях. Найкраща стратегія — використати code fixer у Visual Studio (Ctrl+. на попередженні), який автоматично перетворить усі поля на partial-властивості одним кліком. Я особисто пройшовся так через цілий проєкт за десять хвилин.

Чи варто переходити з MessagingCenter на WeakReferenceMessenger?

Так, обов'язково. MessagingCenter офіційно застарілий з .NET 7 і буде видалений у майбутніх версіях MAUI. WeakReferenceMessenger має кращу продуктивність, типобезпечність на етапі компіляції та автоматично запобігає витокам пам'яті завдяки слабким посиланням. Переходити можна поступово — обидві API можуть співіснувати під час міграції.

Висновки

MVVM Community Toolkit перетворює побудову ViewModel у .NET MAUI 10 з рутини на декларативний процес. Перехід на partial-властивості (8.4+) робить ваш код AOT-сумісним та готовим до Native AOT-компіляції, що зменшує розмір додатка та прискорює запуск. [RelayCommand] з підтримкою CancellationToken та AllowConcurrentExecutions закриває реальні потреби, з якими стикається кожен мобільний розробник. ObservableValidator та WeakReferenceMessenger завершують картину: валідація через атрибути та безпечні повідомлення без витоків пам'яті.

Якщо ви ще пишете ViewModel вручну з RaisePropertyChanged — час перейти. Серйозно. Кожен новий ViewModel у вашому проєкті має починатися з partial class та : ObservableObject, і ви здивуєтеся, як швидко звикнете писати втричі менше коду.

Про Автора Editorial Team

Our team of expert writers and editors.