Nei precedenti articoli abbiamo introdotto le basi del pattern Model View ViewModel, spiegando il ruolo e le responsabilità che sono assegnate a ciascuna classe. Abbiamo visto come collaborano la View e il ViewModel e come le informazioni possono transitare dall'uno all'altro sotto forma di dati bindati all'interfaccia in un senso e sotto forma di comandi nell'altro.
- MVVM: Applicare il pattern in progetti Silverlight
- Applicare il pattern MVVM con Silverlight: i comandi
Rimane tuttavia da capire come deve essere strutturata una applicazione reale, perchè un conto è realizzare un piccolo esempio contenente una sola View e un solo ViewModel, ma un'altra cosa è realizzare una vera applicazione che possa beneficiare del pattern, ma che mantenga il massimo dell'efficacia e consenta di implementare qualunque funzionalità.
Quando serve un ViewModel?
Nel momento in cui si affronta per la prima volta una applicazione MVVM, il primo dubbio che sorge è proprio quanti ViewModel debbano essere realizzati, insomma quanto profondamente il pattern debba permeare l'applicazione. Per rispondere a questa, non semplice, domanda proviamo ad immaginare, a titolo di esercizio, l'interfaccia di un ipotetico client di feed RSS. Qui di seguito riportiamo il wireframe schematico dell'interfaccia:
Il pannello A conterrà i feed registrati, e le relative cartelle che servono a catalogarli, visualizzate per mezzo di un controllo treeview.
La selezione di un nodo nell'albero provocherà la visualizzazione dell'elenco dei messaggi nel pannello B, che è appunto deputato a visualizzare un elenco, sotto forma di DataGrid, riportando il titolo, la data, e altre informazioni.
Infine il pannello C sarà il pannello di lettura che mostra il contenuto del post alla selezione della corrispondente riga nel pannello B.
Ciascun pannello potrà contenere dei comandi, peculiari delle funzioni che esso svolge: i pulsanti per aggiungere e eliminare i feed, un tasto per marcare i post con delle bandierine che ne indicano l'importanza e così via. Quello che conta per ora è decidere quante View e relativi ViewModel implementare.
Chiunque, davanti a questo layout, si renderà conto immediatamente che ciascun pannello è un buon candidato per essere una View. In effetti le responsabilità sono ben delimitate e viene naturale pensare che ogni elemento possa essere completamente indipendente.
Il pannello B ad esempio si occuperà autonomamente di leggere i messaggi presenti nel feed e di visualizzarli su una griglia. Possiamo immaginare il ViewModel di questo pannello come segue:
public class PostListViewModel
{
public ObservableCollection<Post> Posts { get; set; }
public PostListViewModel()
{
this.Posts = new ObservableCollection<Post>();
foreach (Post post in DataSource.GetPostByFeed())
this.Posts.Add(post);
}
}
Il ViewModel in questione dispone di una ObservableCollection<Post>; che verrà collegata alla proprietà ItemsSource
della DataGrid
. Così facendo il ViewModel recupera i post dallo storage e riempie la collection. Essa verrà quindi visualizzata dalla DataGrid
.
Il primo problema però sorge quando dobbiamo gestire l'eventuale comando di cancellazione. Un pulsante ripetuto ad ogni riga della DataGrid
infatti non potrà giocoforza essere bindato al ViewModel perchè l'oggetto che è ripetuto nella grid è una istanza di Post e non il ViewModel.
Questo è un problema abbastanza comune, e la soluzione può sembrare in qualche modo sconvolgente, ma a pensarci bene l'unica possibilità per evitare di scrivere codice nel codebehind della View è di considerare ogni singola riga della grid essa stessa una View e di conseguenza bindare ad essa un ViewModel specifico, e non l'istanza di Post.
Tale ViewModel dovrà contenere il post stesso, ma potrà presentare una serie di altre proprietà, ad esempio il comando di cancellazione.
Per esperienza vi dirò che la maggioranza delle volte in cui ci troviamo di fronte ad una lista di qualche genere dobbiamo preparare un ViewModel che incapsuli l'elemento della lista e ci consenta di esporre altre proprietà senza dover "sporcare" in qualche modo l'entità che viene dal Model.
La regola generale è: se deve contenere della logica, allora deve avere un ViewModel.
Esistono casi in cui esso non è utile, ad esempio nelle ComboBox
e ListBox
senza template, ma un po' alla volta si impara ad accettare questo modo di lavorare ed esso diventa naturale. Ecco come risulterà il ViewModel di B:
public class PostListViewModel
{
public ObservableCollection<PostRowViewModel> Posts { get; set; }
public PostListViewModel()
{
this.Posts = new ObservableCollection<PostRowViewModel>();
foreach (Post post in DataSource.GetPostByFeed())
this.Posts.Add(new PostRowViewModel(post));
}
}
public class PostRowViewModel
{
public Post Post { get; set; }
public PostRowViewModel(Post post)
{
this.Post = post;
}
public DelegateCommand<Post> DeleteCommand { get; set; }
}
Comunicare tra ViewModel: l'event broker
Prima di comprendere se la struttura che abbiamo deciso di implementare - al momento la più promettente in termini di separazione delle responsabilità - sia efficace, dobbiamo verificare se non vi sono casi che non possano essere risolti da essa.
Finché l'interazione dell'utente rimane circoscritta all'interno degli stessi View e ViewModel tutto funziona alla perfezione, ma cosa accade quando una azione compiuta in una View supera la View stessa e deve avere effetto sulle altre View adiacenti?
Quando selezioniamo una riga nel pannello B, l'azione non riguarda solamento quel pannello, ma deve provocare anche la visualizzazione del post nel pannello C cui però abbiamo attribuito un ViewModel diverso. Questo fatto è un limite molto forte alla separazione delle responsabilità delle varie View in quanto nella stragrande maggioranza dei casi ci troveremo nella necessità di condividere azioni tra diverse View. I casi in cui questo non avviene si contano davvero sulle dita di una mano.
Ma allora come dobbiamo fare? La prima soluzione che può venire in mente è quella di usare un solo ViewModel per tutte le View che in qualche modo devono "parlare" tra loro o che condividono le medesime proprietà. Il fatto è che questo ci porterebbe in brevissimo tempo a ricondurre tutti i ViewModel ad uno solo e di fatto ad annullare qualunque tipo di separazione e di conseguenza i benefici che ne derivano.
Se però pensiamo al ruolo di un ViewModel e allo scopo che desideriamo perseguire utilizzandolo, diventa evidente che non vi è nulla che vieti una sorta di comunicazione inter-ViewModel. L'importante è che manteniamo viva la possibilità di testare i ViewModel e che non mettiamo mai in contatto diretto l'interfaccia con la logica.
Ovviamente non è nemmeno pensabile che i ViewModel in qualche modo abbiano riferimenti incrociati tra loro e che chiamino metodi gli uni degli altri. È per questo che molte librerie nate a supporto del pattern (Prism, MVVM toolkit, etc...) sono dotate di uno strumento chiamato Event Broker.
L'event broker è nato con lo scopo di fungere da hub, al quale chiunque possa inviare notifiche di qualunque genere e che altresì chiunque possa ricevere le notifiche relative questi eventi. Con "chiunque" qui si intende "qualunque ViewModel" in quanto è ovviamente un errore che una View invii o riceva qualunque cosa da o verso il broker.
Dal punto di vista implementativo il broker è una classe Singleton (ovvero di cui esiste una unica istanza), che viene istanziata dal primo ViewModel che ne fa uso. Esso contiene al suo interno una coda di "subscriber" - i ViewModel che devono ricevere le notifiche - e semplicemente ogni volta che qualcuno invia una notifica per mezzo del metodo Publish
esso riflette il messaggio su tutti subscriber.
L'implementazione reale del broker - che esula dallo scopo di questo articolo - è nettamente più complicata di quello che le poche parole con cui l'ho descritto possono far intendere. Nella realtà dovremmo ad esempio gestire molte problematiche relative a possibili memory leak per eventi sottoscritti e mai rilasciati, ed è per questo che è consigliabile appoggiarsi ad un broker già pronto per evitare di dover reinventare la ruota e sobbarcarsi questi problemi.
Io personalmente uso la classe EventAggregator del framework Prism. Per usare l'EventAggregator
di Prism occorre fare uso di Unity, il framework per la Dependency Injection presente all'interno di Prism stesso, per questo motivo è necessario inizializzare un bootstrapper all'avvio dell'applicazione. Grazie ad esso avemo a disposizione un ambiente dal quale potremo farci dare l'istanza Singleton dell'EventAggregator in ogni momento.
Approfondire Unity esula dallo scopo dell'articolo, tuttavia ecco come inizializzare l'applicazione:
/// <summary>
/// Classe Bootstrapper per inizializzare Unity
/// </summary>
public class Bootstrapper : UnityBootstrapper
{
protected override DependencyObject CreateShell()
{
return Application.Current.RootVisual = new Shell();
}
protected override IModuleCatalog GetModuleCatalog()
{
return new ModuleCatalog();
}
}
// IN App.xaml.cs -------
private void Application_Startup(object sender, StartupEventArgs e)
{
Bootstrapper bs = new Bootstrapper();
bs.Run();
}
Il controllo chiamato Shell
, altro non è che lo UserControl
che darà vita alla pagina iniziale dell'applicazione, quella che di solito si chiama MainPage
. Nell'ipotetico client di feed RSS essa conterrà il layout di base che suddivide nelle diverse aree, A B e C.
Nel ViewModel del pannello B, alla selezione della riga dovremo inviare un evento nel broker. Prima di tutto dobbiamo creare una classe derivante da CompositePresentationEvent
. Il suo scopo è di fungere da "traghetto" per eventuali dati che dobbiamo trasmettere a chi consumerà l'evento
public class PostSelectedEvent : CompositePresentationEvent<Post>
{}
L'evento così creato vorrà per argomento il post selezionato così che esso sia trasmesso al pannello C che lo deve visualizzare. Quindi alla selezione delle riga, la dove riceviamo il comando relativo, dovremo invocare l'evento; Troviamo l'istanza del broker e pubblichiamo l'evento:
public class PostListViewModel
{
public ObservableCollection<PostRowViewModel> Posts { get; set; }
public DelegateCommand<Post> PostSelectedCommand { get; set; }
public PostListViewModel()
{
this.PostSelectedCommand = new DelegateCommand<Post>(post => this.SelectPost(post));
this.Posts = new ObservableCollection<PostRowViewModel>();
foreach (Post post in DataSource.GetPostByFeed())
this.Posts.Add(new PostRowViewModel(post));
}
private void SelectPost(Post post)
{
IEventAggregator broker =
ServiceLocator.Current.GetInstance<IEventAggregator>();
broker.GetEvent<PostSelectedEvent>().Publish(post);
}
}
Il ViewModel del pannello C invece dovrà ricevere l'evento pertanto esso dovrà mettersi in ascolto per mezzo del metodo
Subscribe. Questa operazione andrà effettuata nel costruttore del ViewModel:
public class PostViewViewModel
{
public IEventAggregator TheBroker { get; set; }
public Post CurrentPost { get; set; }
public PostViewViewModel()
{
this.TheBroker =
ServiceLocator.Current.GetInstance<IEventAggregator>();
this.TheBroker.GetEvent<PostSelectedEvent>().Subscribe(this.ChangePost);
}
public void ChangePost(Post post)
{
this.CurrentPost = post;
// TODO: fare il necessario per visualizzare il post...
}
}
I vantaggi dell'Event Broker
A prima vista, quella dell'event broker, può sembrare una inutile complicazione ma a ben guardare ci si rende conto che esso non porta complessità nell'architettura della soluzione ma invece semplifica notevolmente il lavoro e porta dei vantaggi "collaterali" importanti.
La capacità di separare nettamente i ViewModel tra loro è una caratteristica importante che apre la strada alla creazione di applicazioni "pluggabili". Una caratteristica degli eventi del broker infatti è quella di non essere diretti a qualcuno in particolare, ma semplicemente di fungere da messaggi a disposizione di chiunque ritenga utile gestirli. Se il pannello C dell'esempio esiste oppure no non è importante. Oggi ce n'è uno, domani potremo sostituirlo o aggiungere un altro pannello che operi gestendo il medesimo evento. Tutto il resto non cambia.
E allora, quanti ViewModel?
Nel corso dell'articolo ho illustrato una possibile soluzione per strutturare una applicazione reale suggerendo un criterio per determinare quando c'è bisogno di un ViewModel e quando no.
Parlando in termini di Silverlight vi dirò che ci saranno parecchi casi in cui l'iniezione di codice nel codebehind è necessaria, a causa di qualche limite intrinseco, tuttavia spesso questi casi si risolvono con la creazione di un controllo specifico, riutilizzabile da diverse parti dell'applicazione e che isoli in qualche modo il codice.
Per questo la scelta dei ViewModel dovrà essere oculata, cercando sempre di creare moduli in qualche modo isolati come funzionalità e come interfaccia, ma non esiste una regola precisa: si tratta spesso di buon senso.