So I've had performance issues in my Xamarin.Forms app (on Android) using a ListView
. The reason is, because I'm using a very complex custom control in the ListView's ItemTemplate
.
To improve performance, I implemented a lot of caching functionality in my custom control and set the ListView's CachingStrategy
to RecycleElement
.
Performance didn't went better. So I digged down trying to find out what's the reason about.
I finally noticed some really strange bug and isolated it in a new, empty app. Code is as follows:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:c="clr-namespace:ListViewBug.Controls"
xmlns:vm="clr-namespace:ListViewBug.ViewModels"
x:Class="ListViewBug.MainPage">
<ContentPage.BindingContext>
<vm:MainViewModel />
</ContentPage.BindingContext>
<ListView ItemsSource="{Binding Numbers}" CachingStrategy="RetainElement"
HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand"
HasUnevenRows="True">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<c:TestControl Foo="{Binding}" />
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</ContentPage>
public class TestControl : Grid
{
static int id = 0;
int myid;
public static readonly BindableProperty FooProperty = BindableProperty.Create("Foo", typeof(string), typeof(TestControl), "", BindingMode.OneWay, null, (bindable, oldValue, newValue) =>
{
int sourceId = ((TestControl)bindable).myid;
Debug.WriteLine(String.Format("Refreshed Binding on TestControl with ID {0}. Old value: '{1}', New value: '{2}'", sourceId, oldValue, newValue));
});
public string Foo
{
get { return (string)GetValue(FooProperty); }
set { SetValue(FooProperty, value); }
}
public TestControl()
{
this.myid = ++id;
Label label = new Label
{
Margin = new Thickness(0, 15),
FontSize = Device.GetNamedSize(NamedSize.Medium, typeof(Label)),
Text = this.myid.ToString()
};
this.Children?.Add(label);
}
}
public class MainViewModel
{
public List<string> Numbers { get; set; } = new List<string>()
{
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
"twenty"
};
}
Notice that CachingStrategy
is RetainElement
. Every TestControl
gets a unique ascending ID which is displayed in the UI. Let's run the app!
[0:] Refreshed Binding on TestControl with ID 1. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 1. Old value: '', New value: 'one'
[0:] Refreshed Binding on TestControl with ID 2. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 2. Old value: '', New value: 'two'
[0:] Refreshed Binding on TestControl with ID 3. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 3. Old value: '', New value: 'three'
[0:] Refreshed Binding on TestControl with ID 4. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 4. Old value: '', New value: 'four'
[0:] Refreshed Binding on TestControl with ID 5. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 5. Old value: '', New value: 'five'
[0:] Refreshed Binding on TestControl with ID 6. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 6. Old value: '', New value: 'six'
[0:] Refreshed Binding on TestControl with ID 7. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 7. Old value: '', New value: 'seven'
[0:] Refreshed Binding on TestControl with ID 8. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 8. Old value: '', New value: 'eight'
[0:] Refreshed Binding on TestControl with ID 9. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 9. Old value: '', New value: 'nine'
[0:] Refreshed Binding on TestControl with ID 10. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 10. Old value: '', New value: 'ten'
[0:] Refreshed Binding on TestControl with ID 11. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 11. Old value: '', New value: 'eleven'
[0:] Refreshed Binding on TestControl with ID 12. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 12. Old value: '', New value: 'twelve'
Well, every Binding gets fired twice for some reason. This does not happen in my actual app, so I don't care that much about. I also compare oldValue and newValue and do nothing if they're the same so this behaviour wouldn't affect performance.
The interesting things happen when we set CachingStrategy
to RecycleElement
:
[0:] Refreshed Binding on TestControl with ID 1. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 1. Old value: '', New value: 'one'
[0:] Refreshed Binding on TestControl with ID 2. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 2. Old value: '', New value: 'one'
[0:] Refreshed Binding on TestControl with ID 3. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 3. Old value: '', New value: 'two'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'one', New value: 'two'
[0:] Refreshed Binding on TestControl with ID 4. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 4. Old value: '', New value: 'three'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'two', New value: 'three'
[0:] Refreshed Binding on TestControl with ID 5. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 5. Old value: '', New value: 'four'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'three', New value: 'four'
[0:] Refreshed Binding on TestControl with ID 6. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 6. Old value: '', New value: 'five'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'four', New value: 'five'
[0:] Refreshed Binding on TestControl with ID 7. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 7. Old value: '', New value: 'six'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'five', New value: 'six'
[0:] Refreshed Binding on TestControl with ID 8. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 8. Old value: '', New value: 'seven'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'six', New value: 'seven'
[0:] Refreshed Binding on TestControl with ID 9. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 9. Old value: '', New value: 'eight'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'seven', New value: 'eight'
[0:] Refreshed Binding on TestControl with ID 10. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 10. Old value: '', New value: 'nine'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'eight', New value: 'nine'
[0:] Refreshed Binding on TestControl with ID 11. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 11. Old value: '', New value: 'ten'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'nine', New value: 'ten'
[0:] Refreshed Binding on TestControl with ID 12. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 12. Old value: '', New value: 'eleven'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'ten', New value: 'eleven'
[0:] Refreshed Binding on TestControl with ID 13. Old value: '', New value: ''
[0:] Refreshed Binding on TestControl with ID 13. Old value: '', New value: 'twelve'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'eleven', New value: 'twelve'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'twelve', New value: 'one'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'one', New value: 'two'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'two', New value: 'three'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'three', New value: 'four'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'four', New value: 'five'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'five', New value: 'six'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'six', New value: 'seven'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'seven', New value: 'eight'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'eight', New value: 'nine'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'nine', New value: 'ten'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'ten', New value: 'eleven'
[0:] Refreshed Binding on TestControl with ID 1. Old value: 'eleven', New value: 'twelve'
Oops. Cell 1 is invisible, but it gets its Binding updated a lot. I didn't even touch the screen once, so no scrolling was involved.
When I tap the screen and scroll down about one or two pixels, the Binding of ID 1 gets refreshed about another 15 times.
Please refer to this video of me scrolling the ListView
:
https://www.youtube.com/watch?v=EuWTGclz7uc
This is an absolute performance killer in my real app, where TestControl
is a really complex control.
Interestingly, in my real app it is ID 2 instead of ID 1 that's bugged. I assumed that it is always the second cell, so I ended up with returning instantly if the ID is 2. This made the ListView performance nice and smooth.
Now that I've been able to reproduce this problem with an ID other than 2, I'm scared about my own solution.
Thus my questions are: What is this invisible cell, why is it getting so much Binding updates and how can I bypass the performance problems?
I tested with Xamarin.Forms versions 2.3.4.247, 2.3.4.270 and 2.4.0.269-pre2 on
I didn't test on an iOS device.
Setting CachingStrategy
to RecycleElement
is messing your list view because you are using a value in TextBock
that isn't retrieved from BindingContext. (int myid;
).
Let's look at Xamarin documentation RecycleElement
However, it is generally the preferred choice, and should be used in the following circumstances:
- When each cell has a small to moderate number of bindings.
- When each cell's BindingContext defines all of the cell data.
- When each cell is largely similar, with the cell template unchanging.
During virtualization the cell will have its binding context updated, and so if an app uses this mode it must ensure that binding context updates are handled appropriately. All data about the cell must come from the binding context or consistency errors may occur.
You should consider using RecycleElement
mode when each cell's BindingContext defines all of the cell data. Your int myid
is cell data but isn't defined by Binding Context.
Why?
I can give a guess, that in RecycleElement
mode when scrolling: controls are not being changed, changes are only made to their bindings. I think it is done to reduce time for rendering controls. ( Also to reduce memory usage for large amount of items )
So Text-Block with myId
1 can serve as container for "Two" value. ( This is what virtualization means. )
Answer: Changing your myId
logic, to retrieve it from BindingContext
will do the trick.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With