まめ - たんたんめん

備忘録 C# / WPF 多め

MVVMにおけるデータ仮想化の実装

こんにちは。 今日は仮想化についての話です。

WPF界隈で仮想化と検索するとVirtulizingStackPanelのようなものViewに関する記事などが多く出てくるとと思いますが 今さら使い方の説明を書いても面白みがないので、あまり記事になっていないデータの仮想化について実装記事を書いてみようと思ったのが経緯です。

WPFを使ったサンプルプログラムを作ったのでそれをもとに具体的なコードを交えながらポイントを解説したいと思います。

概要

データの仮想化に触れる前に少しだけUIの仮想化について触れておきます。 f:id:at12k313:20201215011046p:plain UIの仮想化とは表示領域のUIオブジェクトのみをインスタンスを保持することでパフォーマンスを向上される手法です。。 WPFではVirtulizingStackPanelという機能があり、ItemsControlで利用できます。 データ数のオーダーが数百や数千程度であればUIの仮想化だけで十分事足りるケースが多いです。


データの仮想化イメージ f:id:at12k313:20201215010154p:plain UIの仮想化をしていてもデータの数が数万・数十万を超えてくると表示以外の面でも様々な面でパフォーマンスが落ちます。 多くのGUIフレームワークはシングルスレッドであり、UIによる仮想化だけでは速度が足りないことがあります。

例えばWPFにはBindingを利用してデータとUIの紐づけを行いますが、その際にUIスレッドに依存してしまう特徴があります。

  • DependencyObjectを継承したクラス、及びそれを利用するクラス
  • DependencyPropertyにデータの通知を行うクラス(いわゆるViewModel でデータバインドをしている場合等)

これらのクラスはUIスレッド上でしか操作することができません。つまり、これらのクラスが大量に存在するとワーカースレッドへ処理を逃がすのが難しくなるのでUIスレッドを圧迫し、結果としてアプリケーションの操作性を落とすことにつながります。

もちろんViewModelの実装に気を付けてワーカースレッドで処理を行い、PropertyChangedのイベントだけをUIスレッドで行うような手法もとれますが、 実装として複雑になったり、スレッドの切り替えコストが余計なオーバーヘッドを生みます。

そこで登場するのがデータの仮想化です。 データの仮想化といっても様々な概念や観点があるので一概には言えませんがこの記事では扱うデータの仮想化とは UIスレッドで扱わないといけないクラスやオブジェクトの絶対数をなるべく減らし PropertyChangedやCollectionChangedのイベントを最小限で済ませ、必要に応じて遅延読み込みなどをするような仕組みとうことにしておきます。

具体的な実装があったほうが良いだろうと思い今回はWPF+MVVMを使ったシンプルなデモアプリケーションを作ってみました。

デモアプリ

f:id:at12k313:20201215012911g:plain

githubgithub.com

どういうアプリ?

このアプリはディレクトリををスキャンし、すべてのファイルをフラットにファイル名、ファイルサイズ、拡張子、フルパスを表示します。 また、ファイルパスや拡張子でファイルの検索ができるようになっています。

データを仮想化しない場合、ファイル数が少ないようなディレクトリ等であれば特に問題ありませんが、例えばCドライブを直接指定したりするとどうでしょうか。

数100万以上のファイルが存在するので、すべてをフラットにし、1つのリストビューで扱うのは無謀な話です。

実装にあたっての押さえておきたいポイントをまとめます。

  • Cドライブを検索してもUIが固まらない
  • 起動時間が早い
  • 検索が早い

実装

f:id:at12k313:20201215031647p:plain f:id:at12k313:20201215031744p:plain 本アプリケーションではこのようなデータ設計にしています。 (全部読みたい方はこちらをお読みください)

以下ソースコードです。ポイントとなる VIrtualSource.csについて解説します。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Concurrency;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Tasks;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;

