Pull to refresh

Базовая реализация INotifyPropertyChanged

Reading time 12 min
Views 22K
WPF в чём-то повторил судьбу js — в силу некоторых нерешённых на уровне платформы проблем многие пытаются стать первооткрывателями наравне с Карлом фон Дрезем.

Проблема


В случае с INPC в ViewModel часто существуют свойства, зависящие от других или вычисляемые на их основе. Для .net 4.0 ситуация с реализацией усложняется тем, что CallerMemberNameAttribute не поддерживается в этой версии (на самом деле поддерживается, если вы маг и кудесник).

Решение


Предисловие
Разбирая в очередной раз проект с десятками строк в package-файле, мне становится всё ближе концепция UNISTACK, когда комплексное хорошо интегрированное решение позволяет реализовывать типовые задачи в типовых сценариях и оставляет место для расширения под нужды пользователя. И одновременно с этим я вижу фатальные недостатки существующих решений — громоздкость и тяжеловесность. И, иногда, немодульность.

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

Одной из основ библиотеки Rikrop.Core.Wpf служит базовый класс объекта реализующего интерфейс INotifyProprtyChanged — ChangeNotifier, который предлагает своим наследникам следующий набор методов:
[DataContract(IsReference = true)]
[Serializable]
public abstract class ChangeNotifier : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
 
    protected void SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
 
    protected void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
    protected void NotifyPropertyChanged(Expression<Func<object, object>> property)
    protected void NotifyPropertyChanged(Expression<Func<object>> property)
    protected virtual void OnPropertyChanged(string propertyName)
 
    protected ILinkedPropertyChanged AfterNotify(Expression<Func<object> property)
    protected ILinkedPropertyChanged BeforeNotify(Expression<Func<object>> property)
    protected ILinkedPropertyChanged AfterNotify<T>(T changeNotifier, Expression<Func<T, object>> property)
        where T : INotifyPropertyChanged
    protected ILinkedPropertyChanged BeforeNotify<T>(T changeNotifier, Expression<Func<T, object>> property)
        where T : ChangeNotifier
 
    protected ILinkedObjectChanged Notify(Expression<Func<object>> property)
}

Здесь же сразу стоит указать интерфейсы ILinkedPropertyChanged и ILinkedObjectChanged:
public interface ILinkedPropertyChanged
{
    ILinkedPropertyChanged Notify(Expression<Func<object>> targetProperty);
    ILinkedPropertyChanged Execute(Action action);
}
 
public interface ILinkedObjectChanged
{
    ILinkedObjectChanged AfterNotify(Expression<Func<object>> sourceProperty);
    ILinkedObjectChanged AfterNotify<T>(T sourceChangeNotifier, Expression<Func<T, object>> sourceProperty)
            where T : INotifyPropertyChanged;
 
    ILinkedObjectChanged BeforeNotify(Expression<Func<object>> sourceProperty);
    ILinkedObjectChanged BeforeNotify<T>(T sourceChangeNotifier, Expression<Func<T, object>> sourceProperty)
            where T : ChangeNotifier;
}

Надуманный пример использования


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

У нас есть устройство с N однотипных датчиков, которое отображает среднее значение со всех датчков. Каждый датчик отображает измеренное значение и отклонение от среднего. При изменении значения датчика мы должны вначале пересчитать среднее значение, а затем уже уведомить об изменении на самом датчике. При изменении среднего значения нам необходимо пересчитать отклонения от среднего для каждого из датчиков.
/// <summary>
/// Датчик.
/// </summary>
public class Sensor : ChangeNotifier
{
    /// <summary>
    /// Значение измерения.
    /// </summary>
    public int Value
    {
        get { return _value; }
        set { SetProperty(ref _value, value); }
    }
    private int _value;

    /// <summary>
    /// Отклонения значения измерения от среднего.
    /// </summary>
    public double Delta
    {
        get { return _delta; }
        set { SetProperty(ref _delta, value); }
    }
    private double _delta;

