为啥这个 INotifyCollectionChanged 会导致内存泄漏?

Posted

技术标签:

【中文标题】为啥这个 INotifyCollectionChanged 会导致内存泄漏?【英文标题】:Why is this NotifyCollectionChanged causing memory leak?为什么这个 INotifyCollectionChanged 会导致内存泄漏? 【发布时间】:2022-01-03 09:05:24 【问题描述】:

我正在使用 WPF 作为 UI 框架来开发游戏。游戏引擎不会自行改变它,它只会在来自 UI 应用程序的调用告诉它更新到下一帧时才会改变。

这是我的代码的关键部分:

    public class GameWorldViewModel : ViewModel 

        public GameWorldViewModel(GameWorld gameWorld) 
            this.gameWorld = gameWorld;
            GameBodyCollectionViewModel = new GameBodyCollectionViewModel(gameWorld);
            CopyData();
            Proceed();
        

        public GameBodyCollectionViewModel GameBodyCollectionViewModel  get; init; 

        private readonly GameWorld gameWorld;

        private readonly Stopwatch stopwatch = new Stopwatch();
        private bool isProceed = false;

        public void Pause() 
            if (!isProceed) throw new InvalidOperationException("The GameWorld is already paused.");
            isProceed = false;
            stopwatch.Reset();
        

        public void Proceed() 
            if (isProceed) throw new InvalidOperationException("The GameWorld is already proceeding.");
            isProceed = true;
            Action action = () => DispatherLoopCallback(Task.CompletedTask);
            stopwatch.Start();
            Application.Current.Dispatcher.BeginInvoke(action, DispatcherPriority.Background);
        

        private void DispatherLoopCallback(Task task) 
            if (!isProceed) return;
            if (task.IsCompleted) //Check if the backgroud update has completed
                CopyData();//Copy the data but not update UI
                double deltaTime = stopwatch.Elapsed.TotalSeconds;
                stopwatch.Restart();
                task = gameWorld.BeginUpdate(deltaTime);//Let backgroud game engine calculate next frame of the game.
                NotifyChange();//Update UI, runing concurrently with backgroud game engine thread. After this line is removed, the memory leak doesn't occur any more.
            
            Task task_forLambda = task;
            Action action = () => DispatherLoopCallback(task_forLambda);
            Application.Current.Dispatcher.BeginInvoke(action, DispatcherPriority.Background);//Send next call of this method to the dispatcher, leave space for other WPF process.
        

        private void CopyData() 
            GameBodyCollectionViewModel.CopyData();
        

        private void NotifyChange() 
            GameBodyCollectionViewModel.NotifyChange();
        
    

但是,当游戏运行时,即使什么都没有,内存使用量也会不断增加。当游戏暂停时,这种增加停止。所以我确信存在内存泄漏。经过调查,我发现问题来自 NotifyChange()。但我无法弄清楚这些 ViewModel 类是如何导致问题的。

    public class GameBodyCollectionViewModel : CollectionViewModel<GameBodyViewModel> 
        public GameBodyCollectionViewModel(GameWorld gameWorld) 
            this.gameWorld = gameWorld;
        

        private readonly GameWorld gameWorld;

        public override IEnumerator<GameBodyViewModel> GetEnumerator() => copiedData.GetEnumerator();

        internal void CopyData() 
            copiedData.Clear();
            copiedData.AddRange(from gb in gameWorld.GetGameBodies() select new GameBodyViewModel(gb));
        
        private readonly List<GameBodyViewModel> copiedData = new List<GameBodyViewModel>();
        internal void NotifyChange() 
            NotifyCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));//After this line is removed, the memory leak doesn't happen any more.
        
    
    public class GameBodyViewModel : ViewModel 
        public GameBodyViewModel(GameBody gameBody) 
            AABBLowerX = gameBody.AABB.LowerBound.X;
            AABBLowerY = gameBody.AABB.LowerBound.Y;
            AABBWidth = gameBody.AABB.Width;
            AABBHeight = gameBody.AABB.Height;
        
        public double AABBLowerX  get; 
        public double AABBLowerY  get; 
        public double AABBWidth  get; 
        public double AABBHeight  get; 
    

    public abstract class ViewModel : INotifyPropertyChanged 
        public event PropertyChangedEventHandler PropertyChanged;
        protected void NotifyPropertyChanged([CallerMemberName] String propertyName = null) 
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        
    

    public abstract class CollectionViewModel<T> : ViewModel, INotifyCollectionChanged, IEnumerable<T> 
        public abstract IEnumerator<T> GetEnumerator();
        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

        public event NotifyCollectionChangedEventHandler CollectionChanged;

        protected void NotifyCollectionChanged(NotifyCollectionChangedEventArgs e) 
            CollectionChanged?.Invoke(this, e);
        
    

