MVVM în .NET MAUI 9 cu CommunityToolkit.Mvvm: Ghid practic 2026

Ghid practic pentru MVVM în .NET MAUI 9 cu CommunityToolkit.Mvvm 8.4: Source Generators, ObservableProperty, RelayCommand, DI, validare și Messenger. Cod funcțional și capcanele pe care le-am întâlnit în producție.

MVVM în .NET MAUI 9: Ghid CommunityToolkit

Actualizat: 9 iunie 2026

MVVM în .NET MAUI este modelul arhitectural recomandat oficial pentru separarea logicii UI de starea aplicației, iar în 2026 implementarea sa este accelerată dramatic de CommunityToolkit.Mvvm 8.4 cu Source Generators, care elimină 80% din codul boilerplate prin atribute precum [ObservableProperty] și [RelayCommand]. În acest ghid vei învăța cum să construiești ViewModel-uri concise, să integrezi Dependency Injection în .NET 9, să gestionezi comenzi async și să eviți capcanele comune de memorie atunci când folosești Messenger-ul.

  • CommunityToolkit.Mvvm 8.4 generează automat INotifyPropertyChanged, comenzi și validare prin Source Generators introduse în Roslyn, fără reflexie la runtime.
  • Atributul [ObservableProperty] aplicat pe un câmp privat _name creează o proprietate publică Name cu notificare, partial methods OnNameChanging/OnNameChanged și suport pentru validare.
  • [RelayCommand] transformă o metodă în IRelayCommand sau IAsyncRelayCommand, cu suport pentru CanExecute, anulare prin CancellationToken și gestionarea concurenței.
  • În .NET MAUI 9, registrarea ViewModel-urilor în MauiProgram.cs prin builder.Services.AddTransient<T>() este modul standard de a folosi DI cu constructor injection.
  • WeakReferenceMessenger oferă comunicare loose-coupled între ViewModel-uri fără leak-uri de memorie, esențial pentru navigare Shell și actualizări multi-page.

Ce este MVVM în .NET MAUI?

MVVM (Model-View-ViewModel) este un model arhitectural în care View-ul (XAML-ul din .NET MAUI) este complet decuplat de logica de prezentare prin intermediul unui ViewModel care expune date observabile și comenzi. Modelul reprezintă datele și logica de business, iar legăturile (data binding) între View și ViewModel sunt rezolvate de motorul XAML al MAUI prin {Binding} sau prin x:Bind (compiled bindings).

În practică, asta înseamnă că un buton din XAML nu apelează direct o metodă din code-behind. Se leagă, în schimb, la o comandă din ViewModel: <Button Command="{Binding SaveCommand}" />. Avantajul cel mai important e testabilitatea: poți instanția ViewModel-ul într-un test unitar fără să pornești UI-ul, lucru pe care l-am detaliat în ghidul de testing în .NET MAUI 2026.

Echipa Microsoft recomandă oficial pachetul CommunityToolkit.Mvvm pentru implementarea MVVM, conform documentației de pe Microsoft Learn pentru MVVM Toolkit. Toolkit-ul e compatibil cu .NET Standard 2.0, .NET 8 și .NET 9, fără dependențe externe și fără reflexie la runtime. Totul se rezolvă la compilare, prin Source Generators.

Instalarea și configurarea CommunityToolkit.Mvvm 8.4

Începe prin a adăuga pachetul NuGet în proiectul tău .NET MAUI. Versiunea curentă la momentul scrierii este 8.4.0, lansată pentru a fi compatibilă cu .NET 9 SDK:

dotnet add package CommunityToolkit.Mvvm --version 8.4.0

Apoi, în MauiProgram.cs, înregistrează serviciile și ViewModel-urile pe care le vei folosi prin Dependency Injection. Aceasta este abordarea standard în .NET MAUI 9 și înlocuiește vechiul pattern ServiceLocator din Xamarin.Forms:

using CommunityToolkit.Maui;
using Microsoft.Extensions.Logging;

namespace MyApp;

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

        // Servicii
        builder.Services.AddSingleton<IProductService, ProductService>();
        builder.Services.AddSingleton<HttpClient>();

        // ViewModels
        builder.Services.AddTransient<ProductListViewModel>();
        builder.Services.AddTransient<ProductDetailViewModel>();

        // Pages
        builder.Services.AddTransient<ProductListPage>();
        builder.Services.AddTransient<ProductDetailPage>();

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

Cum implementezi ObservableProperty în .NET MAUI

Înainte de Source Generators, o proprietate observabilă necesita 15+ linii de cod pentru fiecare câmp. Sincer, era obositor. Cu [ObservableProperty], scrii doar câmpul privat, iar toolkit-ul generează automat proprietatea publică, evenimentul PropertyChanged și două partial methods opționale pentru hook-uri înainte și după modificare.

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

namespace MyApp.ViewModels;

public partial class ProductListViewModel : ObservableObject
{
    private readonly IProductService _productService;

