WPF: 数据虚拟化






4.95/5 (178投票s)
一个支持大型数据集数据虚拟化的集合类。
引言
WPF 提供了一些巧妙的 UI 虚拟化功能,可以高效地处理大型集合,至少从 UI 的角度来看是这样。但是,并没有提供一个通用的实现数据虚拟化的方法。虽然互联网论坛上有很多关于数据虚拟化的讨论,但据我所知,还没有人发布过一个解决方案。本文将提出这样一个解决方案。
背景
UI 虚拟化
当 WPF 的 ItemsControl
绑定到一个大型集合数据源,并且启用了 UI 虚拟化时,该控件只会为实际可见(加上上面和下面几个)的项创建可视容器。这通常只占整个集合的一小部分。当用户滚动时,随着项变得可见,会创建新的可视容器;当项不再可见时,旧的容器会被处置。如果启用了容器回收,它将重用可视容器而不是创建和处置,从而避免了对象实例化和垃圾回收的开销。
UI 虚拟化意味着控件可以绑定到大型集合,而不会因为可视容器而产生巨大的内存占用。但是,实际数据对象在集合中可能仍然会产生巨大的内存占用。
数据虚拟化
数据虚拟化是指为绑定到 ItemsControl
的实际数据对象实现虚拟化。WPF 本身不提供数据虚拟化。对于相对较小的基本数据对象集合,内存占用并不显著;但是,对于大型集合,内存占用可能会变得非常大。此外,实际检索数据(例如,从数据库)并实例化所有对象可能非常耗时,特别是如果涉及网络操作。出于这些原因,最好使用某种数据虚拟化机制来限制需要检索和实例化到内存中的数据对象的数量。
解决方案
概述
该解决方案利用了这样一个事实:当 ItemsControl
绑定到一个 IList
实现而不是 IEnumerable
实现时,它不会枚举整个列表,而是只访问显示所需的项。它使用 Count
属性来确定集合的大小,这可能用于设置滚动条的范围。然后,它通过列表索引器遍历屏幕上的项。因此,可以创建一个 IList
,它报告拥有大量项,但实际上只在需要时检索这些项。
IItemsProvider<T>
为了利用此解决方案,底层数据源必须能够提供集合中的项数,并且能够提供整个集合的小块(或页面)。这个要求被封装在 IItemsProvider
接口中。
/// <summary>
/// Represents a provider of collection details.
/// </summary>
/// <typeparam name="T">The type of items in the collection.</typeparam>
public interface IItemsProvider<T>
{
/// <summary>
/// Fetches the total number of items available.
/// </summary>
/// <returns></returns>
int FetchCount();
/// <summary>
/// Fetches a range of items.
/// </summary>
/// <param name="startIndex">The start index.</param>
/// <param name="count">The number of items to fetch.</param>
/// <returns></returns>
IList<T> FetchRange(int startIndex, int count);
}
如果底层数据源是数据库查询,那么使用大多数数据库供应商提供的 COUNT()
聚合函数以及 OFFSET
和 LIMIT
表达式来实现 IItemsProvider
接口相对比较简单。
VirtualizingCollection<T>
这是执行数据虚拟化的 IList
实现。VirtualizingCollection(T)
将整个集合空间划分为多个页面。然后,根据需要将页面加载到内存中,并在不再需要时释放它们。
下面将讨论有趣的部分。有关所有详细信息,请参阅附加的源代码项目。
IList
实现的第一个方面是 Count
属性的实现。ItemsControl
使用此属性来估算集合的大小并相应地渲染滚动条。
private int _count = -1;
public virtual int Count
{
get
{
if (_count == -1)
{
LoadCount();
}
return _count;
}
protected set
{
_count = value;
}
}
protected virtual void LoadCount()
{
Count = FetchCount();
}
protected int FetchCount()
{
return ItemsProvider.FetchCount();
}
Count
属性使用延迟加载模式实现。它使用特殊值 -1 来表示尚未加载。首次访问时,它将从 ItemsProvider
加载实际计数。
IList
接口的另一个重要方面是索引器的实现。
public T this[int index]
{
get
{
// determine which page and offset within page
int pageIndex = index / PageSize;
int pageOffset = index % PageSize;
// request primary page
RequestPage(pageIndex);
// if accessing upper 50% then request next page
if ( pageOffset > PageSize/2 && pageIndex < Count / PageSize)
RequestPage(pageIndex + 1);
// if accessing lower 50% then request prev page
if (pageOffset < PageSize/2 && pageIndex > 0)
RequestPage(pageIndex - 1);
// remove stale pages
CleanUpPages();
// defensive check in case of async load
if (_pages[pageIndex] == null)
return default(T);
// return requested item
return _pages[pageIndex][pageOffset];
}
set { throw new NotSupportedException(); }
}
索引器执行了解决方案中最巧妙的部分。首先,它必须确定请求的项所在的页面(pageIndex
)以及在该页面中的偏移量(pageOffset
)。然后,它会为所需的页面调用 RequestPage()
方法。
附加步骤是根据 pageOffset
加载下一页或上一页。这基于这样的假设:如果用户正在查看第 0 页,那么他们很可能会向下滚动以查看第 1 页。提前获取它不会导致显示中断。
然后调用 CleanUpPages()
来清理(或卸载)任何不再使用的页面。
最后,为了应对页面尚未准备好的情况,有一个防御性检查。如果 RequestPage
不是同步操作(如派生类 AsyncVirtualizingCollection<T>
的情况),则此检查是必需的。
// ...
private readonly Dictionary<int, IList<T>> _pages =
new Dictionary<int, IList<T>>();
private readonly Dictionary<int, DateTime> _pageTouchTimes =
new Dictionary<int, DateTime>();
protected virtual void RequestPage(int pageIndex)
{
if (!_pages.ContainsKey(pageIndex))
{
_pages.Add(pageIndex, null);
_pageTouchTimes.Add(pageIndex, DateTime.Now);
LoadPage(pageIndex);
}
else
{
_pageTouchTimes[pageIndex] = DateTime.Now;
}
}
protected virtual void PopulatePage(int pageIndex, IList<T> page)
{
if (_pages.ContainsKey(pageIndex))
_pages[pageIndex] = page;
}
public void CleanUpPages()
{
List<int> keys = new List<int>(_pageTouchTimes.Keys);
foreach (int key in keys)
{
// page 0 is a special case, since the WPF ItemsControl
// accesses the first item frequently
if ( key != 0 && (DateTime.Now -
_pageTouchTimes[key]).TotalMilliseconds > PageTimeout )
{
_pages.Remove(key);
_pageTouchTimes.Remove(key);
}
}
}
页面存储在一个 Dictionary
中,页面索引用作键。另一个 Dictionary
用于存储访问时间。访问时间记录了每个页面上次被访问的时间。CleanUpPages()
方法使用此信息来移除在相当长一段时间内未被访问的页面。
protected virtual void LoadPage(int pageIndex)
{
PopulatePage(pageIndex, FetchPage(pageIndex));
}
protected IList<T> FetchPage(int pageIndex)
{
return ItemsProvider.FetchRange(pageIndex*PageSize, PageSize);
}
为了完成解决方案,FetchPage()
执行从 ItemsProvider
的获取操作,而 LoadPage()
方法负责获取页面并调用 PopulatePage
方法将其存储在 Dictionary
中。
可能看起来有太多无关紧要的方法,但它们的设计是有原因的。每种方法只执行一项任务。这有助于保持代码的可读性,也使得在派生类中扩展或修改功能更容易,如下文所示。
VirtualizingCollection<T>
类实现了数据虚拟化的主要目标。不幸的是,该类在使用时有一个严重的缺点:数据获取方法都是同步执行的。这意味着它们将由 UI 线程执行,这可能会导致应用程序响应迟缓。
AsyncVirtualizingCollection<T>
AsyncVirtualizingCollection<T>
类继承自 VirtualizingCollection<T>
,并重写了 Load
方法以异步执行数据加载。
WPF 中异步数据源的关键在于,它必须在数据获取完成后通过数据绑定通知 UI。在常规对象中,这通常通过 INotifyPropertyChanged
接口实现。但是,对于集合实现,有必要使用其近亲 INotifyCollectionChanged
。这是 ObservableCollection<T>
使用的接口。
public event NotifyCollectionChangedEventHandler CollectionChanged;
protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
NotifyCollectionChangedEventHandler h = CollectionChanged;
if (h != null)
h(this, e);
}
private void FireCollectionReset()
{
NotifyCollectionChangedEventArgs e =
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset);
OnCollectionChanged(e);
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChangedEventHandler h = PropertyChanged;
if (h != null)
h(this, e);
}
private void FirePropertyChanged(string propertyName)
{
PropertyChangedEventArgs e = new PropertyChangedEventArgs(propertyName);
OnPropertyChanged(e);
}
AsyncVirtualizingCollection<T>
同时实现了 INotifyCollectionChanged
和 INotifyPropertyChanged
,提供了最大的数据绑定灵活性。此实现中没有什么特别值得注意的。
protected override void LoadCount()
{
Count = 0;
IsLoading = true;
ThreadPool.QueueUserWorkItem(LoadCountWork);
}
private void LoadCountWork(object args)
{
int count = FetchCount();
SynchronizationContext.Send(LoadCountCompleted, count);
}
private void LoadCountCompleted(object args)
{
Count = (int)args;
IsLoading = false;
FireCollectionReset();
}
在重写的 LoadCount()
方法中,通过 ThreadPool
异步调用获取操作。完成后,设置新的 Count
,并调用 FireCollectionReset()
方法通过 INotifyCollectionChanged
接口更新 UI。请注意,LoadCountCompleted
方法是通过使用 SynchronizationContext
再次在 UI 线程上调用的。此 SynchronizationContext
属性在构造函数中设置,假设集合实例是在 UI 线程上创建的。
protected override void LoadPage(int index)
{
IsLoading = true;
ThreadPool.QueueUserWorkItem(LoadPageWork, index);
}
private void LoadPageWork(object args)
{
int pageIndex = (int)args;
IList<T> page = FetchPage(pageIndex);
SynchronizationContext.Send(LoadPageCompleted, new object[]{ pageIndex, page });
}
private void LoadPageCompleted(object args)
{
int pageIndex = (int)((object[]) args)[0];
IList<T> page = (IList<T>)((object[])args)[1];
PopulatePage(pageIndex, page);
IsLoading = false;
FireCollectionReset();
}
页面数据的异步加载遵循相同的约定,并且再次使用 FireCollectionReset()
方法来更新 UI。
另请注意 IsLoading
属性。这是一个简单的标志,可用于向 UI 指示集合正在加载。当 IsLoading
属性更改时,会调用 FirePropertyChanged()
方法通过 INotifyPropertyChanged
机制更新 UI。
public bool IsLoading
{
get
{
return _isLoading;
}
set
{
if ( value != _isLoading )
{
_isLoading = value;
FirePropertyChanged("IsLoading");
}
}
}
演示项目
为了演示此解决方案,我创建了一个简单的演示项目(包含在附加的源代码项目中)。
首先,创建了一个 IItemsProvider
的实现,它提供模拟数据,并通过线程睡眠来模拟因网络或磁盘活动导致的获取数据延迟。
public class DemoCustomerProvider : IItemsProvider<Customer>
{
private readonly int _count;
private readonly int _fetchDelay;
public DemoCustomerProvider(int count, int fetchDelay)
{
_count = count;
_fetchDelay = fetchDelay;
}
public int FetchCount()
{
Thread.Sleep(_fetchDelay);
return _count;
}
public IList<Customer> FetchRange(int startIndex, int count)
{
Thread.Sleep(_fetchDelay);
List<Customer> list = new List<Customer>();
for( int i=startIndex; i<startIndex+count; i++ )
{
Customer customer = new Customer {Id = i+1, Name = "Customer " + (i+1)};
list.Add(customer);
}
return list;
}
}
集合中的项使用随处可见的 Customer
对象。
创建了一个简单的 WPF 窗口,其中包含一个 ListView
,允许用户试验不同的列表实现。
<Window x:Class="DataVirtualization.DemoWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Data Virtualization Demo - By Paul McClean" Height="600" Width="600">
<Window.Resources>
<Style x:Key="lvStyle" TargetType="{x:Type ListView}">
<Setter Property="VirtualizingStackPanel.IsVirtualizing" Value="True"/>
<Setter Property="VirtualizingStackPanel.VirtualizationMode" Value="Recycling"/>
<Setter Property="ScrollViewer.IsDeferredScrollingEnabled" Value="True"/>
<Setter Property="ListView.ItemsSource" Value="{Binding}"/>
<Setter Property="ListView.View">
<Setter.Value>
<GridView>
<GridViewColumn Header="Id" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Id}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Name" Width="150">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding IsLoading}" Value="True">
<Setter Property="ListView.Cursor" Value="Wait"/>
<Setter Property="ListView.Background" Value="LightGray"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid Margin="5">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<GroupBox Grid.Row="0" Header="ItemsProvider">
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<TextBlock Text="Number of items:" Margin="5"
TextAlignment="Right" VerticalAlignment="Center"/>
<TextBox x:Name="tbNumItems" Margin="5"
Text="1000000" Width="60" VerticalAlignment="Center"/>
<TextBlock Text="Fetch Delay (ms):" Margin="5"
TextAlignment="Right" VerticalAlignment="Center"/>
<TextBox x:Name="tbFetchDelay" Margin="5"
Text="1000" Width="60" VerticalAlignment="Center"/>
</StackPanel>
</GroupBox>
<GroupBox Grid.Row="1" Header="Collection">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<TextBlock Text="Type:" Margin="5"
TextAlignment="Right" VerticalAlignment="Center"/>
<RadioButton x:Name="rbNormal" GroupName="rbGroup"
Margin="5" Content="List(T)" VerticalAlignment="Center"/>
<RadioButton x:Name="rbVirtualizing" GroupName="rbGroup"
Margin="5" Content="VirtualizingList(T)"
VerticalAlignment="Center"/>
<RadioButton x:Name="rbAsync" GroupName="rbGroup"
Margin="5" Content="AsyncVirtualizingList(T)"
IsChecked="True" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<TextBlock Text="Page size:" Margin="5"
TextAlignment="Right" VerticalAlignment="Center"/>
<TextBox x:Name="tbPageSize" Margin="5"
Text="100" Width="60" VerticalAlignment="Center"/>
<TextBlock Text="Page timeout (s):" Margin="5"
TextAlignment="Right" VerticalAlignment="Center"/>
<TextBox x:Name="tbPageTimeout" Margin="5"
Text="30" Width="60" VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
</GroupBox>
<StackPanel Orientation="Horizontal" Grid.Row="2">
<TextBlock Text="Memory Usage:" Margin="5"
VerticalAlignment="Center"/>
<TextBlock x:Name="tbMemory" Margin="5"
Width="80" VerticalAlignment="Center"/>
<Button Content="Refresh" Click="Button_Click"
Margin="5" Width="100" VerticalAlignment="Center"/>
<Rectangle Name="rectangle" Width="20" Height="20"
Fill="Blue" Margin="5" VerticalAlignment="Center">
<Rectangle.RenderTransform>
<RotateTransform Angle="0" CenterX="10" CenterY="10"/>
</Rectangle.RenderTransform>
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Rectangle.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="rectangle"
Storyboard.TargetProperty=
"(TextBlock.RenderTransform).(RotateTransform.Angle)"
From="0" To="360" Duration="0:0:5"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
<TextBlock Margin="5" VerticalAlignment="Center"
FontStyle="Italic" Text="Pause in animation indicates UI thread stalled."/>
</StackPanel>
<ListView Grid.Row="3" Margin="5" Style="{DynamicResource lvStyle}"/>
</Grid>
</Window>
XAML 细节不必深究。唯一需要注意的是,使用自定义 ListView 样式来响应 IsLoading
属性的变化,改变背景和鼠标光标。
public partial class DemoWindow
{
/// <summary>
/// Initializes a new instance of the <see cref="DemoWindow"/> class.
/// </summary>
public DemoWindow()
{
InitializeComponent();
// use a timer to periodically update the memory usage
DispatcherTimer timer = new DispatcherTimer();
timer.Interval = new TimeSpan(0, 0, 1);
timer.Tick += timer_Tick;
timer.Start();
}
private void timer_Tick(object sender, EventArgs e)
{
tbMemory.Text = string.Format("{0:0.00} MB",
GC.GetTotalMemory(true)/1024.0/1024.0);
}
private void Button_Click(object sender, RoutedEventArgs e)
{
// create the demo items provider according to specified parameters
int numItems = int.Parse(tbNumItems.Text);
int fetchDelay = int.Parse(tbFetchDelay.Text);
DemoCustomerProvider customerProvider =
new DemoCustomerProvider(numItems, fetchDelay);
// create the collection according to specified parameters
int pageSize = int.Parse(tbPageSize.Text);
int pageTimeout = int.Parse(tbPageTimeout.Text);
if ( rbNormal.IsChecked.Value )
{
DataContext = new List<Customer>(customerProvider.FetchRange(0,
customerProvider.FetchCount()));
}
else if ( rbVirtualizing.IsChecked.Value )
{
DataContext = new VirtualizingCollection<Customer>(customerProvider, pageSize);
}
else if ( rbAsync.IsChecked.Value )
{
DataContext = new AsyncVirtualizingCollection<Customer>(customerProvider,
pageSize, pageTimeout*1000);
}
}
}
窗口布局非常基础,但足以演示该解决方案。
用户可以配置 DemoCustomerProvider
中的项数以及模拟的获取延迟。
该演示允许用户比较标准的 List(T)
实现与 VirtualizingCollection(T)
和 AsyncVirtualizingCollection(T)
实现。对于 VirtualizingCollection(T)
和 AsyncVirtualizingCollection(T)
,用户可以指定页面大小和页面超时。这些应根据控件的特性和预期的使用模式进行选择。
显示了总的(托管)内存使用情况,以便对不同 IList
实现的内存占用进行比较。旋转的正方形动画用于指示 UI 线程的卡顿。在完全异步的解决方案中,动画应该不会出现卡顿或停顿。
关注点
顺便说一句,在创建此解决方案的过程中,我发现实现必须实现 IList
接口(而不是泛型 IList<T>
接口)。这与当前的 MSDN 文档(链接)相悖。然而,在任何泛型列表实现中都同时实现 IList
和 IList<T>
接口是良好的实践。
在实践中,ItemsControl
绑定似乎还会调用 IndexOf()
方法。我无法解释为何需要它,而且很明显,如果需要正确的实现,这个解决方案将不可能实现。幸运的是,发现仅从 IndexOf()
实现返回 -1 就足够了。
已知问题和未来扩展
- 上述解决方案假设源集合是只读的且不会更改。理想情况下,该解决方案应定期(或按需)重新加载
Count
和页面。 IItemsProvider
接口可以扩展以支持编辑和排序。
最后的 remarks
这是我在 CodeProject 上的第一篇文章。潜伏多年后,终于到了公开的时候。希望这篇文章对您有所帮助,如果您有任何评论或建议,我将不胜感激。如果您发现任何错误,请接受我的道歉并留下评论;我将尽力及时修复任何错误。
更新
自从我首次发布这篇文章以来,Bea Stollnitz 发布了一个更全面、更完整的关于数据虚拟化的解决方案。我建议读者参考她的解决方案。
这篇文章收到的所有积极评论都极大地鼓励了我,我想感谢所有阅读、评论或投票的人。我希望人们会继续发现这篇文章和示例代码很有用。因此,我想移除源代码的许可证,将其置于公共领域。这意味着任何人都可以将其用于任何应用程序,但没有任何保证。
历史
- 2009 年 3 月 23 日 - 首次提交。
- 2011 年 3 月 28 日 - 小幅更新及许可证变更