视图的 xaml 代码不太可能与上下文相关,因此我没有在此处编写它们。如果需要更多详细信息,请告诉我。

更新0

忽略 xaml 代码是个错误。我发现它实际上是相关的。

<v:View x:TypeArguments="local:GameBodyCollectionViewModel" x:Name="view"
    x:Class="Enigma.GameWPF.Visual.Game.GameBodyCollectionView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:v ="clr-namespace:Enigma.GameWPF.Visual"
             xmlns:local="clr-namespace:Enigma.GameWPF.Visual.Game"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <ItemsControl ItemsSource="Binding ViewModel,ElementName=view">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Canvas></Canvas>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemContainerStyle>
            <Style>
                <Setter Property="Canvas.Left" Value="Binding AABBLowerX"/>
                <Setter Property="Canvas.Bottom" Value="Binding AABBLowerY"/>
            </Style>
        </ItemsControl.ItemContainerStyle>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <local:GameBodyView ViewModel="Binding" Width="Binding AABBWidth" Height="Binding AABBHeight"></local:GameBodyView>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</v:View>

删除整个 ItemsControl 后,内存泄漏没有发生。

更新1

根据我观察到的情况,我制作了一个演示项目,以便观众获得更好的信息并能够重现错误。 由于 *** 不支持文件附加,并且同样的内容也发布在 github 上,我在此处发布了该帖子的链接。您可以转到该链接下载演示。 https://github.com/dotnet/wpf/issues/5739

更新2

基于已经完成的工作,我尝试使用 .NET 提供的 ObservableCollection 而不是我自己的 INotifyCollectionChanged 实现。 DemoCollectionViewModel 改为:

    public class DemoCollectionViewModel : ViewModel 
        public DemoCollectionViewModel() 
            DemoItemViewModels = new ObservableCollection<DemoItemViewModel>();
        

        public ObservableCollection<DemoItemViewModel> DemoItemViewModels  get; 

        private readonly List<DemoItemViewModel> copiedData = new List<DemoItemViewModel>();

        internal void CopyData() 
            copiedData.Clear();
            copiedData.AddRange(from SimulatedModelItem smi in SimulatedModel.GetItems select new DemoItemViewModel(smi));
        

        internal void NotifyChange() 
            DemoItemViewModels.Clear();
            foreach (DemoItemViewModel vm in copiedData) 
                DemoItemViewModels.Add(vm);
            
        
    

在视图中,ItemsControl 的 ItemsSource 被绑定到 ObservableCollection。但是问题依然存在

【问题讨论】:

使用您的调试器和跟踪调用:在internal void NotifyChange() 中设置断点并在 VS 的诊断工具窗口中制作内存快照并找出差异。 INotifyCollectionChanged 不会导致内存泄漏,它是一个接口。您对实例的生命周期管理会导致内存泄漏 - 如果有的话。仅仅因为内存消耗增加,您不能假设内存泄漏。垃圾收集器不会立即收集对象。似乎您一直在以相对较高的频率更换所有项目。这会强制 ItemsControl 清除其所有项目容器并生成新容器。旧容器将保留在内存中。在这种情况下,您通常会使用 UI 虚拟化,尤其是容器回收。 重用容器可以避免那些不必要的内存分配和 UI 元素初始化。目前尚不清楚您实际上在做什么。如果你真的需要一个 Canvas,你必须实现容器回收和可能的 UI 虚拟化。这意味着您必须在自定义 Canvas 中实现这些功能。确保不再引用丢弃的项目。使用内存分析器来断言堆上的确切对象以及使它们保持活动/可访问的引用。如果这真的是内存泄漏,那么它是由您的对象生命周期管理而不是框架引起的。 当您的数据模型实现 INotifyPropertyChanged 时,您可能可以避免清除源集合,而是更新现有模型。 【参考方案1】:

如INotifyCollectionChanged is not updating the UI 中所述 INotifyCollectionChanged 应该由集合而不是视图模型实现,这里最好只使用 ObservableCollection,它会在集合更改时自动处理更新 UI 并摆脱 GameBodyCollectionViewModel.NotifyChange()。

更新

抱歉,我之前没有注意到 IEnumerable。 泄漏很可能是因为您每次调用循环时都使用“NotifyCollectionChangedAction.Reset”,这将触发集合中所有项目的 UI 更新,无论是否有任何更改。

【讨论】:

【参考方案2】:

经过多次尝试,我最终自己找到了答案。我没有继续创建新实例,而是将其修改为仅在从模型检索到的集合具有更多项目计数时添加新项目。如果源没有更多计数,则每次从模型复制数据只是更新现有实例。

这是最终代码:

    public class DemoCollectionViewModel : CollectionViewModel<DemoItemViewModel> 
        public DemoCollectionViewModel() 
        

        public override IEnumerator<DemoItemViewModel> GetEnumerator() => copiedData.GetEnumerator();

        private readonly List<DemoItemViewModel> copiedData = new List<DemoItemViewModel>();
        private int includedCount = 0;
        internal void CopyData() 
            int i = 0;
            SimulatedModelItem[] simulatedModelItems = SimulatedModel.Items.ToArray();
            foreach (SimulatedModelItem smi in simulatedModelItems) 
                if (copiedData.Count == i) 
                    DemoItemViewModel demoItemViewModel = new DemoItemViewModel();
                    copiedData.Add(demoItemViewModel);
                    addRecord.Add(demoItemViewModel);
                 
                copiedData[i].CopyData(smi);
                i++;
            
            for (int j = i; j < includedCount; j++) 
                copiedData[j].IsEmpty = true;
            
            includedCount = i;
        

        private readonly List<DemoItemViewModel> addRecord = new List<DemoItemViewModel>();
        private int lastNotifyIncludedCount = 0;
        internal void NotifyChange() 
            foreach (DemoItemViewModel demoItemViewModel in addRecord) 
                NotifyCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, demoItemViewModel));
            
            addRecord.Clear();
            int greaterInt = Math.Max(includedCount, lastNotifyIncludedCount);
            for (int i = 0; i < greaterInt; i++) 
                copiedData[i].NotifyChange();
            
            lastNotifyIncludedCount = includedCount;
        
    
    public class DemoViewModel : ViewModel 
        public DemoViewModel() 
            DemoCollectionViewModel = new DemoCollectionViewModel();
            Proceed();
        

        public DemoCollectionViewModel DemoCollectionViewModel  get; 

        private bool isProceed = false;
        public void Pause() 
            if (!isProceed) throw new InvalidOperationException("The GameWorld is already paused.");
            isProceed = false;
        

        public void Proceed() 
            if (isProceed) throw new InvalidOperationException("The GameWorld is already proceeding.");
            isProceed = true;
            Action action = () => DispatherLoopCallback();
            Application.Current.Dispatcher.BeginInvoke(action, DispatcherPriority.Background);
        

        private void DispatherLoopCallback() 
            if (!isProceed) return;
            CopyData();
            NotifyChange();
            Application.Current.Dispatcher.BeginInvoke(DispatherLoopCallback, DispatcherPriority.Background);//Send next call of this method to the dispatcher, leave space for other WPF process.
        

        private void CopyData() 
            DemoCollectionViewModel.CopyData();
        

        private void NotifyChange() 
            DemoCollectionViewModel.NotifyChange();
        
    

demo 的最终版本,修复了内存泄漏错误,也可以在 github 帖子https://github.com/dotnet/wpf/issues/5739中找到

【讨论】:

很好,你自己找到的。但是您仍在使用通用 List 结构。它还有另一个属性 - Capacity。是的,您确实从列表中删除了所有现有的DemoItemViewModels。但它的容量仍在增长。因此可以改善内存消耗。我更喜欢直接addRecord.Capacity = 0;。 docs.microsoft.com/en-us/dotnet/api/…

以上是关于为啥这个 INotifyCollectionChanged 会导致内存泄漏?的主要内容,如果未能解决你的问题,请参考以下文章

为啥这个函数序言中没有“sub rsp”指令,为啥函数参数存储在负 rbp 偏移量?

为啥这个正则表达式不匹配这个文本?

为啥这个变量还活着?

为啥 println() 打印这个?

为啥这个函数会返回这个值? [复制]

为啥这个例子不离开