Daniel Kolman

Co všechno musíte vyřešit při psaní WPF aplikace

| 0 comments |

WPF je kůl, o tom žádná. Proti Windows Forms je to opravdu obrovský skok kupředu. Ale to ještě neznamená, že při tvorbě aplikace založené na WPF nemůžete dostat osypky a že vás nečekají bezesné noci. V tomto příspěvku se pokusím shrnout hlavní problémy, ke kterým je třeba se postavit čelem, bez nároku na nalezení konečného řešení:

  • INotifyPropertyChanged

  • Validace

  • Lokalizace

  • Undo/Redo


INotifyPropertyChanged


Aby View vědělo kdy se má updatovat, je nutné u všech ViewModel objektů implementovat rozhraní INotifyPropertyChanged. Naštěstí je velmi jednoduché, ale budete muset psát spoustu opakovaného kódu:

string _name;
public string Name
{
get { return _name; }
set
{
_name = value;
OnPropertyChanged("Name);
}
}

Vyhnout se mu můžete buď generováním kostry ViewModelu pomocí DSL jazyka, nebo pomocí AOP (kolegové používají PostSharp), kde stejná property může vypadat takto:

[Notify]
public string Name { get; set; }

Lokalizace


Existuje několik přístupů k lokalizaci WPF aplikace, které se liší způsobem uložení resource stringů, možností výběru lokalizovaných vlastností a možností přepínat jazyk "za běhu", bez nutnosti restartu aplikace. Řešení které doporučuje Microsoft (přes binární XAML, neboli BAML) potřebuje externí nástroj LocBaml.exe, editovat jazykové verze musíte v CSV a při přepnutí jazyka za běhu se nelokalizují již otevřená okna. Které vlastnosti budou lokalizované se dá určit až při lokalizaci, takže překladatel se může rozhodnout, že např. sloupec v tabulce bude mít jinou velikost nebo label se zarovná na jinou stranu. To může být výhoda i problém, záleží na vašich potřebách. Další dva způsoby naleznete v článku Localizing WPF Applications using Locbaml, oba ale neumožňují přepínat jazyk za běhu.

Zajímavé řešení je popsáno v WPF Localization - On-the-fly Language Selection. To už umožňuje skutečné přepnutí jazyka bez nutnosti restartu aplikace. Je založené na vlastním Converteru, který provádí lokalizaci, díky čemuž lze používat poziční parametry v resource stringu ("{0}") a jejich hodnoty dokonce bindovat. Trik umožňující okamžité přepnutí jazyka spočívá v nahrazení lokalizovaných hodnot MultiBinding objektem, kde první vazba je na LanguageContext.Instance.Dictionary, která při změně jazyka posílá událost PropertyChanged. To způsobí znovunačtení všech hodnot. Tomuto řešení bych vytknul pouze způsob uložení resourců (vlastní XML), ale velmi lehce si můžete napsat vlastní LanguageDictionary a číst hodnoty např. z resx souborů.

Validace


Při prvním pohledu na WPF se zdá, že validace je vyřešená věc, ale pokud chcete použít vzor MVVM, není to tak jednoduché. Ve ViewModelu tedy implementujeme IDataErrorInfo a projdeme všechny deklarace Binding v XAMLu a přidáme ValidatesOnDataErrors=true.

Jenže kromě business validací je nutné také validovat vstup od uživatele. To jsou opravdu dvě různé věci: Mějme třídu Person s property Age typu int. "Business" validace je, že Age nesmí být záporné a musí být menší než 150. Když je hodnota mimo tento rozsah, vrátíme přes IDataErrorInfo chybovou hlášku. Jenže co když uživatel zadá "qweqweqwr"? Výchozí chování Bindingu je výjimku prostě spolknout. Můžeme nastavit ValidatesOnExceptions na true, ale jednak nemůžeme změnit validační hlášku, a také nechceme žádné výjimky. Výjimky jsou pro výjimečné případy, např. když je vyhlášen výjimečný stav. Spoléhání na výjimky může aplikaci znatelně přibrzdit.

WPF Binding zpracovává vstup přes řetěz validátor - konvertér - property setter. Možným řešením by bylo vkládat vlastní validátory do deklarace Bindingů. To ale odporuje našemu cíli, dostat všechnu logiku do ViewModelu. Navíc až budeme objekt ukládat (uživatel zmáčkne Ctrl+S), nedozvěděli bychom se, že na formuláři jsou nevalidní data. Informaci o validitě si přece chceme přečíst z ViewModelu. Nejen kvůli tomu odpadá i další možnost: generovat validátory podle metadat ViewModelu. Ani to nezkoušejte, nejde to. Jak zjistil Miro, Binding je jen MarkupExtension, který vytvoří BindingExpression, který se teprve stará o update hodnot, jenže většina jeho důležitých vlastností je internal.

Jediným mně známým řešením je proto deklarovat ve ViewModelu property Age jako string a provádět konverzi až tam. Když se konverze povede, zapíšete property do Modelu, kde už je typu int a kde se provede business validace. Je to víc práce ale zase má ViewModel informaci o tom, jestli je formulář validní. Implementace je popsaná v článku Using a ViewModel to Provide Meaningful Validation Error Messages.

Aby to nebylo tak lehké, představte si že chcete z ViewModelu vrátit hlášku "Věk musí být mezi {0} a {1}". Obstarožní interface IDataErrorInfo umožňuje vracet pouze string, takže ve ViewModelu provedeme String.Format a vrátíme už hotový text. Jenže to komplikuje lokalizaci. Kvůli přepnutí jazyka za běhu bychom potřebovali vracet klíč do resourců a pole parametrů, aby lokalizátor mohl aktualizovat hlášku. Ideální by tedy bylo napsat si vlastní interface, lepší a hezčí než IDataErrorInfo (to by věru nebylo těžké), protože do ValidationError objektu můžeme ukládat daleko více informací než jen string. Pomocí Reflectoru kolega Miro zjistil, že Binding při nastavení ValidatesOnDataErrors=true vkládá do pole validátorů objekt DataErrorValidationRule. Kdybychom tedy napsali vlastní ValidationRule, mohli bychom přečíst errory pomocí vlastního interface a vytvořit takový ValidationError objekt, který by byl lokalizátor schopen za běhu překládat. Jenže opět narážíme na to, že property BindingExpression.ItemSource, která obsahuje zdroj data bindingu (ViewModel), je internal! Zbývá nám tedy buď oželit okamžitou lokalizaci validačních hlášek, přinutit všechny BindingExpression objekty provést refresh, nebo si do stringu serializovat template s parametry a v lokalizátoru je deserializovat, vše mi přijde ošklivé.

Undo/Redo


WPF má sice vestavěnou podporu pro undo/redo v TextBoxu, ale to nám samozřejmě nestačí, potřebujeme undo/redo v celé aplikaci (nebo lépe, v celém editovaném projektu, který tvoří undo scope - projektů můžeme mít otevřených víc naráz). Zde se nabízí klasické řešení pomocí UndoItem objektu a undo a redo stacku, popsané třeba zde. Je ovšem nutné vyřešit otázku jak nalézt undo scope (projekt), když naše ViewModel objekty tvoří košatě rozvětvený strom.

Také musíme implementovat undo transakce. Kvůli tomu, aby bylo uživatelské rozhraní živější, jsme totiž ve všech Binding deklaracích nastavili UpdateSourceTrigger=PropertyChanged. Díky tomu je uživatelův vstup okamžitě validován, ale protože se přes ViewModel posílá každý jednotlivý znak který uživatel zadá do TextBoxu, bylo by undo dost nepoužitelné. Potřebujeme tedy zahájit undo transakci při vstupu do TextBoxu a ukončit ji při jeho opuštění (nebo stisku enter). To nás nejen odsuzuje k handlování událostí nebo vlastnímu TextBoxu, ale má to i jeden háček: Při kliknutí myší do menu nebo na toolbar TextBox neztratí focus. Tím pádem se při Save/Load neukončí undo transakce a nezahájí se nová, na což je potřeba myslet a modelovat root undo scope (projekt) tak, aby ho bylo možné při Load celý zahodit a vytvořit nový (i pak si ale TextBox drží focus a nezahájí novou transakci).

Závěr


Jak je vidět, komplexitu dobré aplikace není radno podceňovat ani s tak vyspělým frameworkem, jako je WPF. Pokud narazíte na další problémy nebo na jiná řešení, dejte vědět.