非ObservableCollection<T>の変更を検出・通知する

Viewへリストの変更を通知するための機能を持つObservableCollectionですが、以下のようにデータソースがListや配列だと変更が検出できません。今回は、Modelのリストが非ObservavleCollectionの場合、やや制約がありますが変更をViewが検出するための仕組みを紹介したいと思います。

ここら辺はReactiveProperty等を使用しても事情が同じなのでいかんともしがたいですね…(ReactiveProperty<List>してもライブラリ側も知らんがなってなるわ…

変更が検出できない例

前述の検出ができない場合のコード例です。

ModelにObservavleCollectionの代わりにListが実装されていたりするケースで変更が堅守できません。

// メインGUIのクラス
public partial class MainWindow : Window
{
    public MainWindow()
    {
        this.InitializeComponent();
        this.DataContext = new ViewModel(); // ViewModelをここで設定
    }
}

// Listを持っているモデル
public class Model
{
    public IList<string> List { get; private set; } = new List<string>();
}

// モデルのListを監視対象にしたいViewModel
public class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    private readonly Model model = new Model();

    // ★★モデルのリストをデータソースにしてObservableCollectionを作成する
    public ObservableCollection<string> List => new ObservableCollection<string>(this.model.List);
}

かなり特定の状況ですが、モデルが他所で作成されていて、画面に表示したいケースでデータがListとかになってるとちょっと扱いに困ります。普通はModel側に更新通知を出すイベントを出してもらうのがよさそうですが、変更できない場合少し考える必要がありそうです。

リストの長さの変更を監視して通知を出す

そこで、リストの長さを監視して要素数に変更があった場合通知を出すクラスを以下のように作成しました。

ちょっと長いですが以下の通り。

// リストの長さを定周期で監視して変更通知を出すクラス
public class CollectionLengthWatcher<T> : IDisposable
{
    private readonly IList<T> collection; // 監視対象のリスト
    private Action raiseAction;           // 更新を検出したときの処理
    private Timer timer = new Timer();    // 定周期監視用のタイマー
    private int latestcount;              // 最後に確認したリストの長さ

    // 更新周期の設定と取得
    public TimeSpan WatchInterval
    {
        get => TimeSpan.FromMilliseconds(this.timer.Interval);
        set => this.timer.Interval = value.TotalMilliseconds;
    }

    // 監視対象のリストと検出時の処理を指定してオブジェクトを初期化する
    public CollectionLengthWatcher(IList<T> collection, Action raiseAction)
    {
        this.timer.Interval = 100/*ms*/;
        this.collection = collection;
        this.raiseAction = raiseAction;
        this.latestcount = collection.Count;
        this.timer.Elapsed += this.Timer_Elapsed;
    }

    // 監視を開始する
    public void Start() => this.timer.Start();

    // 監視を停止する
    public void Stop() => this.timer.Stop();

    // IDisposableの実装
    public void Dispose()
    {
        this.raiseAction = null;
        using (this.timer) { this.Stop(); }
        this.timer = null;
    }

    // リストの長さを監視する定周期処理
    protected virtual void Timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        try
        {
            this.timer.Stop();
            if (this.latestcount != this.collection.Count) // 最後の長さと
            {
                this.raiseAction?.Invoke();
                this.latestcount = this.collection.Count;
            }
        }
        finally
        {
            this.timer.Start();
        }
    }
}

使い方ですが、先ほどのViewModelクラスを以下のように修正します。CollectionLengthWatcherをメンバーに持たせてコンストラクタで監視する処理を追記します。

// モデルのListを監視対象にしたいViewModel
public class ViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    private readonly Model model = new Model();

    // ★追加
    private readonly CollectionLengthWatcher<string> listWatcher;

    // モデルのリストをデータソースにしてObservableCollectionを作成する
    public ObservableCollection<string> List => new ObservableCollection<string>(this.model.List);

    // ★追加
    public ViewModel()
    {
        // ViewModelのList<string> を監視対象に指定して
        // 変更を検出した場合、RaisePropertyChangedを呼び出す
        this.listWatcher =
            new CollectionLengthWatcher<string>(this.model.List,
                () => this.PropertyChanged?.Invoke(this, 
                    new PropertyChangedEventArgs(nameof(this.model.List))));

        // 監視を開始
        this.listWatcher.Start();
    }
}

ちなみに、要素数しか検出できないので、同じ要素数で内容が変更されたときは対応できていません。リストの要素数がそんなに多くない場合は、数秒に一回ごとに強制的に更新通知だしてもそんなに問題ないような気がします。(もちろん数万件もリストがあったら(そもそもの設計としても)ダメだと思いますが…

動作イメージ

動かしてみたところですがそんなに違和感ない動きをしています。

f:id:Takachan:20181015012051g:plain

以上です。