MVVM в .NET MAUI с CommunityToolkit.Mvvm: Source Generators и пълно ръководство за 2026
Пълно ръководство за MVVM в .NET MAUI с CommunityToolkit.Mvvm 8.4: source generators, [ObservableProperty], [RelayCommand], DI, валидация и messenger pattern с готови code примери.
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:
Това е единствената зависимост, която трябва. 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 да преоценява дали бутонът трябва да е активен.
.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:
Подайте параметри и ги приемете чрез [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 приложение и ми отне час да го локализирам.
Каква е разликата между 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-ове.