将ListView与ObservableCollection配合使用时WPF应用程序中出现内存泄漏

odopli94  于 2022-11-30  发布在  其他
关注(0)|答案(1)|浏览(193)

我有一个使用此ListView的WPF应用程序:

<ListView ItemsSource="{Binding Log.Entries}"
          VirtualizingPanel.IsVirtualizing="True"
          VirtualizingPanel.IsVirtualizingWhenGrouping="True">
    <i:Interaction.Behaviors>
        <afutility:AutoScrollBehavior/>
    </i:Interaction.Behaviors>
    <ListView.ItemTemplate>
        <DataTemplate>
            <WrapPanel>
                <TextBlock Foreground="{Binding Path=LogLevel, Mode=OneTime, Converter={StaticResource LogLevelToBrushConverter}}"
                           Text="{Binding Path=RenderedContent, Mode=OneTime}"/>
            </WrapPanel>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

Log.Entries结束于这个类的一个示例(List本身永远不会被替换,它始终是同一个对象):

public class RingList<T> : IList<T>, INotifyCollectionChanged, INotifyPropertyChanged

该类本质上是一个自定义列表,其内容上限为100项。添加一个达到最大容量的项会从头部删除一个项。对于每个添加/删除的项,我调用CollectionChanged如下:

// For added items
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, Count - 1));

// For removed items (only ever removes from the start of the ring)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, headItem, 0));

集合中的项不是对象,它们是如下所示的结构:

public struct RecordedLogEntry : IEquatable<RecordedLogEntry> {
    public string RenderedContent { get; set; }
    public Level LogLevel { get; set; }

    // [...] Equals, GetHashCode, ToString, etc omitted for brevity, all standard
}

我知道,绑定到非INotifyPropertyChange对象可能会导致内存泄漏(请参见:(第10页)
这就是为什么我使用Mode=OneTime来(希望)避免这种情况。然而,剖析使用的是不同的语言。
这是在运行时经过几个小时的工作后进行的内存转储,如果不进行处理,通常会导致系统内存不足:

可以清楚地看到:

  • 有上限集合中的100个项目
  • 再往上一点,当前可见的ListViewItem示例引用了12个项(由于列表视图正在虚拟化项,这大约是预期的数量)
  • ListView本身引用的示例超过700k
  • 随着时间的推移而累积的大量数据

项目在Windows上使用. NET 4.7.2。
如何避免这种泄漏?
编辑,忽略此要求:
理想情况下,我不想改变结构体,因为我在后台产生了许多这样的项(并不是所有的项都显示在100个项中),所以我想保持日志条目的占用空间很小。
正如@Joe正确指出的那样,这是不成熟的优化。事实仍然是,这些日志条目并不纯粹用于UI显示,而是用于其他地方。
它们都不会在生存期内更改内容,因此让实现通知更改似乎违反直觉。
是否有一种方法可以使绑定不关心更新,并在此用例中进行真正的一次性绑定,或者是否只有一种方法可以添加 Package 类/将数据复制到实现INotifyPropertyChange的类中,以便消除内存泄漏?

6ie5vjzr

6ie5vjzr1#

不幸的是,这个问题不能用最小的例子重现,所以我不得不假设这些示例在其他地方保持活动状态。
一个最小的例子,通常可以很容易地显示泄漏,如果它来自绑定:

<Window x:Class="ListViewLeakRepro.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <ListView Margin="10,46,10,10"
                  ItemsSource="{Binding Entries}"
                  VirtualizingPanel.IsVirtualizing="True"
                  VirtualizingPanel.IsVirtualizingWhenGrouping="True"
                  VirtualizingPanel.ScrollUnit="Pixel">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <WrapPanel>
                        <TextBlock Text="{Binding Path=RenderedContent, Mode=OneTime}"/>
                    </WrapPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <Button Content="Start Indirect Log Spam" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Click="StartTimerIndirect"/>
        <Button Content="Start Direct Log Spam" HorizontalAlignment="Left" Margin="143,10,0,0" VerticalAlignment="Top" Click="StartTimerDirect"/>
    </Grid>
</Window>

后面的代码:

using System.Windows;

namespace ListViewLeakRepro {
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window {
        MainWindowModel Model { get; }

        public MainWindow() {
            InitializeComponent();

            Model = new MainWindowModel();
            DataContext = Model;
        }

        private void StartTimerIndirect(object sender, RoutedEventArgs e) {
            Model.StartTimerIndirect();
        }

        private void StartTimerDirect(object sender, RoutedEventArgs e) {
            Model.StartTimerDirect();
        }
    }
}

产品型号:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;

namespace ListViewLeakRepro {
    public class MainWindowModel : INotifyPropertyChanged {
        public event PropertyChangedEventHandler PropertyChanged;

        public RingList<RecordedLogEntry> Entries { get; } = new RingList<RecordedLogEntry>(100);

        private static List<RecordedLogEntry> pollCache = new List<RecordedLogEntry>();
        private static List<RecordedLogEntry> pollCache2 = new List<RecordedLogEntry>();

        private DispatcherTimer timer;
        private DispatcherTimer timer2;
        private int logCounter = 0;

        private static readonly object lockObject = new object();

        public void StartTimerIndirect() {
            if (timer != null) {
                return;
            }
            timer = new DispatcherTimer(TimeSpan.FromMilliseconds(10), DispatcherPriority.Background, SpamLogIndirect, Application.Current.Dispatcher);
            timer2 = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background, RefreshLogDisplay, Application.Current.Dispatcher);
        }

        public void StartTimerDirect() {
            if (timer != null) {
                return;
            }
            timer = new DispatcherTimer(TimeSpan.FromMilliseconds(10), DispatcherPriority.Background, SpamLogDirect, Application.Current.Dispatcher);
        }

        private void SpamLogIndirect(object sender, EventArgs e) {
            // Add some invisible junk data to have results earlier
            byte[] junkData = new byte[50 * 1024 * 1024];

            lock (lockObject) {
                pollCache.Add(new RecordedLogEntry($"Entry {++logCounter}", junkData));
            }
        }

        private void SpamLogDirect(object sender, EventArgs e) {
            // Add some invisible junk data to have results earlier
            byte[] junkData = new byte[50 * 1024 * 1024];

            lock (lockObject) {
                Entries.Add(new RecordedLogEntry($"Entry {++logCounter}", junkData));
            }
        }

        private void RefreshLogDisplay(object sender, EventArgs e) {
            lock (lockObject) {
                // Swap background buffer
                (pollCache, pollCache2) = (pollCache2, pollCache);
            }

            foreach (RecordedLogEntry item in pollCache2) {
                Entries.Add(item);
            }

            pollCache2.Clear();
        }
    }
}

