📜 ⬆️ ⬇️

Automatic calculation of computable properties of view models in .NET

Motivation on the example of presentation models for WPF UI


Begin the discussion and get acquainted with the issues discussed are offered on the example of the approach to the architecture of user interfaces in WPF.

As you know, one of the main features of WPF is a powerful system of banding, which makes it quite easy to separate the presentation model (hereinafter the model ) from the presentation itself (hereinafter referred to as the View ) as such. Usually the programmer creates XAML for the view, binds the properties of its elements to the model in the same XAML by means of buydings and, in fact, forgets about View. This becomes possible since most UI-logic can be implemented through the impact on the model and automatically prokirato on UI by buyding. With this approach, the model plays the role of the View state, being its proxy for the layer that implements the UI logic. For example, changing the property of a model, we thereby change the corresponding View property (or its elements). The latter occurs automatically thanks to the bayding system, which tracks changes both in the model and in the View, synchronizing states at both ends as needed. One of the ways in which a model can inform an observer (which, in our case, is binding) about its change, is to throw the PropertyChanged event with the name of the changed property as a parameter. This event belongs to the INotifyPropertyChanged interface, which, accordingly, must be implemented in the model.

Consider the idea described by a specific example. Let's start with a simple model, which is a kind of order and contains two properties - Price and Quantity . Both properties will be changeable, so for each you need to implement a change notification. This is done by the following code:

public class Order : INotifyPropertyChanged { private decimal _price; private int _quantity; public decimal Price { get { return _price; } set { if (value == _price) return; _price = value; OnPropertyChanged(); } } public int Quantity { get { return _quantity; } set { if (value == _quantity) return; _quantity = value; OnPropertyChanged(); } } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { var handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } } 

Now let's imagine that we have a View representing the order instance in the form of two TextBlocks that are tied to Price and Quantity :
')
 <TextBlock Text="{Binding Price, StringFormat='Price: {0}'}" /> <TextBlock Text="{Binding Quantity, StringFormat='Quantity: {0}'}" /> 

If our UI logic changes any of the model properties, the corresponding binding will be notified of the change and change the text in the associated TextBlock. So far everything is extremely simple.

But now let's add the Cost property calculated to the model using the obvious formula:

  public int Cost { get { return _price * _quantity; } } 

And also corresponding to this TextBlock property:

 <TextBlock Text="{Binding Cost, StringFormat='Cost: {0}'}" /> 

Probably, the one who only gets acquainted with WPF has the right to expect that when the Price or Number changes, the TextBlock representing Cost will change its text too. Naturally, this will not happen, since the PropertyChanged event for the Cost property has not been thrown. Thus, we come to the problem of implementing updates on computable properties (properties whose value depends on the values ​​of other properties).

Possible solutions


In the case of the considered example, the solution is obviously quite simple. You need to throw PropertyChanged for Cost from Price and Quantity setters or change the Cost property from these setters (thereby raising the desired event from Cost ). Below is the code for both options:

 //Raise Cost PropertyChanged from both Price and Quantity setters. public class Order : INotifyPropertyChanged { private decimal _price; private int _quantity; public decimal Price { get { return _price; } set { if (value == _price) return; _price = value; OnPropertyChanged(); OnPropertyChanged("Cost"); } } public int Quantity { get { return _quantity; } set { if (value == _quantity) return; _quantity = value; OnPropertyChanged(); OnPropertyChanged("Cost"); } } public int Cost { get { return _price * _quantity; } } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { var handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } } 

 //Update Cost from both Price and Quantity setters. public class Order : INotifyPropertyChanged { private decimal _price; private int _quantity; public decimal Price { get { return _price; } set { if (value == _price) return; _price = value; OnPropertyChanged(); Cost = _price * _quantity; } } public int Quantity { get { return _quantity; } set { if (value == _quantity) return; _quantity = value; OnPropertyChanged(); Cost = _price * _quantity; } } public int Cost { get { return _cost; } private set { if (value == _cost) return; _cost = value; OnPropertyChanged(); } } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { var handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } } 

