初めに
ObservableCollectionの要素のプロパティ値の変化を捉える必要が出てきたとき、その方法の一つとして要素のPropertyChangedイベントを捉える方法があります。このためには、イベントハンドラーを直接登録する強参照の方法の他に、WeakEventManagerを使う弱参照の方法があります。
後者を考える場合、PropertyChanged用にはPropertyChangedEventManagerが用意されているので、これを使うとして、ObservableCollectionの要素の追加/削除をBindingOperations.EnableCollectionSynchronization
を使ってバックグラウンドのスレッドプールで行う場合に、PropertyChangedEventManager
が正常に機能するか、ふと気になったので確認してみました。
同様の問題について@dyonedaさんがCollectionChangedEventManager
で検証された例があり、結論はAddListenerを実行したスレッドと同じスレッドでなければRemoveListenerは失敗するというものです。
PropertyChangedEventManager
も同じWeakEventManagerの派生なので、結論は半ば見えているようなものですが、もやもやは解消しておくに如くはなしです。
サンプル
PropertyChangedEventManager
を使うためにIWeakEventListenerを実装したクラスが必要ですが、これは以下のようなPropertyChangedEventListener
としました。PropertyChangedイベントが発生するとReceiveWeakEventが呼ばれ、コンストラクターで指定したActionが実行されます。
public class PropertyChangedEventListener<T> : IWeakEventListener
{
private readonly Action<T, string?> _action;
public PropertyChangedEventListener(Action<T, string?> action) => _action = action;
public bool ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
{
if (managerType != typeof(PropertyChangedEventManager))
return false;
_action.Invoke((T)sender, ((PropertyChangedEventArgs)e).PropertyName);
return true;
}
}
ObservableCollectionの要素の追加/削除の際にはBindingOperations.EnableCollectionSynchronization
のためのロックが必要なので、AddとRemoveメソッドを加え、これらの中でPropertyChangedEventManager
のAddListenerとRemoveListenerも行うようにしました。
public partial class MainWindowViewModel : ObservableObject
{
public ObservableCollection<ItemViewModel> Items { get; } = new();
private readonly object _lock = new();
public MainWindowViewModel()
{
BindingOperations.EnableCollectionSynchronization(Items, _lock);
}
private readonly PropertyChangedEventListener<ItemViewModel> _listener = new(OnReceived);
private static void OnReceived(ItemViewModel item, string? propertyName)
{
switch (propertyName)
{
case nameof(ItemViewModel.Name):
Trace.WriteLine($"Received: {item.Name}");
break;
}
}
private void Add(ItemViewModel item)
{
lock (_lock)
{
Items.Add(item);
PropertyChangedEventManager.AddListener(item, _listener, nameof(ItemViewModel.Name));
}
}
private void Remove(ItemViewModel item)
{
lock (_lock)
{
Items.Remove(item);
PropertyChangedEventManager.RemoveListener(item, _listener, nameof(ItemViewModel.Name));
}
}
}
public partial class ItemViewModel : ObservableObject
{
public string? Name
{
get => _name;
set
{
if (_name != value)
{
Trace.WriteLine($"Changed: {value}");
SetProperty(ref _name, value);
}
}
}
private string? _name;
}
ObservableCollectionの要素のプロパティ値が変化するとPropertyChangedEventListener
のActionが呼ばれ、OnReceivedが実行されます。
テスト
初めのテストは、2つの要素のAddとRemoveをUIスレッドで行うものです。Task.Delayで時間を挟んでいるのはViewで変化が見えるようにするためで、テスト上は必須ではありません。
public async Task TestSingleThreadAsync()
{
Trace.WriteLine("-- Add A & B --");
var itemA = new ItemViewModel { Name = "A 0" };
var itemB = new ItemViewModel { Name = "B 0" };
Add(itemA);
Add(itemB);
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine("-- Change names --");
itemA.Name = "A 1";
itemB.Name = "B 1";
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine("-- Remove A and then change names --");
Remove(itemA);
itemA.Name = "A 2";
itemB.Name = "B 2";
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine("-- Remove B and then change names --");
Remove(itemB);
itemA.Name = "A 3";
itemB.Name = "B 3";
}
この結果を見ると、Removeの後にはOnReceivedが実行されていないので、RemoveListenerが正常に機能したことが分かります。
-- Add A & B --
Changed: A 0
Changed: B 0
-- Change names --
Changed: A 1
Received: A 1
Changed: B 1
Received: B 1
-- Remove A and then change names --
Changed: A 2
Changed: B 2
Received: B 2
-- Remove B and then change names --
Changed: A 3
Changed: B 3
次のテストは、AddとRemoveをスレッドプールで行うものです。
public async Task TestMultiThreadAsync()
{
Trace.WriteLine("-- Add A & B --");
var itemA = new ItemViewModel { Name = "A 0" };
var itemB = new ItemViewModel { Name = "B 0" };
await Task.Run(() => Add(itemA));
await Task.Run(() => Add(itemB));
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine("-- Change names --");
itemA.Name = "A 1";
itemB.Name = "B 1";
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine("-- Remove A and then change names --");
await Task.Run(() => Remove(itemA));
itemA.Name = "A 2";
itemB.Name = "B 2";
await Task.Delay(TimeSpan.FromSeconds(1));
Trace.WriteLine("-- Remove B and then change names --");
await Task.Run(() => Remove(itemB));
itemA.Name = "A 3";
itemB.Name = "B 3";
}
この結果は試行によって変わり、RemoveListenerが2つとも成功した例では上記と変わりません。以下はAの方だけ成功した例で、Removeの後にもBのOnReceivedが実行されています。
-- Add A & B --
Changed: A 0
Changed: B 0
-- Change names --
Changed: A 1
Received: A 1
Changed: B 1
Received: B 1
-- Remove A and then change names --
Changed: A 2
Changed: B 2
Received: B 2
-- Remove B and then change names --
Changed: A 3
Changed: B 3
Received: B 3
また、以下は2つとも失敗した例で、Removeの後にもAとBのOnReceivedが実行されています。
-- Add A & B --
Changed: A 0
Changed: B 0
-- Change names --
Changed: A 1
Received: A 1
Changed: B 1
Received: B 1
-- Remove A and then change names --
Changed: A 2
Received: A 2
Changed: B 2
Received: B 2
-- Remove B and then change names --
Changed: A 3
Received: A 3
Changed: B 3
Received: B 3
これらの違いはスレッドプールでAddListenerのスレッドとRemoveListenerのスレッドがたまたま同じになったときには成功し、違うスレッドになったときは失敗すると見なしてよさそうです。
サンプルプロジェクトは以下。
emoacht/WpfWeakEventManagerTest
まとめ
スレッドプールではRemoveListenerが失敗するので(成功することもある)、PropertyChangedEventManager
は実用的に使えないことが確認できました。