Мотивация на примере моделей представлений для WPF UI


Начать обсуждение и познакомиться с обсуждаемой проблематикой предлагается на примере подхода к архитектуре пользовательских интерфейсов в WPF.

Как известно, одна из главных фич WPF — это мощная система байндингов, позволяющая достаточно легко отделить модель представления (далее модель) от самого представления (далее View) как такового. Обычно программист создает XAML для представления, привязывает свойства его элементов к модели в том же XAML посредством байндингов и, фактически, забывает о View. Это становится возможным поскольку большинство UI-логики может быть реализовано через воздействие на модель и автоматически прокинуто на UI посредством байндингов. При таком подходе модель играет роль состояния View, являясь его прокси для слоя, реализующего UI-логику. Например, меняя свойство модели, мы тем самым меняем соответствующее ей свойство View (или его элементов). Последнее происходит автоматически благодаря системе байндингов, которая отслеживает изменения как в модели, так и во View, синхронизируя состояния на обоих концах по мере надобности. Одним из способов, посредством которых модель может сообщить наблюдателю (коим в нашем случае является байндинг) о своем изменении, является бросание события PropertyChanged с именем изменившегося свойства в качестве параметра. Это событие принадлежит интерфейсу INotifyPropertyChanged, который, соответственно, должен быть реализован в модели.

Рассмотрим описанную идею на конкретном примере. Начнем с простой модели, которая представляет собой некий Заказ и содержит два свойства — Цена и Количество. Оба свойства будут изменяемыми, поэтому для каждого нужно реализовать нотификацию об изменении. Это делается следующим кодом:

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));
        }
    }

Теперь давайте представим, что у нас есть View, представляющее экземпляр заказа в виде двух TextBlock'ов, которые привязаны к Цене и Количеству:

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

Если наша UI-логика поменяет любое из свойств модели, соответствующий байндинг получит уведомление об изменении и изменит текст в привязанном TextBlock'е. Пока все предельно просто.

Но теперь добавим в модель свойство Стоимость, вычисляемое по очевидной формуле:

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

А также соответствующий этому свойству TextBlock:

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

Наверное, тот, кто только знакомится с WPF, вправе ожидать, что при изменении Цены или Количества TextBlock, представляющий Стоимость, изменит свой текст тоже. Естественно, этого не произойдет, поскольку не было кинуто событие PropertyChanged для свойства Cost. Таким образом, мы подошли к проблеме реализации уведомлений об обновлениях вычислимых свойств (свойств, значение которых зависит от значений других свойств).

Возможные решения


В случае с рассмотренным примером решение, очевидно, весьма простое. Нужно бросать PropertyChanged для Cost из сеттеров Price и Quantity или же изменять свойство Cost из этих сеттеров (вызывая тем самым рейз нужного события уже из Cost). Ниже представлен код обоих вариантов:

//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));
        }
    }

На самом деле, оба решения не очень хороши сразу с нескольких сторон.

С архитектурной точки зрения мы поступаем нехорошо поскольку инвертируем логически верное направление зависимости Стоимости от Цены и Количества. Теперь не Стоимость «знает» о Цене и Количестве, а, наоборот, Цена и Количество начинают «знать» о Стоимости, становясь ответственными за ее изменение. Это в свою очередь нарушает SRP на микроуровне, так как изначально не зависящие ни от чего (и простые в реализации) свойства теперь вынуждены иметь знания о существовании других свойств и деталей их реализации для того, чтобы иметь возможность в нужные моменты правильно эти свойства обновлять.

С технической точки зрения не все хорошо, потому что могут существовать достаточно сложные связи между свойствами, поддерживать вручную которые достаточно трудоемко (и багоемко). Примерами таких связей являются:

  • зависимости от свойств, которые сами являются зависимыми;
  • зависимости от свойств вложенных объектов (цепочки свойств), как в случае DiscountSum = Order.Sum * Order.Discount.Percent / 100;
  • зависимости от свойств элементов коллекции (TotalQuantity = Orders.Sum(o => o.Quantity)).