    [ObservableProperty]
    private string _searchText = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasProducts))]
    [NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
    private ObservableCollection<Product> _products = new();

    [ObservableProperty]
    private bool _isBusy;

    public bool HasProducts => Products.Count > 0;

    public ProductListViewModel(IProductService productService)
    {
        _productService = productService;
    }

    // Partial method - rulează după ce SearchText se schimbă
    partial void OnSearchTextChanged(string value)
    {
        // Debounce și filtrare aici
    }
}

Atributul [NotifyPropertyChangedFor] declanșează automat notificarea pentru o proprietate calculată (HasProducts) atunci când Products se schimbă, iar [NotifyCanExecuteChangedFor] reevaluează CanExecute pentru o comandă dependentă. Astfel scapi de codul repetitiv care apela manual OnPropertyChanged() pentru fiecare proprietate calculată.

RelayCommand vs AsyncRelayCommand: când să folosești fiecare

Atributul [RelayCommand] transformă orice metodă într-o comandă conformă cu ICommand. Source Generator-ul detectează semnătura metodei și decide între IRelayCommand (pentru metode sincrone) și IAsyncRelayCommand (când metoda este async Task sau primește CancellationToken).

public partial class ProductListViewModel : ObservableObject
{
    // ... câmpurile de mai sus

    [RelayCommand(CanExecute = nameof(CanRefresh))]
    private async Task RefreshAsync(CancellationToken cancellationToken)
    {
        try
        {
            IsBusy = true;
            var items = await _productService.GetAllAsync(cancellationToken);

            Products.Clear();
            foreach (var item in items)
                Products.Add(item);
        }
        catch (OperationCanceledException)
        {
            // Operațiunea a fost anulată - normal
        }
        finally
        {
            IsBusy = false;
        }
    }

    private bool CanRefresh() => !IsBusy;

    [RelayCommand]
    private async Task OpenProductAsync(Product product)
    {
        if (product is null) return;
        await Shell.Current.GoToAsync($"detail?productId={product.Id}");
    }
}

Source Generator-ul creează automat proprietățile RefreshCommand și OpenProductCommand, ambele expuse public pentru binding XAML: <Button Command="{Binding RefreshCommand}" />. Pentru tranziții între pagini, vezi ghidul nostru complet pentru navigarea Shell cu rute și parametri.

Pentru comenzi async, toolkit-ul oferă proprietăți utile. RefreshCommand.IsRunning îți permite să afișezi un ActivityIndicator fără a menține manual o variabilă IsBusy, iar RefreshCommand.Cancel() declanșează CancellationToken-ul. Opțiunea [RelayCommand(IncludeCancelCommand = true)] generează automat o comandă pereche pentru anulare. (Util mai ales pentru butoane de tip „Cancel” care apar peste un overlay de loading.)

Integrarea MVVM cu Dependency Injection

Constructor injection este modelul recomandat în .NET MAUI 9. ViewModel-urile primesc dependențele prin constructor și containerul DI le rezolvă automat. Pentru pagini, injectează ViewModel-ul direct:

public partial class ProductListPage : ContentPage
{
    public ProductListPage(ProductListViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

Pentru parametri de navigare (de exemplu, ID-ul produsului), folosește IQueryAttributable sau atributul [QueryProperty] pe ViewModel:

[QueryProperty(nameof(ProductId), "productId")]
public partial class ProductDetailViewModel : ObservableObject
{
    private readonly IProductService _productService;

    [ObservableProperty]
    private Product? _product;

    public string ProductId
    {
        set => _ = LoadProductAsync(value);
    }

    public ProductDetailViewModel(IProductService productService)
    {
        _productService = productService;
    }

    private async Task LoadProductAsync(string id)
    {
        Product = await _productService.GetByIdAsync(id);
    }
}

Acest pattern păstrează ViewModel-ul complet independent de UI și ușor de testat: pur și simplu mochez IProductService și verific comportamentul. Pentru exemple suplimentare de testare a comenzilor async, consultă repository-ul oficial CommunityToolkit pe GitHub, care are sample-uri complete.

Validare, Messenger și ObservableRecipient

Pentru validarea formularelor, moștenește din ObservableValidator în loc de ObservableObject și aplică atribute DataAnnotations standard. Toolkit-ul oferă [NotifyDataErrorInfo] pentru a integra validarea cu binding-ul XAML:

using System.ComponentModel.DataAnnotations;

public partial class LoginViewModel : ObservableValidator
{
    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Email-ul este obligatoriu")]
    [EmailAddress(ErrorMessage = "Format email invalid")]
    private string _email = string.Empty;

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required]
    [MinLength(8, ErrorMessage = "Parola trebuie să aibă minim 8 caractere")]
    private string _password = string.Empty;

    [RelayCommand]
    private async Task LoginAsync()
    {
        ValidateAllProperties();
        if (HasErrors) return;

        // ... apel autentificare
    }
}

WeakReferenceMessenger rezolvă comunicarea între ViewModel-uri care nu se cunosc reciproc. De exemplu, când utilizatorul șterge un produs din pagina de detaliu, lista trebuie actualizată, fără ca cele două ViewModel-uri să fie cuplate direct:

// Mesajul
public sealed class ProductDeletedMessage : ValueChangedMessage<string>
{
    public ProductDeletedMessage(string productId) : base(productId) { }
}

// În ProductDetailViewModel - emite
WeakReferenceMessenger.Default.Send(new ProductDeletedMessage(product.Id));

// În ProductListViewModel - moștenește din ObservableRecipient și ascultă
public partial class ProductListViewModel
    : ObservableRecipient, IRecipient<ProductDeletedMessage>
{
    public void Receive(ProductDeletedMessage message)
    {
        var item = Products.FirstOrDefault(p => p.Id == message.Value);
        if (item is not null) Products.Remove(item);
    }
}

ObservableRecipient se înregistrează automat la mesaje când IsActive = true și se dezabonează când devine inactiv, evitând leak-urile de memorie tipice pentru pattern-ul publish/subscribe.

Capcane comune și cum le eviți

Cea mai frecventă eroare la dezvoltatorii care vin din Xamarin.Forms este să uite cuvântul partial pe clasa ViewModel. Source Generator-ul depinde de partial classes pentru a injecta codul generat. O altă capcană: numele câmpului trebuie să respecte convenția _camelCase sau camelCase, fiindcă toolkit-ul derivă numele proprietății publice prin transformare (de exemplu, _userName devine UserName). Dacă folosești userName fără underscore, generatorul tot funcționează, dar Visual Studio îți poate semnala stilul.

Atenție la ObservableCollection<T>. Dacă faci Products = new(...) în loc să adaugi elemente, view-ul poate să nu se actualizeze dacă ItemsSource a fost legat la referința originală. Soluția curată este Products.Clear() urmat de Add() într-un loop, sau înlocuirea completă a referinței prin [ObservableProperty]. Am pierdut câteva ore pe această problemă într-o aplicație reală, când lista părea „înghețată” la refresh.

Performanța contează. În .NET MAUI 9, fiecare apel OnPropertyChanged declanșează evaluarea binding-urilor pe firul UI. Pentru actualizări în masă, batchează modificările sau folosește BatchBeginUpdate/EndUpdate pe collection. Pentru detalii suplimentare despre optimizarea startup-ului și a binding-urilor, consultă ghidul nostru de optimizare a performanței în .NET MAUI.

Întrebări frecvente

Care este diferența dintre RelayCommand și AsyncRelayCommand?

RelayCommand rulează sincron și implementează ICommand, potrivit pentru operațiuni rapide care nu blochează UI-ul. AsyncRelayCommand rulează asincron, expune IsRunning și CanBeCanceled și acceptă CancellationToken. Pentru orice I/O (rețea, fișiere, baze de date), folosește versiunea async.

Cum funcționează Source Generators în CommunityToolkit.Mvvm?

Source Generators sunt o caracteristică Roslyn care permite compilatorului să genereze cod C# adițional la momentul compilării, pe baza atributelor și a sintaxei existente. Toolkit-ul scanează atribute precum [ObservableProperty] și produce fișiere .g.cs cu proprietatea, evenimentele și partial methods, fără reflexie la runtime, ceea ce este crucial pentru AOT și trimming pe iOS.

Cum integrez MVVM cu Dependency Injection în .NET MAUI 9?

Înregistrează serviciile, ViewModel-urile și paginile în MauiProgram.cs folosind builder.Services. Foloseste AddSingleton pentru servicii stateless, AddTransient pentru ViewModel-uri și pagini. Injectează ViewModel-ul în constructorul paginii și setează-l ca BindingContext. Containerul DI rezolvă automat dependențele recursive.

Trebuie să folosesc CommunityToolkit.Mvvm sau pot să implementez MVVM manual?

Ambele variante sunt valide, dar implementarea manuală necesită aproximativ 15-20 de linii de boilerplate pentru fiecare proprietate observabilă. Toolkit-ul reduce aceasta la o singură linie cu [ObservableProperty], fără cost la runtime, fiindcă codul generat este identic cu cel scris manual. Recomandarea oficială Microsoft este să folosești toolkit-ul.

Funcționează CommunityToolkit.Mvvm cu .NET MAUI AOT și NativeAOT pe iOS?

Da. Source Generators nu folosesc reflexie, deci codul generat este complet compatibil cu AOT, trimming agresiv și NativeAOT pe iOS. Acesta este unul dintre motivele principale pentru care echipa Microsoft recomandă pachetul pentru .NET MAUI: alternativele bazate pe reflexie (precum vechiul Prism cu BindableBase) pot avea probleme la trimming.

Editorial Team
Despre Autor Editorial Team

Our team of expert writers and editors.