    public Sensor(IAvgValueIndicator indicator)
    {
        // В угоду примеру расскажем реализации немного лишнего
        BeforeNotify(() => Value).Notify(() => indicator.AvgValue);
        IValueProvider valueProvider = new RandomValueProvider();
        Value = valueProvider.GetValue(this);
    }
}

/// <summary>
/// Прибор с датчиками, проводящими измерения.
/// </summary>
public class Device : ChangeNotifier, IAvgValueIndicator
{
    /// <summary>
    /// Число датчиков.
    /// </summary>
    private const int SensorsCount = 3;

    /// <summary>
    /// Множество датчиков в устройстве.
    /// </summary>
    public IReadOnlyCollection<Sensor> Sensors
    {
        get { return _sensors; }
    }
    private IReadOnlyCollection<Sensor> _sensors;

    /// <summary>
    /// Среднее значение с датчиков.
    /// </summary>
    public double AvgValue
    {
        get { return (Sensors.Sum(s => s.Value)) / (double)Sensors.Count; }
    }

    public Device()
    {
        InitSensors();
        AfterNotify(() => AvgValue).Execute(UpdateDelta);
        NotifyPropertyChanged(() => AvgValue);
    }

    private void InitSensors()
    {
        var sensors = new List<Sensor>();
        for (int i = 0; i < SensorsCount; i++)
        {
            var sensor = new Sensor(this);
            //BeforeNotify(sensor, s => s.Value).Notify(() => AvgValue);
            sensors.Add(sensor);
        }

        _sensors = sensors;
    }

    private void UpdateDelta()
    {
        foreach (var sensor in Sensors)
            sensor.Delta = Math.Abs(sensor.Value - AvgValue);
    }
}

Интересующие нас строки кода:
SetProperty(ref _delta, value);
NotifyPropertyChanged(() => AvgValue);
AfterNotify(() => AvgValue).Execute(UpdateDelta);
BeforeNotify(() => Value).Notify(() => indicator.AvgValue);
BeforeNotify(sensor, s => s.Value).Notify(() => AvgValue);

Отдельно разберем каждую конструкцию и посмотрим на реализацию приведенных методов.

Реализация


SetProperty(ref _delta, value)


Этот код присваивает полю, переданному в первом параметре метода, значение из второго параметра, а так же уведомляет подписчиков об изменении свойства, имя которого передаётся третьим параметром. Если третий параметр не задан, используется имя вызывающего свойства.
protected void SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
    if (Equals(field, value))
    {
        return;
    }
 
    field = value;
    NotifyPropertyChangedInternal(propertyName);
}

NotifyPropertyChanged(() => AvgValue)


Все методы нотификации об изменении объектов, принимают ли они дерево выражений или строковое значение имени свойства, в конечном итоге вызывают следующий метод:
private void NotifyPropertyChanged(PropertyChangedEventHandler handler, string propertyName)
{
    NotifyLinkedPropertyListeners(propertyName, BeforeChangeLinkedChangeNotifierProperties);
 
    if (handler != null)
    {
        handler(this, new PropertyChangedEventArgs(propertyName));
    }
    OnPropertyChanged(propertyName);
 
    NotifyLinkedPropertyListeners(propertyName, AfterChangeLinkedChangeNotifierProperties);
}
 
private void NotifyLinkedPropertyListeners(string propertyName,
                                            Dictionary<string, LinkedPropertyChangeNotifierListeners> linkedChangeNotifiers)
{
    LinkedPropertyChangeNotifierListeners changeNotifierListeners;
    if (linkedChangeNotifiers.TryGetValue(propertyName, out changeNotifierListeners))
    {
        changeNotifierListeners.NotifyAll();
    }
}

Каждый объект-наследник ChangeNotifier хранит коллекции связок «имя свойства» -> «набор слушателей уведомлений об изменении свойства»:
private Dictionary<string, LinkedPropertyChangeNotifierListeners> AfterChangeLinkedChangeNotifierProperties { get { ... } }
private Dictionary<string, LinkedPropertyChangeNotifierListeners> BeforeChangeLinkedChangeNotifierProperties { get { ... } }

