MVVM architektúra v .NET MAUI: Praktický sprievodca s CommunityToolkit a DI

Praktický sprievodca MVVM architektúrou v .NET MAUI — od CommunityToolkit.MVVM source generátorov cez dependency injection až po unit testovanie view modelov. Reálne príklady a osvedčené postupy.

Prečo je MVVM kľúčom k úspešnému vývoju v .NET MAUI

Ak vyvíjate mobilné aplikácie v .NET MAUI, tak ste sa s pojmom MVVM — Model-View-ViewModel — už určite stretli. Možno ste si povedali, že je to zas len ďalšia módna skratka. Ale nie je. Je to overený architektonický vzor, ktorý naozaj mení spôsob, akým píšete, testujete a udržiavate kód. A keď ho skombinujete s nástrojmi ako CommunityToolkit.MVVM, dependency injection a správne navrhnutou navigáciou, získate niečo, čo vám ušetrí hodiny práce (a nervov).

V tomto článku sa ponoríme do praktickej implementácie MVVM v .NET MAUI. Žiadna suchá teória — pôjdeme rovno na kód, vzory a osvedčené postupy, ktoré môžete hneď použiť. Prejdeme si všetko od základného nastavenia cez pokročilé techniky až po unit testovanie view modelov.

Základy MVVM vzoru v kontexte .NET MAUI

MVVM rozdeľuje vašu aplikáciu do troch hlavných vrstiev. Každá má jasne definovanú zodpovednosť:

  • Model — reprezentuje dáta a biznis logiku. Sú to vaše entity, DTO objekty, repozitáre a servisné triedy.
  • View — XAML stránka alebo komponent, ktorý zobrazuje používateľské rozhranie. View by nemala obsahovať žiadnu biznis logiku.
  • ViewModel — prostredník medzi View a Modelom. Obsahuje prezentačnú logiku, spravuje stav a vystavuje dáta a príkazy pre View cez data binding.

Kľúčový princíp? ViewModel nemá žiadnu referenciu na View. Komunikácia prebieha výlučne cez data binding a príkazy. Vďaka tomu môžete testovať view modely bez toho, aby ste vôbec spúšťali UI.

Prečo nie code-behind?

Mnohí začínajúci vývojári (a buďme úprimní — niekedy aj tí skúsenejší) píšu logiku priamo do code-behind súborov (*.xaml.cs). Funguje to, jasné. Ale prináša to vážne problémy:

  • Kód je úzko prepojený s UI — nemôžete ho testovať bez spustenia celej aplikácie
  • Logika sa nedá zdieľať medzi rôznymi zobrazeniami
  • Refaktoring a údržba sa stávajú nočnou morou
  • Tímová spolupráca je komplikovanejšia — designéri a vývojári pracujú na tom istom súbore

MVVM tieto problémy rieši elegantným oddelením zodpovedností. A s CommunityToolkit.MVVM to ide ešte jednoduchšie, než by ste čakali.

CommunityToolkit.MVVM — menej boilerplate, viac produktivity

CommunityToolkit.MVVM (predtým známy ako Microsoft MVVM Toolkit) je moderná, rýchla a modulárna MVVM knižnica. Jej najväčšou výhodou sú zdrojové generátory (source generators), ktoré dramaticky znižujú množstvo boilerplate kódu. A keď hovorím dramaticky, myslím tým naozaj dramaticky.

Inštalácia

Pridajte NuGet balíček do vášho .NET MAUI projektu:

dotnet add package CommunityToolkit.Mvvm

Aktuálna stabilná verzia je plne kompatibilná s .NET MAUI 10 a podporuje .NET Standard 2.0, .NET Standard 2.1 aj .NET 10.

ObservableProperty — koniec s manuálnym INotifyPropertyChanged

Bez CommunityToolkit by ste museli pre každú vlastnosť písať niečo takéto:

public class ProduktViewModel : INotifyPropertyChanged
{
    private string _nazov;
    public string Nazov
    {
        get => _nazov;
        set
        {
            if (_nazov != value)
            {
                _nazov = value;
                OnPropertyChanged(nameof(Nazov));
                OnPropertyChanged(nameof(CeleMeno));
            }
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

Únavné, že? S CommunityToolkit.MVVM sa to isté dá napísať takto:

using CommunityToolkit.Mvvm.ComponentModel;

public partial class ProduktViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(CeleMeno))]
    private string _nazov;

    [ObservableProperty]
    private string _popis;

    [ObservableProperty]
    private decimal _cena;

    public string CeleMeno => $"{Nazov} - {Cena:C}";
}

Zdrojový generátor automaticky vytvorí verejné vlastnosti Nazov, Popis a Cena s plnou implementáciou INotifyPropertyChanged. Atribút [NotifyPropertyChangedFor] zabezpečí, že pri zmene poľa _nazov sa oznámi zmena aj vlastnosti CeleMeno. Celé to funguje bez toho, aby ste napísali jediný riadok boilerplate kódu.

Vlastná logika pri zmene vlastnosti

Zdrojový generátor vytvára parciálne metódy OnNazovChanging a OnNazovChanged, ktoré môžete implementovať pre injekciu vlastnej logiky:

public partial class ProduktViewModel : ObservableObject
{
    [ObservableProperty]
    private decimal _cena;

    partial void OnCenaChanging(decimal oldValue, decimal newValue)
    {
        // Validácia pred zmenou
        if (newValue < 0)
        {
            throw new ArgumentException("Cena nemôže byť záporná.");
        }
    }

    partial void OnCenaChanged(decimal value)
    {
        // Logika po zmene
        JeZlavnena = value < PovodnaJeCena;
    }
}