namespace SandBox
{
    /// <summary>
    /// 仮想化コレクション
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public class VirtualCollectionSource<T,U> : IVirtualCollectionProvider , IDisposable
    {
        private readonly IEnumerable<T> _dataSource;
        private readonly List<T> _proxy;
        private readonly int _initialSize;
        private readonly Subject<CollectionChanged<T>> _collectionChangedTrigger;
        private Func<T, bool> _filter;
        private CancellationTokenSource _prevCancelToken;
        private IList<IDisposable> Disposables { get; }= new List<IDisposable>();
        public ReadOnlyReactiveCollection<U> Items { get; }
        public int ProxySize => _proxy.Count;
        public int SourceSize => _dataSource.Count();
        public event EventHandler CollectionReset;

        public VirtualCollectionSource(IEnumerable<T> dataSource, Func<T,U> converter ,int initialSize , IScheduler scheduler)
        {
            _dataSource = dataSource;
            _proxy = new List<T>(_dataSource);
            _initialSize = initialSize;

            var source = _proxy.Take(initialSize).ToArray();
            _collectionChangedTrigger = new Subject<CollectionChanged<T>>().AddTo(Disposables);
            Items = source.ToReadOnlyReactiveCollection(_collectionChangedTrigger,converter,scheduler, false ).AddTo(Disposables);
        }

        /// <summary>
        /// 仮想テーブルに対するフィルター関数を登録します。
        /// </summary>
        /// <param name="filter"></param>
        public void SetFilter(Func<T, bool> filter)
        {
            _filter = filter;
        }

        /// <summary>
        /// データソース→プロキシーへデータを同期させます。
        /// データソースの列挙とフィルターは別スレッドで行われます。
        /// </summary>
        /// <returns></returns>
        private async Task UpdateProxyAsync(CancellationToken cancellationToken)
        {
            var array = Array.Empty<T>();
            await Task.Run(() =>
            {
                array = _dataSource.ToArray();
                array = array.Where(x =>
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    return _filter?.Invoke(x) ?? true;
                }).ToArray();
            },cancellationToken);
            
            _proxy.Clear();
            _proxy.AddRange(array);
        }
        
        /// <summary>
        /// ステージされたデータをすべて破棄し、Itemsを再構築します。
        /// 初期要素数はinitialSizeになります。
        /// </summary>
        /// <returns></returns>
        public async Task ResetCollection()
        {
            if (_prevCancelToken != null)
            {
                _prevCancelToken.Cancel();
                _prevCancelToken.Dispose();
            }
            try
            {
                _prevCancelToken = new CancellationTokenSource();
                await UpdateProxyAsync(_prevCancelToken.Token);
            }
            catch(TaskCanceledException)
            {
                return;
            }

            _collectionChangedTrigger.OnNext(CollectionChanged<T>.Reset);
            int i = 0;
            foreach (var item in _proxy.Take(_initialSize).ToArray())
                _collectionChangedTrigger.OnNext(CollectionChanged<T>.Add(i++,item));
            CollectionReset?.Invoke(this,EventArgs.Empty);
            _prevCancelToken.Dispose();
            _prevCancelToken = null;
        }


        /// <summary>
        /// Proxyからn分のデータをItemsにステージします。
        /// </summary>
        /// <param name="n"></param>
        /// <returns>追加があればtrue</returns>
        public bool Stage(int n)
        {
            var currentIndex = Items.Count;
            var fixedProxy = _proxy.ToArray();
            var result = false;
            for (int i = 0; i < n; ++i)
            {
                var index = currentIndex + i;
                if(fixedProxy.Length <= index )
                    break;
                result = true;
                _collectionChangedTrigger.OnNext(
                    CollectionChanged<T>.Add(index,fixedProxy[index]));
            }

            return result;
        }
        public void Dispose()
        {
            foreach (var disposable in Disposables)
            {
                disposable.Dispose();
            }
        }
    }
    
    /// <summary>
    /// interface です。
    /// 今回はScrollViewerから利用するものを抽象化しています。
    /// </summary>
    public interface IVirtualCollectionProvider
    {
        bool Stage(int step);

        event EventHandler CollectionReset;
    }
}