Отдельно необходимо рассмотреть класс LinkedPropertyChangeNotifierListeners:
private class LinkedPropertyChangeNotifierListeners
{
    /// <summary>
    /// Коллекция пар "связанный объект" - "набор действий над объектом"
    /// </summary>
    private readonly Dictionary<ChangeNotifier, OnNotifyExecuties> _linkedObjects =
        new Dictionary<ChangeNotifier, OnNotifyExecuties>();
 
    /// <summary>
    /// Регистрация нового связанного объекта.
    /// </summary>
    /// <param name="linkedObject">Связанный объект.</param>
    /// <param name="targetPropertyName">Имя свойства связанного объекта для уведомления.</param>
    public void Register(ChangeNotifier linkedObject, string targetPropertyName)
    {
        var executies = GetOrCreateExecuties(linkedObject);
 
        if (!executies.ProprtiesToNotify.Contains(targetPropertyName))
        {
            executies.ProprtiesToNotify.Add(targetPropertyName);
        }
    }
 
    /// <summary>
    /// Регистрация нового связанного объекта.
    /// </summary>
    /// <param name="linkedObject">Связанный объект.</param>
    /// <param name="action">Действие для вызова.</param>
    public void Register(ChangeNotifier linkedObject, Action action)
    {
        var executies = GetOrCreateExecuties(linkedObject);
 
        if (!executies.ActionsToExecute.Contains(action))
        {
            executies.ActionsToExecute.Add(action);
        }
    }
 
    /// <summary>
    /// Получение имеющегося или создание нового набора действий над связанным объектом.
    /// </summary>
    /// <param name="linkedObject">Связанный объект.</param>
    /// <returns>Обёртка над набором действий со связанным объектом.</returns>
    private OnNotifyExecuties GetOrCreateExecuties(ChangeNotifier linkedObject)
    {
        OnNotifyExecuties executies;
        if (!_linkedObjects.TryGetValue(linkedObject, out executies))
        {
            executies = new OnNotifyExecuties();
            _linkedObjects.Add(linkedObject, executies);
        }
        return executies;
    }
 
    /// <summary>
    /// Вызов уведомлений и действий для всех связанных объектоы.
    /// </summary>
    public void NotifyAll()
    {
        foreach (var linkedObject in _linkedObjects)
        {
            NotifyProperties(linkedObject.Key, linkedObject.Value.ProprtiesToNotify);
            ExecuteActions(linkedObject.Value.ActionsToExecute);
        }
    }
 
    /// <summary>
    /// Вызов уведомлений об изменении свойств над связанным объектом.
    /// </summary>
    /// <param name="linkedObject">Связанный объект.</param>
    /// <param name="properties">Имена свойств связанного объекта для уведомления.</param>
    private void NotifyProperties(ChangeNotifier linkedObject, IEnumerable<string> properties)
    {
        foreach (var targetProperty in properties)
        {
            linkedObject.NotifyPropertyChangedInternal(targetProperty);
        }
    }
 
    /// <summary>
    /// Вызов действий.
    /// </summary>
    /// <param name="actions">Действия</param>
    private void ExecuteActions(IEnumerable<Action> actions)
    {
        foreach (var action in actions)
        {
            action();
        }
    }
 
    private class OnNotifyExecuties
    {
        private List<string> _proprtiesToNotify;
        private List<Action> _actionsToExecute;
 
        public List<string> ProprtiesToNotify
        {
            get { return _proprtiesToNotify ?? (_proprtiesToNotify = new List<string>()); }
        }
 
        public List<Action> ActionsToExecute
        {
            get { return _actionsToExecute ?? (_actionsToExecute = new List<Action>()); }
        }
    }
}

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

Стоит отметить, что при регистрации проверяется уникальность подписки. Если вы хоть раз пытались найти причину двухкратной нотификации об изменении объекта, то будете очень рады такой фиче.

