Thursday, September 4, 2014

Утечка памяти в WPF при подписке на List

В этой статье я бы хотел поговорить с вами об утечках памяти в WPF. Эта тема уже затрагивалась в статье "Утечки памяти в WPF". Сегодня мы разберем только одну из утечек, связанную с байндингом на List<T>. Вы, наверное, знаете, почему не стоить делать байндинг на список List<T>, если не использовать интерфейс INotifyPropertyChanged или INotifyCollectionChanged. К чему это может привести? Если вам интересно, что такого происходит в .NET Framework, что может привести к утечке памяти, то мы начнем наше плавное погружение в исходный код .NET Framework, чтобы понять принцип работы байндинга в WPF и почему не стоит связываться со списками List<T>. Мне понравилось описание со stackoverflow с кратким объяснением причин утечки памяти в WPF при подписке на List<T>.
If you are not binding to a DependencyProperty or a object that implements INotifyPropertyChanged then the binding can leak memory, and you will have to unbind when you are done.
This is because if the object is not a DependencyProperty or does not implement INotifyPropertyChanged then it uses the ValueChanged event via the PropertyDescriptors AddValueChanged method. This causes the CLR to create a strong reference from the PropertyDescriptor to the object and in most cases the CLR will keep a reference to the PropertyDescriptor in a global table.
Because the binding must continue to listen for changes. This behavior keeps the reference alive between the PropertyDescriptor and the object as the target remains in use. This can cause a memory leak in the object and any object to which the object refers, This includes the data-binding target.
So in short if you are binding to a DependencyProperty or InotifyPropertyChanged object then you sould be ok, othewise like any subscribed event you should unsubscribe your bindings.

Если вкратце, то речь идет о том, что если вы биндитесь не на DependencyProperty и ваш объект не реализует интерфейс INotifyPropertyChanged, то может произойти утечка памяти. Причиной этого может быть то, что объект, который не реализует INotifyPropertyChaned и не является DependencyProperty, использует событие ValueChanged через метод класса PropertyDescriptor AddValueChanged. Это приведет к тому, что CLR будет держать строгую ссылку на PropertyDescriptor в глобальной таблице. Поскольку объект продолжает прослушиваться, такое поведение сохраняет ссылку между PropertyDescriptor и целевым объектом подписки. Это может привести к утечке памяти в самом объекте, а также в любых объектах, которые относятся к данному объекту.
Давайте немного разберемся с тем, как это работает. Для начала у нас есть несколько ключевых классов, на которых ложится вся логика, связанная с байндингом. Первым классом в данном списке, который вы используете явно, является класс Binding, наследуемый от класса BindableBase. В этом классе доступные все свойства, которые мы задаем в основном через XAML-разметку или в редких случаях через код. Ниже на картинке приведен список доступных свойств класса Binding.
Примечание: В .NET Framework 4.5 компания Microsoft исправила множество мест с байндингом, которые содержали жесткую ссылку на элемент, с использованием паттерна шаблона слабых событий, а также класса WeakReference для использования мягких ссылок. Ниже приведен пример установки свойства Source в байдинге.
/// <summary> object to use as the source </summary>
/// <remarks> To clear this property, set it to DependencyProperty.UnsetValue. </remarks>
public object Source
{
    get
    {
        WeakReference<object> wr = (WeakReference<object>)GetValue(Feature.ObjectSource, null);
        if (wr == null)
            return null;
        else
        {
            object target;
            return wr.TryGetTarget(out target) ? target : null;
        }
    }
    set
    {
        CheckSealed();

        if (_sourceInUse == SourceProperties.None || _sourceInUse == SourceProperties.Source)
        {
            if (value != DependencyProperty.UnsetValue)
            {
                SetValue(Feature.ObjectSource, new WeakReference<object>(value));
                SourceReference = new ExplicitObjectRef(value);
            }
            else
            {
                ClearValue(Feature.ObjectSource);
                SourceReference = null;
            }
        }
        else
            throw new InvalidOperationException(SR.Get(SRID.BindingConflict, SourceProperties.Source, _sourceInUse));
    }
}
Это лишь малая часть айсберга. Для того чтобы устанавливать значения для свойств класса Binding, используется класс BindingExpression. Надеюсь, вы не забыли, что класс Binding наследуется от класса BindableBase, в этом классе происходит создание BindingExpression и привязка к указанному объекту, а также его свойствам.
/// <summary>
    /// Return the value to set on the property for the target for this
    /// binding.
    /// </summary>
    public sealed override object ProvideValue(IServiceProvider serviceProvider)
    {
        // Binding a property value only works on DependencyObject and DependencyProperties.
        // For all other cases, just return this Binding object as the value.

        if (serviceProvider == null)
        {
            return this;
        }

        // Bindings are not allowed On CLR props except for Setter,Trigger,Condition (bugs 1183373,1572537)

        DependencyObject targetDependencyObject;
        DependencyProperty targetDependencyProperty;
        Helper.CheckCanReceiveMarkupExtension(this, serviceProvider, out targetDependencyObject, out targetDependencyProperty);

        if (targetDependencyObject == null || targetDependencyProperty == null)
        {
            return this;
        }

        // delegate real work to subclass
        return CreateBindingExpression(targetDependencyObject, targetDependencyProperty);
    }