Toto je podľa mňa jedna z najlepších vecí na source generátoroch — dostanete plnú kontrolu nad správaním vlastností bez toho, aby ste museli písať celý setter ručne.

RelayCommand — elegantné príkazy

Príkazy sú spôsobom, akým View komunikuje s ViewModelom o akciách používateľa. Atribút [RelayCommand] automaticky generuje príkaz z metódy:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class KosikViewModel : ObservableObject
{
    [ObservableProperty]
    private ObservableCollection<PolozkaKosika> _polozky = new();

    [ObservableProperty]
    private bool _jePrazdny = true;

    [RelayCommand]
    private void PridatPolozkuDoKosika(Produkt produkt)
    {
        var existujuca = Polozky.FirstOrDefault(p => p.ProduktId == produkt.Id);
        if (existujuca != null)
        {
            existujuca.Mnozstvo++;
        }
        else
        {
            Polozky.Add(new PolozkaKosika
            {
                ProduktId = produkt.Id,
                Nazov = produkt.Nazov,
                Cena = produkt.Cena,
                Mnozstvo = 1
            });
        }
        JePrazdny = Polozky.Count == 0;
    }

    [RelayCommand]
    private void OdstranitPolozkuZKosika(PolozkaKosika polozka)
    {
        Polozky.Remove(polozka);
        JePrazdny = Polozky.Count == 0;
    }
}

Generátor vytvorí vlastnosti PridatPolozkuDoKosikaCommand a OdstranitPolozkuZKosikaCommand, ktoré môžete priamo naviazať v XAML:

<Button Text="Pridať do košíka"
        Command="{Binding PridatPolozkuDoKosikaCommand}"
        CommandParameter="{Binding .}" />

AsyncRelayCommand — asynchrónne príkazy

Pre asynchrónne operácie (volania API, databázové operácie a podobne) stačí metódu označiť ako async Task:

public partial class ProduktZoznamViewModel : ObservableObject
{
    private readonly IProduktService _produktService;

    [ObservableProperty]
    private ObservableCollection<Produkt> _produkty = new();

    [ObservableProperty]
    private bool _jeNacitavanie;

    public ProduktZoznamViewModel(IProduktService produktService)
    {
        _produktService = produktService;
    }

    [RelayCommand]
    private async Task NacitatProduktyAsync()
    {
        try
        {
            JeNacitavanie = true;
            var produkty = await _produktService.ZiskatProduktyAsync();
            Produkty = new ObservableCollection<Produkt>(produkty);
        }
        catch (Exception ex)
        {
            await Shell.Current.DisplayAlert("Chyba",
                $"Nepodarilo sa načítať produkty: {ex.Message}", "OK");
        }
        finally
        {
            JeNacitavanie = false;
        }
    }
}

Generátor automaticky rozpozná asynchrónnu metódu a vytvorí AsyncRelayCommand. Ten má navyše vstavaný stav IsRunning, čo sa hodí napríklad na zobrazenie loading indikátora:

<ActivityIndicator IsRunning="{Binding NacitatProduktyCommand.IsRunning}"
                   IsVisible="{Binding NacitatProduktyCommand.IsRunning}" />

<CollectionView ItemsSource="{Binding Produkty}"
                IsVisible="{Binding NacitatProduktyCommand.IsRunning,
                    Converter={StaticResource InvertBoolConverter}}">
    <!-- ... -->
</CollectionView>

Data Binding — prepojenie View a ViewModel

Data binding je mechanizmus, ktorý spája View s ViewModelom. V .NET MAUI máte na výber niekoľko režimov bindingu:

  • OneWay — dáta tečú len z ViewModelu do View. Použite pre zobrazenie dát, ktoré používateľ nemení (štítky, ikony, indikátory).
  • TwoWay — dáta tečú oboma smermi. Použite pre vstupné polia, prepínače a výbery, kde používateľ aktívne mení hodnotu.
  • OneWayToSource — dáta tečú len z View do ViewModelu. Menej časté, ale užitočné pre zachytenie stavu UI komponentu.
  • OneTime — dáta sa prečítajú len raz pri inicializácii. Ideálne pre statické hodnoty, ktoré sa počas životnosti stránky nemenia.

Praktické použitie bindingov

Pozrime sa na reálny príklad obrazovky s detailom produktu. Všimnite si, ako sa tu využívajú rôzne typy bindingov:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MojaAplikacia.Views.ProduktDetailPage"
             Title="{Binding Produkt.Nazov}">

    <ScrollView>
        <VerticalStackLayout Spacing="16" Padding="20">

            <!-- OneWay binding - zobrazenie dát -->
            <Image Source="{Binding Produkt.ObrazokUrl}"
                   HeightRequest="250"
                   Aspect="AspectFill" />

            <Label Text="{Binding Produkt.Nazov}"
                   FontSize="24"
                   FontAttributes="Bold" />

            <Label Text="{Binding Produkt.Cena, StringFormat='Cena: {0:C}'}"
                   FontSize="18"
                   TextColor="{StaticResource Primary}" />

            <Label Text="{Binding Produkt.Popis}"
                   FontSize="14"
                   LineBreakMode="WordWrap" />

            <!-- TwoWay binding - vstup od používateľa -->
            <Label Text="Množstvo:" FontSize="14" />
            <Stepper Value="{Binding Mnozstvo, Mode=TwoWay}"
                     Minimum="1"
                     Maximum="99"
                     Increment="1" />
            <Label Text="{Binding Mnozstvo, StringFormat='Vybrané: {0} ks'}" />

            <!-- Podmienené zobrazenie -->
            <Frame BackgroundColor="#FFF3CD"
                   Padding="10"
                   IsVisible="{Binding Produkt.JeMaloNaSklade}">
                <Label Text="Pozor: Posledné kusy na sklade!"
                       TextColor="#856404" />
            </Frame>

            <Button Text="Pridať do košíka"
                    Command="{Binding PridatDoKosikaCommand}"
                    BackgroundColor="{StaticResource Primary}"
                    TextColor="White" />

        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