In fact, both solutions are not very good from several sides at once.

From an architectural point of view, we act badly because we invert the logical direction of the dependence of Cost on Price and Quantity . Now it is not Cost that “knows” about Price and Quantity , but, on the contrary, Price and Quantity begin to “know” about Cost , becoming responsible for its change. This in turn violates the SRP at the micro level, since the initially independent (and simple to implement) properties are now forced to have knowledge of the existence of other properties and their implementation details in order to be able to update these properties at the right moments.

From a technical point of view, not everything is good, because there can be quite complex links between properties, which can be manually maintained (rather laboriously) (and very difficult). Examples of such links are:


For example, to support the last described dependency, the programmer must write a large amount of routine code (the situation will become even more complicated if there is more than one collection in the dependency chain but several collections at once):

  1. Subscribe to ObservableCollection changes (via the INotifyCollectionChanged interface) representing Orders .
  2. Subscribe to the PropertyChanged of each item in the collection ( order ) to track changes in its Quantity property.
  3. Maintain relevant subscriptions up to date: unsubscribe from the elements removed from the collection and subscribe to the added ones, unsubscribe when the collection of the collection itself changes from the old collection and subscribe to the events of the new one.

To simplify this work, as well as to enable declarative presentation of dependencies, the Dependencies Tracker was created (DependenciesTracker) , which will be discussed below.

Dependencies Tracker (DependenciesTracker)


The .NET library DependenciesTracking implements automatic updating of computable properties and the ability to set dependencies in a declarative style. It is rather lightweight from the point of view of ease of use and from the point of view of implementation: its work requires neither creating any wrappers over properties (such as ObservableProperty <T> , IndependentProperty <T> , etc.), nor inheritance models from any base class, nor the need to label properties with any attributes. The implementation does not significantly use reflection and is not based on rewriting assemblies after compilation. The main component of the assembly is the class DependenciesTracker , the use of which will be further discussed in detail.

In general, in order for tracking of dependent properties to start working, you need to do 2 simple things:

  1. determine dependencies of properties (for a class as a whole),
  2. start tracking these dependencies (for a specific instance, usually in the constructor).

These points are discussed below with various examples.

Simple (single-level) dependencies


