Pull to refresh

Ещё один способ реализации binding-а вычислимых свойств в WPF

Reading time5 min
Views6.1K
Допустим, есть проект на WPF и в нём ViewModel, в которой есть два свойства Price и Quantity, и вычислимое свойство TotalPrice=Price*Quantity

Код
public class Order : BaseViewModel
    {
        private double _price;
        private double _quantity;

        public double Price 
        {
            get { return _price; }
            set
            {
                if (_price == value)
                    return;
                _price = value;
                RaisePropertyChanged("Price");
            }
        }

        public double Quantity
        {
            get { return _quantity; }
            set
            {
                if (_quantity == value)
                    return;
                _quantity = value;
                RaisePropertyChanged("Quantity");
            }
        }

        public double TotalPrice {get { return Price*Quantity; }}
    }

    public class BaseViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void RaisePropertyChanged(string propertyName)
        {
            var propertyChanged = PropertyChanged;
            if (propertyChanged != null)
                propertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }




Если Price будет изменен в коде, то изменения цены автоматически отобразятся в View, потому что ViewModel сообщит View об изменении Price посредством вызовом события RaisePropertyChanged(«Price»). Вычисляемое TotalPrice же не изменится в View, потому что никто не вызывает RaisePropertyChanged(«TotalPrice»). Можно вызывать RaisePropertyChanged(«TotalPrice») в тех же местах, где вызывается RaisePropertyChanged(«Price») и RaisePropertyChanged(«Quantity»), но не хотелось бы размазывать по множеству мест информацию о том, что TotalPrice зависит от Price и Quantity, а хотелось бы хранить информацию об этом в одном месте. С этой целью люди пишут разнообразные менеджеры зависимостей, но давайте посмотрим какой минимальный код на самом деле нужен для этого.

Стандартный способ прокинуть логику туда, где ей не место с точки зрения дизайна, — это события. Подход в лоб заключается в создании двух событий OnPriceChanged и OnQuantityChanged. При срабатывании этих событий делать RaisePropertyChanged(«TotalPrice»). Сделаем подписку на эти события в конструкторе ViewModel. После этого информация о том, что TotalPrice зависит от Price и Quantity будет в одном месте — в конструкторе (ну, или в отдельном методе, если вы того пожелаете).

Немного упростим задачу: у нас уже есть событие PropertyChanged, срабатывающее при изменении Price, вот его и используем.

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

        RegisterPropertiesDependencies("TotalPrice", new List<string> { "Price", "Quantity"});


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

Код OrderProperties и ViewModel. Тут всё очевидно, можно не смотреть
public class OrderProperties : BaseViewModel
    {
        private ObservableCollection<Order> _orders = new ObservableCollection<Order>();

        public ObservableCollection<Order> Orders
        {
            get { return _orders; }
            set
            {
                if (_orders == value)
                    return;
                _orders = value;
                RaisePropertyChanged("Orders");
            }
        }
    }

    public class TestViewModel : BaseViewModel
    {
        public double Summa {get { return OrderProperties.Orders.Sum(o => o.Price*o.Quantity); }}

        public OrderProperties OrderProperties
        {
            get { return _orderProperties; }
            set
            {
                if (_orderProperties == value)
                    return;
                _orderProperties = value;
                RaisePropertyChanged("OrderProperties");
            }
        }

        private OrderProperties _orderProperties;
    }




Подпишемся через события на изменения Price и Quantity каждого элемента коллекции. Но в коллекцию могут добавляться\удаляться элементы. При изменении коллекции нужно вызвать RaisePropertyChanged(«TotalPrice»). При добавлении элемента нужно подписаться на его изменении Price и Quantity. Ещё необходимо учесть, что в OrderProperties кто-то может присвоить новую коллекцию, или в ViewModel новый OrderProperties.

Получился вот такой код:

        public void RegisterElementPropertyDependencies(string propertyName, object element, ICollection<string> destinationPropertyNames, Action actionOnChanged = null)
        {
            if (element == null)
                return;

            if (actionOnChanged != null)
                actionOnChanged();

            if (element is INotifyPropertyChanged == false)
                throw new Exception(string.Format("Невозможно отслеживать изменения при биндинге в {0}, т.к. он не реализует INotifyPropertyChanged", element.GetType()));

            ((INotifyPropertyChanged)element).PropertyChanged += (o, eventArgs) =>
            {
                if (destinationPropertyNames.Contains(eventArgs.PropertyName))
                {
                    RaisePropertyChanged(propertyName);

                    if (actionOnChanged != null)
                        actionOnChanged();
                }
            };
        }

        public void RegisterCollectionPropertyDependencies<T>(string propertyName, ObservableCollection<T> collection, ICollection<string> destinationPropertyNames, Action actionOnChanged = null)
        {
            if (collection == null)
                return;

            if (actionOnChanged != null)
                actionOnChanged();

            foreach (var element in collection)
            {
                RegisterElementPropertyDependencies(propertyName, element, destinationPropertyNames);
            }

            collection.CollectionChanged += (sender, args) =>
            {
                RaisePropertyChanged(propertyName);

                if (args.NewItems != null)
                {
                    foreach (var addedItem in args.NewItems)
                    {
                        RegisterElementPropertyDependencies(propertyName, addedItem, destinationPropertyNames, actionOnChanged);
                    }
                }
            };
        }


В данном случае, для OrderProperties.Orders.Sum(o => o.Price*o.Quantity) его нужно использовать вот так:

RegisterElementPropertyDependencies("Summa", this, new[] {"OrderProperties"},
                () => RegisterElementPropertyDependencies("Summa", OrderProperties, new[] {"Orders"},
                () => RegisterCollectionPropertyDependencies("Summa", OrderProperties.Orders, new[] { "Price", "Quantity" })));


Протестировал этот код в разных ситуациях: менял Quantity у элементов, создавал новые Orders и OrderProperties, сначала менял Orders а потом Quantity и т.п., код отработал корректно.

P.S. Кстати, рекомендую посмотреть в сторону Observables в стиле Knockout. Там вообще не нужно указывать от чего зависит свойство, нужно просто передать алгоритм его вычисления:
fullName = new ComputedValue(() => FirstName.Value + " " + ToUpper(LastName.Value));
Библиотека проанализирует дерево выражений, увидит в нём доступ к членам FirstName и LastName, и сама проконтролирует зависимости. Исчезает риск забыть переуказать зависимости после изменения алгоритма вычисления свойства. Правда, говорят, что библиотека немного не доработана, и не отслеживает вложенные коллекции, но, если у вас вагон свободного времени, то можно открыть исходники (доступны по предыдущей ссылке) и немного поработать напильником, или написать свой велосипед-анализатор дерева выражений.

P.P.S. По поводу сборки мусора: если добавить в финализаторы элементов вывод сообщений, то можно обнаружить, что при закрытии окна все элементы собираются сборщиком мусора (несмотря на то, что ViewModel имеет ссылку на дочерний элемент, а дочерний элемент имеет ссылку на ViewModel в обработчике события). Это объясняется тем, что в WPF для устранения утечек памяти при DataBinding-е используются слабые события посредством PropertyChangedEventManager-а. Подробнее можно почитать по ссылкам: [1], [2], [3]
Tags:
Hubs:
Total votes 8: ↑6 and ↓2+4
Comments44

Articles