Správne nastavenie BindingContext

BindingContext je vlastnosť, ktorá definuje, proti akému objektu sa bindingy vyhodnocujú. Máte tri hlavné možnosti:

1. Cez dependency injection v konštruktore stránky — toto je odporúčaný spôsob a v praxi ho budete používať najčastejšie:

public partial class ProduktDetailPage : ContentPage
{
    public ProduktDetailPage(ProduktDetailViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

2. Priamo v XAML — vhodné pre jednoduché stránky bez závislostí:

<ContentPage xmlns:vm="clr-namespace:MojaAplikacia.ViewModels">
    <ContentPage.BindingContext>
        <vm:JednoduchaViewModel />
    </ContentPage.BindingContext>
</ContentPage>

3. Cez ViewModelLocator pattern — pre pokročilejšie scenáre s dynamickým prepínaním view modelov. Tento vzor sa ale v modernom .NET MAUI používa čoraz menej vďaka vstavanej dependency injection.

Dependency Injection — základ modernej architektúry

.NET MAUI má vstavaný DI kontajner vďaka Microsoft.Extensions.DependencyInjection. To je obrovská výhoda oproti Xamarin.Forms, kde ste museli siahať po externých riešeniach. DI kontajner sa konfiguruje v MauiProgram.cs a umožňuje automatické injektovanie závislostí do konštruktorov view modelov a servisov.

Registrácia služieb, stránok a view modelov

Celá konfigurácia sa odohráva na jednom mieste:

using Microsoft.Extensions.Logging;

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

        // Registrácia služieb
        builder.Services.AddSingleton<IProduktService, ProduktService>();
        builder.Services.AddSingleton<IObjednavkaService, ObjednavkaService>();
        builder.Services.AddSingleton<IAuthService, AuthService>();

        // HTTP klient pre API volania
        builder.Services.AddHttpClient("MojeApi", client =>
        {
            client.BaseAddress = new Uri("https://api.mojaaplikacia.sk/");
            client.DefaultRequestHeaders.Add("Accept", "application/json");
        });

        // Registrácia view modelov
        builder.Services.AddTransient<ProduktZoznamViewModel>();
        builder.Services.AddTransient<ProduktDetailViewModel>();
        builder.Services.AddTransient<KosikViewModel>();
        builder.Services.AddSingleton<NastaveniaViewModel>();

        // Registrácia stránok
        builder.Services.AddTransient<ProduktZoznamPage>();
        builder.Services.AddTransient<ProduktDetailPage>();
        builder.Services.AddTransient<KosikPage>();
        builder.Services.AddSingleton<NastaveniaPage>();

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

        return builder.Build();
    }
}

Singleton vs Transient vs Scoped

Správna voľba životnosti registrácie je kľúčová. Stačí si zapamätať tri pravidlá:

  • Singleton — jedna inštancia počas celej životnosti aplikácie. Vhodné pre služby so zdieľaným stavom (nastavenia, autentifikácia, cache).
  • Transient — nová inštancia pri každom vyžiadaní. Vhodné pre stránky a view modely, ktoré si majú zachovať čistý stav pri každom otvorení.
  • Scoped — v .NET MAUI menej používané, pretože tu neexistuje prirodzený scope ako v ASP.NET Core.

Jednoduché pravidlo na zapamätanie: view modely a stránky registrujte ako Transient, pokiaľ nepotrebujete uchovávať stav medzi navigáciami. Služby, ktoré spravujú zdieľaný stav, registrujte ako Singleton.

Konštruktorová injekcia v praxi

Keď zaregistrujete aj stránku aj jej view model, .NET MAUI ich automaticky prepojí. Stačí, aby stránka prijímala view model v konštruktore:

public partial class ProduktZoznamPage : ContentPage
{
    public ProduktZoznamPage(ProduktZoznamViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

A view model prijíma svoje závislosti úplne rovnako:

public partial class ProduktZoznamViewModel : ObservableObject
{
    private readonly IProduktService _produktService;
    private readonly IConnectivity _connectivity;

    public ProduktZoznamViewModel(
        IProduktService produktService,
        IConnectivity connectivity)
    {
        _produktService = produktService;
        _connectivity = connectivity;
    }

    [RelayCommand]
    private async Task NacitatProduktyAsync()
    {
        if (_connectivity.NetworkAccess != NetworkAccess.Internet)
        {
            await Shell.Current.DisplayAlert("Offline",
                "Pre načítanie produktov je potrebné internetové pripojenie.", "OK");
            return;
        }

        var produkty = await _produktService.ZiskatProduktyAsync();
        Produkty = new ObservableCollection<Produkt>(produkty);
    }
}

Všimnite si tú eleganciu — nikde v kóde nerobíte new ProduktService(). O všetko sa postará DI kontajner.

Shell navigácia s MVVM

.NET MAUI Shell poskytuje URI-založenú navigáciu, ktorá sa dobre integruje s MVVM vzorom. Dôležité je, aby navigačná logika bola vo view modeli, nie v code-behind.

Registrácia trás

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        // Registrácia trás pre navigáciu
        Routing.RegisterRoute(nameof(ProduktDetailPage), typeof(ProduktDetailPage));
        Routing.RegisterRoute(nameof(KosikPage), typeof(KosikPage));
        Routing.RegisterRoute(nameof(ObjednavkaPage), typeof(ObjednavkaPage));
    }
}

