domenica 5 gennaio 2014

Accenni di MVVM - Implementazione base

Nel post precedente, Accenni di MVVM - La teoria, abbiamo introdotto il pattern di sviluppo MVVM, derivato da MVC e pensato per disgiungere completamente la componente View e la componente Model di un software, utilizzando però una "zona franca" dove sia la parte grafica che la parte logica del programma possono accedere bi-direzionalmente.

Procediamo adesso con alcuni esempi di implementazione del pattern MVVM.



Linguaggio designato: C#.

Molti si chiederanno perché il C#, avendo a disposizione tutta una rosa di linguaggi .NET. Mettendo da parte la mia personale predisposizione all'uso dei linguaggi C-Like, venendo dall'assembler e dal C mi riescono più semplici da utilizzare, sono del parare che il .NET Framework malgrado sia pensato e pubblicizzato come piattaforma generica per tutti i linguaggi Microsoft strizzi un occhio maggiormente al C#, linguaggio nato e pensato per il .NET. Alcune sintassi particolari, come ad esempio le espressioni lambda o le query LINQ, risultano visivamente più leggibili e più semplici da gestire rispetto al Visual Basic .NET e C++/CLI.

La base per ogni ViewModel: bindable e commands.

Innanzitutto, ogni classe appartenente allo strato di ViewModel avrà delle caratteristiche base in comune: dovrà permettere il binding su alcune proprietà e dovrà permettere l'implementazione di comandi basati su ICommand. All'interno di un software ben strutturato, saranno presenti anche altre classi che avranno come necessità di esporre delle proprietà adatte all'utilizzo con i bindings. Discutendo quindi viene in mente di implementare tre classi base: BindableBase, DelegateCommand e ViewModelBase.

Schema UML delle classi BindableBase, DelegateCommand e ViewModelBase


Si noti come le classi in comune con altri elementi non relativi al ViewModel, ovvero BindableBase e DelegateCommand, vengono definite all'interno del namespace Common del progetto, mentre la ViewModel all'interno di un namespace dedicato.

Implementazione in C#

Per implementare la classe base per i nostri ViewModel, verranno utilizzate delle caratteristiche dei CompilerServices di .NET Framework 4, nella fattispecie CallerMemberName, introdotte per permettere il passaggio come valore del nome di un metodo chiamante. Dove necessario, anche in seguito, verranno utilizzate le nuove parole chiave async e await per lo sviluppo di funzionalità asincrone introdotte con i nuovi compilatori a partire da Visual Studio 2012.

BindableBase.cs


public abstract class BindableBase
        : INotifyPropertyChanged
{
    #region Events

    public event PropertyChangedEventHandler PropertyChanged;

    #endregion

    #region Methods

    protected virtual bool SetProperty&ltT&gt(ref T storage, T value, [CallerMemberName] String propertyName = null)
    {
        if (object.Equals(storage, value))
            return false;
        storage = value;
        this.OnPropertyChanged(propertyName);
        return true;
    }

    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        if (this.PropertyChanged != null)
            this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

    #endregion
}

Il codice risulta abbastanza semplice. La classe BindableBase implementa System.ComponentModel.INotifyPropertyChanged e contiene due metodi protetti utilizzabili alla classi derivate: SetPropery e OnPropertyChanged.
Il primo è un metodo di set & notify, che richiede come argomento la reference del campo da settare, il valore, e opzionalmente il nome della proprietà che sta subendo la variazione di contenuto. Nel caso non venga passato alcun identificativo, tramite CallerMemberName il compilatore è in grado di recuperare autonomamente il nome del metodo chiamante, in questo caso il nome del set della proprietà a cui afferisce la variazione.
Il secondo metodo è invece un metodo di notifica. Può essere chiamato per lanciare l'evento NotifyPropertyChanged, senza però che il valore del campo venga tocca. Può essere utile nel caso la logica dell'oggetto Bindable richieda un refresh di tutti i bindings. Viene lanciato internamente anche da SetProperty.

DelegateCommand.cs


