INotifyPropertyChanged is an essential interface in the .NET Framework, especially for applications using data binding or the MVVM pattern. It enables automatic UI updates when properties in the data model change by raising the PropertyChanged event. Implementing this interface in data models increases their reusability across different views. The INotifyPropertyChanged is supported by most .NET UI frameworks including WPF, WinForms, WinUI and Blazor.
However, implementing INotifyPropertyChanged often involves a significant amount of boilerplate code, making it cumbersome and time-consuming to maintain. Each property requires additional code to raise the PropertyChanged event not only for itself, but for all dependent properties, which can quickly become unwieldy in large data models. Fortunately, you can use an aspect to inject the necessary code automatically, reducing the manual effort required to implement the interface. With Metalama, you can maintain cleaner and more manageable code, focusing on the core logic of their applications while still benefiting from the responsive UI updates and improved data binding that INotifyPropertyChanged provides.
In the following example, we show how source code is transformed by the aspect. The code generated by Metalama is displayed in green.
1[NotifyPropertyChanged]
2internal partial class MovingVertex
3{
4 public double X { get; set; }
5
6 public double Y { get; set; }
7
8 public double DX { get; set; }
9
10 public double DY { get; set; }
11
12 public void ApplyTime(double time)
13 {
14 this.X += this.DX * time;
15 this.Y += this.DY * time;
16 }
17}
1using System;
2using System.ComponentModel;
3
4[NotifyPropertyChanged]
5internal partial class MovingVertex
6: INotifyPropertyChanged
7{
8private double _x;
9
10 public double X { get { return this._x; } set { if (value != this._x) { this._x = value; OnPropertyChanged("X"); } return; } }
11 private double _y;
12
13 public double Y { get { return this._y; } set { if (value != this._y) { this._y = value; OnPropertyChanged("Y"); } return; } }
14 private double _dX;
15
16 public double DX { get { return this._dX; } set { if (value != this._dX) { this._dX = value; OnPropertyChanged("DX"); } return; } }
17 private double _dY;
18
19 public double DY { get { return this._dY; } set { if (value != this._dY) { this._dY = value; OnPropertyChanged("DY"); } return; } }
20
21 public void ApplyTime(double time)
22 {
23 this.X += this.DX * time;
24 this.Y += this.DY * time;
25 }
26protected void OnPropertyChanged(string name)
27 {
28 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
29 }
30 public event PropertyChangedEventHandler? PropertyChanged;
31}
[!INFO] The objective of this article is didactic. For a production-ready and battle-tested implementation of caching, use the
Metalama.Patterns.Observability
package. See Metalama.Patterns.Observability for details.
Implementation
Warning
The objective of this example is to teach you to build aspects of moderate complexity. For an open-source and production-ready implementation, see Metalama.Patterns.Observability.
In this example, we will explain the simplest possible implementation of the NotifyPropertyChanged
aspect. As with any
aspect, before starting to write code, it is good to take a pause and think a few minutes about the design. Here is how
we want this aspect to work:
- The aspect will add the INotifyPropertyChanged interface to the target type unless that type already implements this interface.
- The aspect will add the
OnPropertyChanged
method unless the target type already contains it. This method will call the PropertyChanged event. - The aspect will override the setter of all properties and call
OnPropertyChanged
. - The aspect will automatically propagate from the base class to derived classes.
Note that this is a basic implementation. Specifically, it does not take into account dependent properties, i.e. properties that depend on other properties.
Here's the code for this aspect.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using System.ComponentModel;
4
5[Inheritable]
6internal class NotifyPropertyChangedAttribute : TypeAspect
7{
8 public override void BuildAspect(IAspectBuilder<INamedType> builder)
9 {
10 builder.Advice.ImplementInterface(builder.Target, typeof(INotifyPropertyChanged),
11 OverrideStrategy.Ignore);
12
13 foreach (var property in builder.Target.Properties.Where(p =>
14 !p.IsAbstract && p.Writeability == Writeability.All))
15 {
16 builder.Advice.OverrideAccessors(property, null, nameof(this.OverridePropertySetter));
17 }
18 }
19
20 [InterfaceMember] public event PropertyChangedEventHandler? PropertyChanged;
21
22 [Introduce(WhenExists = OverrideStrategy.Ignore)]
23 protected void OnPropertyChanged(string name) =>
24 this.PropertyChanged?.Invoke(meta.This, new PropertyChangedEventArgs(name));
25
26 [Template]
27 private dynamic OverridePropertySetter(dynamic value)
28 {
29 if (value != meta.Target.Property.Value)
30 {
31 meta.Proceed();
32 this.OnPropertyChanged(meta.Target.Property.Name);
33 }
34
35 return value;
36 }
37}
As can be seen from the code, the NotifyPropertyChangedAttribute
class inherits
the TypeAspect because this is an aspect that applies to types.
The [Inheritable] at the top of the class indicates that the aspect should be inherited from the base class to derived classes. For further details, see Applying aspects to derived types.
Let's examine the implementation of the BuildAspect
method.
8public override void BuildAspect(IAspectBuilder<INamedType> builder)
9{
10 builder.Advice.ImplementInterface(builder.Target, typeof(INotifyPropertyChanged),
11 OverrideStrategy.Ignore);
12
13 foreach (var property in builder.Target.Properties.Where(p =>
14 !p.IsAbstract && p.Writeability == Writeability.All))
15 {
16 builder.Advice.OverrideAccessors(property, null, nameof(this.OverridePropertySetter));
17 }
18}
19
The BuildAspect
method first calls ImplementInterface to add
the INotifyPropertyChanged interface to the target type. The whenExists
parameter is set
to Ignore
, indicating that this call will just be ignored if the target type or any base type already implements the
interface. The ImplementInterface method requires the interface
members to be implemented by the aspect class and to be annotated with
the [InterfaceMember] custom attribute. Here, our only
member is the PropertyChanged
event:
20[InterfaceMember] public event PropertyChangedEventHandler? PropertyChanged;
21
To read more about this, see Implementing interfaces.
Note that the OnPropertyChanged
method is not a part of
the System.ComponentModel.INotifyPropertyChanged interface. So we
introduce it not by using the [InterfaceMember] custom
attribute but by using the [Introduce] attribute. We again
assign the Ignore
value to the WhenExists property, so we skip
this step if the target type already contains this method.
22[Introduce(WhenExists = OverrideStrategy.Ignore)]
23protected void OnPropertyChanged(string name) =>
24 this.PropertyChanged?.Invoke(meta.This, new PropertyChangedEventArgs(name));
25
The OnPropertyChanged
method invokes the PropertyChanged
event. Note that the expression meta.This
is translated
into simply this
by Metalama. It represents the run-time object, while the this
keyword in the aspect would
represent the aspect itself. For further details about adding members, see Introducing members.
Now, moving back to the BuildAspect
method. The next action it performs is to iterate through all properties that have
a setter. It does this by calling the OverrideAccessors
using OverridePropertySetter
as a template for the new property setter. For further details,
see Overriding fields or properties..
Let's have a look at this template:
26[Template]
27private dynamic OverridePropertySetter(dynamic value)
28{
29 if (value != meta.Target.Property.Value)
30 {
31 meta.Proceed();
32 this.OnPropertyChanged(meta.Target.Property.Name);
33 }
34
35 return value;
36}
The expression meta.Target.Property.Value
gives the current value of the property. When Metalama applies the aspect to
an automatic property, it turns it into a field-backed property, and this expression resolves the backing field.
meta.Proceed()
invokes the original implementation of the property. In the case of an automatic property, this means
that the backing field is set to the value
parameter.
Finally, the template calls the OnPropertyChanged
method. meta.Target.Property.Name
translates to the name of the
current property.
Limitations
This implementation has limitations that you should be aware of.
Limitation 1. Dependent properties
The first limitation of our implementation is that dependent properties are silently ignored. For instance, the
following code won't raise any notification for the FullName
property:
class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
// Notification never raised!
public string FullName => $"{FirstName} {LastName}"
}
Tip
For an open-source and production-ready implementation that support dependent properties, see Metalama.Patterns.Observability.
Limitation 2. Timing of notifications
The second limitation is more subtle. When you have a method that modifies several properties, the notifications with the current implementation will raise notification in the middle of the modifications, as individual properties are being modified, but a correct implementation would need to raise the notifications at the end, after all properties have been modified.
Consider for instance the following code:
class InvoiceLine
{
public decimal UnitPrice {get; private set; }
public decimal Units {get; private set; }
public decimal TotalPrice { get; private set; }
public void Update( decimal unitPrice, decimal totalPrice )
{
this.UnitPrice = unitPrice;
// PropertyChanged raised with broken invariants.
this.Units = units;
// PropertyChanged raised with broken invariants.
this.TotalPrice = unitPrice * units;
}
}
This class has an invariant TotalPrice = UnitPrice * Units
. The PropertyChanged
event will be raised in the middle
of the Update
class at a moment when class invariants are invalid. A listener that would process the event
synchronously would see the InvoiceLine
object in an invalid state. A proper implementation would buffer the events
and raise them at the end of the Update
method when all invariants are valid.
Contrarily to the first limitation, it's possible to address this limitation, but this is quite complex.
Limitation 3. Insufficient precondition checking
If the target type already implements the INotifyPropertyChanged interface but does not
implement the OnPropertyChanged
method, the aspect will generate invalid code. This limitation can be easily addressed
with Metalama.