Navigácia z view modelu

public partial class ProduktZoznamViewModel : ObservableObject
{
    [RelayCommand]
    private async Task PrejstNaDetail(Produkt produkt)
    {
        if (produkt == null) return;

        await Shell.Current.GoToAsync(
            $"{nameof(ProduktDetailPage)}",
            new Dictionary<string, object>
            {
                { "Produkt", produkt }
            });
    }
}

Prijímanie navigačných parametrov

View model na cieľovej stránke prijíma parametre pomocou atribútu [QueryProperty] alebo rozhrania IQueryAttributable:

[QueryProperty(nameof(Produkt), "Produkt")]
public partial class ProduktDetailViewModel : ObservableObject
{
    private readonly IProduktService _produktService;

    [ObservableProperty]
    private Produkt _produkt;

    [ObservableProperty]
    private ObservableCollection<Recenzia> _recenzie = new();

    public ProduktDetailViewModel(IProduktService produktService)
    {
        _produktService = produktService;
    }

    partial void OnProduktChanged(Produkt value)
    {
        // Načítať recenzie po nastavení produktu
        NacitatRecenzieCommand.Execute(null);
    }

    [RelayCommand]
    private async Task NacitatRecenzieAsync()
    {
        if (Produkt == null) return;

        var recenzie = await _produktService.ZiskatRecenzieAsync(Produkt.Id);
        Recenzie = new ObservableCollection<Recenzia>(recenzie);
    }

    [RelayCommand]
    private async Task SpätAsync()
    {
        await Shell.Current.GoToAsync("..");
    }
}

Rozhranie IQueryAttributable pre zložitejšie scenáre

Ak potrebujete komplexnejšie spracovanie parametrov, implementujte rozhranie IQueryAttributable:

public partial class ObjednavkaViewModel : ObservableObject, IQueryAttributable
{
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("ProduktId", out var produktIdObj)
            && produktIdObj is int produktId)
        {
            NacitatProduktAsync(produktId).ConfigureAwait(false);
        }

        if (query.TryGetValue("Zdroj", out var zdrojObj)
            && zdrojObj is string zdroj)
        {
            Zdroj = zdroj;
        }
    }
}

WeakReferenceMessenger — komunikácia medzi komponentmi

Niekedy potrebujete, aby jeden view model informoval iný o zmene — bez toho, aby na seba mali priamu referenciu. Typické scenáre:

  • Košík bol aktualizovaný — hlavná stránka musí aktualizovať badge
  • Používateľ sa prihlásil — všetky stránky musia aktualizovať obsah
  • Nastavenia boli zmenené — téma alebo jazyk sa musí aktualizovať

CommunityToolkit.MVVM na toto ponúka WeakReferenceMessenger. Je to náhrada za zastaraný MessagingCenter (deprecated v .NET 7) a oproti nemu je rádovo efektívnejší. Navyše nevytvára úniky pamäte vďaka slabým referenciám, čo je pri mobilných appkách dosť dôležité.

Definícia správy

using CommunityToolkit.Mvvm.Messaging.Messages;

// Jednoduchá správa s hodnotou
public class KosikAktualizovanyMessage : ValueChangedMessage<int>
{
    public KosikAktualizovanyMessage(int pocetPoloziek) : base(pocetPoloziek) { }
}

// Správa bez hodnoty
public class PouzivatelPrihlasenyMessage
{
    public string MenoPouzivatela { get; }
    public PouzivatelPrihlasenyMessage(string meno) => MenoPouzivatela = meno;
}

// Správa s požiadavkou na odpoveď
public class ZiskatAktualnyPouzivatelMessage : RequestMessage<Pouzivatel> { }

Odosielanie správ

public partial class KosikViewModel : ObservableObject
{
    [RelayCommand]
    private void PridatPolozkuDoKosika(Produkt produkt)
    {
        // ... pridanie položky ...

        // Oznámenie ostatným komponentom
        WeakReferenceMessenger.Default.Send(
            new KosikAktualizovanyMessage(Polozky.Count));
    }
}

Prijímanie správ

Na prijímanie správ máte dva spôsoby. Oba fungujú dobre, záleží na vašich preferenciách:

Spôsob 1: Manuálna registrácia

public partial class HlavnaViewModel : ObservableObject
{
    [ObservableProperty]
    private int _pocetPoloziekVKosiku;

    public HlavnaViewModel()
    {
        WeakReferenceMessenger.Default.Register<KosikAktualizovanyMessage>(
            this, (recipient, message) =>
            {
                PocetPoloziekVKosiku = message.Value;
            });
    }
}

Spôsob 2: Použitie ObservableRecipient

public partial class HlavnaViewModel : ObservableRecipient,
    IRecipient<KosikAktualizovanyMessage>,
    IRecipient<PouzivatelPrihlasenyMessage>
{
    [ObservableProperty]
    private int _pocetPoloziekVKosiku;

    [ObservableProperty]
    private string _menoPouzivatela;

    public HlavnaViewModel()
    {
        // Aktivácia automatickej registrácie
        IsActive = true;
    }

    public void Receive(KosikAktualizovanyMessage message)
    {
        PocetPoloziekVKosiku = message.Value;
    }

    public void Receive(PouzivatelPrihlasenyMessage message)
    {
        MenoPouzivatela = message.MenoPouzivatela;
    }
}

Trieda ObservableRecipient automaticky spravuje registráciu a odregistráciu správ podľa vlastnosti IsActive. Nastavíte IsActive = false a všetky registrácie sa zrušia. Jednoduché a čisté.