public class DelegateCommand
    : ICommand
{
    #region Fields

    private readonly Action&ltobject&gt _execute;
    private readonly Func&ltobject, bool&gt _canExecute;

    #endregion

    #region Events

    public event EventHandler CanExecuteChanged;

    #endregion

    #region .ctor

    public DelegateCommand(Action&ltobject&gt execute)
        : this(execute, null)
    {
    }

    public DelegateCommand(Action&ltobject&gt execute, Func&ltobject, bool&gt canExecute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");
        _execute = execute;
        _canExecute = canExecute;
    }

    #endregion

    #region Methods
        
    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute(parameter);
    }

    public void Execute(object parameter)
    {
        _execute(parameter);
    }

    public void RaiseCanExecuteChanged()
    {
        var handler = CanExecuteChanged;
        if (handler != null)
        {
            handler(this, EventArgs.Empty);
        }
    }

    #endregion
}

DelegateCommand implementa due costruttori, per due comportamenti diversi del comando. Nel primo, come argomento viene richiesto un delegate di tipo Action<Object>, ovvero sostanzialmente:

void NomeMetodo(object parameter);

che punterà al metodo che verrà invocato dalla classe ad ogni chiamata della funzione Execute(parameter) del comando.
Nel secondo costruttore invece, vengono richiesti come delegate sia Action<Object>, che Func<Object, bool>, ovvero una funzione con prototipo del tipo:

bool CanNomeMetodo(object parameter);

e che verrà richiamata dalla View ogniqualvolta l'evento CanExecuteChanged verrà diffuso ai vari handler. Questa funzione dovrà restituire true nel caso il software sia in grado di eseguire il comando mediante i parametri specificati, mentre false se non è possibile eseguire il codice di execute.
La catena di chiamate CanExecute/Execute è del tutto trasparente allo sviluppatore nel caso di WPF. I controlli base del namespace System.Windows.Controls che implementano il pattern dedicato ai comandi e che espongono le proprietà Command e CommandParameter prendono carico internamente di tutti i passaggi necessari ad utilizzare i comandi associati al controllo. Nel caso CanExecute risulti false, solitamente il comportamento standard del controllo è di settare Enabled su false.

ViewModelBase.cs

Definite le due classi astratte di base, l'implementazione della classe astratta ViewModelBase risulta davvero banale. Sebbene le proprietà e i campi che contiene variano di fatto da progetto a progetto, in quanto ad esempio un software che fa largo uso delle entità di Entity Framework potrebbe inserire nel ViewModelBase un riferimento statico sincronizzato al database in uso per poterlo mantenere costante tra tutte le istanze delle View del progetto, al definizione base rimane sempre la stessa e risulta piuttosto semplice:

public abstract class ViewModelBase
    : BindableBase
{
    #region Fields

    private FrameworkElement view;

    #endregion

    #region Properties

    public FrameworkElement View
    {
        get
        {
            return this.view;
        }
        set
        {
            this.SetProperty(ref this.view, value);
        }
    }

    #endregion
}

Io aggiungo sempre al ViewModelBase una proprietà pubblica View di tipo System.Window.FrameworkElement, perchè può fare comodo avere accesso all'elemento visivo alla quale afferisce l'istanza del nostro ViewModel.

Caso pratico: la stampa del testo

Implementiamo adesso una semplice finestra che esegue la visualizzazione di una stringa mediante una MessageBox. Avremo tre pulsanti: uno per settare la stringa di test di default, uno per stamparla a video, uno per chiudere la finestra. Sarà presente anche una TextBox per inserire un messaggio personalizzato.

MainWindowViewModel.cs