利用例(App.xaml.csより)

var virtualSource = new VirtualCollectionSource<FileModel,FileViewModel>(
                dataSource.Items,                           //  データソースを指定します。図でいうDataSourceクラスです。
                x => new FileViewModel(x),      // Model -> VIewModelへのConverterです。 
                defaultCapacityCount,                    // 初期の要素数です。サンプルでは50にしています。
                ImmediateScheduler.Instance);      // コンバーターの実行スケジューラです。コンバーターはUIスレッドから呼ばれることを想定しているのでImmediateSchedulerを指定します。

VirtualCollectionSource<T,U>は以下のような機能を持ちます。

  1. 入力されてきた IEnumerabe から フィルター関数を使って データモデルであるProxy<T>を生成します。
  2. コンストラクタで指定された要素数だけProxyからItems<U>にコンバートします。
  3. Step()がコールされると追加でProxyからItems<U>にコンバートします。
  4. ResetCollection()がコールされるとDataSource→Proxyへデータが同期されフィルターが再適用されます。

実装のポイント部分解説します。

まず、1の IEnumerable<T> ー> Proxy<T>への変換について考えます。 入力ファイルは膨大なリストになるのでここをワーカースレッドで行います。

VIewModelで利用するのは2.で生成されるItemsですが、この生成はUIスレッドから通知を行う必要が出てきます。 なのでここで数を抑えているのが一つポイントです。サンプルアプリでは50を指定しているので、初回は50個分のオブジェクトの通知イベントしか発行されません。

ただ、このままだと画面には50個のデータしか表示されないされないので画面スクロールで追加読み込みができるようにします。 そこで登場したのがStep()関数です。スクロールに合わせてデータを随時ロードするようにしています。

サンプルではスクロール末尾に到達したときにデータを追加読み込みするような形にしています。

(LazyLoadScrollBehavior.csより)

// スクロール割合を計算
var scrollRatio = (scrollViewer.VerticalOffset + scrollViewer.ViewportHeight) / scrollViewer.ExtentHeight;
if (scrollRatio >= 0.995) // 計算誤差がでることがあるので調整
{
       // 末端あたりまでスクロールした段階で仮想テーブルにデータをロードする
       Provider?.Stage(10); // ProviderはVirtualCollectionSourceのinterfaceです。
}

無限スクロールがアプリによっては合わないケースもあると思うので、その場合は オリジナルのデータソースの数やプロキシーの要素数からスクロールバー等を見かけ上全て読み込まれているようなコントロールを作れば問題ありません。

最後に検索機能ですが実は仮想コレクションの再初期化をしているだけです。初期化してもデータは50個しかロードされないのでそこまで気にならないようになっています。実は1. のEnumerable<T> ー> Proxy<T>の部分で検索ワードなどからデータをフィルタしているのでRessetCollection()が呼ばれた段階で自動的にフィルターがかかるような作りになっています。なのでUIスレッドではフィルターされた結果を新たな要素とみなしているだけです。

(MainWindowVm.csより)

// 仮想Cコンテナにフィルター関数を設定します。
virtualSource.SetFilter( x=> x.Filter(LowerFilterName.Value,LowerFilterExtension.Value) );
            
// 検索ボックスの入力からトリガしてフィルターを実行します。
FilterName
            .Merge(FilterExtension)
            .Throttle(TimeSpan.FromMilliseconds(200), UIDispatcherScheduler.Default)
            .Subscribe(async _ => await VirtualSource.ResetCollection())
            .AddTo(Disposables);

デモで利用したライブラリ

仮想化の実装自体に特別必要なライブラリはありません。 が、今回はRxを使うとすっきり書けるのでReactivePropertyとBehaviorを手軽に記述できるMicrosoft.Xaml.Behaviorを利用しています。