Vrstvená architektúra — servisná vrstva

Pre reálne aplikácie je dôležité mať správne vrstvenú architektúru. View model by nemal priamo pristupovať k API alebo databáze — na to slúžia služby a repozitáre.

Definícia rozhraní

// Rozhranie pre produktovú službu
public interface IProduktService
{
    Task<IEnumerable<Produkt>> ZiskatProduktyAsync();
    Task<Produkt> ZiskatProduktPodlaIdAsync(int id);
    Task<IEnumerable<Recenzia>> ZiskatRecenzieAsync(int produktId);
    Task<bool> VytvorObjednavkuAsync(Objednavka objednavka);
}

// Rozhranie pre lokálne úložisko
public interface ILokalneUlozisko
{
    Task UlozitAsync<T>(string kluc, T hodnota);
    Task<T> NacitatAsync<T>(string kluc);
    Task OdstranitAsync(string kluc);
}

Implementácia služby

Tu je príklad produktovej služby s offline cache — niečo, čo v reálnej mobilnej aplikácii oceníte:

public class ProduktService : IProduktService
{
    private readonly HttpClient _httpClient;
    private readonly ILokalneUlozisko _ulozisko;

    public ProduktService(
        IHttpClientFactory httpClientFactory,
        ILokalneUlozisko ulozisko)
    {
        _httpClient = httpClientFactory.CreateClient("MojeApi");
        _ulozisko = ulozisko;
    }

    public async Task<IEnumerable<Produkt>> ZiskatProduktyAsync()
    {
        try
        {
            var odpoved = await _httpClient.GetAsync("api/produkty");
            odpoved.EnsureSuccessStatusCode();

            var produkty = await odpoved.Content
                .ReadFromJsonAsync<IEnumerable<Produkt>>();

            // Uloženie do cache pre offline prístup
            await _ulozisko.UlozitAsync("produkty_cache", produkty);

            return produkty;
        }
        catch (HttpRequestException)
        {
            // Offline režim — načítanie z cache
            return await _ulozisko.NacitatAsync<IEnumerable<Produkt>>(
                "produkty_cache") ?? Enumerable.Empty<Produkt>();
        }
    }

    public async Task<Produkt> ZiskatProduktPodlaIdAsync(int id)
    {
        var odpoved = await _httpClient.GetAsync($"api/produkty/{id}");
        odpoved.EnsureSuccessStatusCode();
        return await odpoved.Content.ReadFromJsonAsync<Produkt>();
    }

    public async Task<IEnumerable<Recenzia>> ZiskatRecenzieAsync(int produktId)
    {
        var odpoved = await _httpClient.GetAsync(
            $"api/produkty/{produktId}/recenzie");
        odpoved.EnsureSuccessStatusCode();
        return await odpoved.Content
            .ReadFromJsonAsync<IEnumerable<Recenzia>>();
    }

    public async Task<bool> VytvorObjednavkuAsync(Objednavka objednavka)
    {
        var odpoved = await _httpClient.PostAsJsonAsync(
            "api/objednavky", objednavka);
        return odpoved.IsSuccessStatusCode;
    }
}

Validácia údajov vo view modeli

CommunityToolkit.MVVM podporuje validáciu pomocou ObservableValidator, ktorý integruje štandardné validačné atribúty z System.ComponentModel.DataAnnotations. Úprimne, toto je oblasť, kde toolkit naozaj žiari:

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

public partial class RegistraciaViewModel : ObservableValidator
{
    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Meno je povinné")]
    [MinLength(2, ErrorMessage = "Meno musí mať aspoň 2 znaky")]
    private string _meno;

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Email je povinný")]
    [EmailAddress(ErrorMessage = "Neplatný formát emailu")]
    private string _email;

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Heslo je povinné")]
    [MinLength(8, ErrorMessage = "Heslo musí mať aspoň 8 znakov")]
    [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$",
        ErrorMessage = "Heslo musí obsahovať veľké písmeno, malé písmeno a číslo")]
    private string _heslo;

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Potvrdenie hesla je povinné")]
    [CustomValidation(typeof(RegistraciaViewModel), nameof(ValidovatZhodu))]
    private string _potvrdHeslo;

    public static ValidationResult ValidovatZhodu(
        string potvrdenie, ValidationContext kontext)
    {
        var instancia = (RegistraciaViewModel)kontext.ObjectInstance;
        if (potvrdenie != instancia.Heslo)
        {
            return new ValidationResult("Heslá sa nezhodujú");
        }
        return ValidationResult.Success;
    }

    [RelayCommand]
    private async Task RegistrovatSaAsync()
    {
        ValidateAllProperties();

        if (HasErrors)
        {
            return;
        }

        // Pokračovanie s registráciou...
    }
}

V XAML sa chyby zobrazujú automaticky vďaka data bindingu:

<VerticalStackLayout Spacing="16" Padding="20">

    <Entry Placeholder="Meno"
           Text="{Binding Meno, Mode=TwoWay}" />
    <Label Text="{Binding (validation:Errors).Meno}"
           TextColor="Red"
           FontSize="12"
           IsVisible="{Binding HasErrors}" />

    <Entry Placeholder="Email"
           Text="{Binding Email, Mode=TwoWay}"
           Keyboard="Email" />

    <Entry Placeholder="Heslo"
           Text="{Binding Heslo, Mode=TwoWay}"
           IsPassword="True" />

    <Entry Placeholder="Potvrďte heslo"
           Text="{Binding PotvrdHeslo, Mode=TwoWay}"
           IsPassword="True" />

    <Button Text="Registrovať sa"
            Command="{Binding RegistrovatSaCommand}" />