public class MainWindowViewModel
    : ViewModelBase
{
    #region Fields

    private string testText = null;
    private string userText = null;

    #endregion

    #region Properties

    public String UserText
    {
        get
        {
            return this.userText;
        }
        set
        {
            if (this.SetProperty(ref this.userText, value))
                this.PrintCommand.RaiseCanExecuteChanged();
        }
    }
    public string TestText
    {
        get
        {
            return this.testText;
        }
        set
        {
            if (this.SetProperty(ref this.testText, value))
                this.PrintCommand.RaiseCanExecuteChanged();
        }                
    }

    #endregion

    #region Commands

    private DelegateCommand setTextCommand = null;
    private DelegateCommand printCommand = null;
    private DelegateCommand cancelCommand = null;
        
    public DelegateCommand SetTextCommand
    {
        get
        {
            if (this.setTextCommand == null)
                this.setTextCommand = new DelegateCommand(this.SetText);
            return this.setTextCommand;
        }
    }

    public DelegateCommand PrintCommand
    {
        get
        {
            if (this.printCommand == null)
                this.printCommand = new DelegateCommand(this.Print, this.CanPrint);
            return this.printCommand;
        }
    }

    public DelegateCommand CancelCommand
    {
        get
        {
            if (this.cancelCommand == null)
                this.cancelCommand = new DelegateCommand(this.Cancel);
            return this.cancelCommand;
        }
    }

    #endregion

    #region Methods

    private bool CanPrint(object parameter)
    {
        if (!String.IsNullOrEmpty(this.TestText) || !String.IsNullOrEmpty(this.UserText))
            return true;
        else
            return false;
    }

    private void Print(object parameter)
    {
        StringBuilder sb = null;
        bool append = false;

        sb = new StringBuilder();

        if (!String.IsNullOrWhiteSpace(this.TestText))
        {
            sb.Append(this.TestText);
            append = true;
        }

        if (!String.IsNullOrWhiteSpace(this.UserText))
        {
            if (append)
                sb.Append(" e ");
            sb.Append(this.UserText);
        }

        MessageBox.Show(sb.ToString(), 
                        "Stampa messaggio a video", 
                        MessageBoxButton.OK, 
                        MessageBoxImage.Hand);
    }

    private void SetText(object parameter)
    {
        this.TestText = "Testo di prova";
        MessageBox.Show("Testo settato", "Set del testo di prova", MessageBoxButton.OK, MessageBoxImage.Information);
    }

    private void Cancel(object parameter)
    {
        MainWindow window = null;
        if (this.View != null)
        {
            window = this.View as MainWindow;
            if (window != null)
                window.Close();
        }
    }

    #endregion
}

Le proprietà TestText e UserText verranno utilizzate per un binding sulla View. Con SetText, collegata a un comando, andremo a settare la proprietà TestText in maniera automatica. Una volta settata la stringa di test, verranno alzati due eventi, il primo di cambio della proprietà per rinnovare il binding. Il secondo, l'evento di verifica della possibilità di esecuzione del comando Print. Abbiamo scritto come logica dell'esecuzione del comando di stampa le condizioni che almeno una delle due stringa, quella utente o quella di test, siano settate. Nel caso la condizione fosse verificata, il comando di stampa verrebbe abilitato sull'interfaccia grafica. L'unico comando con una funzione di CanExecute associata è quello di stampa. La chiusura e il set sono sempre disponibili all'utente.

I comandi vengono memorizzati come campi privati della classe perchè è importante non istanziarli tutte le volte, in quanto porterebbe a uno utilizzo maggiore di memoria dato che il metodo che il controllo di input sulla GUI va ad eseguire durante l'interazione con l'utente è memorizzato nella classe DelegateCommand. Creare un nuovo oggetto DelegateCommand per ogni chiamata get del comando darebbe solo del lavoro extra da fare al Garbage Collector e non porterebbe nessun vantaggio nell'immediato.

MainWindow.xaml


<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:ViewModel="clr-namespace:MVVMTutorial1.ViewModel" 
        x:Class="MVVMTutorial1.MainWindow"
        Title="MainWindow" 
        Height="350" 
        Width="525">
    <Window.DataContext>
        <ViewModel:MainWindowViewModel/>
    </Window.DataContext>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition />
        </Grid.RowDefinitions>
        <StackPanel Grid.Row="0" Margin="25">
            <TextBox Margin="5" Text="{Binding UserText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
            <TextBlock Margin="5" HorizontalAlignment="Center" Text="{Binding TestText, TargetNullValue='Nessun testo settato'}" />
        </StackPanel>
        <StackPanel Grid.Row="1" Orientation="Horizontal" VerticalAlignment="Bottom" Margin="5" HorizontalAlignment="Right">
            <Button Content="Setta testo" Margin="5" Command="{Binding SetTextCommand}"/>
            <Button Content="Stampa testo" Margin="5" Command="{Binding PrintCommand}"/>
            <Button Content="Chiudi" Margin="5" Command="{Binding CancelCommand}"/>
        </StackPanel>
    </Grid>
