Progetto c#/wpf in MVVM

WPF e pattern MVVM: un esempio di applicazione

Windows Presentation Foundation del .net framework (e di .net core e da poco anche per .net 5), il principale tool offerto da Microsoft per la creazione di interfacce utente grafiche per applicazioni desktop su Windows, offre pieno supporto al pattern MVVM. Ciò offre non pochi vantaggi nell’utilizzo di questo set di strumenti che consente una capillare stilizzazione e personalizzazione dello strato di presentazione del software.

In questo articolo presenterò un programma di esempio, sviluppato in MVVM. In questo progetto di test trovano applicazione anche i pattern Commanding e Mediator.

SitemapUrlChecker, l’applicazione di esempio.

I sorgenti del progetto sono reperibili presso questo repository Avevo necessità di verificare i redirect 301 di un sito web dopo che, per motivazioni prettamente di tipo seo, si era deciso per una completa ristrutturazione degli url indicizzati dai motori di ricerca. Non avendo grandi conoscenze dei principali tool seo utili allo scopo e mancando il tempo per ricerca e studio degli stessi, dato che il problema era facilmente risolvibile con un banale script, ho deciso di costruirci su un’app WPF che desse a colpo d’occhio la lista dei 301 andati a buon fine, insieme agli errori interni del server (http status code 500) e alle pagine non trovate (status code 404), colorando le celle con sfondi diversi in base al valore.

Il programma prende in ingresso una sitemap in xml o una lista di url da file txt o csv; opzionalmente si può indicare la root del sito da testare qualora non sia presente nella list degli indirizzi. Tramite il tasto di conferma si avvia la procedura asincrona, che può essere bloccata in qualsiasi istante con il tasto cancel.

Il pattern MVVM

Come indicato dall’acronimo stesso, il pattern Model – View – ViewModel consente la netta separazione dello strato di accesso ai dati dalla business logic e dalla presentazione degli stessi. Ciò si risolve in una serie di vantaggi, di cui i più importanti sono:

  • Testabilità: separando la business logic dalla presentation, è possibile eseguire test unitari sullo strato di ViewModel, ma anche sui Command e sullo scambio di messaggi tra la View e il ViewModel
  • Riuso: la parte di Model e di ViewModel può essere condivisa con altre applicazioni
  • Manutenibilità: modifiche alla parte di presentazione o a quella di accesso ai dati non intaccano la logica del ViewModel. Inoltre è possibile separare anche i ruoli di sviluppo all’interno del team, tra chi si occupa del backend e chi si occupa del frontend e/o della grafica

Il Model

Il model dell’applicazione implementa l’interfaccia INotifyPropertyChanged, che permette a ViewModel e View di inviarsi notifiche reciproche sul cambiamento di stato di proprietà e comandi. Dato che l’interfaccia deve essere ereditata anche dal ViewModel, definiamo una classe base BaseModel come segue:

public class BaseModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

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

Successivamente definiamo il Model dell’applicazione che rappresenta gli url processati che verranno dati in pasto a una datagrid di riepilogo nella view

public class UrlCheckModel : BaseModel
    {
        private string _url;
        private int _statusCode;
        private string _statusDescription;

        public string Url
        {
            get => _url; 
            set
            {
                _url = value;
                OnPropertyChanged("Url");
            }
        }

        public int StatusCode
        {
            get => _statusCode; 
            set
            {
                _statusCode = value;
                OnPropertyChanged("StatusCode");
            }
        }

        public string StatusDescription
        {
            get => _statusDescription; 
            set
            {
                _statusDescription = value;
                OnPropertyChanged("StatusDescription");
            }
        }
    }

Come si può notare, la classe eredita da BaseModel e nel set di ogni property chiama l’evento OnPropertyChanged per notificare al chiamante il cambiamento di stato.

Il ViewModel

Il ViewModel, che come abbiamo detto eredita da BaseModel, espone le seguenti proprietà che verrano legate tramite databinding alla view

public string SitemapFilePath
        {
            get => _sitemapFilePath;
            set
            {
                _sitemapFilePath = value;
                OnPropertyChanged("SitemapFilePath");

            }
        }

        public string SiteRoot
        {
            get => _siteRoot;
            set
            {
                _siteRoot = value;
                OnPropertyChanged("SiteRoot");

            }
        }

        public string CheckProgress
        {
            get => _checkProgress;
            set
            {
                _checkProgress = value;
                OnPropertyChanged("CheckProgress");
            }
        }

        public ObservableCollection<UrlCheckModel> UrlCollection
        {
            get => _urlCollection;
            set
            {
                _urlCollection = value;
                OnPropertyChanged("UrlCollection");

            }
        }

