WPF:資料虛擬化

WPF:資料虛擬化

這篇文章不錯,本來借助Google翻譯,站長想再人工檢查一遍,發現裡面的專業術語挺多的,個人英文也太爛,直接原文照搬了,希望你的英文可以的。

最後更新 2022/4/13 下午9:14
Paul McClean
預計閱讀 16 分鐘
分類
WPF
標籤
.NET WPF 資料虛擬化 UI虛擬化

原文標題:WPF:資料虛擬化

原文連結:https://www.codeproject.com/Articles/34405/WPF-Data-Virtualization

原文作者:Paul McClean

這篇文章不錯,本來借助 Google 翻譯,站長想再人工檢查一遍,發現裡面專業術語挺多的,個人英文也太渣,直接原文照搬了,希望你的英文可以的。

以下為原文:

一個提供資料虛擬化、適用於大型資料集的集合類別。

下載原始碼 - 15.7 KB

簡介

WPF 提供了巧妙的 UI 虛擬化功能,能有效率地處理大型集合(至少從 UI 的角度來看)。但它沒有提供通用的方法來達成資料虛擬化。雖然網路論壇上有幾篇文章討論資料虛擬化,但據我所知,沒有人發表過解決方案。本文提出了一個這樣的解決方案。

背景

UI 虛擬化

當 WPF 的 ItemsControl 繫結到大型集合資料來源,且啟用 UI 虛擬化時,控制項只會為實際可見的項目建立視覺容器(再加上前後少數幾個)。這通常只佔整個集合的一小部分。當使用者捲動時,新的視覺容器會隨著項目可見而建立,舊的容器則在項目不再可見時釋放。若啟用容器回收,則會重複使用視覺容器,而不是建立和釋放,從而避免物件實體化和記憶體回收的負擔。

UI 虛擬化意味著控制項可以繫結到大型集合,而不會因為視覺容器而佔用大量記憶體。然而,集合中的實際資料物件仍可能佔用大量記憶體。

資料虛擬化

資料虛擬化這個術語指的是對繫結到 ItemsControl 的實際資料物件實現虛擬化。WPF 並未提供資料虛擬化。對於較小的基本資料物件集合,記憶體消耗並不顯著;但對於大型集合,記憶體消耗就變得非常可觀。此外,實際擷取資料(例如從資料庫)並實體化所有物件可能很耗時,特別是涉及網路操作時。基於這些原因,最好使用某種資料虛擬化機制來限制需要擷取和實體化到記憶體中的資料物件數量。

解決方案

概述

此解決方案利用了一點:當 ItemsControl 繫結到 IList 實作(而非 IEnumerable 實作)時,它不會列舉整個清單,而只存取顯示所需的項目。它使用 Count 屬性來判斷集合的大小,大概是為了設定捲動範圍。然後它會使用清單索引子來疊代螢幕上的項目。因此,可以建立一個 IList,它回報有大量項目,但只在需要時才實際擷取項目。

IItemsProvider

為了使用此解決方案,底層資料來源必須能夠提供集合中的項目數量,並且能夠提供整個集合的小塊(或頁面)。這個需求封裝在 IItemsProvider 介面中。

/// <summary>
/// 表示集合詳細資訊的提供者。
/// </summary>
/// <typeparam name="T">集合中項目的型別。</typeparam>
public interface IItemsProvider<T>
{
    /// <summary>
    /// 擷取可用項目的總數。
    /// </summary>
    /// <returns></returns>
    int FetchCount();

    /// <summary>
    /// 擷取一個範圍的項目。
    /// </summary>
    /// <param name="startIndex">起始索引。</param>
    /// <param name="count">要擷取的項目數量。</param>
    /// <returns></returns>
    IList<T> FetchRange(int startIndex, int count);
}

如果底層資料來源是資料庫查詢,實作 IItemsProvider 介面就相對簡單,可以使用大多數資料庫供應商提供的 COUNT() 彙總函式以及 OFFSETLIMIT 運算式。

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 屬性使用延遲載入模式實作。它使用特殊值 -1 來表示尚未載入。第一次存取時,它會從 ItemsProvider 載入實際的計數。

IList 介面的另一個重要面向是索引子的實作。