Например, чтобы поддержать последнюю описанную зависимость, программист должен написать большое количество рутинного кода (ситуация еще более усложнится, если в цепочке зависимости присутствует не одна, а сразу несколько коллекций):

  1. Подписаться на изменения ObservableCollection (через интерфейс INotifyCollectionChanged), представляющую Orders.
  2. Подписаться на PropertyChanged каждого элемента коллекции (заказа), чтобы отслеживать изменение его свойства Quantity.
  3. Поддерживать соответствующие подписки в актуальном состоянии: отписываться от удаляемых из коллекции элементов и подписываться на добавляемые, отписываться при смене экземпляра самой коллекции от старой коллекции и подписываться на события новой.

Для упрощения подобной работы а также для возможности декларативного представления зависимостей и был создан Трекер Зависимостей (DependenciesTracker), речь о котором пойдет ниже.

Трекер зависимостей (DependenciesTracker)


.NET библиотека DependenciesTracking реализует автоматическое обновление вычислимых свойств и возможность задавать зависимости в декларативном стиле. Она достаточно легковесна как с точки зрения простоты использования, так и с точки зрения реализации: для ее работы не требуется ни создания каких-либо оберток над свойствами (типа ObservableProperty<T>, IndependentProperty<T> и т.п.), ни наследования модели от какого-либо базового класса, ни необходимости помечать свойства какими-либо атрибутами. Реализация никак существенно не использует рефлексию и не базируется на переписывании сборок после компиляции. Основным компонентом сборки является класс DependenciesTracker, использование которого далее будет подробно разобрано.

В целом, для того, чтобы трекинг зависимых свойств начал работать, нужно сделать 2 простых вещи:

  1. определить зависимости свойств (для класса в целом),
  2. начать отслеживать эти зависимости (для конкретного экземпляра, обычно в конструкторе).

Указанные пункты рассмотрены ниже на различных примерах.

Простые (одноуровневые) зависимости


Начнем с примера, который был описан в начале статьи. Перепишем класс Order так, чтобы зависимость Cost от Price и Quantity отслеживалась автоматически и влекла пересчет Cost при изменении Price или Quantity. В соответствии с пп.1-2 для этого нужно реализовать класс Order следующим образом:

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));
        }

Из примера видно, что мы используем IDependenciesMap для того, чтобы определить зависимости и начать их отслеживать. Разберем этот интерфейс подробнее.

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);
    }

В примере для добавления зависимости мы использовали первую версию перегруженного метода AddDependency. Он имеет следующие параметры:

  1. dependentProperty — выражение (Expression), описывающее зависимое свойство (o => o.Cost),
  2. calculator — метод, который вычисляет значение зависимого свойства на конкретном экземпляре модели (o => o.Price * o.Quantity),
  3. obligatoryDependencyPath и dependencyPaths — Expression'ы, которые описывают пути, от которых свойство зависисит (o => o.Price, o => o.Quantity).

Вторая версия AddDependency первым параметром принимает сеттер зависимого свойства ((o, val) => o.Cost = val), вместо Expression'а, который его описывает (и который в итоге компилируется в этот же сеттер). В остальном методы аналогичны.

На втором шаге мы добавили вызов StartTracking в конструктор. Это означает, что отслеживание изменений свойств в путях зависимости начнется сразу при создании объекта заказа. В методе StartTracking производятся примерно следующие действия:

  1. делаются необходимые подписки на изменения свойств в путях зависимостей,
  2. происходит начальный подсчет и установка значений вычислимых свойств.

Метод возвращает IDisposable, который может быть использован для остановки отслеживания изменений на любом этапе жизненного цикла модели.

Зависимости от цепочек свойств


Теперь усложним пример. Для этого перенесем Price и Quantity в отдельный объект OrderProperties:

 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));
        }
    }

И положим объект OrderProperties внутрь Order, сделав, соответственно, свойство Cost зависимым от OrderProperties.Price и 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));
        }
    }

Теперь Cost будет автоматически пересчитываться при изменении Price или Quantity свойства Properties у заказа или же при изменении самого экземпляра Properties. Как видим, определить зависимость от цепочки свойств оказалось не сложнее, чем простую одноуровневую зависимость.