SitemapFilePath è l’input del percorso del file da processare; SiteRoot è il base url del sito opzionale; CheckProgress viene legato all’etichetta di notifica dei progressi dell’applicazione e UrlCollection è il datasource della datagrid. Si tratta di una ObservableCollection, una lista “speciale” che permette di comunicare all’esterno i cambiamenti di stato della collezione, in modo che si riflettano sull’interfaccia grafica utente.

La logica del commanding

Dato che una WPF che segue l’MVVM non utilizza la classica programmazione ad eventi, bisogna implementare i comandi dell’app all’interno del ViewModel. Un comando deve implementare l’interfaccia ICommand che obbliga ad implementare i seguenti metodi:

  • CanExecute: che notifica se il comando può essere attivato o meno, provocando un cambiamento sulla view (ad esempio mettendo in readonly un bottone);
  • Execute, che esegue il comando
  • CanExecuteChanged, gestore d’evento che viene scatenato quando CanExecute cambia di stato

Nei sorgenti dell’applicazione, all’interno della cartella Misc, troverete una classe RelayCommand che implementa l’interfaccia ICommand e può essere riutilizzata in tutti i ViewModel che si desidera.

Il comando principale del ViewModel, avviato dal pulsante di conferma, chiama il metodo asincrono LoadUrls, che dopo aver letto il file degli indirizzi web, li processa in sequenza, tramite il Metodo ProcessUrls, che avvia una richiesta http di tipo HEAD e, una volta ottenuta la risposta dal server, appende a UrlCollection il risultato dell’elaborazione. Il metodo GetResponse istanzia un oggetto della classe HttpClient di cui si chiama il metodo SendAsync a cui viene passato un CancellationToken per bloccare in qualunque momento la procedura. Dato che tutti i metodi sono di tipo async Task l’interfaccia grafica non viene bloccata dall’esecuzione della lunga procedura. Ecco il codice dei tre metodi:

private async Task<UrlCheckModel> GetResponse(string url)
        {
            using (var client = new HttpClient())
            {
                
                HttpClientHandler httpClientHandler = new HttpClientHandler();
                httpClientHandler.AllowAutoRedirect = false;
                httpClientHandler.AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip;

                var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, url),  _cancellationToken.Token);

                var urlCheckItem = new UrlCheckModel()
                {
                    Url = url,
                    StatusCode = (int)response.StatusCode,
                    StatusDescription = response.StatusCode.ToString()
                };                

                return urlCheckItem;
            }
        }

        private async Task ProcessUrls(string[] urls)
        {
            try
            {
                var fileName = $"report_{DateTime.Now.ToString("yyyyMMddHHmmss")}.csv";
                File.Create(fileName).Close();
                File.AppendAllText(fileName, $"Url,StatusCode,StatusDescription{Environment.NewLine}");

                UrlCollection = new ObservableCollection<UrlCheckModel>();

                CheckProgress = $"Url 0 di {urls.Length}";

                foreach (var line in urls)
                {
                    var url = "";
                    var ln = "";

                    if (line.Contains(","))
                        ln = line.Split(',')[0];
                    else if (line.Contains(";"))
                        ln = line.Split(';')[0];
                    else
                        ln = line;

                    if (!string.IsNullOrEmpty(_siteRoot))
                    {
                        url = $"{_siteRoot.TrimEnd('/').TrimEnd('\\')}/{ln.TrimStart('/').TrimEnd('\\')}";
                    }
                    else
                    {
                        url = line;
                    }


                    var urlCheckItem = await GetResponse(url);
                    UrlCollection.Add(urlCheckItem);

                    File.AppendAllText(fileName,
                        $"{urlCheckItem.Url},{urlCheckItem.StatusCode},{urlCheckItem.StatusDescription}{Environment.NewLine}");

                    CheckProgress = $"Url {UrlCollection.Count()} di {urls.Length}";

                    Thread.Sleep(200);
                }

            }
            catch (OperationCanceledException)
            {
                App.Msn.NotifyColleagues(App.MSGBOX_CANCEL);
            }
            catch (Exception ex)
            {

                App.Msn.NotifyColleagues(App.LOAD_ERROR, ex);
            }
        }

        private async Task LoadUrls()
        {

            string[] urls;

            if (System.IO.Path.GetExtension(_sitemapFilePath) == ".xml")
            {
                XmlDocument urldoc = new XmlDocument();
                urldoc.Load(_sitemapFilePath);

                XmlNodeList xnList = urldoc.GetElementsByTagName("url");

                var nodeList = new List<XmlNode>(urldoc.DocumentElement.GetElementsByTagName("url").OfType<XmlNode>());
                urls = nodeList.Select(x => x["loc"].InnerText).ToArray();
            }
            else
            {
                urls = System.IO.File.ReadAllLines(_sitemapFilePath);
            }

            await ProcessUrls(urls);


        }