AfterNotify(() => AvgValue).Execute(UpdateDelta)
BeforeNotify(sensor, s => s.Value).Notify(() => AvgValue)
BeforeNotify(() => Value).Notify(() => indicator.AvgValue);


Для добавления нового связанного объекта и действий над ним служит последовательность вызова методов AfterNotify/BeforeNotify класса ChangeNotifier и методов Notify/Execute классов-наследников ILinkedPropertyChanged. В качестве последних выступают вложенные по отношению к ChangeNotifier классы AfterLinkedPropertyChanged и BeforeLinkedPropertyChanged.
/// <summary>
/// Связыватель для событий перед нотификаций об изменении свойства объекта.
/// </summary>
private class BeforeLinkedPropertyChanged : ILinkedPropertyChanged
{
    /// <summary>
    /// Исходный объект.
    /// </summary>
    private readonly ChangeNotifier _sourceChangeNotifier;
 
    /// <summary>
    /// Имя свойство исходного объекта.
    /// </summary>
    private readonly string _sourceProperty;
 
    /// <summary>
    /// Связываемый объект.
    /// </summary>
    private readonly ChangeNotifier _targetChangeNotifier;
 
    public BeforeLinkedPropertyChanged(ChangeNotifier sourceChangeNotifier,
                                        string sourceProperty,
                                        ChangeNotifier targetChangeNotifier)
    {
        _sourceChangeNotifier = sourceChangeNotifier;
        _sourceProperty = sourceProperty;
        _targetChangeNotifier = targetChangeNotifier;
    }
 
    /// <summary>
    /// Связывание объекта и нотификации свойства с исходным объектом.
    /// </summary>
    /// <param name="targetProperty">Свойство целевого объекта.</param>
    /// <returns>Связыватель.</returns>
    public ILinkedPropertyChanged Notify(Expression<Func<object>> targetProperty)
    {
        _sourceChangeNotifier.RegisterBeforeLinkedPropertyListener(
                                    _sourceProperty, _targetChangeNotifier, (string) targetProperty.GetName());
        return this;
    }
 
    /// <summary>
    /// Связывание объекта и действия с исходным объектом.
    /// </summary>
    /// <param name="action">Действие.</param>
    /// <returns>Связыватель.</returns>
    public ILinkedPropertyChanged Execute(Action action)
    {
        _sourceChangeNotifier.RegisterBeforeLinkedPropertyListener(
                                    _sourceProperty, _targetChangeNotifier, action);
        return this;
    }
}

Для связывания используются методы RegisterBeforeLinkedPropertyListener/RegisterAfterLinkedPropertyListener класса ChangeNotifier:
public abstract class ChangeNotifier : INotifyPropertyChanged
{
    ...
    private void RegisterBeforeLinkedPropertyListener(string linkedPropertyName,
                                    ChangeNotifier targetObject,
                                    string targetPropertyName)
    {
        RegisterLinkedPropertyListener(
                                    linkedPropertyName, targetObject, 
                                    targetPropertyName, BeforeChangeLinkedChangeNotifierProperties);
    }
 
    private void RegisterBeforeLinkedPropertyListener(string linkedPropertyName,
                                    ChangeNotifier targetObject,
                                    Action action)
    {
        RegisterLinkedPropertyListener(linkedPropertyName, targetObject, action,
                                    BeforeChangeLinkedChangeNotifierProperties);
    }
 
    private static void RegisterLinkedPropertyListener(string linkedPropertyName,
                                    ChangeNotifier targetObject,
                                    string targetPropertyName,
                                    Dictionary<string, LinkedPropertyChangeNotifierListeners> linkedProperties)
    {
        GetOrCreatePropertyListeners(linkedPropertyName, linkedProperties).Register(targetObject, targetPropertyName);
    }
 
