Skip to content
This repository has been archived by the owner on May 1, 2024. It is now read-only.

[Bug] UWP - ListView does not recycle cells [HUUGE MEMORY ISSUE] #10473

Closed
gentledepp opened this issue Apr 27, 2020 · 10 comments · Fixed by #13277
Closed

[Bug] UWP - ListView does not recycle cells [HUUGE MEMORY ISSUE] #10473

gentledepp opened this issue Apr 27, 2020 · 10 comments · Fixed by #13277

Comments

@gentledepp
Copy link
Contributor

gentledepp commented Apr 27, 2020

Description

On UWP, the viewcells of a listview are not recycled. Rather, every time the datacontext changes, a new cell is created from the datatemplate!

Steps to Reproduce

  1. Create a page with a Xamarin.ListView and set the itemssource to 200 items
  2. Create a custom ViewCell (so that you have the xaml and the code behind)
  3. Set the HeightRequest of the viewcell to 80 or more
  4. Set a breakpoint in the ctor of your ViewCell
  5. Scroll all the way up and all the way down

Expected Behavior

Constructor of the custom ViewCell should never be called during scrolling

Actual Behavior

Constructor is called all the time...

Basic Information

  • Version with issue: 4.4.0 and all above
  • Last known good version: no idea...
  • IDE: VS 2019
  • Platform Target Frameworks:
    • UWP: irrelevant
  • Android Support Library Version:
  • Nuget Packages: irrelevant
  • Affected Devices: all UWP devices

Screenshots

Reproduction Link

Workaround

Solution

We already found the issue for this.
The culprit is the Xamarin.Forms.Platform.UAP.CellControl.SetCell(..) method:

The error is marked with a ⚠

void SetCell(object newContext)
{
	var cell = newContext as Cell;

	if (cell != null)
	{
		Cell = cell;
		return;
	}

	if (ReferenceEquals(Cell?.BindingContext, newContext))
		return;

	// If there is a ListView, load the Cell content from the ItemTemplate.
	// Otherwise, the given Cell is already a templated Cell from a TableView.
	ListView lv = _listView.Value;
	if (lv != null)
	{
		bool isGroupHeader = IsGroupHeader;
		DataTemplate template = isGroupHeader ? lv.GroupHeaderTemplate : lv.ItemTemplate;
		object bindingContext = newContext;

		if (template is DataTemplateSelector)
		{
			template = ((DataTemplateSelector)template).SelectTemplate(bindingContext, lv);
		}
                        // ⚠ ERROR: The code should now check if there already exists a Cell that
                        // could be recycled!
                        // Instead, however, it just creates a new cell using the template or the defaultcell (in case it is a groupheader)
		if (template != null)
		{
			cell = template.CreateContent() as Cell;
		}
		else
		{
			if (isGroupHeader)
				bindingContext = lv.GetDisplayTextFromGroup(bindingContext);

			cell = lv.CreateDefaultCell(bindingContext);
		}

		// A TableView cell should already have its parent,
		// but we need to set the parent for a ListView cell.
		cell.Parent = lv;

		// Set inherited BindingContext after setting the Parent so it won't be wiped out
		BindableObject.SetInheritedBindingContext(cell, bindingContext);

		// This provides the Group Header styling (e.g., larger font, etc.) when the
		// template is loaded later.
		cell.SetIsGroupHeader<ItemsView<Cell>, Cell>(isGroupHeader);
	}

	Cell = cell;
}

Now we patched this ourselves like the following:
(We marked our additions with rockeds: 🚀)

