Implementazione di INotifyPropertyChanged nel modo più semplice

Completato

Se si sono seguite le lezioni precedenti, si potrebbe pensare che l’implementazione del data binding sia eccessivamente complesso. Perché implementare INotifyPropertyChanged, generando eventi molteplici, quando per visualizzare l'ora è possibile usare semplicemente TimeTextBlock.Text = DateTime.Now.ToLongTime()? Ed è vero, in un caso semplice come questo il data binding sembra eccessivo.

Il data binding, tuttavia, può offrire molto di più. Consente di trasferire dati dall'interfaccia utente al codice e viceversa, visualizzare elenchi di elementi e modificare i dati. Tutte queste funzionalità con un'architettura che garantisce una netta separazione tra i dati usati dalla logica dell'app e la presentazione dei dati.

Ma come è possibile ridurre la quantità di codice che lo sviluppatore deve scrivere? Nessuno vuole immettere dieci righe di codice per ogni proprietà che deve dichiarare. Fortunatamente, è possibile estrarre le funzionalità comuni e ridurre i setter di proprietà a una singola riga di codice Questa lezione illustra come farlo.

Obiettivo

L'obiettivo è di spostare tutto il plumbing per l'implementazione dell'interfaccia INotifyPropertyChanged in una classe separata, per semplificare la creazione di una proprietà in grado di inviare una notifica all'interfaccia utente in caso di modifiche. Come promemoria, ecco il codice da semplificare:

private bool _isNameNeeded = true;

public bool IsNameNeeded
{
    get { return _isNameNeeded; }
    set
    {
        if (value != _isNameNeeded)
        {
            _isNameNeeded = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsNameNeeded)));
        }
    }
}

Qui non è possibile usare proprietà automatiche (ad esempio public bool IsNameNeeded { get; set;}), perché è necessario eseguire operazioni nel setter. Non c'è molto da fare, quindi, per il campo sottostante, la riga di dichiarazione della proprietà. Tramite funzionalità C# moderne è possibile cambiare il getter in get => _isNameNeeded;, ma in questo modo si risparmiano solo pochi caratteri. È quindi necessario concentrare l'attenzione sul setter di proprietà. È possibile ridurlo a una sola riga?

Classe ObservableObject

È possibile creare una nuova classe di base: ObservableObject. Verrà chiamata observable perché può essere osservata dall'interfaccia utente tramite l'interfaccia INotifyPropertyChanged. I dati e la logica vengono ospitati in classi che ereditano da questa classe, alle quali viene anche associata l'interfaccia utente.

1. Creare la ObservableObject classe

Si creerà una nuova classe denominata ObservableObject. Fare clic con il pulsante destro del mouse sul progetto DatabindingSample in Esplora soluzioni, scegliere Aggiungi/Classe e immettere ObservableObject come nome della classe. Selezionare Aggiungi per creare la classe.

1. Creare la ObservableObject classe

Si creerà una nuova classe denominata ObservableObject. Fare clic con il pulsante destro del mouse sul progetto DatabindingSampleWPF in Esplora soluzioni, selezionare Aggiungi/Classe e immettere ObservableObject come nome della classe. Selezionare Aggiungi per creare la classe.

Screenshot of Visual Studio showing the Add New Item dialog with a Visual C# class type selected.

2. Implementare l'interfaccia INotifyPropertyChanged

Si implementerà ora l'interfaccia INotifyPropertyChanged e si renderà pubblica la classe. Modificare la firma della classe in modo che abbia un aspetto simile al seguente:

public class ObservableObject : INotifyPropertyChanged

Visual Studio avvisa l'utente che si sono verificati alcuni problemi con INotifyPropertyChanged. Si trova in uno spazio dei nomi senza riferimenti ed è possibile aggiungerla come illustrato di seguito.

using System.ComponentModel;

È necessario ora implementare l'interfaccia. Aggiungere questa riga all'interno del corpo della classe.

public event PropertyChangedEventHandler? PropertyChanged;

3. Metodo RaisePropertyChanged

