MVVM в .NET MAUI с CommunityToolkit.Mvvm: Source Generators и пълно ръководство за 2026

Пълно ръководство за MVVM в .NET MAUI с CommunityToolkit.Mvvm 8.4: source generators, [ObservableProperty], [RelayCommand], DI, валидация и messenger pattern с готови code примери.

MVVM в .NET MAUI: CommunityToolkit (2026)

Актуализирано: 30 юни 2026

CommunityToolkit.Mvvm е официалната библиотека на Microsoft, която реализира MVVM шаблона в .NET MAUI чрез Roslyn source generators. Добавяте атрибут като [ObservableProperty] върху поле и компилаторът автоматично генерира пълния INotifyPropertyChanged код по време на билд. Това премахва над 80% от boilerplate-а в типичен view model, не изисква reflection и работи без runtime overhead. В това ръководство ще видите пълна настройка за .NET MAUI 9, всички ключови атрибути, DI интеграция и реални примери от продукционни приложения, които съм поддържал лично.

  • CommunityToolkit.Mvvm 8.4 (юни 2026) е де-факто стандартът за MVVM в .NET MAUI и заменя MVVM Light, Prism boilerplate-а, както и собствените базови класове.
  • Source generators превръщат [ObservableProperty] private string name; в пълно property с OnPropertyChanged и опционални OnPropertyChanging/partial hooks.
  • [RelayCommand] генерира IRelayCommand от метод, поддържа async, CancellationToken и CanExecute предикати без ръчно писан код.
  • Интеграцията с Microsoft.Extensions.DependencyInjection в MauiProgram.cs позволява конструкторно инжектиране на сервиси директно във view models.
  • ObservableValidator добавя валидация чрез DataAnnotations атрибути с реактивно подсветяване в UI без отделна библиотека.
  • WeakReferenceMessenger заменя event aggregator-ите и не задържа референции, които биха предизвикали memory leak.

Какво е CommunityToolkit.Mvvm и защо да го използвате

CommunityToolkit.Mvvm (исторически известен като Microsoft.Toolkit.Mvvm) е модерна, лека MVVM библиотека, поддържана от .NET Foundation. Тя е platform-agnostic, тоест работи в .NET MAUI, WPF, WinUI 3, Avalonia и Uno Platform, и се фокусира върху едно нещо: премахване на повтарящия се код, който всеки MVVM проект трупа. Версия 8.4, излязла през май 2026 г., добавя поддръжка за .NET 10, подобрена диагностика на source generators и нов [ObservableRecipient] атрибут за автоматична регистрация в messenger.

Защо да я предпочетете пред алтернативите? Първо, тя е официално поддържана от Microsoft и се препоръчва в документацията на .NET MAUI. Второ, source generators работят по време на компилация, така че няма reflection, няма startup penalty и AOT компилацията (критична за iOS и Mac Catalyst) работи безпроблемно. Трето, API-то е минималистично. Научавате 3-4 атрибута и сте продуктивни. Сравнете това с Prism, който носи цял framework с регион мениджъри, навигационни сервиси и dialog сервиси, които често не са нужни в мобилно приложение.

Честно казано, в моя опит типичен view model с ръчно реализиран INotifyPropertyChanged съдържа около 15-20 реда boilerplate на property. Със source generators това става един ред. На голям проект с 50+ view model-а спестявате хиляди редове код и още толкова часове на ревюта.

Инсталация и настройка в .NET MAUI 9

Започнете с празен .NET MAUI 9 проект (или .NET 10 preview, ако сте на bleeding edge). Добавете пакета чрез CLI:

dotnet add package CommunityToolkit.Mvvm --version 8.4.0

Това е единствената зависимост, която трябва. Source generators се активират автоматично, не са нужни допълнителни <AnalyzerReference> елементи в .csproj файла. За да проверите, че работят, отворете Solution Explorer → вашият проект → Dependencies → Analyzers → CommunityToolkit.Mvvm.SourceGenerators. Трябва да видите изброени генератори като ObservablePropertyGenerator, RelayCommandGenerator и ObservableObjectGenerator.