Подпиской на события об обновлении свойств оперирует класс BindingWorker. Этот класс абстрактный и имеет две реализации: XmlBindingWorker и ClrBindingWorker. XmlBindingWorker, хоть и наследуется от класса BindingWorker, но является своего рода декоратором для класса ClrBindingWorker и используется для работы с XML. Класс BindingExpression, который мы рассмотрели вскользь выше, использует в себе класс BindingWorker (по сути, используется класс ClrBindingWorker) для подписки на изменения элементов.
private void CreateWorker()
{
    Invariant.Assert(Worker == null, "duplicate call to CreateWorker");

    _worker = new ClrBindingWorker(this, Engine);
}
Теперь, наконец, мы подошли к классу ClrBindingWorker. Этот класс является своего рода менеджером, так как он управляет установкой связывания. Непосредственно самим связыванием управляет класс PropertyPathWorker, а классом ParopertyPathWorker управляет уже класса ClrBindingWorker.
internal ClrBindingWorker(BindingExpression b, DataBindEngine engine) : base(b)
{
    PropertyPath path = ParentBinding.Path;

    if (ParentBinding.XPath != null)
    {
        path = PrepareXmlBinding(path);
    }

    if (path == null)
    {
        path = new PropertyPath(String.Empty);
    }

    if (ParentBinding.Path == null)
    {
        ParentBinding.UsePath(path);
    }

    _pathWorker = new PropertyPathWorker(path, this, IsDynamic, engine);
    _pathWorker.SetTreeContext(ParentBindingExpression.TargetElementReference);
}
Чтобы не запутаться, рекомендую посмотреть на код выше, чтобы понять, кто от кого зависит и кто кем оперирует. Теперь более детально ознакомимся с классом PropertyPathWorker. Этот класс и занимается уведомлением об изменении значений, обновлении целевого объекта и т.д. А как он это делает, мы сейчас разберем детально, так как мы, по сути, добрались до основного класса, который управляет обновлением привязки. Если вы перейдете по ссылке для класса PropertyPathWorker, приведенную в предыдущем предложении, то сможете найти функцию UpdateSourceValueState.
// fill in the SourceValueState with updated infomation, starting at level k+1.
// If view isn't null, also update the current item at level k.
private void UpdateSourceValueState(int k, ICollectionView collectionView)
{
    UpdateSourceValueState(k, collectionView, BindingExpression.NullDataItem, false);
}

