WPFでのデータ仮想化

良い一日。



レコードの数が1,000万件を超える場合など、データベースから情報を最適にロードする独自のクラスを作成することに長い間興味がありました。

情報の遅延読み込み、複数のデータソースの使用など。



ハブにはこのトピックに関する専用の投稿が見つからなかったため、Paul McCleanによる記事の翻訳を紹介しました。これは、タスクを解決するための出発点となりました。



元の記事: ここ

プロジェクトソースファイル: ここ



さらに本文では、著者に代わって書きます。



はじめに



WPFは、少なくともユーザーインターフェイスの観点から、大規模なコレクションを効率的に操作するための興味深いユーザーインターフェイス仮想化機能を提供しますが、データ仮想化の一般的な方法は提供しません。 多くのフォーラムの投稿でデータ仮想化に関する議論がありますが、誰も解決策を投稿していません(私の知る限り)。 この記事では、これらのソリューションの1つを紹介します。



背景



UI仮想化


WPF ItemsControlコントロールが、UI仮想化が有効になっているソースデータの大規模なコレクションに関連付けられている場合、コントロールは、可視アイテム(およびいくつかの上下)のビジュアルコンテナーのみを作成します。 これは通常、元のコレクションのごく一部です。 ユーザーがリストをスクロールすると、要素が表示されると新しいビジュアルコンテナが作成され、要素が非表示になると古いコンテナが破棄されます。 ビジュアルコンテナを再利用する場合、オブジェクトの作成と破棄のオーバーヘッドを削減します。



ユーザーインターフェイスの仮想化は、コントロールを大量のデータコレクションに関連付けることができると同時に、表示されるコンテナーの数が少ないため、メモリの消費量が少なくなることを意味します。



データ仮想化


データ仮想化は、ItemsControlに関連付けられたデータオブジェクトの仮想化を実現することを意味する用語です。 データの仮想化はWPFでは提供されません。 ベースオブジェクトの比較的小さなコレクションの場合、メモリ消費は問題になりません。 ただし、大規模なコレクションの場合、メモリ消費が非常に大きくなる可能性があります。 さらに、データベースからの情報の取得やオブジェクトの作成には、特にネットワーク操作中に多くの時間がかかります。 これらの理由から、何らかの種類のデータ仮想化メカニズムを使用して、ソースから取得してメモリに配置する必要があるデータオブジェクトの数を制限することをお勧めします。



解決策



復習


このソリューションは、ItemsControlコントロールがIEnumerableではなくIList実装に関連付けられている場合、リスト全体をリストするのではなく、表示に必要なアイテムの選択のみを提供するという事実に基づいています。 Countプロパティを使用してコレクションのサイズを決定し、スクロールバーのサイズを設定します。 将来的には、リストインデクサーを使用して画面アイテムを繰り返し処理します。 したがって、多数の要素があることを報告できるIListを作成し、必要な場合にのみ要素を受け取ることができます。



IItemsProvider <T>


このソリューションを使用するには、ベースソースがコレクション内の要素数に関する情報を提供し、コレクション全体の小さな部分(またはページ)を提供できる必要があります。 これらの要件は、IItemsProviderインターフェイスで表されます。

/// <summary> ///     of collection details. /// </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); }
      
      





基になるデータソースがデータベースクエリである場合、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インターフェイスのもう1つの重要な側面は、インデクサーの実装です。

 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()は、未使用のページをクリーニング(またはアンロード)するために呼び出します。



最後に、ページの存在の保護チェック。 このチェックは、派生AsyncVirtualizingCollection <T>クラスを使用する場合など、RequstPage()メソッドが同期モードで機能しない場合に必要です。

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





ページはディクショナリに保存され、インデックスはキーとして使用されます。 辞書は、最後に使用した時間に関する情報を保存するためにも使用されます。 この時間は、ページにアクセスするたびに更新されます。 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()メソッドを呼び出します。



コードには必須ではないメソッドがたくさんあるように見えるかもしれませんが、特定の理由でこのように設計されています。 各メソッドは、1つのタスクのみを実行します。 これにより、コードが読みやすくなり、派生クラスの機能を簡単に拡張および変更できます(後で説明します)。



VirtualizingCollection <T>クラスは、データ仮想化の主要な目標を達成します。 残念ながら、このクラスを使用するプロセスには1つの重大な欠点があります-データを取得するためのすべてのメソッドは同期的に実行されます。 これは、ユーザーインターフェイススレッドによって起動されることを意味し、その結果、アプリケーションが潜在的に抑制されます。



AsyncVirtualizingCollection <T>


AsyncVirtualizingCollection <T>クラスはVirtualizingCollection <T>から継承され、Load()メソッドをオーバーライドして非同期データ読み込みを実装します。 非同期データソースの重要な機能は、データの受信時に、データバインディングを通じてユーザーインターフェイスに通知する必要があることです。 通常のオブジェクトでは、これは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>クラスはINotifyPropertyChangedインターフェイスとINotifyCollectionChangedインターフェイスの両方を実装して、バンドルの柔軟性を最大限に高めます。 この実装には注意すべき点はありません。

 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()メソッドでは、getはThreadPoolを介して非同期的に呼び出されます。 完了すると、新しい数量が設定され、FireCollectionReset()メソッドが呼び出されて、InotifyCollectionChangedを介してユーザーインターフェイスが更新されます。 LoadCountCompletedメソッドは、SynchronizationContextを使用してユーザーインターフェイススレッドから呼び出されることに注意してください。 SynchronizationContextプロパティは、コレクションインスタンスがユーザーインターフェイスストリームで作成されることを前提として、クラスのコンストラクターで設定されます。

 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()メソッドを使用してユーザーインターフェイスを更新します。



IsLoadingプロパティにも注意してください。 これは、コレクションの読み込みを示すためにユーザーインターフェイスで使用できる単純なフラグです。 IsLoadingプロパティが変更されると、FirePropertyChanged()メソッドにより、INotifyProperyChangedメカニズムを介してユーザーインターフェイスが更新されます。

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





ウィンドウのレイアウトは非常にシンプルですが、ソリューションを実証するには十分です。



ユーザーは、DemoCustomerProviderインスタンスの要素数と遅延シミュレーター時間を調整できます。



このデモでは、ユーザーはリストの標準実装(T)、VirtualizingCollection(T)をロードする同期データの実装、およびAsyncVirtualizingCollection(T)をロードする非同期データの実装を比較できます。 VirtualizingCollection(T)およびAsyncVirtualizingCollection(T)を使用する場合、ユーザーはページサイズとタイムアウト(ページをメモリからアンロードするまでの時間を設定)を設定できます。 それらは、要素の特性と予想される使用パターンに従って選択されるべきです。







異なるタイプのコレクションを比較するために、ウィンドウには使用されているメモリの合計量も表示されます。 回転する正方形のアニメーションは、ユーザーインターフェイスフローの停止を視覚化するために使用されます。 完全に非同期のソリューションでは、アニメーションが遅くなったり停止したりすることはありません。



All Articles