Зависимость от свойств элементов коллекций


Представляется, что зависимость от свойств элементов коллекций является наиболее трудоемкой с точки зрения ручной поддержки. Шаги, которые нужно предпринять программисту для имплементации такой связи, были описаны на примере TotalQuantity = Orders.Sum(o => o.Quantity) выше в статье. Но стоит отметить, что это лишь случай зависимости от одной коллекции. Если в цепочке встретятся две и более коллекции, то реализация существенно усложнится. DependenciesTracker поддерживает этот тип зависимости и, также как и в предыдущих случаях, делает его определение декларативным:

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));
        }
    }

Как и ранее, мы определили вычислимое свойство, его калькулятор, пути зависимости и запустили трекинг в конструкторе экземпляра. Единственная новая конструкция, которая нам встретилась — это метод EachElement для перехода от коллекции к ее элементу в цепочке свойств. В нашем случае expression i => i.Orders.EachElement().Price означает, что TotalCost зависит от цены каждого заказа из коллекции Orders. EachElement предназначен только для конструирования путей зависимостей, которые являются expression'ами, поэтому вызов метода в рантайме не поддерживается:

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

Зависимость от некоторого агрегата коллекции


Одним из случаев зависимости от элементов коллекции, который стоит рассмотреть отдельно, является зависимость от агрегата коллекции, вычисление которого не использует каких либо CLR-свойств элементов коллекции. Примеры:

  • HasNullValues = Orders.Any(o => o == null)
  • EvensCount = Ints.Count(i % 2 == 0)

В таких случаях пути необходимо заканчивать методом EachElement (а не просто коллекцией):

  • i => i.Orders.EachElement(), а не i => i.Orders,
  • i => i.Ints.EachElement(), а не i => i.Ints.

Статус проекта, ссылки, дальнейшие планы


Текущая стабильная версия: 1.0.1.
Поддерживаемая платформа: .NET 4.0 и выше.
Ссылки: страница проекта на github, вики проекта (на англ. яз.), NuGet пакет.

Весь описанный функционал покрыт юнит-тестами (степень покрытия — 88%).

В следующих версиях библиотеки планируется добавить поддержку трекинга при наследовании моделей (добавление, замену и переопределение зависимостей в производных классах), а также вынести «путь» из внутренностей трекера, сделав это понятие публичным. Последнее, в частности, позволит легко реализовывать триггеры, зависящие от сложных путей.

Альтернативные решения


DependenciesTracker — это, разумеется, не единственное существующее решение для трекинга изменений зависимых свойств.
Ниже рассмотрены некоторые из альтернатив.

  1. Простая (прототипная) реализация подхода, основанного на атрибутах:

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

    Решение:

    • базируется на рефлексии,
    • требует наследования модели от специального базового класса,
    • не поддерживает зависимости от цепочек свойств и коллекций.

  2. NotifyPropertyChangeWeaver (add-in к Fody):

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

    Решение:

    • основано на переписывании сборки, результирующий IL для примера выше будет содержать рейз события об изменении Cost из сеттеров Price и Quantity;
    • не поддерживает зависимости от цепочек свойств и коллекций.

  3. Один из стандартных аспектов PostSharp'a 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; }
    }
    

    Решение:

    • базируется на переписывании сборки;
    • поддерживает (распознает) зависимости от цепочек свойств;
    • не поддерживает зависимости от элементов коллекций;
    • не поддерживает (не распознает) зависимости от цепочек свойств, «завернутых» в методы, как например:

      [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);
       }
      }
      

      Последний факт, возможно, не является большой проблемой, но, тем не менее, может приводить к трудноуловимым багам, особенно в динамике — при изменениях кода и рефакторингах.

  4. Аспект NotifyPropertyChanged из PostSharp Domain Toolkit:
    • по сравнению со стандартным аспектом из предыдущего примера это такой вариант «на стероидах», способный распознавать много кейсов зависимостей;
    • основан на переписывании сборки;
    • кроме зависимостей от свойств поддерживает также зависимости от полей и методов (того же класса);
    • поддерживает зависимости от цепочек свойств;
    • вызывает ошибки компиляции (что очень важно), если при вычислении зависимости встречает неподдерживаемую конструкцию (например, зависимость от метода другого класса);
    • не поддерживает зависимости от элементов коллекций.

  5. Observables в стиле Knockout (прототип).
    Решение:

    • основано на обертках над свойствами;
    • не поддерживает зависимостей от цепочек свойств и элементов коллекций;
    • имеет достаточно многословный синтаксис:

      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. Автоматический трекер зависимостей, основанный на анализе стека вызовов.

    Решение:

    • поддерживает простые зависимости;
    • поддерживает зависимости от цепочек свойств, но реализация оставляет желать лучшего (при изменении свойства внутреннего объекта бросается событие об изменении всего этого объекта целиком);
    • требует наследования модели от специального базового класса и оборачивание кода геттеров и сеттеров в специальные конструкции.

    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, предлагающее декларативный fluent-синтаксис задания зависимостей:

    • требует наследования модели от специального базового класса;
    • не поддерживает зависимостей от цепочек свойств и коллекций;
    • поддерживает «триггеры» (возможность вызова делегата по изменению свойства модели).

    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);
        }
    }
    