Включете nullable и ImplicitUsings в .csproj. Без тях генерираният код става токсичен. По подразбиране новите MAUI проекти ги имат, но при миграция от Xamarin.Forms към .NET MAUI те често са изключени и трябва да се активират ръчно. Накрая, регистрирайте основните си сервиси в MauiProgram.cs. Ще се върнем към това в секцията за DI.

ObservableObject и [ObservableProperty]: край на boilerplate-а

Базовият клас ObservableObject реализира INotifyPropertyChanged и INotifyPropertyChanging. Наследявате го във вашия view model и сте готови. Но истинската магия започва с [ObservableProperty]:

using CommunityToolkit.Mvvm.ComponentModel;

public partial class ProductViewModel : ObservableObject
{
    [ObservableProperty]
    private string name = string.Empty;

    [ObservableProperty]
    private decimal price;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TotalWithTax))]
    private int quantity = 1;

    public decimal TotalWithTax => Price * Quantity * 1.20m;
}

Какво се случва зад кулисите? Source generator-ът генерира публични property-та Name, Price и Quantity (имената се правят PascalCase с премахнат underscore префикс). Всяко setter извиква OnPropertyChanged. Атрибутът [NotifyPropertyChangedFor] прави така, че промяна на Quantity автоматично нотифицира UI-я да опресни и TotalWithTax, без нито един ред ръчно писан код.

Освен това генерираните property-та извикват partial методи, които можете да реализирате за custom логика:

partial void OnQuantityChanging(int oldValue, int newValue)
{
    if (newValue < 0)
        throw new ArgumentException("Количеството не може да е отрицателно");
}

partial void OnQuantityChanged(int value)
{
    _analytics.TrackEvent("QuantityChanged", new { Old = value });
}

Тези hook-ове са незадължителни. Ако не ги дефинирате, компилаторът ги изрязва напълно (zero overhead). Това е силата на source generators в сравнение с runtime подходи като Fody или PostSharp.

[RelayCommand]: декларативни команди с CanExecute и async

Командите са вторият голям източник на boilerplate. Класическият подход иска от вас да създадете поле private ICommand _saveCommand;, lazy property с делегат, реализация на CanExecuteChanged и т.н. С [RelayCommand] това става:

using CommunityToolkit.Mvvm.Input;

public partial class CheckoutViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SubmitOrderCommand))]
    private bool isFormValid;

    [RelayCommand(CanExecute = nameof(IsFormValid))]
    private async Task SubmitOrderAsync(CancellationToken token)
    {
        try
        {
            await _orderService.SubmitAsync(token);
            await Shell.Current.GoToAsync("//confirmation");
        }
        catch (OperationCanceledException)
        {
            // потребителят е отменил
        }
    }
}

Генерираното property се казва SubmitOrderCommand (суфиксът Async се изрязва) и е от тип IAsyncRelayCommand. То автоматично разкрива IsRunning и CanBeCanceled, които можете да bind-нете в XAML за loading индикатори. Атрибутът [NotifyCanExecuteChangedFor] прави промяна на IsFormValid да преоценява дали бутонът трябва да е активен.

В XAML свържете командата по стандартния начин:

<Button Text="Поръчай"
        Command="{Binding SubmitOrderCommand}"
        IsEnabled="{Binding SubmitOrderCommand.CanExecute}" />
<ActivityIndicator IsRunning="{Binding SubmitOrderCommand.IsRunning}" />

Dependency Injection във view models

.NET MAUI има вграден DI контейнер, базиран на Microsoft.Extensions.DependencyInjection. CommunityToolkit.Mvvm няма собствен резолвер, просто използвате конструкторно инжектиране. Регистрирайте всичко в MauiProgram.cs:

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

    // Сервиси
    builder.Services.AddSingleton<IApiClient, ApiClient>();
    builder.Services.AddSingleton<IOrderService, OrderService>();

    // View models - Transient за всяка нова страница
    builder.Services.AddTransient<CheckoutViewModel>();
    builder.Services.AddTransient<ProductListViewModel>();

    // Pages
    builder.Services.AddTransient<CheckoutPage>();

    return builder.Build();
}

