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
_nazovgeneruje 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ď
WeakReferenceMessengerpoužíva slabé referencie, je dobrou praxou odregistrovať sa vOnDisappearingalebo použiťObservableRecipients vlastnosťouIsActive. - Blokovanie UI vlákna — nikdy nepoužívajte
.Resultalebo.Wait()na asynchrónne operácie. Vždyasync/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:
- Oddeľujte zodpovednosti — View pre zobrazenie, ViewModel pre logiku, Model pre dáta.
- Používajte zdrojové generátory —
[ObservableProperty]a[RelayCommand]vám ušetria stovky riadkov kódu. - Injektujte závislosti — vždy cez konštruktor, nikdy cez Service Locator vzor.
- Abstrahujte platformové služby — za rozhrania, ktoré môžete mockovať v testoch.
- Testujte view modely — sú srdcom vašej aplikácie a mali by byť pokryté unit testami.
- 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.