Il comando che li avvia è LoadFileCommand di tipo RelayCommand, che viene istanziato passando al costruttore i metodi LoadFileExecute e CanLoadFileExecute. Nel repository troverete anche l’implementazione di CancelCommand, legato al tasto cancel.

La View

La view è molto semplice: all’interno di un file xaml, definiamo la finestra dell’applicazione, che al suo interno ospita un layout a griglia (tra i tag <grid />) di 4 righe e una colonna; alla prima riga viene posizionato l’input opzionale del base url del sito; alla seconda riga l’input del file avviene con l’apertura di un file dialog; alla terza si trova una label di notifica dell’andamento della procedura (numero file processati e numero file mancanti in totale); infine, all’ultima riga della grid, una datagrid viene progressivamente popolata col risultato degli url processati. Ogni controllo grafico ha un binding con una proprietà del viewmodel.

<Window x:Class="SiteMapUrlChecker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:SiteMapUrlChecker"
        xmlns:converters="clr-namespace:SiteMapUrlChecker.Converters"
        mc:Ignorable="d"
        Title="MainWindow" Height="800" Width="950" Loaded="Window_Loaded" WindowStartupLocation="CenterScreen">
    <Window.Resources>
        <converters:StatusCodeConverter x:Key="statusConverter"/>
        <Style TargetType="TextBox">
            <Setter Property="Margin" Value="0 3 3 0"></Setter> 
        </Style>
        
    </Window.Resources>
    <Grid>
        <Grid.Resources>
            <Style TargetType="Button">
                <Setter Property="Margin" Value="0 3 3 0"/>
            </Style>
        </Grid.Resources>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <StackPanel Orientation="Horizontal">
            <Label MinWidth="72">Site root</Label>
            <TextBox MinWidth="400" Name="txtSiteRoot" Text="{Binding SiteRoot, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"></TextBox>
        </StackPanel>
        <StackPanel Grid.Row="1" Orientation="Horizontal">
            
            <Label>Sitemap file</Label>
            <TextBox MinWidth="400" Name="txtFile" IsReadOnly="True" Text="{Binding SitemapFilePath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"></TextBox>
            <Button Content="Carica file" Command="{Binding LoadFileCommand}"></Button>
            <Button Content="Annulla"   HorizontalAlignment="Right" Command="{Binding CancelCommand}"></Button>
        </StackPanel>
        <TextBlock Grid.Row="2" HorizontalAlignment="Center" FontSize="20" FontWeight="Bold" Text="{Binding CheckProgress, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"></TextBlock>
        <DataGrid Grid.Row="3" ItemsSource="{Binding UrlCollection, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Url" MinWidth="500" Binding="{Binding Path=Url}"/>
                <DataGridTextColumn Header="Codice stato" Binding="{Binding Path=StatusCode}">
                    <DataGridTextColumn.ElementStyle>
                        <Style TargetType="{x:Type TextBlock}">
                            <Setter Property="Background" Value="{Binding Path=StatusCode, Converter={StaticResource statusConverter}}"/>
                        </Style>
                    </DataGridTextColumn.ElementStyle>
                </DataGridTextColumn>
                <DataGridTextColumn Header="Descrizione status" Binding="{Binding Path=StatusDescription}" />
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

Il converter

Per colorare le celle in base allo status code http, abbiamo fatto uso di un converter di cui viene fatto il binding a una DataGridTextColumn della datagrid nello stile della colonna e che si trova nella cartella Converters. Vediamone il codice

