ListView で表示用データを仮想化する

現在、プロジェクトの関係で初めて WPF を触っています。「GUI とデータ(および関連する処理)の分離と言う事にかなり気を使って設計されてるんだなぁ」と言う感動と「XAML の書き方がよく分からない!」と言う絶望が織り交じったファーストインプレッション。Binding には何が書けるのか、どう書いたらどことバインディングできるのか、については一度きちんと体系立てて纏めておかないと行き当たりばったりのコードになりそうでちょっと(私が)危ない感じでした。

さて。今、サムネイルビューワみたいな部品を作っているのですが、ここに表示されるものが 1000 個を超えると言う事も有り得るため消費メモリやパフォーマンス(表示速度)について少し真面目に考えておく必要があります。取りあえずざっとググってみると ListView や ListBox には VirtualizingStackPanel なるものが存在するようで、これを適切に設定しておくと「表示されているもののみが生成される」と言う動作になるそうです。

ListBoxItemが足りなくなったらListBoxItemを生成し、ListBoxItemを使い回せる場合は使い回していることがわかります。リサイクルされてますね。

とんち絵で表すと

【WPF】VirtualizingStackPanel Recyclingはコンテナがリサイクルされる。 | 創造的プログラミングと粘土細工

VirtualizingStackPanel で画面上に表示されているデータのみ動的に生成して、残りのデータに関しては画像ファイルへのパスなどメタ情報のみを保持しておく事で消費メモリはかなり抑えられそうな予感がします。後は、「現在、どのデータ(画像)を生成して表示する必要があるのか」を知る方法についてですが、WPF: Data Virtualization - CodeProject と言う参考になりそうな記事が見つかりました。

この記事で何をやっているかと言うと「配列 (IList) っぽいクラスを作成して、[] 演算子 (this[int index]) をハックする」と言うもののようです。上記を参考にして、似たようなクラスを作成してみました。

using System;
using System.Collections;
using System.Collections.Generic;

// IList っぽいクラスの Count, this[int index] メソッドがコールされた時に該当する
// データを返すためのインターフェース。表示用の画像データ以外のメタデータ等は、
// このインターフェースを実装するクラスで保持する。
public interface IItemsProvider<T> {
    int ProvideItemsCount();
    T ProvideItem(int index);
}

// ハリボテ IList<T>
public class VirtualizingListProxy<T> : IList<T>, IList {
    public VirtualizingListProxy(IItemsProvider<T> provider) { _provider = provider; }
    public int Count { get { return _provider.ProvideItemsCount(); } }
    public T this[int index] { get { return _provider.ProvideItem(index); } }
    public IEnumerator<T> GetEnumerator() { for (int i = 0; i < Count; ++i) yield return this[i]; }
    
    // その他 IList<T>, IList に必要なメソッドを取りあえず定義だけする
    // ...(省略)...
}

ListView (の VirtualizingStackPanel) が画像を表示するためにデータを取得しようと [] 演算子をコールすると、ハリボテ IList オブジェクトが IItemsProvider を継承したクラスの ProvideItem() メソッドをコールするため、最終的に各種メタデータを管理しているクラスが、今、どのデータを生成すべきかを判断する事ができると言う仕組みのようです。ProvideItem() で取得した「表示しようとしている画像のインデックス」からうまく逆算すれば「表示されていない」インデックスも推測できるので、それに応じて画像用キャッシュから削除していけば省メモリを実現する事ができそうです。

ここで問題となったのは、WPF: Data Virtualization - CodeProject では「要素の追加や削除は考慮していない」と言う点でした。現在やろうとしているサムネイルビューワは要素の追加や削除も有り得るので、何らかの修正を行う必要があります。

取りあえずやってみたのは、「要素の追加や削除はハリボテ IList ではなく実際にメタデータを管理しているオブジェクトに対して行い、このオブジェクトが INotifyCollectionChanged.CollectionChanged イベントを発生させる。そして、IList はそのイベントを伝播する」と言うものです。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;

public interface IItemsProvider<T> : INotifyCollectionChanged  {
    int ProvideItemsCount();
    T ProvideItem(int index);
}

// ハリボテ IList<T>
public class VirtualizingListProxy<T> : IList<T>, IList, INotifyCollectionChanged {
    public VirtualizingListProxy(IItemsProvider<T> provider) {
        _provider = provider;
        _provider.CollectionChanged -= new NotifyCollectionChangedEventHandler(CollectionChangedProxy);
        _provider.CollectionChanged += new NotifyCollectionChangedEventHandler(CollectionChangedProxy);
    }
    
    public int Count { get { return _provider.ProvideItemsCount(); } }
    public T this[int index] { get { return _provider.ProvideItem(index); } }
    public IEnumerator<T> GetEnumerator() { for (int i = 0; i < Count; ++i) yield return this[i]; }
    
    // その他 IList<T>, IList に必要なメソッドを取りあえず定義だけする
    // ...(省略)...
    
    // IItemsProvider<T> の CollectionChanged イベントを受け取り、
    // それをそのまま伝播するためのイベントハンドラ
    public event NotifyCollectionChangedEventHandler CollectionChanged;
    protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e) {
        var handler = CollectionChanged;
        if (handler != null) handler(this, e);
    }
}

これは割とうまくいきそうだったのですが、諸々の問題で動作が不安定になってしまったので取りあえず保留としています。何となく ObservableCollection クラスを継承して [] 演算子だけオーバーライド(ハイディング?)した方が楽なんじゃないかと言う気もしてきたので、そっち方向でも一度トライしてみようかと思います。

もう一つの問題は、画像を表示するために WrapPanel を使用しているのですが、どうやら WrapPanel を使用すると VirtualizingMode がうまく機能しないようです(いきなり全インデックスに対してアクセスが発生する)。こっちの問題はまだまったく触ってないのですが、Virtualizing WrapPanel - CodeProject のような記事も見つかったので、この辺りも参考に検討していく予定です。