Let's start with the example that was described at the beginning of the article. We rewrite the Order class so that the Cost dependency of Price and Quantity is automatically tracked and entails a Cost recalculation when the Price or Quantity is changed. In accordance with PP.1-2 for this you need to implement the Order class as follows:

 public class Order : INotifyPropertyChanged { private decimal _price; private int _quantity; private decimal _cost; public decimal Price { get { return _price; } set { if (value == _price) return; _price = value; OnPropertyChanged(); } } public int Quantity { get { return _quantity; } set { if (value == _quantity) return; _quantity = value; OnPropertyChanged(); } } public decimal Cost { get { return _cost; } private set { if (value == _cost) return; _cost = value; OnPropertyChanged(); } } //  " ",       private static readonly IDependenciesMap<Order> _dependenciesMap = new DependenciesMap<Order>(); static Order() { //      _dependenciesMap.AddDependency(o => o.Cost, o => o.Price * o.Quantity, o => o.Price, o => o.Quantity) } private IDisposable _tracker; public Order() { //       _dependenciesMap.StartTracking(this); } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string propertyName = null) { var handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } 

The example shows that we use IDependenciesMap to determine dependencies and start tracking them. We will analyze this interface in more detail.

 public interface IDependenciesMap<T> { IDependenciesMap<T> AddDependency<U>(Expression<Func<T, U>> dependentProperty, Func<T, U> calculator, Expression<Func<T, object>> obligatoryDependencyPath, params Expression<Func<T, object>>[] dependencyPaths); IDependenciesMap<T> AddDependency<U>(Action<T, U> setter, Func<T, U> calculator, Expression<Func<T, object>> obligatoryDependencyPath, params Expression<Func<T, object>>[] dependencyPaths); IDisposable StartTracking(T trackedObject); } 

In the example, to add dependencies, we used the first version of the overloaded AddDependency method. It has the following parameters:

  1. dependentProperty - expression (Expression), describing the dependent property ( o => o.Cost ),
  2. calculator is a method that calculates the value of a dependent property on a specific model instance ( o => o.Price * o.Quantity ),
  3. ObligatoryDependencyPath and dependencyPaths - Expression'y, which describe the ways on which the property depends ( o => o.Price, o => o.Quantity ).

The second version of AddDependency takes the first parameter to the setter of the dependent property ( (o, val) => o.Cost = val ), instead of the Expression that describes it (and which ultimately compiles to the same setter). The rest of the methods are similar.

In the second step, we added the StartTracking call to the constructor. This means that tracking changes in properties in dependency paths will begin immediately when an order object is created. In the StartTracking method, the following actions are performed:

  1. necessary subscriptions are made for changes to properties in dependency paths,
  2. there is an initial calculation and setting of values ​​of computable properties.

The method returns IDisposable , which can be used to stop tracking changes at any stage of the model's life cycle.

Dependencies on property chains


Now let's complicate the example. To do this, move the Price and Quantity to a separate OrderProperties object:

  public class OrderProperties : INotifyPropertyChanged { private int _price; private int _quantity; public int Price { get { return _price; } set { if (_price != value) { _price = value; OnPropertyChanged("Price"); } } } public int Quantity { get { return _quantity; } set { if (_quantity != value) { _quantity = value; OnPropertyChanged("Quantity"); } } } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { PropertyChangedEventHandler handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } } 

And we put the OrderProperties object inside the Order , making, respectively, the Cost property dependent on OrderProperties.Price and OrderProperties.Quantity :

 public class Order : INotifyPropertyChanged { private OrderProperties _properties; private int _cost; public OrderProperties Properties { get { return _properties; } set { if (_properties != value) { _properties = value; OnPropertyChanged("Properties"); } } } public int Cost { get { return _cost; } private set { if (_cost != value) { _cost = value; OnPropertyChanged("Cost"); } } } private static readonly IDependenciesMap<Order> _map = new DependenciesMap<Order>(); static Order() { _map.AddDependency(o => o.Cost, o => o.Properties != null ? o.Properties.Price * o.Properties.Quantity : -1, o => o.Properties.Price, o => o.Properties.Quantity); } private IDisposable _tracker; public Order() { _tracker = _map.StartTracking(this); } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { PropertyChangedEventHandler handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } } 

Now Cost will be automatically recalculated when the Price or Quantity property Properties are changed at the order or when the Properties instance itself is changed. As you can see, to determine the dependence on the chain of properties turned out to be no more difficult than a simple single-level dependence.

Dependency on collection item properties


It seems that dependence on the properties of the elements of the collections is the most time-consuming in terms of manual support. The steps to be taken by the programmer to implement such a connection were described using the example TotalQuantity = Orders.Sum (o => o.Quantity) above in the article. But It is worth noting that this is only a case of dependence on one collection. If there are two or more collections in the chain, then the implementation will become much more complicated. DependenciesTracker supports this type of dependency and, as in previous cases, makes its definition declarative:

 public class Invoice : INotifyPropertyChanged { private readonly ObservableCollection<Order> _orders = new ObservableCollection<Order>(); private decimal _totalCost; public ObservableCollection<Order> Orders { get { return _orders; } set { if (value == _orders) return; _orders = value; OnPropertyChanged(); } } public decimal TotalCost { get { return _totalCost; } set { if (value == _totalCost) return; _totalCost = value; OnPropertyChanged(); } } private static readonly IDependenciesMap<Invoice> _dependenciesMap = new DependenciesMap<Invoice>(); static Invoice() { _dependenciesMap.AddDependency(i => i.TotalCost, i => i.Orders.Sum(o => o.Price * o.Quantity), i => i.Orders.EachElement().Price, i => i.Orders.EachElement().Quantity); } public Invoice() { _dependenciesMap.StartTracking(this); } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChangedEventHandler handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName)); } } 

