空談録

世界で5人くらいに役立ちたい

ObservableCollectionでAddRangeする

2016年の霊圧がどんどん小さくなっていく…

XAMLな世界でItemsSourceといえばObservableCollection!という感じですが3回に1回くらいAddRangeがほしくなります。
foreachでAddしてるとなんか残念な気分になるというかこちらでいちいちforeachしたくないという。

// collection = IEnumerable<T>
// observableCollection = ObservableCollection<T>

foreach (var item in collection)
{
    observableCollection.Add(item);
}

別に4行だしいいじゃんといわれればそんな気分にもなりますが何度も書くのは嫌になります。
というわけで拡張メソッドでAddRangeを作ってみましょう。
もちろん上のやつを拡張メソッドにしてもうれしいかもしれませんがうれしくないのでそこそこパフォーマンスの向上を狙ってみます。

完成したソースコード

解説しても9割は不要な情報でしかないので今回のコードを先に貼っておきます

public static class ObservableCollectionExtensions
{
    public static void AddRange<T>(this ObservableCollection<T> source, IEnumerable<T> collection)
    {
        if (ValidateCollectionCount(source, collection))
        {
            return;
        }

        var itProperty = typeof(ObservableCollection<T>).GetProperty("Items", BindingFlags.NonPublic | BindingFlags.Instance);
        var colResetMethod = typeof(ObservableCollection<T>).GetMethod("OnCollectionReset", BindingFlags.NonPublic | BindingFlags.Instance);

        var list = itProperty.GetValue(source) as List<T>;
        if (list != null)
        {
            list.AddRange(collection);
            colResetMethod.Invoke(source, null);
        }
    }

    private const int switchForeachThresold = 2;
    private static bool ValidateCollectionCount<T>(ObservableCollection<T> source, IEnumerable<T> collection)
    {
        var count = collection.Count();
        if (count <= switchForeachThresold)
        {
            foreach (var item in collection)
            {
                source.Add(item);
            }
            return true;
        }

        return false;
    }
}

パフォーマンスについては後で書きますがforeachで追加するよりは10 ~ 20倍くらい早くはなります。
ただし素数1のコレクションをAddRangeで追加する場合、foreachの2倍ほど遅くなります。
collectionの要素数見てもいいかもしれませんが閾値までは調べてないので対応はしてません。
とはいえ要素数1のコレクションを100万回AddRangeで追加しての情報なので基本は問題ないとはおもいます? 気になるなら対応するとよいとおもいます。

追記(12/30):↑の問題にとりあえず対応してみました。ぶっちゃけ閾値1でいい気はします。(大幅なパフォーマンス改善が行えるのは1のみ)
閾値の変更についてはswitchForeachThresoldとかいう定数をいじってください。
そもそもforeachは絶対に使いたくない場合はif (ValidateCollectionCount)のブロックを削除してください。

ObservableCollectionの内部コレクションについて

AddRange処理には次の手順が必要になります。
・ObservableCollectionの内部コレクションに対して要素の追加
・NotifyCollectionChangedの実行

ところでObservableCollectionの内部コレクションの型はなんでしょうか? という疑問が出ます。
これについてはReferenceSourceから拾ってきましょう。ObservableCollectionはCollection<T>を継承しているためCollection<T>の内部コレクションがそのままObservableCollectionの内部コレクションになります。

Collectionのコンストラクタは次の通り。
referencesource/collection.cs at master · Microsoft/referencesource · GitHub

public class Collection<T>: IList<T>, IList, IReadOnlyList<T>
{
    IList<T> items;

    public Collection() {
        items = new List<T>();
    }

    // ...
}

まあわかりやすい。List<T>だそうです。
List<T>ということはList<T>.AddRangeで内部コレクションへの追加が行えます。知らない型だったらそれこそAddRangeの内部ソースについても調べる必要が出そうでしたがこれなら問題ないですね。

ObservableCollectionから先ほどのitemsにアクセスするには Collection(T).Items プロパティ (System.Collections.ObjectModel) が使えます。
しかしこの子はprotectedです。ぐぬぬ

ついでにNotifyCollectionChangedも見ておきましょう。

ObservableCollection.NotifyCollectionChangedの呼び出し

これはMSDNに載ってるのでReferenceSourceを見なくても余裕です。
ObservableCollection(T).OnCollectionChanged メソッド (NotifyCollectionChangedEventArgs) (System.Collections.ObjectModel)