void SetCell(object newContext)
{
	var cell = newContext as Cell;

	if (cell != null)
	{
		Cell = cell;
		return;
	}

	if (ReferenceEquals(Cell?.BindingContext, newContext))
		return;

	// If there is a ListView, load the Cell content from the ItemTemplate.
	// Otherwise, the given Cell is already a templated Cell from a TableView.
	ListView lv = _listView.Value;
	if (lv != null)
	{
		// 🚀 If there is an old cell, check if it was a group header
		// we need this later to know whether we can recycle this cell
		bool? wasGroupHeader = null;
		var oldCell = Cell;
		if (oldCell != null)
		{
			wasGroupHeader = oldCell.GetIsGroupHeader<ItemsView<Cell>, Cell>();
		}
		
		bool isGroupHeader = IsGroupHeader;
		DataTemplate template = isGroupHeader ? lv.GroupHeaderTemplate : lv.ItemTemplate;
		object bindingContext = newContext;

		bool sameTemplate = false;
		if (template is DataTemplateSelector dataTemplateSelector)
		{
			template = dataTemplateSelector.SelectTemplate(bindingContext, lv);

			 // 🚀 If there exists an old cell, get its data template and check
		        // whether the new- and old template matches. In that case, we can recycle it
		        if (oldCell?.BindingContext != null)
			{
				DataTemplate oldTemplate = dataTemplateSelector.SelectTemplate(oldCell?.BindingContext, lv);
				sameTemplate = oldTemplate == template;
			}
		}
		// 🚀 if there is no datatemplateselector, we now verify if the old cell
		// was a groupheader and whether the new one is as well.
		// Again, this is only to verify we can reuse this cell
		else if(wasGroupHeader.HasValue)
		{
			sameTemplate = wasGroupHeader == isGroupHeader;
		}

		// reuse cell
		var canReuseCell = Cell != null && sameTemplate;
		
		// 🚀 If we can reuse the cell, just reuse it...
		if (canReuseCell)
		{
			cell = Cell;
		}
		else if (template != null)
		{
			cell = template.CreateContent() as Cell;
		}
		else
		{
			if (isGroupHeader)
				bindingContext = lv.GetDisplayTextFromGroup(bindingContext);

			cell = lv.CreateDefaultCell(bindingContext);
		}

		// A TableView cell should already have its parent,
		// but we need to set the parent for a ListView cell.
		cell.Parent = lv;

		// Set inherited BindingContext after setting the Parent so it won't be wiped out
		BindableObject.SetInheritedBindingContext(cell, bindingContext);

		// This provides the Group Header styling (e.g., larger font, etc.) when the
		// template is loaded later.
		cell.SetIsGroupHeader<ItemsView<Cell>, Cell>(isGroupHeader);
	}

        // 🚀 Only set the cell if it DID change
	// Note: The cleanup (SendDisappearing(), etc.) is done by the Cell propertychanged callback so we do not need to do any cleanup ourselves.

	if(Cell != cell)
		Cell = cell;
	// 🚀 even if the cell did not change, we **must** call SendDisappearing() and SendAppearing()
	// because frameworks such as Reactive UI rely on this! (this.WhenActivated())
	else if(Cell != null)
	{
		Cell.SendDisappearing();
		Cell.SendAppearing();
	}

This works like a charm.
I do not know where to target our PR though as there are so many versions affected.
Maybe you just copy paste the fix yourself in all platforms that apply?

@bruzkovsky @BrayanKhosravian - fyi ;-)

Update:

It seems that the CellControl has another memory leak:
image

The Cell.PropertyChanged event is not unsubscribed when the cell is unloaded

		public CellControl()
		{
			_listView = new Lazy<ListView>(GetListView);

			DataContextChanged += OnDataContextChanged;

			Unloaded += (sender, args) =>
			{
				Cell?.SendDisappearing();
			};

			_propertyChangedHandler = OnCellPropertyChanged;
		}

We'd fix it like this:

		public CellControl()
		{
			_listView = new Lazy<ListView>(GetListView);

			DataContextChanged += OnDataContextChanged;

			Loaded += OnLoaded;
			Unloaded += OnUnloaded;

			_propertyChangedHandler = OnCellPropertyChanged;
		}

		void OnLoaded(object sender, RoutedEventArgs e)
		{
			if (Cell == null)
				return;

                        /// 🚀 subscribe topropertychanged
			// make sure we do not subscribe twice (because this could happen in SetSource(Cell oldCell, Cell newCell))
			Cell.PropertyChanged -= _propertyChangedHandler;
			Cell.PropertyChanged += _propertyChangedHandler;
		}

		void OnUnloaded(object sender, RoutedEventArgs e)
		{
			if (Cell == null)
				return;

			Cell.SendDisappearing();
                        /// 🚀 unsubscribe from propertychanged
			Cell.PropertyChanged -= _propertyChangedHandler;
		}

.....


		void SetSource(Cell oldCell, Cell newCell)
		{
			if (oldCell != null)
			{
				oldCell.PropertyChanged -= _propertyChangedHandler;
				oldCell.SendDisappearing();
			}

			if (newCell != null)
			{
				newCell.SendAppearing();

				UpdateContent(newCell);
				UpdateFlowDirection(newCell);
				SetupContextMenu();

				// 🚀 make sure we do not subscribe twice (OnLoaded!)
				newCell.PropertyChanged -= _propertyChangedHandler;
				newCell.PropertyChanged += _propertyChangedHandler;
			}
		}

@gentledepp gentledepp added s/unverified New report that has yet to be verified t/bug 🐛 labels Apr 27, 2020
@pauldipietro pauldipietro added this to New in Triage Apr 27, 2020
gentledepp pushed a commit to Opti-Q/Xamarin.Forms that referenced this issue Apr 27, 2020
@jsuarezruiz jsuarezruiz added a/listview Problems with the ListView/TableView a/performance p/UWP labels Apr 27, 2020
gentledepp pushed a commit to Opti-Q/Xamarin.Forms that referenced this issue Apr 27, 2020
gentledepp pushed a commit to Opti-Q/Xamarin.Forms that referenced this issue Apr 28, 2020
@gentledepp
Copy link
Contributor Author

Update: We have a fairly complex app which uses listview to the max:
Lazy loading, many data templates, sometimes datatemplateselectors and also groupings are used.
I now tested this thorougly and it works perfectly (although I had to fix my commented code 2 times 😁)

gentledepp pushed a commit to Opti-Q/Xamarin.Forms that referenced this issue Apr 29, 2020
gentledepp pushed a commit to Opti-Q/Xamarin.Forms that referenced this issue May 5, 2020
…ring() to support frameworks such as ReactiveUI and the like
@nbevans
Copy link