Във view model-а инжектирате чрез конструктора:

public partial class CheckoutViewModel : ObservableObject
{
    private readonly IOrderService _orderService;

    public CheckoutViewModel(IOrderService orderService)
    {
        _orderService = orderService;
    }
}

В страницата приемете view model-а през конструктора и го присвоете на BindingContext:

public partial class CheckoutPage : ContentPage
{
    public CheckoutPage(CheckoutViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
    }
}

Тази конструкция работи безпроблемно с Shell навигацията, защото Shell разрешава страниците чрез DI контейнера. За интеграция с HttpClient и Refit за REST API заявки, регистрирайте Refit интерфейса като сервис и го инжектирайте в съответния view model.

Комуникация между view models с IMessenger

Когато два несвързани view model-а трябва да комуникират (например CartViewModel трябва да научи, че ProductDetailsViewModel е добавил продукт), events водят до tight coupling и memory leaks. Решението е WeakReferenceMessenger:

using CommunityToolkit.Mvvm.Messaging;

// Съобщението
public record ProductAddedMessage(int ProductId, int Quantity);

// Издателят
public partial class ProductDetailsViewModel : ObservableObject
{
    [RelayCommand]
    private void AddToCart()
    {
        WeakReferenceMessenger.Default.Send(
            new ProductAddedMessage(ProductId, 1));
    }
}

// Абонатът
public partial class CartViewModel : ObservableRecipient,
    IRecipient<ProductAddedMessage>
{
    public CartViewModel() => IsActive = true;

    public void Receive(ProductAddedMessage message)
    {
        ItemCount += message.Quantity;
    }
}

Базовият клас ObservableRecipient автоматично регистрира и отрегистрира абонатите, когато IsActive се сменя. Това гарантира, че когато страницата се навигира назад и view model-ът се освободи, той не остава абониран и не държи референции, които биха попречили на GC. В последния ми проект този pattern спести два дни дебъг на memory leak, който се появяваше само след 40-50 навигации.

Shell навигация с параметри и view models

.NET MAUI Shell поддържа query параметри по време на навигация. CommunityToolkit.Mvvm не предоставя собствен навигационен сервис. Препоръчителният подход е тънка обвивка около Shell.Current.GoToAsync. Регистрирайте маршрутите в AppShell.xaml.cs:

Routing.RegisterRoute(nameof(ProductDetailsPage), typeof(ProductDetailsPage));

Подайте параметри и ги приемете чрез [QueryProperty]:

await Shell.Current.GoToAsync($"{nameof(ProductDetailsPage)}?id=42");

[QueryProperty(nameof(ProductId), "id")]
public partial class ProductDetailsViewModel : ObservableObject
{
    [ObservableProperty]
    private int productId;

    partial void OnProductIdChanged(int value) => LoadProductAsync();
}

Когато productId се сетне от Shell, partial методът OnProductIdChanged се извиква автоматично и зарежда продукта. Това е чисто декларативен начин да реагирате на навигационни параметри без overrides на OnAppearing.

Валидация на формуляри с ObservableValidator

За формуляри наследете ObservableValidator вместо ObservableObject. Той интегрира System.ComponentModel.DataAnnotations и реализира INotifyDataErrorInfo:

public partial class RegisterViewModel : ObservableValidator
{
    [ObservableProperty]
    [Required(ErrorMessage = "Имейлът е задължителен")]
    [EmailAddress(ErrorMessage = "Невалиден имейл формат")]
    [NotifyDataErrorInfo]
    private string email = string.Empty;

    [ObservableProperty]
    [Required]
    [MinLength(8, ErrorMessage = "Паролата трябва да е поне 8 символа")]
    [NotifyDataErrorInfo]
    private string password = string.Empty;