</VerticalStackLayout>

Správa stavu načítavania a chýb

V reálnych aplikáciách musíte zvládnuť tri stavy: načítavanie, úspešné zobrazenie dát a chybový stav. Znie to jednoducho, ale prekvapivo veľa aplikácií to robí zle. Tu je vzor, ktorý funguje spoľahlivo:

public partial class ProduktZoznamViewModel : ObservableObject
{
    private readonly IProduktService _produktService;

    [ObservableProperty]
    private bool _jeNacitavanie;

    [ObservableProperty]
    private bool _maChybu;

    [ObservableProperty]
    private string _chybovaSprava;

    [ObservableProperty]
    private bool _jePrazdnyZoznam;

    [ObservableProperty]
    private ObservableCollection<Produkt> _produkty = new();

    public ProduktZoznamViewModel(IProduktService produktService)
    {
        _produktService = produktService;
    }

    [RelayCommand]
    private async Task NacitatProduktyAsync()
    {
        try
        {
            JeNacitavanie = true;
            MaChybu = false;
            ChybovaSprava = string.Empty;

            var vysledok = await _produktService.ZiskatProduktyAsync();
            var zoznam = vysledok.ToList();

            Produkty = new ObservableCollection<Produkt>(zoznam);
            JePrazdnyZoznam = zoznam.Count == 0;
        }
        catch (HttpRequestException ex)
        {
            MaChybu = true;
            ChybovaSprava = "Nepodarilo sa pripojiť k serveru. Skontrolujte internetové pripojenie.";
        }
        catch (Exception ex)
        {
            MaChybu = true;
            ChybovaSprava = $"Nastala neočakávaná chyba: {ex.Message}";
        }
        finally
        {
            JeNacitavanie = false;
        }
    }
}

V XAML potom zobrazíte príslušný obsah podľa aktuálneho stavu:

<Grid>
    <!-- Indikátor načítavania -->
    <VerticalStackLayout IsVisible="{Binding JeNacitavanie}"
                         VerticalOptions="Center"
                         HorizontalOptions="Center">
        <ActivityIndicator IsRunning="True" />
        <Label Text="Načítavam produkty..." />
    </VerticalStackLayout>

    <!-- Chybový stav -->
    <VerticalStackLayout IsVisible="{Binding MaChybu}"
                         VerticalOptions="Center"
                         HorizontalOptions="Center"
                         Spacing="12">
        <Label Text="⚠" FontSize="48"
               HorizontalOptions="Center" />
        <Label Text="{Binding ChybovaSprava}"
               HorizontalTextAlignment="Center" />
        <Button Text="Skúsiť znova"
                Command="{Binding NacitatProduktyCommand}" />
    </VerticalStackLayout>

    <!-- Prázdny zoznam -->
    <Label Text="Žiadne produkty na zobrazenie"
           IsVisible="{Binding JePrazdnyZoznam}"
           VerticalOptions="Center"
           HorizontalTextAlignment="Center" />

    <!-- Zoznam produktov -->
    <CollectionView ItemsSource="{Binding Produkty}">
        <!-- ... šablóna položiek ... -->
    </CollectionView>
</Grid>

Tento vzor zabezpečuje, že používateľ vždy vidí relevantný obsah — či už ide o loading spinner, chybovú správu s možnosťou retry, alebo samotné dáta. Je to malý detail, ale výrazne zlepšuje celkový dojem z aplikácie.

Unit testovanie view modelov

Tak, a teraz sa dostávame k jednej z najväčších výhod MVVM architektúry — testovateľnosti. Keď sú závislosti injektované cez konštruktor, môžete ich v testoch jednoducho nahradiť mock objektmi.

Nastavenie testovacieho projektu

Vytvorte xUnit testovací projekt a pridajte potrebné balíčky:

dotnet new xunit -n MojaAplikacia.Tests
dotnet add MojaAplikacia.Tests package Moq
dotnet add MojaAplikacia.Tests package FluentAssertions

Testovanie view modelu

using Moq;
using FluentAssertions;

public class ProduktZoznamViewModelTests
{
    private readonly Mock<IProduktService> _produktServiceMock;
    private readonly Mock<IConnectivity> _connectivityMock;
    private readonly ProduktZoznamViewModel _viewModel;

    public ProduktZoznamViewModelTests()
    {
        _produktServiceMock = new Mock<IProduktService>();
        _connectivityMock = new Mock<IConnectivity>();

        _connectivityMock.Setup(c => c.NetworkAccess)
            .Returns(NetworkAccess.Internet);

        _viewModel = new ProduktZoznamViewModel(
            _produktServiceMock.Object,
            _connectivityMock.Object);
    }

    [Fact]
    public async Task NacitatProdukty_SInternetom_NaplniKolekciu()
    {
        // Arrange
        var ocakavane = new List<Produkt>
        {
            new() { Id = 1, Nazov = "Telefón", Cena = 599.99m },
            new() { Id = 2, Nazov = "Tablet", Cena = 449.99m }
        };

        _produktServiceMock
            .Setup(s => s.ZiskatProduktyAsync())
            .ReturnsAsync(ocakavane);

        // Act
        await _viewModel.NacitatProduktyCommand.ExecuteAsync(null);

        // Assert
        _viewModel.Produkty.Should().HaveCount(2);
        _viewModel.Produkty[0].Nazov.Should().Be("Telefón");
        _viewModel.JeNacitavanie.Should().BeFalse();
    }

