5. MVVM¶
A gyakorlat célja¶
A labor során egy egyszerű alkalmazást fogunk refaktorálni MVVM minta segítségével a jobb átláthatóság és karbantarthatóság jegyében.
Előfeltételek¶
A labor elvégzéséhez szükséges eszközök:
- Windows 10 vagy Windows 11 operációs rendszer (Linux és macOS nem alkalmas)
- Visual Studio 2022
- Windows Desktop Development Workload
Kiinduló projekt¶
Klónozzuk le a kiinduló projektet az alábbi paranccsal:
git clone https://github.com/bmeviauab00/lab-mvvm-kiindulo
A kész megoldás letöltése
Lényeges, hogy a labor során a laborvezetőt követve kell dolgozni, így értelmetlen a kész megoldás letöltése. Ugyanakkor az utólagos önálló gyakorlás során hasznos lehet a kész megoldás áttekintése, így ezt elérhetővé tesszük.
A megoldás GitHubon érhető el a megoldas ágon. A legegyszerűbb mód a letöltésére, ha parancssorból a git clone utasítással leklónozzuk a gépünkre a megoldas ágat:
git clone https://github.com/bmeviauab00/lab-mvvm-kiindulo -b megoldas
Az MVVM mintáról¶
Az MVVM (Model-View-ViewModel) egy architekturális tervezési minta, amelyet a XAML alkalmazások fejlesztése során használhatunk, de gyakran más kliens oldali technológiák esetében is megjelenik (pl.: Android, iOS, Angular stb.). Az MVVM minta célja, hogy a felhasználói felületet és a mögötte lévő logikát szétválassza, és ezzel egy lazább csatolású alkalmazást hozzon létre, ami növeli a tesztelhetőséget, a karbantarthatóságot és az újrafelhasználhatóságot.
Az MVVM minta három (+1) fő részből áll:
- Model: Domainspecifikus adatokat fog össze, melyet a ViewModel-ek használhatnak az adatok tárolására. Pl. Recipe/Product/Order osztály, egy recept/termék/megrendelés adatait fogja össze.
- View: A felhasználói felület leírását tartalmazza, (és a tisztán a nézetekhez kapcsolódó logikát, pl. animációk kezelését). Tipikusan
Window,Page,UserControlleszármazott osztály, XAML-beli deklaratív leírással, a code-behind sokszor üres (mert a logika a ViewModel-ben van). - ViewModel: A nézethez tartozó logika van benne: tartalmazza a nézet állapotát és a nézeten végrehajtható műveleteket. Független a nézettől, a laza csatolást a ViewModel és a nézet között adatkötés biztosítja (a nézet vezérlői kötnek a ViewModel tulajdonságaihoz). Unit tesztelhető!
- Services (szolgáltatások): Az alkalmazás üzleti/alkalmazás logikáját tartalmazó osztályok, amelyeket a ViewModel-ek használnak. Ha minden üzleti logika a ViewModel-ekben lenne, azok túl bonyolultak és átláthatatlanok lennének. Ez nem az MVVM minta része, de itt említjük meg, mert mi is így fogjuk felépíteni az alkalmazás architektúráját.
Mihez készítünk ViewModel osztályokat?
- Az egyes nézetekhez (pl.
Window,Page,Dialog,UserControl) tipikusan készítünk ViewModel osztályt, és belőle egy nézethez egy objektumot hozunk létre. Pl.MainPage-hezMainPageViewModel,DancerDialog-hozDancerDialogViewModel. Ezt a gyakorlat során is alkalmazzuk. - Az egyes modell osztályokhoz (pl.
Recipe,Product,Dancerstb.) opcionálisan készíthetünk csomagoló ViewModel osztályokat (pl.RecipeViewModel,ProductViewModel,DancerViewModel), ilyeneket a gyakorlat során nem fogunk készíteni. Ez azért van, mert nem a Strict, hanem a Relaxed MVVM mintát követjük (lásd előadás).
0. Feladat - Kiinduló projekt áttekintése¶
Az alkalmazásunk egy egyszerű könyveket listázó alkalmazás, ahol a könyvek egy ItemsView-ban jelennek meg táblázatos formában.
A lista felett pedig egy ComboBox található, amellyel a könyvek szűrhetők műfaj szerint.
A szűrő egy Clear gombbal törölhető.
Próbáljuk ki!
ComboBox és ItemsView
A ComboBox és az ItemsView is alapvetően listás vezérlők, amiket az ItemsSource tulajdonság segítségével tudunk adatokkal feltölteni.
-
A
ComboBoxegy legördülő menü, amely lehetővé teszi a felhasználó számára, hogy kiválasszon egy elemet a listából -
Az
ItemsViewegy táblázatos megjelenítést biztosít, ahol több elem is látható egyszerre. AzItemsViewlehetőséget biztosít több fajta megjelenítési módra, például rácsos vagy listás nézetre is, amit aLayouttulajdonsággal állíthatunk be. Különbség az előző laborban használtListView-hoz képest, hogy a lista elem sablonokban mindenképpen egyItemContainerobjektumnak kell szerepelnie gyökér elemként.
A kiinduló projektben az alkalmazás logikája a BooksPage.xaml.cs fájlban található, a felhasználói felület pedig a BooksPage.xaml fájlban.
Ez a megoldás nem MVVM mintát követ, így a felhasználói felület és a mögötte lévő logika szorosan összefonódik, szinte már-már spagetti kód jelleget öltve.
Jó példa erre, hogy ebben a fájlban található az adatok betöltése közvetlenül a vezérlők adatait manipulálva. Az interakciók lekezelése is eseménykezelőkben történik, ami egy idő után átláthatatlanná válik, és keverednek a felelősségi körök.
Esetünkben a példaadatokat a SeedDatabase függvény tölti fel, amely a BooksPage konstruktorában kerül meghívásra.
A LoadGenres és LoadBooks függvények pedig a legördülő menü és a táblázat feltöltéséért felelnek.
A legördülő menü aktuális kiválasztásának megváltozását és a Clear gomb megnyomását egy-egy eseménykezelő függvény kezeli le, melyek újratöltik a listát a kiválasztott műfaj szerint (keressük meg ezeket a kódban).
Adatok betöltése ADO.NET-tel SQLite adatbázisból
Az alkalmazásban az adatok tárolására SQLite adatbázist használunk, amelyet ADO.NET-tel érünk el. Ezt a technológiát a labor során nem fogjuk részletesen bemutatni, a félév végén fogunk részletesen foglalkozni vele.
Page osztály Windows helyett
A nézetünk most nem egy Window, hanem egy Page leszármazott osztály. Mint a neve is utal rá, a Page egy "oldalt" reprezentál az alkalmazásban: önmagában nem tud megjelenni, hanem pl. egy ablakon kell elhelyezni. Előnye, hogy az ablakon - megfelelő navigáció kialakításával - lehetőség van oldalak (különböző Page leszármazottak) között navigálni. Ezt mi nem fogjuk kihasználni, egyetlen oldalunk lesz csak. Az oldal bevezetésével a célunk mindössze az volt, hogy szemléltessük: az MVVM architektúrában a nézeteket nem csak Window (teljes ablak), hanem pl. Page objektumokkal (vagy akár más UI komponens pl.: UserControl) is meg lehet valósítani.
1. Feladat - MVVM minta bevezetése¶
A labor során a kiinduló projektet MVVM mintára fogjuk átalakítani.
Model¶
Építkezzünk most alulról felfelé, így kezdjük a modell osztályunkkal.
A BooksPage.xaml.cs fájlban található Book osztályt helyezzük át egy új fájlba egy újonnan létrehozott Models mappába.
namespace Lab.Mvvm.Models;
public class Book
{
public string Title { get; set; }
public string Genre { get; set; }
public string ImageUrl { get; set; }
// Other properties like Author, ISBN etc.
}
A Book osztályunk a korábbi Lab.Mvvm névtérből a Lab.Mvvm.Models névtérbe került. Emiatt - annak érdekében, hogy ne kapjunk emiatt hosszú ideig fordítási hibát - a View-t (BooksPage.xaml.cs) már most igazítsuk a névtér változáshoz. Konkrétan, be kell vezessünk egy új névteret (models), és az ItemsView adatsablon típusának megadásakor ezt kell használjuk:
<Page x:Class="Lab.Mvvm.BooksPage"
// ...
xmlns:model="using:Lab.Mvvm.Models">
<ItemsView x:Name="booksGridView"
Grid.Row="2"
ItemsSource="{x:Bind ViewModel.Books, Mode=OneWay}">
<ItemsView.Layout>
<LinedFlowLayout ItemsStretch="Fill"
LineHeight="160"
LineSpacing="5"
MinItemSpacing="5" />
</ItemsView.Layout>
<ItemsView.ItemTemplate>
<DataTemplate x:DataType="model:Book">
// ...
</DataTemplate>
</ItemsView.ItemTemplate>
</ItemsView>
Service¶
Az adatok betöltéséért felelős kódot helyezzük át egy új BookService nevű osztályba, amit egy újonnan létrehozott Services mappába helyezzünk el.
-
A
BookServiceosztályba aSeedDatabase,LoadGenresésLoadBooksfüggvényeket emeljük át aBookPage.xaml.cs-ből -
Mozgassuk át a
_connectionStringmezőt is. -
A függvények láthatóságát állítsuk
public-ra, hogy a ViewModel osztályunk elérhesse őket.
A SeedDatabase függvény így rendben van, de a másik két függvényben több UI elemet is használunk, amiktől meg kell szabaduljunk.
Alakítsuk át a függvényeket, hogy csak a szükséges adatokat adják vissza, és ne közvetlenül a UI elemeket használják. Nevezzük is át őket GetGenres és GetBooks-ra.
-
A
LoadGenresfüggvényben egyList<string>típusú listát fogunk visszaadni. -
A
LoadBooksfüggvényben pedig egyList<Book>típusú listát fogunk visszaadni. Itt arra is gondolnunk kell, hogy korábban aComboBoxkiválasztott értékét használtuk a lekérdezéshez, most viszont ezt a paramétert át kell adnunk a függvénynek opcionálisan.
using Lab.Mvvm.Models;
using Microsoft.Data.Sqlite;
using System.Collections.Generic;
namespace Lab.Mvvm.Services;
public class BookService
{
private readonly string _connectionString = "Data Source=books.db";
public void SeedDatabase()
{
// ...
}
public List<string> GetGenres()
{
// ...
return genres;
}
public List<Book> GetBooks(string genre = null)
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
string query = "SELECT Title, Genre, ImageUrl FROM books";
if (genre != null)
{
query += " WHERE Genre = @genre";
}
using var command = new SqliteCommand(query, connection);
if (genre != null)
{
command.Parameters.AddWithValue("@genre", genre);
}
List<Book> books = [];
// ...
return books;
}
}
- a
GetGenresfüggvényben agenreFilterComboBox-ot ésclearGenreFilterButton-t manipuláló sorokat is töröljük. - a
BooksPageosztályban töröljük a fordítási hibát okozóSeedDatabase,LoadGenresésLoadBookshívásokat.
Ekkor, ha jól dolgoztunk, a BookService osztályunkban már nem lehet fordítási hiba.
A SeedDatabase metódust hívjuk meg az alkalmazás indulásakor, hogy a könyvek és műfajok adatai betöltődjenek az adatbázisba.
Ezt az App.xaml.cs fájlban a OnLaunched metódusban tehetjük meg legkönnyebben.
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
m_window = new MainWindow();
new BookService().SeedDatabase();
m_window.Activate();
}
ViewModel¶
Készítsük el az új (BooksPage-hez tartozó) BooksPageViewModel osztályt egy új ViewModels mappába. Ez, mint egy klasszikus ViewModel, a nézet állapotát és a rajta végrehajtható műveleteket fogja tartalmazni - vagyis a BooksPage nézethez tartozó megjelenítési logikát.
Ha belegondolunk, a BooksPage az alábbi állapotinformációkat tartalmazza:
- A könyvek listája
- A műfajok listája a legördülő menüben
- A kiválasztott műfaj
Ezeket vegyük fel tulajdonságokként a BooksPageViewModel osztályba, és implementáljuk az előző laboron tanult INotifyPropertyChanged interfész alapú változásértesítést az adatkötés támogatásához.
using Lab.Mvvm.Models;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Lab.Mvvm.ViewModels;
public class BooksPageViewModel : INotifyPropertyChanged
{
private List<Book> _books;
public List<Book> Books
{
get => _books;
set => SetProperty(ref _books, value);
}
private List<string> _genres;
public List<string> Genres
{
get => _genres;
set => SetProperty(ref _genres, value);
}
private string _selectedGenre;
public string SelectedGenre
{
get => _selectedGenre;
set => SetProperty(ref _selectedGenre, value);
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual bool SetProperty<T>(ref T property, T value, [CallerMemberName] string propertyName = null)
{
if (object.Equals(property, value))
return false;
property = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
}
SetProperty
Az SetProperty metódus egy segédfüggvény, amely megkönnyíti a tulajdonságok beállítását és a változásértesítést.
A visszatérési érték true, ha a tulajdonság értéke megváltozott, és false, ha nem. Ez segít majd a későbbiekben eldönteni, hogy történt-e változás a tulajdonság értékében.
A ref kulcsszó lehetővé teszi, hogy a metódus közvetlenül módosítsa a változó értékét (nem csak a referencia kerül átadásra, hanem maga a referencia is módosítható, így megváltoztatható, hogy az eredeti változó hova mutat).
A CallerMemberName attribútum automatikusan átadja a hívó (itt property) nevét, így nem kell mindenhol megadni a tulajdonság nevét kézzel.
Az adatok betöltését a BookService osztály segítségével fogjuk megvalósítani (görgessünk fel az útmutatóban, és a bevezető MVVM ábrán nézzük meg, hogy valóban a ViewModel használja a Service osztályt/osztályokat). Példányosítsuk a BookService osztályt, és a konstruktorában töltsük be a műfajokat és a könyveket.
private readonly BookService _booksService;
public BooksPageViewModel()
{
_booksService = new BookService();
Genres = _booksService.GetGenres();
LoadBooks();
}
private void LoadBooks()
{
// A Books property állítása kiváltja az INPC PropertyChanged eseményt (lásd Books property setter fent) - a nézet frissülni fog
Books = _booksService.GetBooks(SelectedGenre);
}
A könyv betöltést nem csak a konstruktorban kell elvégezni, hanem a SelectedGenre tulajdonság setterében is, hogy a kiválasztott műfaj megváltozása esetén újra betöltsük a könyveket.
A SelectedGenre setterében a LoadBooks metódust hívjuk meg, ha változás történt.
private string _selectedGenre;
public string SelectedGenre
{
get => _selectedGenre;
set
{
if (SetProperty(ref _selectedGenre, value))
LoadBooks();
}
}
View¶
Most már csak a nézetet kell átalakítanunk, hogy a ViewModelt használja.
Hozzunk létre a BooksPage.xaml.cs fájlban egy új BooksPageViewModel típusú readonly propertyt, és adjunk neki értéket egy új BooksPageViewModel példány létrehozásával.
public BooksPageViewModel ViewModel { get; } = new BooksPageViewModel();
readonly property vs getter only property
Emlékezzünk vissza, hogy az autoimplementált (egyszer inicializált) readonly property és a getter only property között lényeges különbség van. A fenti példában autoimplementált readonly propertyt használunk, ami azt jelenti, hogy a ViewModel property értéke csak egyszer jön létre. Ezzel szemben a getter only property esetén minden egyes híváskor új példányt hoznánk létre, ami nem kívánt viselkedést okozna: public BooksPageViewModel ViewModel => new BooksPageViewModel();
A BooksPage.xaml fájlban innentől kezdve használhatjuk a ViewModel propertyt az adatkötéshez.
-
Fókuszáljunk első körben a
ComboBox-ra:- A
SelectedItemés azItemsSourcetulajdonságokat a kiinduló megoldásban a code-behind fájlban kézzel manipuláltuk. Ezek kezelését alakítsuk át adatkötés alapú megoldásra: az MVVM mintának megfelelőan a code-behindban definiált ViewModel objektum tulajdonságaihoz kötjük. - Töröljük a xaml fájlban a
SelectionChangedesemény feliratkozást és a code-behindban aGenreFilterComboBox_SelectionChangedeseménykezelőt (erre aSelectedItemadatkötése miatt nincs már szükség).
<ComboBox x:Name="genreFilterComboBox" Grid.Row="1" PlaceholderText="Filter Genre" ItemsSource="{x:Bind ViewModel.Genres}" SelectedItem="{x:Bind ViewModel.SelectedGenre, Mode=TwoWay}" /> - A
-
A Clear gomb esetében is töröljük a
Clickesemény feliratkozást és a code-behindban aGenreFilterComboBox_SelectionChangedeseménykezelőt. Ennek viselkedését majd csak később implementáljuk a ViewModel-ben.<Button x:Name="clearGenreFilterButton" Content="Clear" /> -
Az
ItemsView-ban is adatkötést kell használnunk aItemsSourcetulajdonsághoz.<ItemsView x:Name="booksGridView" Grid.Row="2" ItemsSource="{x:Bind ViewModel.Books, Mode=OneWay}"> ... </ItemsView>
Klasszikus Binding használata
Ha klasszikus bindingot használnánk x:Bind helyett, akkor az adott vezérlő/oldal DataContext tulajdonságát be kellene állítani egy ViewModel példányra.
Próbáljuk ki!
Az alkalmazásunknak az előzőekhez hasonlóan kell működnie (kivéve a Clear gomb), de most már MVVM mintát követ az alkalmazásunk architektúrája.
Összefoglalás¶
Értékeljük ki a megoldásunkat, a kódot is nézve. A kezdeti megoldásunkban csak egy Page osztályunk volt, ebben az egyben volt mixelve a megjelenítés (.xaml-ben) az alkalmazáslogika és a megjelenítési logika (ez utóbbi kettő a Page code-behindban). Az MVVM alapú megoldásunkban:
- A Page-ben csak a megjelenítés maradt (View), a code-behind gyakorlatilag üres (csak egy ViewModel-t tartalmaz).
- Az alkalmazáslogika egy Service osztályba került.
- Az oldalhoz tartozó megjelenítési logika egy ViewModel osztályba került (és a View adatköt hozzá).
A jobb áttekinthetőségen felül a megközelítés legfőbb előnye, hogy a ViewModel és a View között lazább csatolás van, így a ViewModel könnyebben tesztelhető és akár újrafelhasználható. A ViewModel nem függ a View-tól, így könnyen átírható vagy lecserélhető anélkül, hogy a View-t módosítani kellene.
2. Feladat - MVVMToolkit¶
MVVM mintát ritkán szoktunk kizárólag a .NET keretrendszerre támaszkodva implementálni. Érdemes használni valamilyen MVVM könyvtárat, amelyek segítségével a kódunk tömörebb, átláthatóbb, és kevesebb boilerplate kódot fog tartalmazni. A könyvtárak közül a legelterjedtebbek a következők:
- MVVM Toolkit: Microsoft által gondozott MVVM könyvtár.
- Prism: Régen a Microsoft gondozásában állt és nagyon elterjedt volt, de már külső fejlesztők tartják karban és fizetős lett idő közben.
- ReactiveUI: A Reactive Extensions (Rx) könyvtárakat használja a ViewModel állapotának kezelésére, és a View-ViewModel közötti adatkötésre. Ez a könyvtár nyújtja a legtöbb szolgáltatást, de a legnehezebben tanulható is.
- Uno.Extensions: MVVM Toolkitre épül, de több olyan szolgáltatást is tartalmaz, amelyek a WinUI keretrendszer hiányosságait pótolják.
- A Windows Template Studio egy Visual Studio kiegészítő, ami komplexebb WinUI alkalmazások kiinduló projektsablonját teszi elérhetővé.
A labor során a Microsoft által gondozott MVVM Toolkitet fogjuk kipróbálni.
Telepítés¶
A MVVM Toolkit telepítéséhez nyissuk meg a NuGet Package Manager-t a Visual Studio-ban (jobb katt a projekten majd "Manage Nuget Packages"), és keressük meg a CommunityToolkit.Mvvm csomagot.
Lényeges, hogy a labortermekben a 8.4.0-s verziót telepítsük!
Ez valójában a projektfájlban az alábbi
PackageReference bejegyzést fogja létrehozni (akár kézzel is felvehetjük a fenti lépések helyett a többi PackageReference mellé):
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
ObservableObject és ObservableProperty¶
A BooksPageViewModel osztályunkban az INotifyPropertyChanged megvalósítása meglehetősen terjengős. A INotifyPropertyChanged interfész közvetlen implementálása helyett használhatjuk a ObservableObject osztályt, amely már implementálja ezt az interfészt és több segédfüggvényt is tartalmaz, amelyek megkönnyítik a tulajdonságok beállítását és a változásértesítést.
Továbbá lehetőségünk van az ObservableProperty attribútum használatára is, amely egy kódgenerátort vezérel, így automatikusan létrehozhatóak a tulajdonságok kézzel írt boilerplate kód nélkül, kizárólag a mezők attributált deklarálásával. Hajtsuk végre az alábbi átalakításokat:
-
A
BooksPageViewModelosztályunknak aCommunityToolkit.Mvvm.ComponentModelnévtérben találhatóObservableObjectosztályból kell leszármaznia. -
A source generator használatához azt osztályt
partialkulcsszóval kell ellátni, hogy a generált kód és a kézi kód külön fájlokban kaphassanak helyet. -
A fullproperty szintaxis helyett pedig elég megtartanunk a mezőket, amikre az
ObservablePropertyattribútumot helyezzük el.public partial class BooksPageViewModel : ObservableObject { // ... [ObservableProperty] private List<Book> _books; [ObservableProperty] private List<string> _genres; [ObservableProperty] private string _selectedGenre; // ... }
Lényeges, hogy a korábbi BooksPageViewModel megoldásból töröljük a tagváltozókat (a _booksService kivételével), a property-ket (hiszen ezeket a kódgenerátor hozza létre), a PropertyChanged eseményt és a SetProperty műveletet. az átalakítás után buildeljünk egyet (pl. Build/Build solution menü): enélkül a fordítási hibák nem szűnnek meg, a Visual Studio számos hibát jelez a kódban. Ez logikus is, hiszen az adatkötött propertyket a kódgenerátor csak a build során generálja le (egy "rejtett" állományban).
Ellenőrizhetjük, hogy milyen kód generálódott, ha például F12-vel navigálunk a Genres tulajdonságra (a xaml fájlban az ItemsSource adatkötésnél a kurzorral a ViewModel.Genres-en állva).
ObservableProperty attribútum property-re
Az ObservableProperty attribútumot mezők helyett property-kre is alkalmazhatjuk egy új C# nyelvi funkció segítéségével, ehhez viszont preview C# verziót kellene használnunk, így ezt idén még kihagyjuk.
Próbáljuk ki!
Azt tapasztaljuk, hogy a könyvek betöltődnek, de a műfaj kiválasztásakor nem töltődnek be újra a könyvek.
Igen, mert korábban a SelectedGenre változására meghívtuk a LoadBooks metódust (ezt a generált kód nem teszi meg).
Három lehetőségünk van:
- Visszalakítjuk a
SelectedGenrepropertyt nem kódgenerált változatra, hogy a settert mi tudjuk definiálni. - Feliratkozunk a ViewModel
PropertyChangedeseményre a konstruktorban, az eseménykezelőnkben aLoadBooksmetódust meghívjuk, ha aSelectedGenreproperty változik. - Használjuk a kódgenerátor által elkészített partial metódusokat, melyekkel kibővíthetjük a setterek viselkedését.
A 3. lehetőség tűnik a legegyszerűbbnek, ehhez viszont ismerni kell a partial metódusok működését (erről a tárgy keretében nem volt még szó).
A partial metódusok olyan metódusok, amelyeknek a deklarációja és definíciója külön (egy adott partial classhoz tartozó) fájlokban kap helyet, és amiket a fordító automatikusan összekapcsol. Ráadásul a partial metódusok megvalósítása nem kötelező.
Esetünkben a kódgenerátor deklarálja őket, hívja meg ezeket a setterekben, és mi implementálhatjuk őket a BooksPageViewModel osztályban.
Készítsünk egy implementációt az OnSelectedGenreChanged(string value) partial metódusra, amelyben meghívjuk a LoadBooks metódust.
partial void OnSelectedGenreChanged(string value) => LoadBooks();
Több teendőnk nincs, a generált kód ezt meg is hívja.
Próbáljuk ki!
Most már a műfaj kiválasztásakor újra betöltődnek a könyvek is.
3. Feladat - Command¶
A felhasználói felületek kialakításakor két feladatunk van:
- Adatok megjelenítése a felületen. Ezt az MVVM minta alapú megoldásunkban adatkötéssel elegánsan megoldottuk.
- A felhasználói interakciók/parancsok kezelése. Az eredeti megoldásunkban ez eseménykezelőkkel volt megoldva, majd ezeket szintén "elegánsan" mindenestől töröltük (emiatt nem működik a
Cleargomb). A következőkben azt vizsgáljuk meg, hogy az MVVM minta alkalmazásával milyen megoldás kínálkozik erre (spoiler: ViewModel-ben definiált commandok vagy műveletek kötése a View-ba).
A ViewModel tipikusan publikálja a rajta végrehajtható műveleteket a View felé. Ezt megtehetjük publikus függvényeken keresztül vagy egy ICommand interfészt megvalósító objektumokon keresztül.
ICommand
Az ICommand előnye, hogy összefogjuk egy objektumba a műveletet és annak végrehajthatósági állapotát, melynek változásáról még eseményt is publikál.
public interface ICommand
{
event EventHandler? CanExecuteChanged;
bool CanExecute(object? parameter);
void Execute(object? parameter);
}
Ezt a mechanizmust használja a Button vezérlő is, amelynek Command tulajdonságához rendelhetjük a ViewModel-ben definiált parancsokat.
Az ICommand-ban definiált műveletek közül legfontosabb számunkra az Execute, mely a parancs futtatásakor hívódik meg. A CanExecute-tal a felület le tudja kérdezni a parancstól, hogy adott pillanatban végrehajtható-e (pl. a gomb tiltott/engedélyezett lesz ennek megfelelően). A CanExecuteChanged eseménnyel pedig - az esemény nevének megfelelően - azt tudja jelezni a parancs a felület felé, hogy a parancs "CanExecute" állapota megváltozott, a felületnek frissítenie kell a tiltott/engedélyezett állapotát.
ICommand használata¶
Készítsünk egy ICommand típusú propertyt a BooksPageViewModel osztályban, amely "nem beállított" állapotba teszi a kiválasztott műfajt (a Clear gombnál használjuk majd).
Megvalósításként az MVVMToolkit RelayCommand osztályt fogjuk használni, amely a CommunityToolkit.Mvvm.Input névtérben található.
Ebből készítünk egy új példányt a BooksPageViewModel konstruktorban, ahol egy lambda kifejezésben definiáljuk a parancs végrehajtását (a parancs Execute művelete ezt a lambdát hívja).
public BooksPageViewModel()
{
// ...
ClearFilterCommand = new RelayCommand(() => SelectedGenre = null);
}
public ICommand ClearFilterCommand { get; }
Kössük rá a Clear gomb Command tulajdonságára a ClearFilterCommand propertyt.
<Button Content="Clear"
Command="{x:Bind ViewModel.ClearFilterCommand}" />
Vegyük észre, milyen elegáns a megoldás. Pontosan ugyanúgy dolgoztunk, mint a labor során korábban az adatok megjelenítésénél: a View-ban adatkötést alkalmaztunk a ViewModel-ben levő tulajdonságra (csak éppen az most egy parancs objektum volt).
Próbáljuk ki! Működik a Clear gomb, a kiválasztott műfaj törlődik.
ICommand végrehajthatósági állapota¶
Ami viszont még nem működik, az a gomb letiltása, ha nincs kiválasztott műfaj.
Ehhez a RelayCommand osztály konstruktorában adjunk meg egy Func<bool> típusú függvényt második paraméterben, amely megmondja, hogy a parancs végrehajtható-e vagy sem (a parancs CanExecute művelete ezt a lambdát hívja).
ClearFilterCommand = new RelayCommand(
execute: () => SelectedGenre = null,
canExecute: () => SelectedGenre != null);
Note
A fenti kódban az execute: és canExecute: egy általános C# nyelvi eszköz alkalmazására mutat példát: C#-ban egy függvény hívásakor paraméterek megadásakor lehetőség van a paraméter nevének megadására (: előtt). Ezt ritkán alkalmazzuk, mert többet kell gépelni, viszont néha - amikor nagyban segíti a kód olvashatóságát - érdemes megfontolni a használatát.
Viszont a UI csak akkor frissül - és ezáltal a canExecute paraméterben megadott függvény csak akkor hívódik meg -, ha az ICommand.CanExecuteChanged eseménye elsütésre kerül.
Ezt az esemény elsütést az IRelayCommand interfészen keresztül (ami egyben ICommand is) mi is ki tudjuk váltani, ha a SelectedGenre property setterében meghívjuk a NotifyCanExecuteChanged() metódust.
Módosítsuk a property típusát IRelayCommand-ra.
public IRelayCommand ClearFilterCommand { get; }
A NotifyCanExecuteChanged() metódust pedig a már létező OnSelectedGenreChanged partial metódusunkban hívjuk meg.
partial void OnSelectedGenreChanged(string value)
{
LoadBooks();
ClearFilterCommand.NotifyCanExecuteChanged();
}
Próbáljuk ki! Most már a Clear gomb letiltásra kerül, ha nincs kiválasztott műfaj.
Command MVVMToolkit kódgenerátorral¶
A RelayCommand property kézi deklarálása és példányosítása helyett használhatjuk a RelayCommand attribútumot is egy függvényen, amely automatikusan legenerálja a szükséges körítést a kódgenerátor segítségével.
-
Töröljük ki a korábban használt
ClearFilterCommandpropertyt és a konstruktorban való példányosítást. -
Helyette hozzunk létre egy új
ClearFilternevű metódust, amely aRelayCommandattribútum segítéségével a háttérben legenerálja a szükséges command propertyt.BooksPageViewModel.cs[RelayCommand] private void ClearFilter() => SelectedGenre = null; -
A
CanExecutelogikához pedig behivatkozhatunk egy másik metódust vagy propertyt, amely megadja a parancs végrehajthatóságát.BooksPageViewModel.csprivate bool IsClearFilterCommandEnabled => SelectedGenre != null; [RelayCommand(CanExecute = nameof(IsClearFilterCommandEnabled))] private void ClearFilter() => SelectedGenre = null;
Próbáljuk ki! Úgy kell működnie, mint eddig (csak most a ClearFilterCommand tulajdonságot a kódgenerátor hozza létre).
Ráadásul a NotifyCanExecuteChanged is kiváltható deklaratívan attribútumok segítségével.
Esetünkben a NotifyCanExecuteChangedFor-ral kössük össze a SelectedGenre változását a ClearFilterCommand végrehajthatóságával.
Így az OnSelectedGenreChanged partial metódusunkból törölhetjük az esemény elsütését.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ClearFilterCommand))]
private string _selectedGenre;
partial void OnSelectedGenreChanged(string value)
{
LoadBooks();
}
Próbáljuk ki! Úgy kell működnie, mint eddig.
Ha nem támogatott a Command minta közvetlenül
Nem minden vezérlő támogatja a Command mintát közvetlenül. Ilyenkor két lehetőségünk van:
-
Használhatunk
x:Bindadatkötést, amely nem csak a tulajdonságokhoz, hanem eseménykezelőkhöz is használható. Így akár ViewModel-ben lévő eseménykezelőt is köthetünk a vezérlő eseményéhez. Ennek hátránya, hogy sértheti az MVVM mintát, mivel a ViewModel függeni fog a View-tól (pl.: eseménykezelő szignatúra és paraméterek tekintetében). -
Továbbra is Command mintát használunk, de az adott vezérlő kívánt eseményét egy úgynevezett Behavior segítségével köthetjük a ViewModelhez. A Behavior egy olyan osztály, amely lehetővé teszi, hogy a vezérlő viselkedését módosítsuk anélkül, hogy közvetlenül módosítanánk a vezérlő kódját. Esetünkben a Microsoft.Xaml.Behaviors csomagot kell telepítenünk, melyben előre elkészítve található olyan behavior, amivel eseményeket tudunk Command meghívássá konvertálni.
Összefoglalás¶
A labor során a kiinduló projektet MVVM mintára alakítottuk át, így a felelősségi körök el lettek választva a View és a ViewModel között:
- A ViewModel tartalmazza a nézet állapotát és a rajta végrehajtható műveleteket, míg a View csak a felhasználói felület megjelenítéséért felelős.
- A ViewModel és a View között lazább csatolás van adatkötés formájában, így a ViewModel könnyebben tesztelhető és akár újrafelhasználható.
- A ViewModel nem függ a View-tól, így könnyen átírható vagy lecserélhető anélkül, hogy a View-t módosítani kellene.
- A ViewModel sem tartalmazza a teljes üzleti logikát, például az adatelérést, hanem egy külön Service osztályban helyeztük el.