As before, we defined a computable property, its calculator, dependency paths, and started tracking in the instance constructor. The only new construct we encountered is the EachElement method for moving from a collection to its element in a property chain. In our case, expression i => i.Orders.EachElement (). Price means TotalCost depends on the price of each order from the Orders collection. EachElement is intended only for constructing dependency paths that are expressions, so the method call in runtime is not supported:

  public static class CollectionExtensions { ... public static T EachElement<T>(this ICollection<T> collection) { throw new NotSupportedException("Call of this method is not supported"); } } 

Dependence on some collection aggregate


One of the cases of dependence on elements of the collection, which should be considered separately, is the dependence on the aggregate of the collection, the calculation of which does not use any CLR properties of the elements of the collection. Examples:


In such cases, the paths must be terminated with the method EachElement (and not just a collection):


Project status, links, future plans


Current stable version: 1.0.1.
Supported platform: .NET 4.0 and higher.
Links: project page on github , project wiki (in English) , NuGet package .

All the described functionality is covered by unit tests (coverage rate is 88%).

In the next versions of the library, it is planned to add support for tracking when models are inherited (adding, replacing and redefining dependencies in derived classes), as well as making the “path” out of the tracker's insides, making this concept public. The latter, in particular, makes it easy to implement triggers that depend on complex paths.

Alternative solutions


