WPF:数据虚拟化

下载源代码

介绍

从UI的观点上看,WPF为有效的处理大型集合提供了一些聪明的UI虚拟化特征。但那没有为数据归档虚拟化提供通用的方法。当有人在互联网论坛上发贴讨论数据虚拟化时,没有一个人提供一个解决方案(至少我没有看到)。本文提出这个的一个解决方案。

 背景

UI虚拟化

当一个WPF的ItemControl被绑定到一个大型集合的数据源时,如果可以UI虚拟化,该控件将只为那些在可以看到的项创见可视化的容器(加上面和下面的少许)。这是一个完整集合中有代表性的一小部分。用户移动滚动条时,将为那些滚动到可视区域的项创建新的可视化容器,那些不再可见的项的容器将被销毁。当容器设置为循环使用时,它将再使用可视化容器代替不断的创建和销毁可视化容器,避免对象的实例化和垃圾回收器的过度工作。

数据虚拟化

数据虚拟化是指绑定到ItemControl的真实的数据对象的归档虚拟化的时间段。数据虚拟化不是由WPF提供的。作为对比,基本数据对象的小集合对内存的消耗不是很多;但是,大集合的内存消耗是非常严重的。另外,真实的检索数据(例如,从数据库)和实例化数据对象是很耗时的,尤其当是网络数据调用时。因此,我们希望使用数据虚拟化机制来限制检索的数据的数量和在内存中生成数据对象的数量。

解决方案

总览

这个解决方案是只在ItemControl绑定到IList接口的实现时起作用,而不是IEumerable的实现,它并不枚举整个列表,而只是读取需要显示的项。它使用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表达式的一个IItemProvider接口的一个简单实现。

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属性使用延迟和懒惰加载(lazy loading)模式。它使用特殊值-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()执行从ItemProvider中抓取数据,LoadPage()方法完成调用PopulatePage方法获取页并把该页存储到字典(Dictionary)中的工作。

看起来好象有一些太多的不全逻辑的方法(a few too many inconsequential methods),但这样设计是有原因的:每一个方法做且只做一件事,有助于提高代码的可读性,并使在子类中进行功能扩展和维护变得容易,下面可以看到。

VirtualizingCollection<T>实现了数据虚拟化的基本目标。不幸的是,在使用中,它有一个严重不足:数据抓取方法是全部同步执行的。这就是说它们要在UI线程中执行,造成一个缓慢的程序(原文是:This means they will be executed by the UI thread, resulting, potentially, in a sluggish application.)

AsyncVirtualizingCollection<T>

AsyncVirtualizingCollection<T>继承自VirtualizingCollection<T>,重载了Load方法,以实现数据的异步加载。

WPF中异步数据源的关键是在数据抓取完成后必须通知UI的数据绑定。在规则的对象中,是通过实现INotifyPropertyChanged接口实现的。对一个集合的实现,需要紧密的关系,INotifyCollectionChanged。(原文:For a collection implementation, however, it is necessary to use its close relative, 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);
}

 AsyncVirtualizingColliection<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,UI的更新是由INotifyCollectionChanged接口调用FireCollectionReset方法实现的。注意LoadCountCompleted方法会在UI线程通过SynchronizationContext再一次被调用。假定集合的实例在UI线程中被创建,SynchronationContext属性就会被设置。

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改变后,由INotifyPropertyChanged机制调用FirePropertyChanged方法更新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(消费者)对象作为集合中的项。

为了允许用户试验不同的列表实现,创建一个包含ListView的简单WPF窗体。

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

 

 窗体设计是相当的简单,只是为了演示这个解决方案。

用户可以在DomoCustomerProvider中配置项的数量和模拟抓取的延迟。

 

原文引自:

http://www.codeproject.com/KB/WPF/WpfDataVirtualization.aspx

 

这是我第一次翻译文章,限于本人的英文水平有限,如果有错误和不当之处,还请各位朋友指正,谢谢。

posted on 2009-04-06 17:42  常绍新  阅读(3632)  评论(2编辑  收藏  举报