Nelle lezioni precedenti è stato spesso generato l'evento PropertyChangedEvent nel codice, anche all'esterno dei setter di proprietà. Sebbene il moderno C# e l'operatore condizionale null o (?.) consentano di eseguire questa operazione in un'unica riga, è comunque possibile semplificare il processo creando un'apposita funzione simile alla seguente:

protected void RaisePropertyChanged(string? propertyName)
{
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

A questo punto, per generare l'evento PropertyChanged nelle classi che ereditano da ObservableObject è sufficiente procedere come descritto di seguito:

RaisePropertyChanged(nameof(MyProperty));

4. Metodo Set<T>

Quali operazioni possono essere eseguite sul modello di setter che controlla se il valore è rimasto invariato, lo imposta se è cambiato e genera l'evento PropertyChanged? In teoria, il modello dovrebbe essere trasformato in una singola riga di codice simile alla seguente:

private bool _isNameNeeded = true;

public bool IsNameNeeded
{
    get { return _isNameNeeded; }
    set { Set(ref _isNameNeeded, value); }  // Just one line!
}

In realtà, è possibile eseguire un'operazione più semplice: si chiama una funzione, si passa un riferimento al campo sottostante della proprietà e si imposta il nuovo valore. Che aspetto ha, quindi, il metodo Set?

protected bool Set<T>(
    ref T field,
    T newValue,
    [CallerMemberName] string? propertyName = null)
{
    if (EqualityComparer<T>.Default.Equals(field, newValue))
    {
        return false;
    }

    field = newValue;
    RaisePropertyChanged(propertyName);
    return true;
}

Copiare il codice precedente nel corpo della classe ObservableObject. Per [CallerMemberName], è anche necessario aggiungere la linea seguente all'inizio del file:

using System.Runtime.CompilerServices;

In questa fase sono presenti molti degli aspetti magici del compilatore e di C# avanzato. Di seguito si spiegherà meglio questo concetto.

Set<T> è un metodo generico che consente al compilatore di verificare che il campo sottostante e il valore siano dello stesso tipo. Il terzo parametro del metodo, propertyName, è contraddistinto dall'attributo [CallerMemberName]. Se si non definisce il parametro propertyName quando si chiama il metodo, durante la procedura di compilazione verrà adottato il nome del membro chiamante. Pertanto, se si chiama Set dal setter del metodo IsNameNeeded, il compilatore inserisce automaticamente il valore letterale stringa, "IsNameNeeded", come terzo parametro. Non è necessario impostare stringhe come hardcoded o usare nameof().

Il metodo Set richiama ora EqualityComparer<T>.Default.Equals per confrontare il valore corrente del campo con quello nuovo. Se i due valori sono uguali, il metodo Set restituisce false. In caso contrario, il campo sottostante viene impostato sul nuovo valore e viene generato l'evento PropertyChanged prima che venga restituito true. È possibile usare il valore restituito del metodo Set per determinare se il valore è stato modificato.

Dopo aver implementato la classe ObservableObject, verrà ora illustrato come usarla nell'app.

5. Creare la MainPageLogic classe

Nelle fasi precedenti della lezione sono stati spostati tutti i dati e tutta la logica dalla classe MainPage in una classe che eredita da ObservableObject.

Si creerà quindi una nuova classe denominata MainPageLogic. Fare clic con il pulsante destro del mouse sul progetto DatabindingSample in Esplora soluzioni, scegliere Aggiungi/Classe e immettere MainPageLogic come nome della classe. Selezionare Aggiungi per creare la classe.

Modificare la firma della classe in modo che diventi pubblica ed erediti da ObservableObject.

public class MainPageLogic : ObservableObject
{
}

6. Spostare la funzionalità orologio nella MainPageLogic classe

Il codice per la funzionalità dell'orologio è costituito da tre parti: il campo _timer, l'impostazione di DispatcherTimer nel costruttore e la proprietà CurrentTime. Al termine della seconda lezione il codice era composto come illustrato di seguito:

private DispatcherTimer _timer;

public MainPage()
{
    this.InitializeComponent();
    _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };

    _timer.Tick += (sender, o) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentTime)));

    _timer.Start();
}