DependenciesTracker is, of course, not the only existing solution for tracking changes in dependent properties.
Some of the alternatives are discussed below.

  1. A simple (prototypical) implementation of the attribute-based approach:

     [DependentProperty("Price", "Quantity")] public decimal Cost { get { return _cost; } private set { if (value == _cost) return; _cost = value; OnPropertyChanged(); } } 

    Decision:

    • based on reflection,
    • requires the inheritance of the model from a special base class,
    • does not support dependencies on property chains and collections.

  2. NotifyPropertyChangeWeaver (add-in to Fody ):

     [DependsOn("Price", "Quantity")] public decimal Cost { get; set; } 

    Decision:

    • based on the rewriting of the assembly, the resulting IL for the example above will contain a raise of the Cost change event from the Price and Quantity setters;
    • does not support dependencies on property chains and collections.

  3. One of the standard aspects of PostSharp NotifyPropertyChanged :

     [NotifyPropertyChanged] public class CustomerViewModel { public CustomerModel Customer { get; set; } public string FullName { get { return string.Format("{0} {1} ({2})", Customer.FirstName, Customer.LastName, Customer.Address.City); } } } [NotifyPropertyChanged] public class CustomerModel { public AddressModel Address { get; set; } public FirstName { get; set; } public LastName { get; set; } } 

    Decision:

    • based on rewriting the assembly;
    • supports (recognizes) dependencies on chains of properties;
    • does not support dependencies on collection items;
    • does not support (does not recognize) dependencies on chains of properties wrapped in methods, like for example:

       [NotifyPropertyChanged] public class CustomerViewModel { public CustomerModel Customer { get; set; } public string FullName { get { return FormatFullName(); } } public string FormatFullName() { return string.Format("{0} {1} ({2})", Customer.FirstName, Customer.LastName, Customer.Address.City); } } 

      The latter fact may not be a big problem, but, nevertheless, it can lead to subtle bugs, especially in dynamics - with code changes and refactorings.

  4. Aspect NotifyPropertyChanged from PostSharp Domain Toolkit:
    • compared to the standard aspect from the previous example, this is an “on steroids” option that can recognize many case dependencies;
    • based on rewriting the assembly;
    • in addition to dependencies on properties, it also supports dependencies on fields and methods (of the same class);
    • supports dependencies on property chains;
    • causes compilation errors (which is very important) if, in calculating dependencies, it encounters an unsupported construct (for example, dependence on a method of another class);
    • does not support dependencies on collection elements.

  5. Knockout style observables (prototype).
    Decision:

    • based on wrappers over properties;
    • does not support dependencies on chains of properties and elements of collections;
    • has a rather verbose syntax:

       class ViewModel { readonly ObservableValue<string> firstName = new ObservableValue<string>("Alan"); public ObservableValue<string> FirstName { get { return firstName; } } readonly ObservableValue<string> lastName = new ObservableValue<string>("Turing"); public ObservableValue<string> LastName { get { return lastName; } } readonly ComputedValue<string> fullName; public ComputedValue<string> FullName { get { return fullName; } } public MainWindowViewModel() { fullName = new ComputedValue<string>(() => FirstName.Value + " " + ToUpper(LastName.Value)); } string ToUpper(string s) { return s.ToUpper(); } } 


  6. Automatic dependency tracker based on the analysis of the call stack.

    Decision:

    • supports simple dependencies;
    • supports dependencies on chains of properties, but the implementation leaves much to be desired (when the property of an internal object changes, an event about the change of this entire object rushes);
    • requires the inheritance of the model from a special base class and wrapping the code of getters and setters into special constructions

     public class Person : BindableObjectBase3 { private string firstName; private string lastName; public Person(string firstName, string lastName) { this.FirstName = firstName; this.LastName = lastName; } public string FirstName { get { using (this.GetPropertyTracker(() => this.FirstName)) { return this.firstName; } } set { this.SetValue(ref this.firstName, value, () => this.FirstName); } } public string LastName { get { using (this.GetPropertyTracker(() => this.LastName)) { return this.lastName; } } set { this.SetValue(ref this.lastName, value, () => this.LastName); } } public string FullName { get { using (this.GetPropertyTracker(() => this.FullName)) { return this.FirstName + " " + this.LastName; } } } } 

  7. Wintellect solution offering declarative fluent dependency definition syntax:

    • requires the inheritance of the model from a special base class;
    • does not support dependencies on property chains and collections;
    • supports "triggers" (the ability to call the delegate to change the properties of the model).

     public class MyViewModel : ObservableObject { string _firstName; string _lastName; bool _showLastNameFirst; public string FirstName { get { return _firstName; } set { SetPropertyValue(ref _firstName, value); } } public string LastName { get { return _lastName; } set { SetPropertyValue(ref _lastName, value); } } public string FullName { get { return ShowLastNameFirst ? String.Format ("{0}, {1}", _lastName, _firstName) : String.Format ("{0} {1}", _firstName, _lastName); } } public bool ShowLastNameFirst { get { return _showLastNameFirst; } set { SetPropertyValue(ref _showLastNameFirst, value); } } public string Initials { get { return (String.IsNullOrEmpty(FirstName) ? "" : FirstName.Substring(0,1)) + (String.IsNullOrEmpty(LastName) ? "" : LastName.Substring(0,1)); } } public DelegateCommand SaveCommand { get; private set; } public MyViewModel() { SaveCommand = new DelegateCommand(() => { // Save Data }, () => !(String.IsNullOrEmpty (FirstName) || String.IsNullOrEmpty (LastName))); WhenPropertyChanges(() => FirstName) .AlsoRaisePropertyChangedFor(() => FullName) .AlsoRaisePropertyChangedFor(() => Initials) .AlsoInvokeAction(SaveCommand.ChangeCanExecute); WhenPropertyChanges(() => LastName) .AlsoRaisePropertyChangedFor(() => FullName) .AlsoRaisePropertyChangedFor(() => Initials) .AlsoInvokeAction(SaveCommand.ChangeCanExecute); WhenPropertyChanges(() => ShowLastNameFirst ) .AlsoRaisePropertyChangedFor(() => FullName); } } 

Source: https://habr.com/ru/post/270979/


All Articles