    [Fact]
    public async Task NacitatProdukty_BezInternetu_NezavolaService()
    {
        // Arrange
        _connectivityMock.Setup(c => c.NetworkAccess)
            .Returns(NetworkAccess.None);

        // Act
        await _viewModel.NacitatProduktyCommand.ExecuteAsync(null);

        // Assert
        _produktServiceMock.Verify(
            s => s.ZiskatProduktyAsync(), Times.Never);
    }

    [Fact]
    public void PridatPolozkuDoKosika_NovyProdukt_PridaDoKolekcie()
    {
        // Arrange
        var kosikVm = new KosikViewModel();
        var produkt = new Produkt { Id = 1, Nazov = "Telefón", Cena = 599.99m };

        // Act
        kosikVm.PridatPolozkuDoKosikaCommand.Execute(produkt);

        // Assert
        kosikVm.Polozky.Should().HaveCount(1);
        kosikVm.JePrazdny.Should().BeFalse();
    }
}

Testovanie správ (Messenger)

public class KosikViewModelMessengerTests
{
    [Fact]
    public void PridatPolozku_OdosleSpravuSPoctom()
    {
        // Arrange
        var prijataSprava = false;
        var prijataPocetPoloziek = 0;

        WeakReferenceMessenger.Default
            .Register<KosikAktualizovanyMessage>(this,
                (r, m) =>
                {
                    prijataSprava = true;
                    prijataPocetPoloziek = m.Value;
                });

        var kosikVm = new KosikViewModel();
        var produkt = new Produkt { Id = 1, Nazov = "Test", Cena = 10m };

        // Act
        kosikVm.PridatPolozkuDoKosikaCommand.Execute(produkt);

        // Assert
        prijataSprava.Should().BeTrue();
        prijataPocetPoloziek.Should().Be(1);

        // Cleanup
        WeakReferenceMessenger.Default.UnregisterAll(this);
    }
}

Kompletný príklad — štruktúra projektu

Pre stredne veľkú .NET MAUI aplikáciu odporúčam nasledujúcu štruktúru priečinkov. Nie je to jediný správny spôsob, ale v praxi sa mi osvedčila:

MojaAplikacia/
├── Models/
│   ├── Produkt.cs
│   ├── Objednavka.cs
│   ├── Pouzivatel.cs
│   └── PolozkaKosika.cs
├── ViewModels/
│   ├── BaseViewModel.cs
│   ├── ProduktZoznamViewModel.cs
│   ├── ProduktDetailViewModel.cs
│   ├── KosikViewModel.cs
│   └── NastaveniaViewModel.cs
├── Views/
│   ├── ProduktZoznamPage.xaml(.cs)
│   ├── ProduktDetailPage.xaml(.cs)
│   ├── KosikPage.xaml(.cs)
│   └── NastaveniaPage.xaml(.cs)
├── Services/
│   ├── Interfaces/
│   │   ├── IProduktService.cs
│   │   ├── IObjednavkaService.cs
│   │   └── ILokalneUlozisko.cs
│   ├── ProduktService.cs
│   ├── ObjednavkaService.cs
│   └── LokalneUlozisko.cs
├── Messages/
│   ├── KosikAktualizovanyMessage.cs
│   └── PouzivatelPrihlasenyMessage.cs
├── Converters/
│   └── InvertBoolConverter.cs
├── App.xaml(.cs)
├── AppShell.xaml(.cs)
└── MauiProgram.cs

BaseViewModel — spoločný základ

Pre zdieľanú funkcionalitu vytvorte základný view model:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class BaseViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(NieJeZaneprazdneny))]
    private bool _jeZaneprazdneny;

    [ObservableProperty]
    private string _nadpis;

    public bool NieJeZaneprazdneny => !JeZaneprazdneny;
}

Pokročilé techniky a osvedčené postupy

1. Vyhýbajte sa priamym volaniam Shell.Current v testovateľnom kóde

Namiesto priameho volania Shell.Current.GoToAsync() vo view modeli abstrahujte navigáciu do služby. Vaše testy vám za to poďakujú:

public interface INavigacnaSluzba
{
    Task NavigovatNaAsync(string trasa);
    Task NavigovatNaAsync(string trasa, IDictionary<string, object> parametre);
    Task NavigovatSpatAsync();
}

public class ShellNavigacnaSluzba : INavigacnaSluzba
{
    public Task NavigovatNaAsync(string trasa) =>
        Shell.Current.GoToAsync(trasa);

    public Task NavigovatNaAsync(
        string trasa, IDictionary<string, object> parametre) =>
        Shell.Current.GoToAsync(trasa, parametre);

    public Task NavigovatSpatAsync() =>
        Shell.Current.GoToAsync("..");
}

2. Používajte partial metódy pre vedľajšie efekty

Zdrojový generátor vytvorí parciálne metódy, ktoré môžete implementovať pre spúšťanie vedľajších efektov pri zmenách vlastností. Nemusíte kvôli tomu písať celý vlastný setter.

3. Spravujte životný cyklus stránky

Pre čistenie zdrojov pri opustení stránky využite metódy životného cyklu:

public partial class ProduktZoznamPage : ContentPage
{
    private readonly ProduktZoznamViewModel _viewModel;

    public ProduktZoznamPage(ProduktZoznamViewModel viewModel)
    {
        InitializeComponent();
        _viewModel = viewModel;
        BindingContext = viewModel;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        await _viewModel.NacitatProduktyCommand.ExecuteAsync(null);
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();
        // Prípadné čistenie
    }
}

4. Nepreháňajte to s abstrakciami

Bežná chyba (a ruku na srdce, sám som sa jej v minulosti dopustil) je vytváranie príliš veľa vrstiev abstrakcie. Pre menšie aplikácie naozaj nepotrebujete repozitárový vzor navyše nad servisnou vrstvou. Držte sa princípu — pridávajte vrstvy len keď to komplexnosť skutočne vyžaduje.