public string CurrentTime => DateTime.Now.ToLongTimeString();

Tutto il codice inerente a _timer verrà ora spostato nella classe MainPageLogic. Le righe del costruttore (ad eccezione della chiamata this.InitializeComponent()) devono essere spostate nel costruttore di MainPageLogic. L'unico elemento del codice precedente che deve essere lasciato in MainPage è la chiamata InitializeComponent del costruttore.

public MainPage()
{
    this.InitializeComponent();
}

Per il momento si interverrà solo su questa parte del codice, ma si tornerà presto anche sulla parte rimanente del codice della classe MainPage.

Dopo lo spostamento, la classe MainPageLogic ha l'aspetto seguente:

public class MainPageLogic : ObservableObject
{
    private DispatcherTimer _timer;

    public MainPageLogic()
    {
        _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };

        _timer.Tick += (sender, o) =>
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentTime)));

        _timer.Start();
    }

    public string CurrentTime => DateTime.Now.ToLongTimeString();
}

Tenere a mente che è disponibile un'apposita funzione per generare l'evento PropertyChanged. Verrà usata nel gestore _timer.Tick.

_timer.Tick += (sender, o) => RaisePropertyChanged(nameof(CurrentTime));

7. Modificare il codice XAML per usare MainPageLogic

Se si prova ora a compilare il progetto, si ottiene l'errore "La proprietà 'CurrentTime' non è stata trovata nel tipo 'MainPage'" nel file MainPage.xaml. La classe MainPage, ovviamente, non ha più una proprietà CurrentTime, che è stata spostata nella classe MainPageLogic. Per risolvere questo problema, verrà creata una proprietà denominata Logic nella classe MainPage, che sarà di tipo MainPageLogic e attraverso la quale verranno effettuati tutti i binding.

Aggiungere il codice seguente alla classe MainPage:

public MainPageLogic Logic { get; } = new MainPageLogic();

Nel file MainPage.xaml trovare l'oggetto TextBlock che consente di visualizzare il clock.

<TextBlock Text="{x:Bind CurrentTime, Mode=OneWay}"
           HorizontalAlignment="Right"
           Margin="10"/>

Cambiare quindi il binding aggiungendo Logic. all'oggetto.

<TextBlock Text="{x:Bind Logic.CurrentTime, Mode=OneWay}"
           HorizontalAlignment="Right"
           Margin="10"/>

A questo punto l'app viene compilata e, se eseguita, il clock funziona come previsto. Fantastico!

8. Spostare il resto della logica

Per recuperare terreno, la parte restante del codice della classe MainPage verrà spostata in MainPageLogic. Devono essere lasciati solo la proprietà Logic, il costruttore e l'evento PropertyChanged.

9. Semplificare IsNameNeeded

In MainPageLogic.cs sostituire il setter di proprietà IsNameNeeded con una chiamata al nuovo metodo Set.

public bool IsNameNeeded
{
    get { return _isNameNeeded; }
    set { Set(ref _isNameNeeded, value); }
}

10. Correggere il OnSubmitClicked metodo

A livello di logica, non ci si interesserà più agli argomenti dell'evento o al mittente dell'evento di selezione dei pulsanti. È anche opportuno riconsiderare il nome del metodo. Non si creeranno più operazioni di selezione dei pulsanti ma si svilupperà la logica di invio. Il metodo OnSubmitClicked verrà quindi rinominato in Submit, per renderlo pubblico, e verranno rimossi i parametri.

All'interno del metodo è sempre attiva la precedente modalità di generazione dell'evento PropertyChanged. Sostituirla con una chiamata a ObservableObject.RaisePropertyChanged. Al termine, l'intero metodo dovrebbe avere un aspetto simile al seguente:

public void Submit()
{
    if (string.IsNullOrEmpty(UserName))
    {
        return;
    }

    IsNameNeeded = false;
    RaisePropertyChanged(nameof(GetGreetingVisibility));
}

11. Modificare il codice XAML per fare riferimento a Logic

Si passerà ora al file MainPage.xaml e si modificheranno i binding rimanenti in modo che passino attraverso la proprietà Logic. Al termine, l'oggetto Grid dovrebbe avere un aspetto simile al seguente:

<Grid>
    <TextBlock Text="{x:Bind Logic.CurrentTime, Mode=OneWay}"
               HorizontalAlignment="Right"
               Margin="10"/>

    <StackPanel HorizontalAlignment="Center"
                VerticalAlignment="Center"
                Orientation="Horizontal"
                Visibility="{x:Bind Logic.IsNameNeeded, Mode=OneWay}">
        <TextBlock Margin="10"
                   VerticalAlignment="Center"
                   Text="Enter your name: "/>
        <TextBox Name="tbUserName"
                 Margin="10"
                 Width="150"
                 VerticalAlignment="Center"
                 Text="{x:Bind Logic.UserName, Mode=TwoWay}"/>
        <Button Margin="10"
                VerticalAlignment="Center"
                Click="{x:Bind Logic.Submit}" >Submit</Button>
    </StackPanel>

    <TextBlock Text="{x:Bind sys:String.Format('Hello {0}!',  tbUserName.Text), Mode=OneWay}"
               Visibility="{x:Bind Logic.GetGreetingVisibility(), Mode=OneWay}"
               HorizontalAlignment="Left"
               VerticalAlignment="Top"
               Margin="10"/>
</Grid>

Osservare come anche l'evento Button.Click possa essere associato al metodo Submit nella classe MainPageLogic.

Se si compila ora il progetto, si ottiene comunque un messaggio di avviso in cui si informa che MainPage.PropertyChanged non è mai stato usato.

12. Riordinare la MainPage classe

L'avviso appare perché non è più necessaria l'interfaccia INotifyPropertyChanged nella classe MainPage. È quindi possibile rimuoverla dalla dichiarazione di classe, insieme all'evento PropertyChanged.

Al termine, l'intera classe MainPage avrà un aspetto simile al seguente:

public sealed partial class MainPage : Page
{
    public MainPageLogic Logic { get; } = new MainPageLogic();

    public MainPage()
    {
        this.InitializeComponent();
    }

}

Per quanto possibile, la classe è stata pulita.

13. Eseguire l'app

Se non si sono verificati errori, a questo punto dovrebbe essere possibile eseguire l'app e verificare che funzioni esattamente come prima. Complimenti.

Riepilogo

In sintesi, quali vantaggi sono stati ottenuti portando a termine questo processo? L'app funziona esattamente come prima ma è stata messa a punto un'architettura scalabile, sostenibile e testabile.

La classe MainPage è ora molto semplice. Contiene un riferimento alla logica e si limita a ricevere e inoltrare un evento di selezione dei pulsanti. Il flusso di dati tra la logica e l'interfaccia utente avviene tramite data binding, che è un metodo veloce, affidabile e collaudato.

La classe MainPageLogic è ora indipendente dall'interfaccia utente. Non importa se l'orologio viene visualizzato in un TextBlock o in un altro controllo. L'invio del modulo può verificarsi in diversi modi. Questi metodi includono la selezione di un pulsante, la pressione del tasto INVIO o l'applicazione di un algoritmo di riconoscimento del volto che rileva un sorriso. Il modulo può essere inviato anche tramite unit test automatici sulla logica che verificano che soddisfi i requisiti del progetto.

Per questi e altri motivi, è consigliabile che nel code-behind della pagina siano presenti solo funzionalità correlate all'interfaccia utente e che la logica venga separata in un'altra classe. Le app più complesse possono contenere anche un controllo Animation e altre funzionalità concrete correlate all'interfaccia utente. Quando si useranno app più complesse, si apprezzerà ancora di più la separazione tra l'interfaccia utente e la logica eseguita in questa lezione.

Sarà possibile usare di nuovo la classe ObservableObject nel progetto. Dopo un po' di pratica, si riscontrerà come sia effettivamente più semplice e veloce affrontare i problemi in questo modo o sfruttare i vantaggi di una libreria esistente ben consolidata, come MVVM Toolkit, che si basa sui principi illustrati in questo modulo.

