Implementazione di INotifyPropertyChanged nel modo più semplice
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.
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.