public class StatusCodeConverter : IValueConverter
    {
        object IValueConverter.Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if ((HttpStatusCode)value == HttpStatusCode.OK)
                return new SolidColorBrush(Colors.Green);
            else if ((HttpStatusCode)value == HttpStatusCode.InternalServerError)
                return new SolidColorBrush(Colors.Red);
            else if ((HttpStatusCode)value == HttpStatusCode.Moved)
                return new SolidColorBrush(Colors.LightGreen);
            else if ((HttpStatusCode)value == HttpStatusCode.MovedPermanently)
                return new SolidColorBrush(Colors.GreenYellow);
            else if ((HttpStatusCode)value == HttpStatusCode.NotFound)
                return new SolidColorBrush(Colors.Orange);

            return new SolidColorBrush();
        }

        object IValueConverter.ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

La classe implementa l’interfaccia IValueConverter tramite il metodo Convert, che riceve in input il valore della cella e, in base allo status code, restituisce il colore di sfondo che verrà assegnato alla proprietà Background dello stile della DataTextColumn.

Il pattern Mediator

Dato che la View deve occuparsi solo dello strato di presentazione del programma, le uniche istruzioni che scriveremo nel code behind saranno l’assegnazione del DataContext, con cui avviene il binding dei controlli della vista al ViewModel, e la registrazione dei messaggi che la finestra deve mandare all’utente o ad altre finestre. Con il pattern commanding, infatti, abbiamo un utilissimo strumento per separare la logica dei comandi da tutto il resto, tuttavia non possiamo comunicare all’esterno quale comando eseguire se non all’interno dell’istanza della singola view. Per questo ho inserito nella certella Misc la classe che si occupa di registrare i messaggi che la finestra manda e riceve. La classe è tratta da un esempio di Karl Shifflett, così come citata dal libro di Alessandro Del Sole, “Programmare per WPF con .net 4.5.1” (Apogeo) – al quale sono ispirate anche le classi BaseModel e RelayCommand del progetto; purtroppo sia il link originale del blog di K. Shifflet, sia alcuni articoli di Del Sole non sono più disponibili on line per citare direttamente la fonte (chiunque li trovi mi faccia sapere). Non esamineremo qui la classe Messenger, in quanto troppo complessa per la trattazione in questo già lungo articolo; la useremo tuttavia come black box nel code behind della view:

private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            var viewModel = new MainWindowViewModel();

            

            DataContext = viewModel;

            App.Msn.Register(App.FILE_DIALOG, new Action(() =>
            {
                //MessageBox.Show("Operazione annullata", "Caricamento file", MessageBoxButton.OK, MessageBoxImage.Exclamation);
                OpenFileDialog openFileDialog = new OpenFileDialog();
                openFileDialog.Filter = "File sitemap (*.xml, *.txt, *.csv) | *.xml;*.txt;*.csv";
                openFileDialog.ShowDialog();
                txtFile.Text = openFileDialog.FileName;

                

            }));

            App.Msn.Register(App.MSGBOX_CANCEL, new Action(() =>
            {

                MessageBox.Show("Operazione annullata", "Caricamento file", MessageBoxButton.OK, MessageBoxImage.Exclamation);

            }));

            App.Msn.Register(App.LOAD_ERROR, new Action<Exception>((Exception ex) =>
            {

                MessageBox.Show(ex.ToString(), "Caricamento file", MessageBoxButton.OK, MessageBoxImage.Error);

            }));

        }

All’evento WindowLoaded viene istanziato il ViewModel e viene assegnato al DataContex della finestra; successivamente vengono registrati il comando di apertura del file dialog, quello di stop della procedura ed eventuali eccezioni gestite scatenate nel ViewModel. La dichiarazione delle costanti si trova in App.xaml.cs.

Conclusioni

Abbiamo visto un esempio di applicazione del pattern MVVM in un piccolo programma WPF e ne abbiamo elencato i vantaggi di utilizzo. Ovviamente, il programma di esempio poteva essere creato seguendo la classica programmazione ad eventi, supportata da WPF; tuttavia, isolando le varie logiche del software abbiamo posto le basi per una futura estendibilità del progetto, oltre che aver garantito una migliore testabilità e manutenibilità. La separazione delle logiche può essere spinta più in là spostando altrove i comandi presenti nel ViewModel e spostando in una libreria esterna tutte le base class. Quindi sentitevi liberi di eseguire un fork del progetto e di estenderlo come preferite, oppure di usarlo come scheletro per altre applicazioni.

One thought on “WPF e pattern MVVM: un esempio di applicazione

Comments are closed.