5. Modificare la Clock classe per sfruttare i vantaggi ObservableObject

Modificare la firma di Clock, in modo che erediti da ObservableObject anziché da INotifyPropertyChanged.

public class Clock : ObservableObject

L'evento PropertyChanged risulta ora definito sia nella classe Clock che nella classe di base e viene quindi visualizzato un avviso del compilatore. Eliminare l'evento PropertyChanged dalla classe Clock.

Per generare l'evento PropertyChanged è stata creata un'apposita funzione nella classe ObservableObject. Per usarla, sostituire la riga _timer.Tick con questa:

_timer.Tick += (sender, o) => RaisePropertyChanged(nameof(CurrentTime));

La classe Clock è già diventata più semplice. Si scoprirà ora quali operazioni è possibile eseguire con una classe MainWindowDataContext più complessa.

6. Modificare la MainWindowDataContext classe per sfruttare i vantaggi ObservableObject

Come con la classe Clock, anche in questo caso si inizia modificando la dichiarazione di classe in modo che erediti da ObservableObject.

public class MainWindowDataContext : ObservableObject

Assicurarsi anche di eliminare l'evento PropertyChanged qui.

Esaminando il setter della proprietà IsNameNeeded, si osserverà come ora abbia l'aspetto seguente:

set
{
    if (value != _isNameNeeded)
    {
        _isNameNeeded = value;
        PropertyChanged?.Invoke(
            this, new PropertyChangedEventArgs(nameof(IsNameNeeded)));
        PropertyChanged?.Invoke(
            this, new PropertyChangedEventArgs(nameof(GreetingVisibility)));
    }
}

Questo è il modello INotifyPropertyChanged standard, con in più la chiamata dell'evento PropertyChanged se il nuovo valore della proprietà IsNameNeeded è diverso.

Si tratta esattamente della situazione per cui è stata creata la funzione ObservableObject.Set. La funzione Set restituisce anche un valore bool che indica se il nuovo valore della proprietà è diverso da quello precedente. Il setter di proprietà precedente, quindi, può essere semplificato come segue:

if (Set(ref _isNameNeeded, value))
{
    RaisePropertyChanged(nameof(GreetingVisibility));
}

Non male.

7. Eseguire l'app

Se non si sono verificati errori, a questo punto dovrebbe essere possibile eseguire l'app e verificare che funzioni esattamente come prima. Complimenti.

Riepilogo

In sintesi, quali vantaggi sono stati ottenuti portando a termine questo processo? L'app funziona esattamente come prima ma è stata messa a punto un'architettura scalabile, sostenibile e testabile.

La classe MainWindow è molto semplice. Contiene un riferimento alla logica e si limita a ricevere e inoltrare un evento di selezione dei pulsanti. Il flusso di dati tra la logica e l'interfaccia utente avviene tramite data binding, che è un metodo veloce, affidabile e collaudato.

La classe MainWindowDataContext è ora indipendente dall'interfaccia utente. Non importa se l'orologio viene visualizzato in un TextBlock o in un altro controllo. L'invio del modulo può verificarsi in diversi modi. Questi metodi includono la selezione di un pulsante, la pressione del tasto INVIO o l'applicazione di un algoritmo di riconoscimento del volto che rileva un sorriso. Il modulo può essere inviato anche tramite unit test automatici sulla logica che verificano che soddisfi i requisiti del progetto.

Per questi e altri motivi, è consigliabile che nel code-behind della finestra siano presenti solo funzionalità correlate all'interfaccia utente e che la logica venga separata in un'altra classe. Le app più complesse possono contenere anche un controllo Animation e altre funzionalità concrete correlate all'interfaccia utente. Quando si useranno app più complesse, si apprezzerà ancora di più la separazione tra l'interfaccia utente e la logica eseguita in questa lezione.

Sarà possibile usare di nuovo la classe ObservableObject nel progetto. Dopo un po' di pratica, si riscontrerà come sia effettivamente più semplice e veloce affrontare i problemi in questo modo o sfruttare i vantaggi di una libreria esistente ben consolidata, come MVVM Toolkit, che si basa sui principi illustrati in questo modulo.