nbevans commented May 5, 2020

Finally somebody has found the root cause of UWP's ListView memory leaks! You absolute legend.

Very much looking forward to these getting merged into master.

Question: what tool is that in your screenshot which you are using for memory analysis?

@gentledepp
Copy link
Contributor Author

Well... don't be too happy. There is still some serious memory leaks, but we are currently not sure if they are within Xamarin.Forms for UWP or our own app

We are using Jetbrains dotMemory. Since the 2020 release it is really a great tool - even shows you the instantiation path of some object instance.

If you don't know Jetbrains - they are the brain behind the mother of tools: Resharper

@rmarinho rmarinho removed the s/unverified New report that has yet to be verified label May 19, 2020
@rmarinho rmarinho moved this from New to Ready For Work in Triage May 19, 2020
@rmarinho rmarinho added the e/2 🕑 2 label May 19, 2020
@samhouts samhouts removed this from Ready For Work in Triage May 19, 2020
@samhouts samhouts added this to New in Triage via automation May 27, 2020
@samhouts samhouts moved this from New to Ready For Work in Triage May 27, 2020
@samhouts samhouts removed this from Ready For Work in Triage Jun 3, 2020
@samhouts samhouts added this to To do in UWP Ready For Work via automation Jun 15, 2020
@Pandoraman92
Copy link

Is there any updates about that topic? I think gentledepp provided a solution for at least one of the memory leaks of the listview. We in our company still use that listview on a fairly large installation base. So we would appreciate that this fix would be merged, sine this solution is pending since end of April!

@samhouts samhouts added this to the 5.0.0 milestone Aug 13, 2020
@samhouts samhouts added this to To do in vNext+1 (5.0.0) Aug 13, 2020
@samhouts samhouts removed this from the 5.0.0 milestone Nov 2, 2020
@curtisconner
Copy link

Any update, seems like this was removed from 5.0.0. any idea when this will be fixed or are you providing another fix for this?

@gentledepp
Copy link
Contributor Author

I do not understand how such an important fix is pushed back further and further...
in 4.8.x this critical bug is still there.
We gave up on waiting for fixes a long time ago and are maintaining our own Xamarin.Forms fork.
I strongly encourage you to do so as

  • known bugs tend to not be fixed no matter how critical
  • new versions of XF tend to introduce and reintroduce bugs all the time

This really is a maintenance nightmare, but thankfully it is open source so you can do something about it.

gentledepp added a commit to Opti-Q/Xamarin.Forms that referenced this issue Dec 17, 2020
…ramatic leakage of memory when using a listview
bruzkovsky added a commit to Opti-Q/Xamarin.Forms that referenced this issue Dec 17, 2020
xamarin#10473 - UWP - Fixed memoryleak in UWP CellControl causing a dramatic…
@CliffAgius
Copy link
Contributor

If there is a fix that @gentledepp has implemented that looks like it works and merged into their private fork of XF any chance one of the team @jsuarezruiz @samhouts @rmarinho etc can look at merging the same fix and resolve this MASSIVE memory issue that plagues the use of CollectionViews on the UWP platform....

@rmarinho
Copy link
Member

rmarinho commented Jan 2, 2021

Hi @CliffAgius i will ping @hartez he knows very well inner works of CollectionView.
Is it possible @gentledepp to make a PR with all the changes he implemented so it easier for us to look and check if makes sense and fixes the issues?

Thanks

@CliffAgius
Copy link
Contributor

@rmarinho if you scroll up slightly you can see they merged a PR into thier own fork but have not done a PR into the XF Repo.

If needed I'm happy to create the PR obviously attributing it to @gentledepp if it will speed things up.

CliffAgius added a commit to CliffAgius/Xamarin.Forms that referenced this issue Jan 4, 2021
…o a XF PR. These have been tested and seem work solve the issue of the Cell not being disposed of as they are now reused rather than created from scratch. I am only putting forward the PR.
@CliffAgius
Copy link
Contributor

@rmarinho I have created a PR from @gentledepp work and having tested here it seems to work really well. Not sure if this will make it into XF5 or if it's going to get pushed in MAUI but a project I am working on really needs this so hopefully the former.

I know there are other Issues based on the same fault that this could cross off as well.

Let me know if you need anything else from me to push this along.

UWP Ready For Work automation moved this from To do to Done Jan 15, 2021
rmarinho added a commit that referenced this issue Jan 15, 2021
…0473

* Update azure-pipelines.yml

* Fix #10473 using changes made by @gentledepp but not subject to a XF PR.  These have been tested and seem work solve the issue of the Cell not being disposed of as they are now reused rather than created from scratch.  I am only putting forward the PR.

* Changed the .yml file back to the current version not sure how or why it changed....

* Update azure-pipelines.yml

* Update azure-pipelines.yml

FIxed so that it's now reverted to the current live version of the file...

* Update azure-pipelines.yml

Co-authored-by: Rui Marinho <me@ruimarinho.net>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
Development

Successfully merging a pull request may close this issue.

8 participants