Комментарии (24)


  1. astudent
    16.11.2015 16:17

    В 95% случаев спасает NotifyPropertyChangeWeaver, в остальных 5% (зависимости от цепочек свойств) можно объявить внутреннее свойство, которое обновляется вручную по внешним событиям и по которому плагин будет определять изменения в вычисляемых свойствах. Учитывая, что этот плагин сильно упрощает жизнь и крайне прост в использовании (он автоматически распознает вычисляемые свойства, если они состоят только из геттера — атрибуты не нужны), не понятно, зачем еще один велосипед.


    1. ademchenko
      16.11.2015 18:21

      Собственно, NotifyPropertyChangeWeaver описан в «Конкурентах», его возможности там рассмотрены. На мой взгляд, возможностей у него меньше, а трекер не менее прост в использовании. По поводу соотношения 95% и 5% — это, все же, определяется «жирностью» ViewModel и, в зависимости, от опыта, может варьироваться. Мне вот, например, в одно время, часто требовались вычислимые свойства «поверх» ObservableCollection, NotifyPropertyChangeWeaver мало мог в этом помочь.


  1. HomoLuden
    16.11.2015 16:22

    Статью не прочитал еще до конца, но уже есть, возможно, несущественное замечание.
    Мой комментарий уже не первый, так что могу себе позволить побрюзжать.

    Неудачно Вы выбрали шорткат для ViewModel: «модель». Такой выбор у знающих паттерн MVVM постоянно будет вызывать путаницу.


  1. HomoLuden
    16.11.2015 16:29

    Это в свою очередь нарушает SRP на микроуровне, так как изначально не зависящие ни от чего (и простые в реализации) свойства теперь вынуждены иметь знания о существовании других свойств и деталей их реализации для того, чтобы иметь возможность в нужные моменты правильно эти свойства обновлять.

    Если перейти по ссылке, то в самом начале написано, что SRP есть понятие применяемое к классу. Конкретно в данном случае «якобы SRP» нарушен конкретно указанной реализацией.
    Достаточно, например, сделать байндинг на стоимость read-only и при изменении остальных двух свойств делать вычисление и выставку стоимости в ViewModel, и сразу противоречие иссякнет.


    1. HomoLuden
      16.11.2015 16:33

      С технической точки зрения не все хорошо, потому что могут существовать достаточно сложные связи между свойствами, поддерживать вручную которые достаточно трудоемко (и багоемко). Примерами таких связей являются:

      зависимости от свойств, которые сами являются зависимыми;
      зависимости от свойств вложенных объектов (цепочки свойств), как в случае DiscountSum = Order.Sum * Order.Discount.Percent / 100;
      зависимости от свойств элементов коллекции (TotalQuantity = Orders.Sum(o => o.Quantity)).

      А вот это уже понятный довод. Но как и написали в комментарии выше, в сети можно найти реализации аттрибутов для свойств или других способов, реализующих зависимости между свойствами.


  1. HomoLuden
    16.11.2015 16:39

    //Определяем статическую "карту зависимостей", которая будет хранить зависимости для класса
            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);
            }
    


    Метод возвращает IDisposable, который может быть использован для остановки отслеживания изменений на любом этапе жизненного цикла модели.


    Я бы в Вашей статье разместил «АХТУНГ» (большими красными буквами), что ОБЯЗАТЕЛЬНО следует вычищать статически определенный _dependenciesMap, а не то возможны утечки памяти.

    И тут сразу вопрос, а как, когда и кто, по-вашему, должен диспозить _dependenciesMap?


    1. a553
      16.11.2015 17:09

      Я думаю StartTracking не хранит ссылку на переданный инстанс, поэтому утечки памяти не будет.


    1. ademchenko
      16.11.2015 17:14
      +1

      _dependenciesMap это статическое поле класса, диспозить его не нужно. Вычищать обязательно тоже ничего не нужно. В _dependenciesMap задана карта зависимостей для вычислимых свойств. Диспозить может потребоваться трекер — объект, которые следит за изменениями цепочек зависимостей в конкретном экземпляре класса. Да и то требуется это только тогда, когда вы хотите в какой-то момент жизненного цикла остановить трекинг зависимости. Если не хотите (это обычно, by default), то и не нужно. Объект принадлежит конкретному инстансу, соотв-но, соберется вместе с ним. Естественно, ссылок на него внутри _dependenciesMap нет.


      1. HomoLuden
        16.11.2015 17:50
        +1

        Да, код я слабовато изучил. Заглянул в репозиторий и поглядел на реализацию.

        1. В приведенном коде DependenciesMap создается как статическое поле и висит в памяти пока не завершит работу приложение.
        2. При каждом вызове StartTracking создается новый инстанс DependenciesTracker, который в приватное поле сохраняет ссылку на DependenciesMap и на текущий инстанс ViewModel.
        3. DependenciesMap ссылку на трекер и на инстанс VM не хранит. Вместо этого трекер хранит ссылку на VM и имеет подписку на ее события. Также трекер пользуется ссылкой на статически определенную DependenciesMap.
        4. Таким образом, трекер не собирается GC только благодаря подпискам на события VM. Больше на него никто не ссылается в указанном примере. Если собирется инстанс VM, то и трекер уйдет вслед за VM.

        Правильно понял? Может стоит в статье описать принцип работы, а то по примеру складывается впечатление, что DependenciesMap может сохранить у себя ссылку на инстанс VM?


        1. ademchenko
          16.11.2015 18:16

          Да, вы все верно написали. Наверное, вы правы по поводу необходимых уточнений в статье.
          По поводу п.4 в разных примерах у меня по разному (и это конечно, огрехи при написании статьи) — где-то ссылка на трекер есть, где-то ее нет внутри класса. В первом случае, от подписок ничего не зависит, во втором — зависит. Замечу, что второй случай тоже безопасен. Если трекер не смог создать ни одной подписки на путь (например, когда в пути только readonly-поля, это тоже поддерживается), значит, и отслеживать никаких изменений ему не удастся. Значит, можно его собирать :-)


  1. Athari
    16.11.2015 18:32

    Зачёт! И за сравнение с конкурентами зачёт.

    Но есть нюансы…

    зависимости от свойств, которые сами являются зависимыми;

    Если немного приукрасить реализацию INPC и учесть, что реализация интерфейса обычно находится в базовом классе (а при использовании решарпера можно и атрибутами приукрасить), то код получается более-менее чистым:

    public class Order : ModelBase
    {
        private decimal _price;
        private int _quantity;
    
        public decimal Price
        {
            get { return _price; }
            set { Set(ref _price, value, "Price", "Cost"); }
        }
    
        public int Quantity
        {
            get { return _quantity; }
            set { Set(ref _quantity, value, "Quantity", "Cost"); }
        }
    
        public int Cost
        {
            get { return _price * _quantity; }
        }
    
        // ...
    }

    То есть для таких простых случаев большого смысла шаманить нет. Хотя, конечно, списки зависимых свойств иногда разрастаются, что не очень приятно. Например, при использовании Caliburn.Micro часто возникает куча свойств с префиксом «Can»…

    С другой стороны, реализация с помощью трекера выглядит не такой уж простой, и при этом сказывается на архитектуре: read-only свойство использовать нельзя, добавляется поле типа IDisposable, код из свойства выкидывается в статический конструктор… Можно сделать как-нибудь почище?

    Например, хотелось бы, чтобы код можно было оставить в свойстве, а трекер только кидал уведомления об изменении.

    зависимости от свойств вложенных объектов (цепочки свойств)

    Часто бывает, что такие свойства только для чтения, поэтому подписок не слишком много.

    зависимости от свойств элементов коллекции

    Если коллекция одна, то можно обойтись какой-нибудь коллекцией «NotifyingCollection where T: INPC», где у коллекции бросается какое-нибудь событие при изменении любого элемента. Тогда почти всё шаманство с подписками остаётся внутри коллекции.

    Если коллекций несколько, то что-то не так с архитектурой. Длинных цепочек надо избегать. Закон Деметры и всё такое.


    1. ademchenko
      19.11.2015 01:50

      read-only свойство использовать нельзя

      read-only нельзя, а приватный сеттер можно. Это, конечно, не read-only, но наличие а) private setter б) возможности того, что «property changed» диссонанса не вызывает, а скорее даже наоборот — кажется вполне логичным. Проперти должны вести себя как поля? ОК, если проперти меняется, значит, его кто-то поменял, значит, должен быть сеттер. Но это так, пространные размышления. А на практике, согласитесь, не ограчение это вовсе :-) (уж в случае вью-моделей точно).
      добавляется поле типа IDisposable

      Ну уж :-) В жирной-то вью-модели добавление одного свойства — это усложнение? Особенно в канонической реализации MVVM по статьям MS, где view model — это вообще God Object. Но вообще-то можно это поле и не добавлять — хранить его нужно только в случае, если хочется в какой-то из моментов жизненного цикла view model трекинг остановить. Если не нужно — то и поля не нужно.
      Например, хотелось бы, чтобы код можно было оставить в свойстве, а трекер только кидал уведомления об изменении.

      Я бы не сказал, что подход совсем плохой. Думаю, он вполне заслуживает права на жизнь. Более того, некоторые конкуренты так и делают. Просто очевидных плюсов, достаточных для того, чтобы все переделать нет, а вот некоторые проблемы видны сразу. А именно, если есть сеттер, куда значение кладется, то совершенно забесплатно, можно получить следующие вещи:
      1. Кеширование вычисленного значения. TotalCost = Orders.Sum(o => o.Price * o.Quantity) вообще-то имеет O(n) сложность вычисления.
      2. Рейз события только когда значение поменялось, благодаря стандартному if (_property != value). А можно и всегда рейзить — если эту проверку не написать. В любом случае — слово за тем, кто написал сеттер, а не за трекером. Что, конечно, хорошо, так как без навязывания. Иногда проверка на новое значение не нужна, а иногда проверка очень даже нужна — когда зависимости настолько нетривиальны, что появляются циклы, когда A --> B, а B --> A через какие-нибудь сложные цепочки. И простой способ сделать так, чтобы «колебание затухло» — это не рейзить новых событий, если ничего не поменялось.

      Если коллекция одна, то можно обойтись какой-нибудь коллекцией «NotifyingCollection where T: INPC»

      Разве эту коллекцию не нужно реализовать также, как трекер? Чем подход проще, когда уже есть трекер?

      где у коллекции бросается какое-нибудь событие при изменении любого элемента.

      А дальше что с ним делать? Событие нестандартное (надеюсь, вы не собираетесь бросать какой-нибудь CollectionChanged с Reset'ом), биндинг его не поймет. Надо, значит, внутри вью-модели подписываться на него и обновлять вычислимое свойство. А если инстанс коллекции поменяли? :-) :-) Надо переподписаться на событие :-) :-) Ну это как-то тот же трекер и получается :-)


      1. Athari
        19.11.2015 15:03

        ОК, если проперти меняется, значит, его кто-то поменял, значит, должен быть сеттер.

        То есть ты отказываешь вычислимым read-only свойствам в праве на существование? :)

        Но вообще-то можно это поле и не добавлять — хранить его нужно только в случае, если хочется в какой-то из моментов жизненного цикла view model трекинг остановить.

        А вот это, кстати, можно упомянуть. VM не так уж часто реализуют IDisposable, поэтому отписку девать некуда. А без поля и код будет проще.

        Кеширование вычисленного значения. TotalCost = Orders.Sum(o => o.Price * o.Quantity) вообще-то имеет O(n) сложность вычисления.

        А вот тут есть нюансы.

        Во-первых, этот бонус не всегда работает. Если к этому свойству обращаются по несколько раз, причём всегда — да, всё круто. Если к свойству обращаются на одном экране из десяти, то пересчитывать его при каждом чихе — это наоборот удар по производительности. В кэше должно лежать то, что запрошено, а не всё и на всякий случай.

        Во-вторых, если подобные вычисления ударяют по производительности (например, в списке миллион элементов), то вычисление функций Sum, Count и прочих прекрасно оптимизируется: при изменении одного элемента достаточно посчитать дельту. Здесь от «кэширования» польза была бы, но трекер такое не поддерживает.

        Рейз события только когда значение поменялось

        Здесь опять компромисс, и трекер реализует только один вариант. Я хочу иметь возможность выбирать, что для меня дороже по ресурсам: пересчитывать каждое свойство, даже если оно не нужно, или пересчитывать нужное свойство, даже если оно не поменялось. В идеале, конечно, хотелось бы избавиться от обеих проблем, а не только от одной.

        Иногда проверка на новое значение не нужна, а иногда проверка очень даже нужна — когда зависимости настолько нетривиальны, что появляются циклы, когда A --> B, а B --> A через какие-нибудь сложные цепочки.

        Подобные цепочки — это уже признак, что что-то не так. В идеале их не должно быть, и избавляться от них желательно не проверкой новых значений с прежними, что по сути костыль. Никто ведь даже не гарантирует, что цикл зависимостей приведёт к «сходящейся» паре значений.

        Кроме того, все эти проверки избыточны, если все вычислимые через трекер свойства будут read-only и вычисляться геттером. Скажем, если изменилось свойство A, то трекер может запомнить, что значения свойств B и C устарели, но не перевычислять их. Тогда, если гуй заинтересуется изменением, он обратится к геттеру, который либо возьмёт из трекера актуальное значение, либо перезапишет. Что-нибудь в духе:

        public string FullName
        {
            get { _tracker.Get(() => FirstName + " " + LastName); }
        }

        где вторым скрытым аргументом Get будет имя свойства через CallerMemberName. И всё, мы имеем и кэширование, и отсутствие перевычислений ненужных свойств, и основную логику свойства прямо в свойстве, и отсутствие лишних полей. Зависимости правда останутся в статическом конструкторе, и в мусоре на одно замыкание больше, ну да и шут с ними.

        Я ничего не упустил?

        Разве эту коллекцию не нужно реализовать также, как трекер? Чем подход проще, когда уже есть трекер?

        Эта коллекция простая как топор, в отличие от трекера. :) А так разницы мало.

        А если инстанс коллекции поменяли?

        Нормальные люди так не делают.


  1. jack128
    16.11.2015 21:45

    Один из конкурентов — ReactiveUI

    public class ViewModel : ReactiveObject
    {
    	public ViewModel()
    	{
    		_fullName = this.WhenAny(x => x.FirstName, y => y.LastName, 
    			(firstName, lastName) => firstName + " " + lastName)
    			.ToProperty(this, x => x.FullName);
    	}
    	private string _firstName;
      	public string FirstName 
      	{
      		get { return _firstName; }
      		set { this.RaiseAndSetIfChanged(ref _firstName, value); }
      	}
    
      	private string _lastName;
    	public string LastName 
      	{
      		get { return _lastName; }
      		set { this.RaiseAndSetIfChanged(ref _lastName, value); }
      	}
    
      	private ObservableAsPropertyHelper<string> _fullName;
      	public string FullName 
      	{
      		get { return _fullName.Value; }
      	}
    }
    


    1. ademchenko
      19.11.2015 01:55

      И обладает всем набором недостатков (глубоко не копал, так что поправьте, если ошибаюсь):
      Не поддерживает цепочек свойств и зависимостей от элементов коллекций.

      : ReactiveObject

      Требует наследования от специального класса.

      ObservableAsPropertyHelper _fullName

      Требует оберток над полями.


  1. EngineerSpock
    17.11.2015 09:40

    Ещё есть Assisticant от Майкла Перри.


  1. novar
    17.11.2015 14:08
    +1

    Для привязки вычисляемого из нескольких свойств значения использую MultiBinding, делая при необходимости простенькие конвертеры. Обновление при этом происходит автоматически, без заморочек. Предложенный в статье метод показался сложноватым.


  1. FiresShadow
    17.11.2015 14:13

    Не совсем понимаю, зачем городить огород, почему нельзя сделать что-то наподобие:

            public void RegisterPropertiesDependencies(string propertyName, List<string> dependenciesProperties)
            {
                foreach (var dependencyProperty in dependenciesProperties)
                {
                    this.PropertyChanged += (sender, args) =>
                    {
                        if (args.PropertyName == dependencyProperty) RaisePropertyChanged(propertyName);
                    };  
                }
            }
    
            ...
            RegisterPropertiesDependencies("FullName", new List<string> { "FirstName", "LastNameName"});
    


    Для коллекций вложенных объектов пробегать в цикле по их PropertyChanged (для этого использовать какой-нибудь ObservableCollection, чтобы отследить добавление нового объекта и подписаться на его PropertyChanged).


    1. ademchenko
      17.11.2015 18:12
      +1

      Сделать так, как вы пишете, разумеется, можно. Но на мой взгляд это достаточно трудоемко. Было бы интересно увидеть актуальный код от вас, который поддерживает, допустим, цепочку с коллекцией типа TotalCost = o.OrderProperties.Orders.Sum(o => o.Price * o.Quantity), а также оценку сколько по времени у вас заняла реализация всего этого.


      1. FiresShadow
        18.11.2015 12:50

        Ответил в виде статьи — вдруг кому-то ещё будет интересно посмотреть. Код писал примерно час-полтора.



  1. FiresShadow
    17.11.2015 15:08

    Имхо, вероятность изменить алгоритм вычисления свойства и забыть вызвать PropertyChanged в нужном месте примерно равна вероятности забыть указать зависимость в менеджере зависимостей. Сложность обеих операций тоже примерно одинакова. Knockout Style Observables решает проблему необходимости указывания зависимостей (при условии, что в expression не встречаются вызовы кастомных методов). Не знаю, может он и не умеет обрабатывать вложенные коллекции, но его этому можно научить — достаточно отслеживать в дереве выражений вызов методов-расширений над IEnumerable.
    А какую проблему решает ваш проект? Незначительно увеличивает cohesion? Вы думаете, что незначительное увеличение cohesion окупает усложнение кода путём добавления нового проекта? Вижу, что проделано много работы, но пока что не понимаю, для чего.


    1. ademchenko
      17.11.2015 18:32

      Cohesion тут, конечно, почти не при чем. Как и написано в статье решается проблема сложности ручной поддержки нетривиальных зависимостей. KSO решает только одну проблему — простые одноуровневые зависимости. Если у вас только такие зависимости, то без особых проблем можно использовать как KSO, так и любой из конкурентов. Как и в предыдущем комментарии, хотелось бы увидеть код, который «научил» бы простым образом KSO работать с коллекциями. В частности, интересно сколько времени займет аккуратная реализация обработки каждого из типов событий INotifyCollectionChanged.


    1. ademchenko
      17.11.2015 18:36

      Вы думаете, что незначительное увеличение cohesion окупает усложнение кода путём добавления нового проекта?

      Использование KSO — это тоже добавление нового проекта :-)