// fill in the SourceValueState with updated infomation, starting at level k+1.
// If view isn't null, also update the current item at level k.
private void UpdateSourceValueState(int k, ICollectionView collectionView, object newValue, bool isASubPropertyChange)
{
    // give host a chance to shut down the binding if the target has
    // gone away
    DependencyObject target = null;
    if (_host != null)
    {
        target = _host.CheckTarget();
        if (_rootItem != BindingExpression.NullDataItem && target == null)
            return;
    }

    int initialLevel = k;
    object rawValue = null;

    // optimistically assume the new value will fix previous path errors
    _status = PropertyPathStatus.Active;

    // prepare to collect changes to dependency sources
    _dependencySourcesChanged = false;

    // Update the current item at level k, if requested
    if (collectionView != null)
    {
        Debug.Assert(0<=k && k<_arySVS.Length && _arySVS[k].collectionView == collectionView, "bad parameters to UpdateSourceValueState");
        ReplaceItem(k, collectionView.CurrentItem, NoParent);
    }

    // update the remaining levels
    for (++k; k<_arySVS.Length; ++k)
    {
        isASubPropertyChange = false;   // sub-property changes only matter at the last level

        ICollectionView oldCollectionView = _arySVS[k].collectionView;

        // replace the item at level k using parent from level k-1
        rawValue = (newValue == BindingExpression.NullDataItem) ? RawValue(k-1) : newValue;
        newValue = BindingExpression.NullDataItem;
        if (rawValue == AsyncRequestPending)
        {
            _status = PropertyPathStatus.AsyncRequestPending;
            break;      // we'll resume the loop after the request completes
        }

        ReplaceItem(k, BindingExpression.NullDataItem, rawValue);

        // replace view, if necessary
        ICollectionView newCollectionView = _arySVS[k].collectionView;
        if (oldCollectionView != newCollectionView && _host != null)
        {
            _host.ReplaceCurrentItem(oldCollectionView, newCollectionView);
        }
    }

    // notify binding about what happened
    if (_host != null)
    {
        if (initialLevel < _arySVS.Length)
        {
            // when something in the path changes, recompute whether we
            // need direct notifications from the raw value
            NeedsDirectNotification = _status == PropertyPathStatus.Active &&
                    _arySVS.Length > 0 &&
                    SVI[_arySVS.Length-1].type != SourceValueType.Direct &&
                    !(_arySVS[_arySVS.Length-1].info is DependencyProperty) &&
                    typeof(DependencyObject).IsAssignableFrom(_arySVS[_arySVS.Length-1].type);
        }

        _host.NewValueAvailable(_dependencySourcesChanged, initialLevel < 0, isASubPropertyChange);
    }

    GC.KeepAlive(target);   // keep target alive during changes (bug 956831)
}
Эта функция заполняет SourceValueState, а также позволяет заменить данные с полученного айтема. Как это работает, можно увидеть ниже.
internal void OnPropertyChangedAtLevel(int level)
{
    UpdateSourceValueState(level, null);
}

internal void OnCurrentChanged(ICollectionView collectionView)
{
    for (int k=0; k<Length; ++k)
    {
        if (_arySVS[k].collectionView == collectionView)
        {
            _host.CancelPendingTasks();

            // update everything below that level
            UpdateSourceValueState(k, collectionView);
            break;
        }
    }
}
internal void OnDependencyPropertyChanged(DependencyObject d, DependencyProperty dp, bool isASubPropertyChange)
{
    if (dp == DependencyObject.DirectDependencyProperty)
    {
        // the only way we get notified about this property is when the raw
        // value reports a subProperty change.
        UpdateSourceValueState(_arySVS.Length, null, BindingExpression.NullDataItem, isASubPropertyChange);
        return;
    }

    // find the source level where the change happened
    int k;
    for (k=0; k<_arySVS.Length; ++k)
    {
        if ((_arySVS[k].info == dp) && (BindingExpression.GetReference(_arySVS[k].item) == d))
        {
            // update everything below that level
            UpdateSourceValueState(k, null, BindingExpression.NullDataItem, isASubPropertyChange);
            break;
        }
    }
}

