Model-View-ViewModel je návrhový vzor pro WPF aplikace. Nabízí řešení, jak oddělit logiku aplikace od uživatelského rozhraní. Kódu je pak méně, vše je přehlednější a případné změny nejsou implementační noční můrou. MVVM odděluje data, stav aplikace a uživatelské rozhraní. Samotné WPF bylo vytvořeno tak, aby se v něm MVVM používal pohodlně. Proto se v něm využívá binding a command – náhrada za uživatelské rozhraní řízené událostmi.
Ve většině desktopových aplikací probíhá na pozadí nějaký proces a okna slouží ke konzumaci informací z tohoto procesu. Je žádoucí, aby byl proces na okně nezávislý. To umožňuje zavření okna bez jeho ovlivnění a jednoduché sdílení informací mezi více okny. Jedním řešením je právě Model-View-ViewModel, který představuje vyzkoušené a ověřené řešení. Podle tohoto vzoru je naprogramován například Expression Blend.
Princip
Hlavní myšlenka MVVM je prostá – vytvořit třídu, která si drží stav aplikace. Nazývá se ViewModel. Té se dotazuje uživatelské rozhraní, které podle ní vykresluje ovládací prvky. A naopak zadá-li uživatel do uživatelského rozhraní nějaké údaje, zpropagují se automaticky do ViewModelu. WPF je pro toto použití dobře uzpůsobeno, protože díky bindingu lze deklarativně napojit uživatelské rozraní na ViewModel.
ViewModel je nejdůležitější třída. Poskytuje všechna data pro uživatelské rozhraní, které se nazývá View. Důležité je, že poskytuje svá data v takových datových strukturách, které vyvolávají události při jejich změně. To umožňuje uživatelskému rozhraní nová data automaticky zobrazit hned jak se ve ViewModelu změní. ViewModel má dva základní kameny. Prvním je kolekce ObservableCollection<T>
, která hlásí, když je přidán nebo odebrán její prvek. Druhým je rozhraní INotifyPropertyChanged
. Popisuje událost, která nastane, když se změní některá z vlastností ViewModelu.
Vrstvy
Model popisuje data, se kterými aplikace pracuje. Pokud používáte Code-First Entity Framework, třídy, které se mapují na tabulky databáze, jsou Modely. Pokud referencujete webovou službu, třídy, které vám Visual Studio vygeneruje, jsou taktéž Modely. Model nesmí o stavu ovládacích prvků nic vědět.
View reprezentuje uživatelské rozhraní v jazyce XAML. Může se jednat o okno aplikace, stránku, nebo ovládací prvek. Označuje se také jako UI, formulář, nebo prezentační vrstva ale jde pořád o to samé.
ViewModel spojuje Model a View a drží si stav aplikace. Ovládací prvky jsou pomocí bindingu propojeny s ViewModelem a čerpají z něj svůj obsah. Provádí se v něm filtrování dat v závislosti na stavu aplikace. Aby se jeho vlastnosti propagovaly do View, musí implementovat rozhraní INotifyPropertyChanged
. Jeho vlastnosti typu kolekce musí obdobně implementovat rozhraní INotifyCollectionChanged
.
Ukázka
Na primitivní ukázkové aplikaci číslo 4, která využívá SQL Server Express a Entity Framework 4.2, si ukážeme, jak se MVVM implementuje. Aplikace má za úkol zobrazit fotografie civilních dopravních letadel. Každé letadlo je uloženo i se svými fotografiemi v databázi. Model jsou třídy, které využívá Code-First Entity Framework jako databázový model. ViewModel obsahuje seznam všech letadel. Uživatelské rozhraní zobrazuje seznam letadel a fotografie letadla, které je právě vybrané. Základní dekompozice vypadá takto:
Modely jsou datové třídy Aircraft
a Image
, které do kterých mapuje Entity Framework databázi reprezentovanou třídou Database. View je XAML kód, který popisuje vzhled uživatelského rozhraní. ViewModel je nejdůležitější část, která seskupuje datové třídy, stará se o business logiku a promítá data do uživatelského rozhraní. ViewModel se na uživatelské rozhraní naváže tak, že se instance ViewModelu ve View přiřadí do vlastnosti DataContext.
Ukázkový ViewModel nabízí seznam letadel ve vlastnosti Aircrafts
typu ObservableCollection<Aircraft>
. Neimplementuje rozhraní INotifyPropertyChanged
, protože nemá žádné vlastnosti, které by toto rozhraní využily.
public class WorkspaceViewModel {
public WorkspaceViewModel() {
using (var db = DemoDb.Connection) {
Aircrafts = new ObservableCollection<Aircraft>(db.Aircrafts.Include(a => a.Images));
}
}
public ObservableCollection<Aircraft> Aircrafts { get; private set; }
}
ViewModel musí mít veřejný konstruktor, pokud chceme jeho instanci vytvářet v XAMLu. Správně by to tak mělo být, ovšem pokud má být ViewModel singleton, nezbývá, než nastavit DataContext
z code behindu. Aby mohla být instance v XAML vytvořena, je potřeba namapovat jmenný prostor ViewModelu na jmenný prostor XML:
xmlns:wm="clr-namespace:Dajbych.Demo4.ViewModels"
Takto se nastavuje DataContext v XAMLu:
<Window.DataContext>
<wm:WorkspaceViewModel/>
</Window.DataContext>
Binding
Když má View svůj datový kontext, může se napojit na jeho data pomocí bindingu:
<Label Content="{Binding Name}" />
<ListBox ItemsSource="{Binding Aircrafts}" Name="aircraftList" SelectedValuePath="Images" />
<ListBox ItemsSource="{Binding Path=SelectedValue, ElementName=aircraftList}" />
První label žádá ViewModel o vlastnost jménem Name
, která z logiky věci vrací string
. Na této úrovni se však typovost nekontroluje. Případná chyba se projeví až při běhu aplikace.
První listbox žádá ViewModel o vlastnost jménem Aircrafts
a očekává kolekci. Pokud kolekce implementuje rozhraní INotifyCollectionChanged
, což ObservableCollection<T>
implementuje, změny této kolekce se projeví i v listboxu. A protože ObservableCollection<T>
implementuje rozhraní Collection<T>
, změny v listboxu se projeví i ve ViewModelu.
Druhý ListBox
má také binding, ale tentokrát není napojen na ViewModel ale na View. Parametr ElementName
určuje jméno ovládacího prvku, který se používá jako zdroj dat. Parametr Path
určuje, že zdroj dat nebude listbox, ale jeho vlastnost SelectedValue
. Vlastnost SelectedItem
drží právě vybraný prvek typu Aircraft
z kolekce typu ObservableCollection<Aircraft>
. Vlastnost SelectedValue
drží nějakou jeho vlastnost, kterou určuje vlastnost SelectedValuePath
. Protože je nastavena na hodnotu Images
, je vlastnost SelectedValue
typu ICollection<Image>
.
Data Template
DataTemplate je šablona pro jeden prvek kolekce. Může používat binding, přičemž datový kontext šablony je právě prvek kolekce, která byla přiřazena parametrem ItemsSource.
<ResourceDictionary>
<DataTemplate x:Key="image">
<Image Source="{Binding Uri}" Width="655" />
</DataTemplate>
</ResourceDictionary>
<ListBox ItemsSource="{Binding Path=SelectedValue, ElementName=aircraftList}" ItemTemplate="{StaticResource image}" />
Listbox má svůj datový kontext typu ICollection<Image>*
. To znamená, že šablona má svůj datový kontext typu Image
. Typ Image
má vlastnost Uri
typu Uri
, na kterou odkazuje ovládací prvek Image
šablony image
. Šablonu nemusíme být definovat zvlášť, ale můžeme jí vložit přímo do prvku:
<ListBox ItemsSource="{Binding Path=SelectedValue, ElementName=aircraftList}">
<ListBox.ItemTemplate>
<DataTemplate>
<Image Source="{Binding Uri}" Width="655" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Data Template Selector
V další ukázkové aplikaci číslo 5 si předvedeme DataTemplateSelector
. Využijeme ho ve chvíli, kdy máme v kolekci objekty sice poděděné ze stejné abstraktní třídy, ale jsou to instance rozdílných tříd. V našem příkladu je abstraktní třída AircraftViewModel
, ze které dědí třídy AirbusViewModel
a BoeingViewModel
. Listbox
má nastavený ItemTemplateSelector
:
<ListBox ItemsSource="{Binding Aircrafts}" ItemTemplateSelector="{StaticResource templateSelector}" />
ItemTemplateSelector
určí na základě typu instance třídy konkrétní DataTemplate
:
public class PropertyDataTemplateSelector : DataTemplateSelector {
public DataTemplate Boeing { get; set; }
public DataTemplate Airbus { get; set; }
public override DataTemplate SelectTemplate(object item, DependencyObject container) {
if (item is AirbusViewModel) return Airbus;
if (item is BoeingViewModel) return Boeing;
throw new Exception();
}
}
DataTemplateSelector
se pak nasteví v ResourceDictionary
:
xmlns:s="clr-namespace:Dajbych.Demo5.Selectors"
<ResourceDictionary>
<s:PropertyDataTemplateSelector x:Key="templateSelector" Airbus="{StaticResource airbus}" Boeing="{StaticResource boeing}" />
</ResourceDictionary>
Command
V poslední ukázce číslo 6 si představíme Command
. Jedná se o metodu ViewModelu, kterou volá View, pokud uživatel stiskne tlačítko (nebo libovolný jiný ovládací prvek implementující rozhraní ICommandSource
). ViewModel tuto metodu vystavuje jako ICommand
:
public class WorkspaceViewModel {
public WorkspaceViewModel() {
Switch = new Command<Machine>(ChangeMachine);
}
public ICommand Switch { get; private set; }
private void ChangeMachine(Machine machine) { }
}
View může vyvolat metodu nastavením vlastnosti Command
u tlačítka. Zároveň může předat i parametr metody, typicky nějaký vybraný prvek:
<Button Command="{Binding Switch}" CommandParameter="{Binding ElementName=listbox, Path=SelectedItem}" />
Třída Command<T>
je konkrétní implementace rozhraní ICommand
. Může obsahovat logiku pro povolení či zakázání vyvolání příkazu v metodě CanExecute
. To se projevuje případným zakázáním tlačítka.
public class Command<T> : ICommand where T : class {
private Action<T> execute;
private Func<T, bool> enabled;
public Command(Action<T> execute, Func<T, bool> enabled = null) {
this.execute = execute;
this.enabled = enabled;
}
public event EventHandler CanExecuteChanged {
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object parameter) {
if (enabled == null) {
return true;
} else {
return enabled.Invoke(parameter as T);
}
}
public void Execute(object parameter) {
execute.Invoke(parameter as T);
}
}
Converter
Přetypování v bindingu často nachází své uplatnění. V případě, kdy chceme ovládací prvek zobrazit nebo skrýt, můžeme onu vlastnost reprezentovat typem bool
a převést jí na výčtový typ Visbility
právě v konvertoru. Tím ViewModel nemusí vystavovat vlastnosti, jejichž typ je příliš svázaný s prezentační vrstvou. V ukázce číslo 6 se však využívá konvertování z typu Machine
na Uri
:
[ValueConversion(typeof(Machine), typeof(Uri))]
public class MachineToUriConverter : IValueConverter {
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) {
if (value != null) {
var machine = value as Machine;
if (machine != null) {
return new Uri(machine.Url);
} else {
throw new ArgumentException("Type Machine expected.");
}
} else {
return null;
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
throw new Exception("One way only.");
}
}
Konverzní třída převání hodnoty oběma směry, protože binding funguje obousměrně. V tomto konkrétním případě však využijeme jen jeden směr. Konverzní třída musí být registrovaná v ResourceDictionary
:
<Application.Resources>
<ResourceDictionary>
<c:MachineToUriConverter x:Key="MachineToUriConverter" />
</ResourceDictionary>
</Application.Resources>
Poté můžeme použít konverzi v bindingu:
<Image Source="{Binding Selected, Converter={StaticResource MachineToUriConverter}}" />
Závěrem
Návrhový vzor Model-View-ViewModel přináší konkrétní řešení, jak přistupovat k architektuře aplikace. Její přínos spočívá ve snadnější údržbě, rozšiřitelnosti a testovatelnosti.