アッアッ…これもprotected…

というわけでAddRangeするためには
・Reflectionでごり押し
・ObservableCollectionを継承したクラスを作る
のどちらかで対応する必要があります。グヌヌ

実際にAddRangeを追加しよう

というわけでReflectionでごり押ししたのが上の完成したなんたらです。
えっ?OnCollectionChanged呼び出してないじゃんって? これはこれでまた理由があります。ありますがいったん放置。

継承パターンについてはこんな感じになります。すっきりしてます。

public class ExtendObservableCollection<T> : ObservableCollection<T>
{
    public void AddRange(IEnumerable<T> collection)
    {
        ((List<T>)this.Items).AddRange(collection);
        OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
    }
}

問題は通常のObservableCollectionが使えないという点ですがそれを許容できるなら…?
(ただしパフォーマンスはReflectionと大差ない)

ところでNotifyCollectionChangedEventArgsではIListを用いて複数アイテムの変更を通知できます。
じゃあAddRangeでも使えばいいじゃん!っていうと確かに使えますが今度はListCollectionViewの制約にひっかかります。
どういうことかというと、"ListBoxにバインドした状態でAddRangeすると落ちる"ようになります。

この辺については
ListCollectionView/CollectionView doesn't support NotifyCollectionChanged with multiple items
とかで対応する方法とかは書いてあるようです。ObservableCollectionでAddRangeしたいがためにListCollectionViewとかいじるのは嫌なのでResetで対応しています。

(もちろんResetなのでUIをRefreshするのとほぼ同義の処理です。そのため1件とかでAddRangeしまくるとパフォーマンスが強烈に低下します。)

パフォーマンスを見てみる

とまあだらだら書いてきましたが、これでforeachと大差なかったらforeachでよくね!って感じなので見てみます。

比較用のAddRangeTestは次のようなものを用意。

public static void AddRangeTest<T>(this ObservableCollection<T> source, IEnumerable<T> collection)
{
    foreach (var item in collection)
    {
        source.Add(item);
    }
}

普通のforeachですね。ふつう。

計測ですがWindow.LoadedでObservableCollectionに追加して測定します。このときObservableCollectionはListBoxにバインドしておきます。

こんな感じ

private void Window_Loaded(object sender, RoutedEventArgs e)
{
    var sw = new Stopwatch();
    sw.Start();
    // Test Method #1
    col.AddRange(Enumerable.Range(0, 1000000).Select(x => x.ToString()).ToArray());
    col.AddRange(Enumerable.Range(1000000, 1000000).Select(x => x.ToString()).ToArray());

    // Test Method #2
    for (var i = 0; i < 100; i++)
    {
        col.AddRange(Enumerable.Range(10000 *i, 10000).Select(x => x.ToString()).ToArray());
    }

    sw.Stop();
    Debug.WriteLine(sw.ElapsedMilliseconds);
}

LINQ使ってるあたりでなんか遅い気はしますが全部同じ条件だし問題ないでしょう。
5回測定した平均が下の通りです。値はミリ秒。
AddTestがforeach、AddRngがReflection、ExtAddが継承ベースのAddRangeです。

AddTest AddRng ExtAdd
test#1 12589.4 833.8 839.4
test#2 6076.8 424.0 383.8

基本的にforeachの場合、件数に対して一定の時間だけかかります。私の環境で100万件だとほぼ常に6000msですね。
逆に追加したReflectionなAddRangeだとcollectionの件数が少ないほど遅いですが大体3 ~ 5個要素があるとforeachより早くなります。
また200個くらいからそこまで実行時間が変化しなくなります。
AddRangeに対して、コレクションの要素数を変化させつつ100万件の要素を追加したときの時間を下に書いておきます。こちらもミリ秒。
(elementが2の場合、2個の要素を持つコレクションをAddRangeで50万回追加した。)

element time
1 12967
2 6707
4 4118
5 3040
10 2063
20 1078
50 715
100 582
200 484
1000 380
10000 389

もちろん常に一定数追加することはないと思いますので理論値として見といてほしいのですが、まあそこそこなパフォーマンスかなぁという感想です。
collection.Count < 3とかのときはAddで追加とかにしてもいいのですが単純に面倒だった…。


というわけでObservableCollectionにAddRangeする拡張メソッドの話でした。
これで楽していきたい…

そろそろ今年も終わるしまた今年の感想的な記事を書いて今年のブログは終わりかなぁ。

この辺で