internal void OnNewValue(int level, object value)
{
    // optimistically assume the new value will fix previous path errors
    _status = PropertyPathStatus.Active;
    if (level < Length - 1)
        UpdateSourceValueState(level, null, value, false);
}
Когда мы добавляем новое значение или получаем уведомление, что у нас что-то изменилось, мы сразу же вызываем функцию UpdateSourceValueState. В этой функции мы можем увидеть, как происходит подписка и отписка на изменения нового айтема (прослушка элемента), посмотрев метод ReplaceItem. Понять принцип работы метода ReplaceItem несложно; хотя он большой, но для понимания достаточно просто вдумчиво взглянуть в написанный код. Но так как нас больше интересует, как происходит уведомление, мы рассмотрим только как происходит отписка на айтем, а затем посмотрим, как работает подписка. Первым делом начнем рассмотрение с отписки от айтема по переданной позиции.
// replace the item at level k with the given item, or with an item obtained from the given parent
private void ReplaceItem(int k, object newO, object parent)
{
    bool isExtendedTraceEnabled = IsExtendedTraceEnabled(TraceDataLevel.ReplaceItem);
    SourceValueState svs = new SourceValueState();

    object oldO = BindingExpression.GetReference(_arySVS[k].item);

    // stop listening to old item
    if (IsDynamic && SVI[k].type != SourceValueType.Direct)
    {
        INotifyPropertyChanged oldPC;
        DependencyProperty oldDP;
        PropertyInfo oldPI;
        PropertyDescriptor oldPD;
        DynamicObjectAccessor oldDOA;
        PropertyPath.DowncastAccessor(_arySVS[k].info, out oldDP, out oldPI, out oldPD, out oldDOA);

        if (newO == BindingExpression.StaticSource)
        {
            Type declaringType = (oldPI != null) ? oldPI.DeclaringType
                                : (oldPD != null) ? oldPD.ComponentType
                                : null;
            if (declaringType != null)
            {
                StaticPropertyChangedEventManager.RemoveHandler(declaringType, OnStaticPropertyChanged, SVI[k].propertyName);
            }
        }
        else if (oldDP != null)
        {
            _dependencySourcesChanged = true;
        }
        else if ((oldPC = oldO as INotifyPropertyChanged) != null)
        {
            PropertyChangedEventManager.RemoveHandler(oldPC, OnPropertyChanged, SVI[k].propertyName);
        }
        else if (oldPD != null && oldO != null)
        {
            ValueChangedEventManager.RemoveHandler(oldO, OnValueChanged, oldPD);
        }
    }
// Упущенный код который нам не нуже для рассмотрения
}
В данном методе мы создаем несколько переменных, для того чтобы определить, с каким айтемом мы работаем и на какое изменение событий нам подписаться.
  • INotifyPropertyChanged – интерфейс, который мы используем в WPF для уведомления об изменении какого-то значения;
  • DependencyProperty представляет собой свойство, которое может быть установлено такими способами, как стилизация, привязка данных, анимация и наследование;
  • PropertyInfo позволяет получить доступ к свойства с помощью атрибутов;
  • PropertyDescriptor – класс, который позволяет уведомлять другие объекты об изменении какого-то свойства;
  • DynamicObjectAccessor – внутренний класс, который используется для работы с динамическими объектами.
