0

I am using WPF as UI framework to develop a game. The game engine doesn't mutate it self, it only got changed when a call from the UI application telling it to update to next frame.

This is the key part of my codes:

    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();
        }
    }

However, when the game is running, even there are nothing, the memory usage keep increasing. And this increasing stop when the game pause. So I am sure there is a memory leak. After investigation, I found the problem come from NotifyChange(). But I cannot figure out how are these ViewModel classes causing problem.

    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);
        }
    }

The xaml codes of views are unlikely to be relevant to the context so I didn't write them here. If any more detail is required please tell me.

Update0

It was a fault to neglect the xaml codes. I found its actually relevant.

<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>

After removing the whole ItemsControl, the memory leak was not happenning.

Update1

Based on what I observed, I made a demo project inorder for audiences to have better information and able to reproduce the bug. As StackOverFlow dosen't support file attach and the same thing is posted on github as well, I am posting the link of that post here. You may goto that link to download the demo. https://github.com/dotnet/wpf/issues/5739

Update2

Based on whats done already, I tried to used .NET provided ObservableCollection instead of my own implementation of INotifyCollectionChanged. DemoCollectionViewModel was changed to :

    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);
            }
        }
    }

And in the view, the ItemsControl's ItemsSource is binded to the ObservableCollection instead. However the problem persists

martinrhan
  • 362
  • 1
  • 18
  • Use your debugger and tracing calls: set a breakpoint in `internal void NotifyChange()` and make memory snapshots in VS's Diagnostics Tools window and play spot-the-difference. – Dai Nov 25 '21 at 04:41
  • INotifyCollectionChanged can not cause a memory leak, it's an interface. Your lifetime management of your instances causes a memory leak - if there is any. Just because the memory consumption grows you can't assume a memory leak. The garbage collector does not collect objects immediately. It seems you are constantly replacing all items at a relatively high frequency. This forces the ItemsControl to clear all its item containers and generate new ones. The old containers will remain in the memory. In such a scenario you usually use UI virtualization and especially container recycling. – BionicCode Nov 27 '21 at 03:47
  • Reusing containers avoids those unnecessary memory allocations and UI element initializations. It's not clear what you are actually doing. If you really need a Canvas, you must implement container recycling and probably UI virtualization too. This means you must implement those features in a custom Canvas. Make sure the discarded items are no longer referenced. Use a memory profiler to assert the exact objects on the heap and what references keep them alive/reachable. If this is really a memory leak, then it is caused by your object lifetime management and not the framework. – BionicCode Nov 27 '21 at 03:47
  • When your data models implement INotifyPropertyChanged you could probably avoid clearing the source collection and instead update the existing models. – BionicCode Nov 27 '21 at 05:20

2 Answers2

1

After a lot of attempts, I eventually found the answer by myself. Instead of keep creating new instances, I modified it to be only adding new item when the collection retrieved from model has more item count. Each time copying data from model is just updating existing instances if the source has not more count.

Here's the final code:

    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();
        }
    }

The final version of demo, with the memory leak bug fixed is also available in the github post https://github.com/dotnet/wpf/issues/5739

martinrhan
  • 362
  • 1
  • 18
  • Good, you found it on your own. But you are using a generic List structure still. It has another property - `Capacity`. Yes, you do remove all existing `DemoItemViewModel`s from the list. But its capacity grows still. So the memory consumption could be improved. I prefer to do it directly `addRecord.Capacity = 0;`. https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1.clear?view=net-5.0#remarks – PIoneer_2 Nov 29 '21 at 10:48
-2

As stated in INotifyCollectionChanged is not updating the UI INotifyCollectionChanged should be implemented by a collection and not by a view model and here it would be better to just use ObservableCollection which automatically handles updating the UI when the collection changes and get rid of the GameBodyCollectionViewModel.NotifyChange().

Update

I apologize forI haven't noticed the IEnumerable before. The leak is most likely because you're using "NotifyCollectionChangedAction.Reset" every time the loop is called which would trigger a UI update for all the items in the collection regardless of whether there were any changes.

ouflak
  • 2,458
  • 10
  • 44
  • 49
DSMSTHN
  • 40
  • 4