</Window>

Nella View viene istanziato come DataContext, ovvero come fonte base per i bidings, un oggetto MainWindowViewModel. In questo modo non è necessario fare uso di riferimenti Source nei bindings a venire e istanziare un oggetto DictionaryEntry all'interno del dizionario Resources dell'elemento Window che punta a una StaticResource.
Il codice XAML risulta quindi più leggibile e l'uso della memoria lievemente ottimizzato.
L'implementazione della MainWindow espone esempi vari di bindings. Nel caso della TextBox per l'input dell'utente, il binding viene definito come un TwoWay, ovvero non solo il ViewModel comunica le variazioni della proprietà UserText, ma la View può indicare al ViewModel di effettuare una variazione con un dato valore. Viene anche specificata una tempistica per il trigger di modifica, ovvero ad ogni lancio dell'evento PropertyChanged del controllo.

La TextBox di stampa del testo di prova invece definisce un binding standard OneWay, dove è il ViewModel che indica alla View di aggiornare il contenuto del test della label.

L'uso di MVVM in questo esempio è iconico: pressoché tutta la logica di gestione della View è presente nel ViewModel, mentre nello XAML è definita solo la struttura visuale e i bindings ai dati e ai comandi. Tutta la gestione del refresh dei dati e dell'input utente è demandato al meccanismo automatico dei binding e all'esecuzione dei delegate dei comandi.

MainWindow.xaml.cs

MVVM non costringe a non scrivere codice all'interno del code-behind degli oggetti View. Semplicemente andrebbe aggiunto solo codice relativo alla componente visiva dell'applicazione. Io mi sono permesso di aggiungere del codice per indicare al ViewModel l'istanza della finestra che stava gestendo. Rientra perfettamente nella definizione di MVVM.

public partial class MainWindow : Window
{
    #region .ctor
    public MainWindow()
    {
        InitializeComponent();
    }
    #endregion

    #region Methods

    protected override void OnInitialized(EventArgs e)
    {
        MainWindowViewModel viewModel = null;

        base.OnInitialized(e);

        if (this.DataContext != null)
        {
            viewModel = this.DataContext as MainWindowViewModel;
            if (viewModel != null)
                viewModel.View = this;
        }
    }

    #endregion
}

Il codice è molto semplice. Una volta inizializzato l'oggetto, WPF lancia il metodo OnInizialized che può subire override. Ovviamente, lasciamo il codice di esecuzione del riferimento a OnInizialized di base, ma aggiungiamo del semplice codice di verifica dell'esistenza dell'istanza del ViewModel. Se il DataContext è stato inizializzato (e deve essere così), effettuiamo un cast sicuro con "as" e settiamo la proprietà View all'istanza della finestra corrente. In questo modo, il ViewModel può avere accesso a tutti i metodi pubblici dell'oggetto visuale.

Il risultato finale

Come risultato finale otteniamo che al lancio l'applicativo non ha stringhe di test settate e il pulsante di stampa non è abilitato. Questo comportamento è gestito automaticamente da WPF.


Settando il testo di prova, oppure la stringa utente mediante TextBox otteniamo che automaticamente il comando di stampa testo diventa disponibile. Anche questa gestione è demandata completamente a WPF.


A questo punto è possibile visualizzare le stringhe tramite MessageBox. Io ho settato entrambe le stringhe.


Conclusioni

MVVM facilita di molto lo sviluppo modulare dell'applicazione e l'implementazione in .NET risulta abbastanza agevole. Per progetti estremamente semplici risulta però uno aumento del tempo richiesto allo sviluppo, una difficoltà superiore nella progettazione e un amento della complessità del progetto non proporzionato ai vantaggi. I risultati ottenuti in questi esempi avrebbero richiesto tre eventi e solo il file di code-behind della finestra principale. Ma, anche con questi esempi molto ristretti, è evidente che la modularità dello sviluppo comporta un aumento della manutenibilità e della qualità del codice per progetti che fanno qualcosa di più che stampare un semplice messaggio a video.

Sorgenti

Il sorgente in formato Visual Studio 2013 dell'esempio è disponibile qui

Nessun commento:

Posta un commento