背景
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