Bežné chyby a ako sa im vyhnúť

Počas práce s MVVM v .NET MAUI som videl (a sám urobil) viacero typických chýb. Tu sú tie najčastejšie:

  • Zabudnutie na partial — trieda s atribútmi CommunityToolkit musí byť partial. Bez toho zdrojový generátor jednoducho nemôže fungovať a vy budete márne hľadať chybu.
  • Pomenovanie polí — pole _nazov generuje vlastnosť Nazov. Dodržiavajte konvenciu s podčiarkovníkom na začiatku, inak sa generátor bude správať neočakávane.
  • Registrácia v DI kontajneri — ak zabudnete zaregistrovať stránku alebo view model, dostanete runtime výnimku. Vždy zaregistrujte obe.
  • Únik pamäte pri Messenger — aj keď WeakReferenceMessenger používa slabé referencie, je dobrou praxou odregistrovať sa v OnDisappearing alebo použiť ObservableRecipient s vlastnosťou IsActive.
  • Blokovanie UI vlákna — nikdy nepoužívajte .Result alebo .Wait() na asynchrónne operácie. Vždy async/await. Toto sa môže zdať ako samozrejmosť, ale stretávam sa s tým stále.
  • Prílišné používanie Singleton — view modely by mali byť zvyčajne Transient, aby sa predišlo stavu, ktorý nechcene pretrváva medzi navigáciami.

Migrácia existujúceho projektu na MVVM

Ak máte existujúci .NET MAUI projekt s logikou v code-behind a chcete ho migrovať na MVVM, mám pre vás dobrú správu: nemusíte všetko prepisovať naraz. Postupná migrácia je nielen možná, ale aj odporúčaná.

Krok 1: Pridajte CommunityToolkit.MVVM

Nainštalujte NuGet balíček a nakonfigurujte DI kontajner v MauiProgram.cs. Existujúci kód sa tým nerozbije.

Krok 2: Vytvorte view model pre jednu stránku

Vyberte si jednu stránku — najlepšie tú najjednoduchšiu — a presťahujte z jej code-behind všetku logiku do nového view modelu. Zaregistrujte obe triedy v DI kontajneri.

Krok 3: Nahraďte event handlery príkazmi

Kde máte Clicked="OnButtonClicked", nahraďte to za Command="{Binding MojPrikazCommand}". Code-behind event handler presťahujte do view modelu ako metódu s atribútom [RelayCommand].

Krok 4: Extrahujte služby

Ak view model priamo pristupuje k HTTP klientu, databáze alebo platformovým API, vytvorte rozhranie a implementáciu služby. Zaregistrujte ju v DI kontajneri a injektujte do view modelu.

Krok 5: Opakujte pre ďalšie stránky

Postupne migrujte ďalšie stránky. Počas migrácie je úplne v poriadku mať v projekte mix stránok s MVVM a bez neho. Nikto vás nebude súdiť za to, že nemáte všetko perfektné hneď od začiatku.

Kľúčom k úspešnej migrácii je inkrementálny prístup. Každá migrovaná stránka okamžite získa výhody testovateľnosti a čistejšej architektúry, pričom zvyšok aplikácie funguje bez zmeny.

Záver

MVVM architektúra v kombinácii s CommunityToolkit.MVVM a vstavaným dependency injection kontajnerom tvorí robustný základ pre .NET MAUI aplikácie. Zdrojové generátory dramaticky znižujú boilerplate, WeakReferenceMessenger poskytuje efektívnu komunikáciu medzi komponentmi a správne navrhnutá servisná vrstva zabezpečuje testovateľnosť.

Kľúčové princípy, ktoré si z tohto článku odneste:

  1. Oddeľujte zodpovednosti — View pre zobrazenie, ViewModel pre logiku, Model pre dáta.
  2. Používajte zdrojové generátory[ObservableProperty] a [RelayCommand] vám ušetria stovky riadkov kódu.
  3. Injektujte závislosti — vždy cez konštruktor, nikdy cez Service Locator vzor.
  4. Abstrahujte platformové služby — za rozhrania, ktoré môžete mockovať v testoch.
  5. Testujte view modely — sú srdcom vašej aplikácie a mali by byť pokryté unit testami.
  6. Nepredimenzujte architektúru — pridávajte vrstvy a abstrakcie len keď to naozaj potrebujete.

S týmito princípmi budete schopní vytvárať .NET MAUI aplikácie, ktoré sú nielen funkčné, ale aj dobre udržiavateľné a pripravené na rast.

Ak ste doteraz písali logiku do code-behind, nebojte sa začať s MVVM. Krivka učenia naozaj nie je strmá — najmä s CommunityToolkit.MVVM, ktorý robí väčšinu ťažkej práce za vás. Začnite jednou stránkou, vyskúšajte si zdrojové generátory a uvidíte, ako rýchlo sa MVVM stane prirodzenou súčasťou vášho workflow. A verte mi — vaši kolegovia (aj vaše budúce ja) vám za to poďakujú pri každom refactoringu aj pri každom bug fixe.

Pre ďalšie kroky odporúčam preskúmať oficiálnu dokumentáciu Enterprise Application Patterns pre .NET MAUI od Microsoftu, kde nájdete ešte hlbší ponor do architektonických vzorov. Taktiež sa oplatí sledovať projekt CommunityToolkit na GitHube — komunita aktívne prispieva novými funkciami a vylepšeniami.

O Autorovi Editorial Team

Our team of expert writers and editors.