public T this[int index]
{
    get
    {
        // 決定所屬頁面和頁面內偏移量
        int pageIndex = index / PageSize;
        int pageOffset = index % PageSize;

        // 請求主要頁面
        RequestPage(pageIndex);

        // 如果存取上半部 50%,則請求下一頁
        if ( pageOffset > PageSize/2 && pageIndex < Count / PageSize)
            RequestPage(pageIndex + 1);

        // 如果存取下半部 50%,則請求前一頁
        if (pageOffset < PageSize/2 && pageIndex > 0)
            RequestPage(pageIndex - 1);

        // 移除過時的頁面
        CleanUpPages();

        // 非同步載入時的防禦性檢查
        if (_pages[pageIndex] == null)
            return default(T);

        // 回傳請求的項目
        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)
    {
        // 第 0 頁是特殊情況,因為 WPF ItemsControl
        // 經常存取第一個項目
        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

AsyncVirtualizingCollection<T> 類別衍生自 VirtualizingCollection<T>,並覆寫了載入方法以非同步執行資料載入。

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> 同時實作了 INotifyCollectionChangedINotifyPropertyChanged,提供最大的資料繫結彈性。這個實作沒有什麼特別值得注意的地方。

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 物件用作集合中的項目。

建立了一個包含 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="資料虛擬化示範 - 作者 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="項目數量:" Margin="5"
                  TextAlignment="Right" VerticalAlignment="Center"/>
                <TextBox x:Name="tbNumItems" Margin="5"
                  Text="1000000" Width="60" VerticalAlignment="Center"/>
                <TextBlock Text="擷取延遲 (毫秒):" 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="集合">
            <StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,2,0,0">
                    <TextBlock Text="型別:" 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="頁面大小:" Margin="5"
                      TextAlignment="Right" VerticalAlignment="Center"/>
                    <TextBox x:Name="tbPageSize" Margin="5"
                      Text="100" Width="60" VerticalAlignment="Center"/>
                    <TextBlock Text="頁面逾時 (秒):" 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="記憶體使用量:" Margin="5"
              VerticalAlignment="Center"/>
            <TextBlock x:Name="tbMemory" Margin="5"
              Width="80" VerticalAlignment="Center"/>

            <Button Content="重新整理" 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="動畫暫停表示 UI 執行緒停滯。"/>

        </StackPanel>

        <ListView Grid.Row="3" Margin="5" Style="{DynamicResource lvStyle}"/>

    </Grid>
</Window>

無需詳細說明 XAML。唯一要注意的是使用了自訂的 ListView 樣式,根據 IsLoading 屬性變更背景和滑鼠游標。

public partial class DemoWindow
{
    /// <summary>
    /// 初始化 <see cref="DemoWindow"/> 類別的新執行個體。
    /// </summary>
    public DemoWindow()
    {
        InitializeComponent();

        // 使用計時器定期更新記憶體使用量
        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)
    {
        // 根據指定參數建立示範項目提供者
        int numItems = int.Parse(tbNumItems.Text);
        int fetchDelay = int.Parse(tbFetchDelay.Text);
        DemoCustomerProvider customerProvider =
                       new DemoCustomerProvider(numItems, fetchDelay);

        // 根據指定參數建立集合
        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> 時,使用者可以指定頁面大小和頁面逾時。這些應根據控制項的特性和預期使用模式來選擇。

WpfDataVirtualization/screenshot.png

顯示總(受控)記憶體使用量,以便比較不同 IList 實作的記憶體佔用。旋轉方形動畫用於指示 UI 執行緒的停滯。在完全非同步的解決方案中,動畫不應出現停頓或暫停。

重點

順便一提,在建立此解決方案的過程中,我發現實作必須實作 IList 介面(而非泛型 IList<T> 介面)。這與當前的 MSDN 文件(連結)相矛盾。但,在任何泛型清單實作中,同時實作 IListIList<T> 介面是良好的做法。

在實務上,ItemsControl 繫結似乎也會呼叫 IndexOf() 方法。我無法解釋為什麼需要這樣做,很明顯地,如果需要正確的實作,此解決方案就不可能实现。幸運的是,發現只要從 IndexOf() 實作回傳 –1 就足夠了。

已知問題與未來擴充

  • 上述解決方案假設來源集合是唯讀且不會變更。理想情況下,解決方案應定期(或按需)重新載入 Count 和頁面。
  • IItemsProvider 介面可以擴充以提供編輯和排序的支援。

結語

這是我在 CodeProject 上的第一篇文章。在潛伏了數年之後,終於到了公開亮相的時候。希望您覺得這篇文章有用,我將感激任何評論或建議。如果您發現任何錯誤,請接受我的歉意並留下評論;我將盡力迅速修正任何錯誤。

更新

自從我第一次發表這篇文章以來,Bea Stollnitz 發表了一個更全面、更完整的資料虛擬化解決方案。我建議讀者參考她的解決方案

我非常感謝這篇文章收到的所有正面評論,並感謝所有閱讀、評論或投票的人。我希望人們能繼續發現這篇文章和範例程式碼有用。因此,我想取消原始碼的授權,將其置於公共領域。這意味著任何人都可以在任何應用程式中使用它,但不提供任何擔保。

歷史

  • 2009 年 3 月 23 日 - 首次提交。
  • 2011 年 3 月 28 日 - 小幅度更新並變更授權

授權

本文連同任何相關的原始碼和檔案,採用 公共領域貢獻 授權。

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2025/1/26

WPF 藉助自訂 XML 檔案實現國際化

本文詳細介紹了在WPF程式中使用自訂XML檔案實現國際化的方法,包括安裝必備NuGet套件、動態獲取語言清單、動態切換語言、在程式碼和XAML介面中使用翻譯字串等內容,同時提供了原始碼連結,幫助開發者輕鬆實現WPF應用程式的國際化。

繼續閱讀