    [RelayCommand]
    private void Submit()
    {
        ValidateAllProperties();
        if (HasErrors) return;
        // ... продължи
    }
}

В XAML свържете грешките към Label с Triggers или използвайте Behaviors за подсветяване. Този подход е значително по-кратък от FluentValidation за прости случаи. За съвместимост с достъпността и WCAG 2.2 изискванията, задайте SemanticProperties.Description върху полетата, така че екранните четци да обявяват грешките правилно.

Чести грешки и как да ги избегнем

Първата грешка, която почти всички правят: забравят partial на класа или на под-файловете. Резултатът е грешка CS0260 или мистериозно липсващи property-та в IntelliSense. Винаги пишете public partial class.

Втората: именуване на полетата. Source generator-ът премахва underscore префикса или маркера m_ и капитализира първата буква. Поле name става Name, поле _name също става Name, но поле Name ще генерира конфликт. Винаги използвайте camelCase или underscore-camelCase за полета.

Третата: използване на StrongReferenceMessenger, без да отписвате. Това задържа референции и причинява memory leaks, особено в навигационни сценарии. Винаги използвайте WeakReferenceMessenger.Default, освен ако нямате много специфична причина.

Четвъртата: тежки операции в setter-и. Partial методите OnXxxChanged се извикват синхронно от UI нишката. Не правете I/O или дълги изчисления там. Стартирайте Task или използвайте [RelayCommand], който се извиква експлицитно.

Петата: пропускане на NotifyPropertyChangedFor при изчислими свойства. Ако имате FullName => $"{FirstName} {LastName}", маркирайте и двете полета с [NotifyPropertyChangedFor(nameof(FullName))], иначе UI няма да се опресни. Аз бях ударен от точно този бъг при shipping на първото си .NET MAUI приложение и ми отне час да го локализирам.

За още най-добри практики и архитектурни препоръки, разгледайте официалното GitHub хранилище на CommunityToolkit, където са документирани open issues и роудмапа за версия 9. Microsoft също публикува препоръчителни шаблони в .NET MAUI документацията.

Често задавани въпроси

Каква е разликата между CommunityToolkit.Mvvm и MVVM Light?

MVVM Light е архивиран през 2018 г. и не получава обновления. CommunityToolkit.Mvvm е модерният наследник, официално поддържан от Microsoft, използва source generators вместо reflection, поддържа .NET 8/9/10 и работи с AOT компилация. За нови проекти изборът е еднозначен.

Мога ли да използвам CommunityToolkit.Mvvm без source generators?

Да. Класовете ObservableObject, RelayCommand и WeakReferenceMessenger работят напълно функционално без атрибути. Можете да викате SetProperty(ref _name, value) ръчно. Source generators са optimization, не задължителни.

Защо моят [ObservableProperty] не генерира property?

Най-честите причини: класът не е partial, името на полето започва с главна буква (трябва camelCase), таргетирате .NET Standard 2.0 без съвместим Roslyn компилатор, или липсва LangVersion поне 8.0 в csproj. Затворете и отворете отново IDE-то след добавяне на пакета.

Как се прави unit тест на view model с CommunityToolkit.Mvvm?

View model-ите са обикновени POCO класове. Инстанциирайте ги с mock сервиси (Moq или NSubstitute), извикайте командите чрез vm.SubmitOrderCommand.ExecuteAsync(null) и проверявайте PropertyChanged с vm.MonitorEvents() от FluentAssertions. Няма нужда от UI runner.

Работи ли CommunityToolkit.Mvvm с iOS AOT и Mac Catalyst?

Да, напълно. Тъй като source generators работят по време на компилация, не се изпълнява reflection в runtime. Това е една от ключовите причини библиотеката да е препоръчителна за .NET MAUI, където AOT е задължителен за iOS Release build-ове.

Editorial Team
За Автора Editorial Team

Our team of expert writers and editors.