RingList类:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;

namespace ListViewLeakRepro {
    /// <summary>
    /// A collection with a fixed buffer size which is reused when full.
    /// Only the most recent items are retained, the oldest are overwritten,
    /// always keeping a constant amount of items in the collection without reallocating memory.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public class RingList<T> : IList<T>, INotifyCollectionChanged, INotifyPropertyChanged {
        private T[] buffer;
        private int head;
        private int tail;
        private bool firstRevolution;

        public event NotifyCollectionChangedEventHandler CollectionChanged;
        public event PropertyChangedEventHandler PropertyChanged;

        public RingList(int capacity) {
            buffer = new T[capacity];
            firstRevolution = true;
        }

        public int Capacity {
            get {
                return buffer.Length;
            }

            set {
                T[] oldBuffer = buffer;
                int oldHead = head;
                // int oldTail = tail;

                int oldLength = Count;

                buffer = new T[value];
                head = 0;
                tail = 0;

                for (int i = 0; i < oldLength; i++) {
                    Add(oldBuffer[(oldHead + i) % oldBuffer.Length]);
                }
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Capacity)));
                CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
            }
        }

        public int IndexOf(T item) {
            for (int i = 0; i < Count; i++) {
                if (Equals(this[i], item)) {
                    return i;
                }
            }
            return -1;
        }

        public void Insert(int index, T item) => throw new NotImplementedException();

        public void RemoveAt(int index) => throw new NotImplementedException();

        public bool Remove(T item) => throw new NotImplementedException();

        public T this[int index] {
            get {
                if (index < 0 || index >= Count) {
                    throw new IndexOutOfRangeException();
                }
                int actualIndex = (index + head) % Capacity;
                return buffer[actualIndex];
            }
            set {
                if (index < 0 || index > Count) {
                    throw new IndexOutOfRangeException();
                }
                int actualIndex = (index + head) % Capacity;
                T previous = buffer[actualIndex];
                buffer[actualIndex] = value;
                CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, previous, index));
            }
        }

        public void Add(T item) {
            if (Count == Capacity) {
                RemoveHead();
            }

            buffer[tail] = item;
            tail++;
            if (tail == Capacity) {
                tail = 0;
                head = 0;
                firstRevolution = false;
            }

            CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, Count - 1));
        }

        private void RemoveHead() {
            if (Count == 0) {
                throw new InvalidOperationException("Cannot remove from an empty collection");
            }
            T headItem = buffer[head];
            head++;
            if (head == Capacity) {
                head = 0;
            }
            CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, headItem, 0));
        }

        public void Clear() {
            buffer = new T[buffer.Length];
            head = 0;
            tail = 0;
            firstRevolution = true;
            CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
        }

        public bool Contains(T item) => IndexOf(item) >= 0;

        public void CopyTo(T[] array, int arrayIndex) => throw new NotImplementedException();

        public void CopyTo(List<T> collection) {
            if (collection == null) {
                throw new ArgumentNullException(nameof(collection));
            }

            for (int i = 0; i < Count; i++) {
                collection.Add(this[i]);
            }
        }

        public int Count {
            get {
                if (tail == head) {
                    if (firstRevolution) {
                        return 0;
                    } else {
                        return Capacity;
                    }
                } else if (tail < head) {
                    return Capacity - head + tail;
                } else {
                    return tail - head;
                }
            }
        }

        public bool IsReadOnly => false;

        public IEnumerator<T> GetEnumerator() {
            int position = 0; // state
            while (position < Count) {
                yield return this[position];
                position++;
            }
        }

        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    }
}

集合的项目:

namespace ListViewLeakRepro {
    public class RecordedLogEntry {
        public RecordedLogEntry(string renderedContent, byte[] junkData) {
            RenderedContent = renderedContent;
            JunkData = junkData;
        }

        public string RenderedContent { get; set; }
        public byte[] JunkData { get; set; }
    }
}
    • 使用上述复制代码进行分析**

使用"直接"日志垃圾邮件启动模拟,即直接写入Entries不会导致泄漏。
它消耗大量内存,但GC能够清除它:

使用"Indirect"日志垃圾启动模拟,即在每500ms循环一次的缓冲区中收集条目,似乎会导致内存泄漏。GC不会立即清除它:

但最终还是会的,只是需要更长的时间:

结论:不是绑定中的泄漏,更可能是某个缓冲区或保持示例活动的其他位置中的无限增长。
旁注:从类更改为结构体不会对情况产生显著影响(可能会更改总体消耗,但不会导致或解决问题)。

相关问题