    private static void RegisterLinkedPropertyListener(string linkedPropertyName,
                                    ChangeNotifier targetObject,
                                    Action action,
                                    Dictionary<string, LinkedPropertyChangeNotifierListeners> linkedProperties)
    {
        GetOrCreatePropertyListeners(linkedPropertyName, linkedProperties).Register(targetObject, action);
    }
 
    private static LinkedPropertyChangeNotifierListeners GetOrCreatePropertyListeners(string linkedPropertyName,
                                    Dictionary<string, LinkedPropertyChangeNotifierListeners> linkedProperties)
    {
        LinkedPropertyChangeNotifierListeners changeNotifierListeners;
        if (!linkedProperties.TryGetValue(linkedPropertyName, out changeNotifierListeners))
        {
            changeNotifierListeners = new LinkedPropertyChangeNotifierListeners();
            linkedProperties.Add(linkedPropertyName, changeNotifierListeners);
        }
        return changeNotifierListeners;
    }
    ...
}

Методы AfterNotify/BeforeNotify создают новые экземпляры «связывателей» для предоставления простого интерфейса связывания:
protected ILinkedPropertyChanged AfterNotify(Expression<Func<object>> property)
{
    var propertyCall = PropertyCallHelper.GetPropertyCall(property);
    return new AfterLinkedPropertyChanged((INotifyPropertyChanged) propertyCall.TargetObject,
                                            propertyCall.TargetPropertyName,
                                            this);
}
 
protected ILinkedPropertyChanged BeforeNotify(Expression<Func<object>> property)
{
    var propertyCall = PropertyCallHelper.GetPropertyCall(property);
    return new BeforeLinkedPropertyChanged((ChangeNotifier) propertyCall.TargetObject,
                                            propertyCall.TargetPropertyName,
                                            this);
}
 
protected ILinkedPropertyChanged AfterNotify<T>(T changeNotifier, Expression<Func<T, object>> property)
    where T : INotifyPropertyChanged
{
    return new AfterLinkedPropertyChanged(changeNotifier, property.GetName(), this);
}
 
protected ILinkedPropertyChanged BeforeNotify<T>(T changeNotifier, Expression<Func<T, object>> property)
    where T : ChangeNotifier
{
    return new BeforeLinkedPropertyChanged(changeNotifier, property.GetName(), this);
}

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

Пожалуйста, не надо больше листингов


Ок. Ещё раз на пальцах. Объект ChangeNotifier содержит несколько коллекций, в которых хранятся данные о связанных с нотификацией свойства объектах, нотифицируемых свойствах этих объектов, а так же о действиях, которые должны быть вызваны до или после нотификации. Для предоставления простого интерфейса связывания объектов методы AfterNotify/BeforeNotify возвращают наследников ILinkedPropertyChanged, которые позволяют легко добавлять нужную информацию в коллекции. Методы ILinkedPropertyChanged возвращают исходный объект ILinkedPropertyChanged, что позволяет использовать цепочку вызовов для регистрации.

При нотификации об изменении свойства объект обращается к коллекциям связанных объектов и вызывает все необходимые зарегистрированные заранее действия.

ChangeNotifier предоставляет удобный интерфейс для изменения свойств объектов и нотификации об изменениях свойствах, который минимизирует затраты на разбор деревьев выражений. Все зависимости можно собрать в конструкторе.

Решение об использовании


Это не совсем статья про библиотеку, которой можно просто начать пользоваться. Я хотел показать внутреннюю реализацию одного из вариантов решения типовой для WPF в рамках MVVM задачи, простоту этого решения, простоту его использования, расширяемость. Без знания реализации гораздо проще неправильно применить используемый инструмент. Например, Microsoft Prism 4 позволял уведомлять об изменении свойств при помощи передачи дерева выражений, но в разборе участвовал только базовый сценарий "() => PropertName". Таким образом, если вычисляемое свойство находилось в другом классе, то не было никакой возможности уведомить об его изменении из исходного свойства. Что логично, но оставляет пространство для ошибки.

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

Tags:
Hubs:
+5
Comments 12
Comments Comments 12

Articles