详谈WPF开发中的数据虚拟化
编辑推荐《专题:让你的代码“炫”起来――WPF开发教程》
UI虚拟化
当一个WPF的ItemControl被绑定到一个大型集合的数据源时,如果可以UI虚拟化,该控件将只为那些在可以看到的项创见可视化的容器(加上面和下面的少许)。这是一个完整集合中有代表性的一小部分。用户移动滚动条时,将为那些滚动到可视区域的项创建新的可视化容器,那些不再可见的项的容器将被销毁。当容器设置为循环使用时,它将再使用可视化容器代替不断的创建和销毁可视化容器,避免对象的实例化和垃圾回收器的过度工作。
数据虚拟化
数据虚拟化是指绑定到ItemControl的真实的数据对象的归档虚拟化的时间段。数据虚拟化不是由WPF提供的。作为对比,基本数据对象的小集合对内存的消耗不是很多;但是,大集合的内存消耗是非常严重的。另外,真实的检索数据(例如,从数据库)和实例化数据对象是很耗时的,尤其当是网络数据调用时。因此,我们希望使用数据虚拟化机制来限制检索的数据的数量和在内存中生成数据对象的数量。
解决方案
总览
这个解决方案是只在ItemControl绑定到IList接口的实现时起作用,而不是IEumerable的实现,它并不枚举整个列表,而只是读取需要显示的项。它使用Count属性判断集合的大小,推测并设置滚动的范围。然后使用列表索引重新确定要在屏幕上显示的项。因此,创建一个可以报告具有大量项的,并且可以只检索需要的项的IList。
IItemsProvider 为了利用这个解决方案,下面的数据源必须能提供集合中项的数量,并且能够提供完整集合的小块(或页)。这需要在IItemsProvider接口封装。
/// /// Represents a provider of collection details. /// /// The type of items in the collection. public interface IItemsProvider { /// /// Fetches the total number of items available. /// /// int FetchCount(); /// /// Fetches a range of items. /// /// The start index. /// The number of items to fetch. /// IList FetchRange(int startIndex, int count); } |
如果下面的查询是一个数据库查询,它是一个利用大多数据库供应商都提供的COUNT()聚集函数和OFFSET与LIMIT表达式的一个IItemProvider接口的一个简单实现。
VirtualizingCollection 这是一个执行数据虚拟化的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接口的实现的另一个重要方面是索引的实现。
这个索引是这个解决方案的一个聪明的操作。首先,它必须确定请求的项在哪个页(pageIndex)中,在页中的位置(pageOffset),然后调用RequestPage()方法请求该页。
附加的步骤是然后根据pageOffset加载后一页或前一页。这基于一个假设,如果用户正在浏览第0页,那么他们有很高的机率接下来要滚动浏览第1页。提前把数据取来,就可以无延迟的显示。
然后调用CleanUpPages()清除(或卸载)所有不再使用的页。
最后,放置页不可用的一个防御性的检查, 当RequestPage没有同步操作时是必要的,例如在子类AsyncVirtualizingCollection中。
// ... private readonly Dictionary> _pages = new Dictionary>(); private readonly Dictionary _pageTouchTimes = new Dictionary(); 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 page) { if (_pages.ContainsKey(pageIndex)) _pages[pageIndex] = page; } public void CleanUpPages() { List keys = new List(_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 FetchPage(int pageIndex) { return |
为完成该解决方案,FetchPage()执行从ItemProvider中抓取数据,LoadPage()方法完成调用PopulatePage方法获取页并把该页存储到字典(Dictionary)中的工作。
看起来好象有一些太多的不全逻辑的方法(a few too many inconsequential methods),但这样设计是有原因的:每一个方法做且只做一件事,有助于提高代码的可读性,并使在子类中进行功能扩展和维护变得容易,下面可以看到。
类VirtualizingCollection实现了数据虚拟化的基本目标。不幸的是,在使用中,它有一个严重不足:数据抓取方法是全部同步执行的。这就是说它们要在UI线程中执行,造成一个缓慢的程序
AsyncVirtualizingCollection类AsyncVirtualizingCollection继承自VirtualizingCollection,重载了Load方法,以实现数据的异步加载。
WPF中异步数据源的关键是在数据抓取完成后必须通知UI的数据绑定。在规则的对象中,是通过实现INotifyPropertyChanged接口实现的。对一个集合的实现,需要紧密的关系,INotifyCollectionChanged。那是ObservableCollection要使用的接口。
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); } |
实现了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 page = FetchPage(pageIndex); SynchronizationContext.Send(LoadPageCompleted, new object[]{pageIndex, page});} private void LoadPageCompleted(object args){int pageIndex=(int)((object[]) args)[0]; IList page = (IList)((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的一个实现,它通过使用线程休眠来模拟网络或磁盘行为的延迟提供虚拟数据。