Метод DowncastAccessor просто проставляет данные свойства.
// Convert an "accessor" into one of the legal types
internal static void DowncastAccessor(object accessor,
                    out DependencyProperty dp, out PropertyInfo pi, out PropertyDescriptor pd, out DynamicObjectAccessor doa)
{
    if ((dp = accessor as DependencyProperty) != null)
    {
        pd = null;
        pi = null;
        doa = null;
    }
    else if ((pi = accessor as PropertyInfo) != null)
    {
        pd = null;
        doa = null;
    }
    else if ((pd = accessor as PropertyDescriptor) != null)
    {
        doa = null;
    }
    else
    {
        doa = accessor as DynamicObjectAccessor;
    }
}
Затем мы смотрим, какое значение к нам пришло.
if (newO == BindingExpression.StaticSource)
{
    Type declaringType = (oldPI != null) ? oldPI.DeclaringType
                        : (oldPD != null) ? oldPD.ComponentType
                        : null;
    if (declaringType != null)
    {
        StaticPropertyChangedEventManager.RemoveHandler(declaringType, OnStaticPropertyChanged, SVI[k].propertyName);
    }
}
else if (oldDP != null)
{
    _dependencySourcesChanged = true;
}
else if ((oldPC = oldO as INotifyPropertyChanged) != null)
{
    PropertyChangedEventManager.RemoveHandler(oldPC, OnPropertyChanged, SVI[k].propertyName);
}
else if (oldPD != null && oldO != null)
{
    ValueChangedEventManager.RemoveHandler(oldO, OnValueChanged, oldPD);
}
Если новый объект равен StaticSource, то пытаемся удалить подписку на данный айтем с помощью StaticPropertyChangedEventManager. Если же айтем, который пришел к нам, не равен свойству StaticSource, то смотрим по переменным, которые нам возвратил метод DowncastAccessor. Если тот айтем, который пришел к нам, является DependencyPropert, то просто устанавливаем булевую переменную _dependencySourcesChanged в значение true. Затем проверяем, не наследуется ли наш айтем от интерфейса INotifyPropertyChanged, и если он наследуется и при этом не равен null, то можно удалить подписчик на изменения данного айтема с помощью PropertyChangedEventManager. И последняя проверка: если полученный ранее объект не равен null и переменная oldDP класса PropertyDescriptor не равна null, то отписываемся от события с помощью менеджера ValueChangedEventManager. Все эти менеджеры реализуют паттерн, который называется "шаблон слабых событий" и который представлен в языке C# реализацией класса WeakEventManager. В самом начале статьи есть ссылка на статью, в которой рассматривается использование данного паттерна в языке C# более детально.
Нам осталось рассмотреть, как происходит подписка на отслеживание изменений в переданного айтема.
if (IsDynamic && SVI[k].type != SourceValueType.Direct)
{
    Engine.RegisterForCacheChanges(newO, svs.info);

    INotifyPropertyChanged newPC;
    DependencyProperty newDP;
    PropertyInfo newPI;
    PropertyDescriptor newPD;
    DynamicObjectAccessor newDOA;
    PropertyPath.DowncastAccessor(svs.info, out newDP, out newPI, out newPD, out newDOA);

    if (newO == BindingExpression.StaticSource)
    {
        Type declaringType = (newPI != null) ? newPI.DeclaringType
                            : (newPD != null) ? newPD.ComponentType
                            : null;
        if (declaringType != null)
        {
            StaticPropertyChangedEventManager.AddHandler(declaringType, OnStaticPropertyChanged, SVI[k].propertyName);
        }
    }
    else if (newDP != null)
    {
        _dependencySourcesChanged = true;
    }
    else if ((newPC = newO as INotifyPropertyChanged) != null)
    {
        PropertyChangedEventManager.AddHandler(newPC, OnPropertyChanged, SVI[k].propertyName);
    }
    else if (newPD != null && newO != null)
    {
        ValueChangedEventManager.AddHandler(newO, OnValueChanged, newPD);
    }
}
Отличий от отписки ,которую мы рассмотрели выше, практически нет. Разве что для того чтобы отписаться от отслеживания изменений с помощью событий с приставкой RemoveHandler, мы используем добавление этих событий с помощью методов AddHandler. Ниже на рисунке представлена компактная схемка, демонстрирующая, что с чем связывается и какой класс за что отвечает, так как Visual Studio 2013 не смог сгенерировать нормальную диаграмму классов.

Итоги

Если вы используете .NET Framework 4.5, то у вас не будет утечки памяти, как вы можете увидеть из рассмотренного примера. Microsoft сделала небольшие изменения в коде с использованием слабых ссылок, поэтому ссылка не будет храниться долгое время в памяти. Как только объект станет недоступен, слушатели, которые реализуют менеджеры, приведенные на рисунке выше, почистят все за собой. Статья получилась больше не о том, где и почему произойдет утечка памяти, а о том, как работает вообще обновление объектов в WPF, для понимания, что может грозить в случае использования того или иного подхода. Надеюсь, что статья получилась не слишком заумной и запутанной и, возможно, вам пригодится в более глубоком понимании работы байндинга. 

No comments:

Post a Comment