えっ?beta3はどこに行ったって?beta2リリース直後にバグを見つけてこっそり差し替えていました。テヘペロッ(๑´ڡ`๑)
ListView Extensions - Nuget
バグフィックスのbeta2,3に比べたら今回はかなり大きなアプデです。バージョン変えようかと思いましたが、1.0.0-beta*という名前である以上、1.0.0をリリースしない限りバージョン変えられないなと思い、結局このままになっています…。
これで安定していたら今度こそ正式リリースするぞ!
今回の変更点
さて、今回のアプデ内容はただ一つにして大きい内容です。コレクションのスレッドセーフ化です。シングルスレッドが許されるのは小学生までだよねー。
名前は「SyncedSortableObsevableCollection」にしようかと思いましたが、いまどきスレッドセーフじゃないCollectionなんてWPFで使う上では用途が無いと判断し、名前は「SortableObservableCollection」のままとしました。インターフェースは互換性を維持していますので、そのままコンパイルは通るはずです。
一応今までの実装は残していて、同じ名前空間にUnsyncedSortableObservableCollectionとしてObsolate属性を付けています。問題がある方はこっちを使ってください。
なお、スレッドセーフ化する過程で継承構造をより素直な形にしました。以前のバージョンは「SortableCollection」というクラスを継承していましたが、こちらもObsolateになっており、基底クラスは「SyncedObservableCollection」にしております。
SyncedObservableCollection
これは私がフルで実装したクラスで、スレッドセーフな変更通知可能なコレクションです。ReaderWriterLockSlimを使って効率的なロックを行っております。要素技術としては詳しくは前回の記事を見て下さい。なお、このクラスは非ジェネリックのICollectionインターフェースを継承しているため、IsSynchronizedプロパティとSyncRootプロパティを実装しています。コレクションを外部から操作するときに複数回のメソッド呼び出しにわたってlockが必要な場合はこのSyncRootを使ってlockを行ってください。
例えば次のようなケースです。
SortableObservableCollection<Person> People = new SortableObservableCollection<Person>(); //中略 lock(People.SyncRoot) People.RemoveAt(People.Count - 1); //Remove last element
最後の要素を削除する処理ですが、これは
- コレクションの長さを取得
- コレクションの長さ-1の要素を削除
この1.と2.の間に他のスレッドからの書き込みアクセスがあって要素数が増減した場合、「コレクションの長さ-1」が最後の要素のインデックスではなくなってしまいます。これは問題です。
そこで、対策としてSyncRootを使ったlockを行っております。これによって他のスレッドからの書き込みアクセスを阻止しています。
これこそ「ReaderWriterLockSlimを使うべきじゃないの?」と思うかもしれませんが、このようなケースは書き込みアクセスを伴うことが圧倒的に多く、書き込みアクセスをする場合はReaderWriterLockもlock構文も大差がありません。ですので、既存の枠組みで簡単に実現できるこの方法を使うことにしました。
もちろん、コレクションの外でlock構文を使って書き込みをしている間も、中でReaderWriterLockSlimを併用していますので、書き込みのタイミング以外(上記で言えばPeople.Count読み取り時など)は他のスレッドからの読み込みアクセスは許可されます。
ちなみに、本家のObservableCollectionはAddRange/InsertRange/RemoveRangeといった複数の項目を一気に操作する機能は付いていません。しかし、INotifyCollectionChangedインターフェースの機能としてはNotifyCollectionChangedEventArgsクラスを見てもわかりますが複数項目のコレクション操作機能にも対応しています。
なので、このSyncedObservableCollectionではそれらの機能にも対応しています。ループでAddメソッドを複数回呼び出すとその呼び出しごとにイベントが発生しますが、AddRangeだと1回で済むので効率が格段と高まります。
ListViewViewModel
ListViewViewModelは今までもModelをUI以外のスレッドから操作した場合に発生した変更通知をUIのDispatcher経由でViewに反映させる機能を持っていました。ただし、肝心のスレッドセーフ設計は(面倒なので)やっていませんでした。今回は、このSyncedObservableCollectionを活用してスレッドセーフに設計しています。今までの実装はSortableObservableCollectionと同じくUnsyncedListViewViewModeに移したうえでObsolate属性を付けています。
デッドロックに注意
ここで、ListViewViewModelを使う上での注意を紹介します(私もハマってしばらく原因探しに苦戦していました)。ListViewViewModelはViewに設置されたボタンなどの入力を受け付けてModelに渡すためのコマンドをいくつか持っています。
例えばRemoveCommandはListViewのうち選択されている項目を削除するコマンドです。Viewのボタン押下を受けてCommandが発火し、Model(SortableObservableCollection)のRemoveメソッドを呼び出します。
気を付けないとここでデッドロックが発生しうるのです。
UIスレッドからのSortableObservableCollectionへのアクセスの直前にその他のスレッドから同じくSortableObservableCollectionに書き込みアクセスがあったとします。
そうすると、先に書き込みアクセスを始めたほうがLockを保持するのでUIスレッドはそのLockが解放されるまで待機します。
しかし、その他のスレッドは変更後に変更通知イベントを発生させ、UIのスレッドでUIの更新作業をしようとします。このときは、すでにUIスレッドはSortableObservableCollectionのLockが解放されるのを待っているのでUIスレッドでのInvokeが行えずデッドロックが発生してしまいます。
AとBの排他制御機構があって、AをLockしたうえでBをLockしようとするスレッドと、BをLockしたうえでAをLockしようとするスレッドがあるとデッドロックが発生する、というのはマルチスレッドプログラミングの基本中の基本です。
今回はUIスレッドとSortableObservableCollectionがそれぞれの排他制御機構となっています。
さてさて、これに対抗する手段もListViewViewModelには追加しております。
UIスレッドが直接SortableObservableCollectionへアクセスしなければよいのです。
UIスレッドからSortableObservableCollectionへのアクセスを行いたいときは別のスレッドに書き込みアクセスを依頼したうえですぐに処理をUIスレッドに戻すようにします。
そうすることで、先にその他のスレッドがSortableObservableCollectionのLockを保持していたとしてもUIスレッドはアイドル状態になっており、その他のスレッドからのUI更新作業もブロックされることはありません。この機構によってデッドロックが回避されるわけです。
ただ、重い作業を並列実行するわけでもないのに別スレッドを起動するので単純に処理が重い上、UI起点の処理をやっているのにその処理が終わる前にUIがいじれるようになるので不都合が起きることもあります。
そこで、これはオプションとし、デフォルトではOFFにしています。
別スレッドからの書き込みとUIスレッドからの書き込みが衝突する可能性がある場合は、ListViewViewModelにてUseNonUIThreadWhenCallingModelプロパティをtrueにしてください。
また、DisableCommandWhenCommandTaskRunningを使うことによってCommand起点のタスクが別スレッドで実行されているときにCommandを無効化することができます。Command起点のタスクが実行中かどうかはCommandTaskRunningプロパティでわかります。
なお、他の注意点として、変更通知イベントを受けたUI更新作業のときに、UIスレッドから直接SortableObservableCollectionに書き込みアクセスをしようとするとやはりデッドロックが起こりますので注意してください。この場合は確実に起きます。
ただ、UI更新作業中にSortableObservableCollectionを書き込むなんておかしな話です。なんでUIの更新作業の名目でデータの変更作業までしてんねんって話です。そんなデッドロックを起こすようなコードを書く人のことは知りません。
ListViewViewModelは同一インスタンスの要素を複数持ってはいけない
これは実は前からある制約です。ですが、自分自身ですら忘れていたので書いておきます。
SortableObservableCollectionは問題ないのですが、ListViewViewModelは同一インスタンスの要素を複数持てません。
もしも同一インスタンスを追加しようとすると例外(ArgumentException)が発生します。ListViewViewModelのTViewModelが構造体やプリミティブ型の場合は、同値だった場合に例外が発生してしまいます。
なぜこんな仕様になっているかというと、ひとえにSelectedItemsのせいです。
WPFのListViewは複数のアイテムを選択している場合はSelectedItemsでしかその選択されたアイテムをすべて取得することができません。インデックスではなく選択されているアイテムのViewModelインスタンスのコレクションとなります。
この際、複数の同じインスタンスがあった場合、どちらのアイテムが選択されているのか求めることができません。ですので、このような不便な制約があるわけです。
しかし、MVVMモデルをなぞって実装しているのならば、ListViewの各行のアイテムとしてバインディングするのは、通常はSortableObservableCollectionの要素の型に対応するViewModelになるはずです。 なので、SortableObservableCollectionが複数の同一インスタンスを持っていたとしてもViewModelの要素としては別個のインスタンスを作ればいいだけなのです。
これはどのように実現するかというと、ListViewViewModelのコンストラクタで指定します。
public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher); public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher, DispatcherPriority priority);
ListViewViewModelは2つのコンストラクタを持っています。ですが、Dispatcherの優先度を指定するかデフォルト値(Normal)を採用するかの違いだけで、それ以外は同じです。
sourceはModelのコレクションとなります。SortableObservableCollection<T>を指定する場合が圧倒的に多いでしょう。
問題は2番目の引数です。
このconverterがSortableObservableCollectionで要素が追加されたときに、ViewModelの要素を追加するために呼ばれるデリゲートになります。
ModelをViewModelに変換(ラッピング)するわけですから、ここで
model => new ElementViewModel(model)
のようなラムダ式を渡してやれば十分でしょう。
これでどのようなインスタンスが追加されたときも全自動で新しいインスタンスが生成されていくのでListViewViewModelに同一インスタンスが複数追加されることはありません。
めんどくさがって
model => model
にした場合は例外が飛ぶことがあるので注意してください。
ちなみに、要素がRemoveされたり、別の要素で書き換えられたときは、ElementViewModelがIDisposableを継承していればちゃんとDispose()を呼びますのでご安心を。
さーて、次回こそ正式リリースをするぞ!!!
0 件のコメント:
コメントを投稿