0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PropertyChangedEventManagerはスレッドプールでは失敗する

Posted at

初めに

ObservableCollectionの要素のプロパティ値の変化を捉える必要が出てきたとき、その方法の一つとして要素のPropertyChangedイベントを捉える方法があります。このためには、イベントハンドラーを直接登録する強参照の方法の他に、WeakEventManagerを使う弱参照の方法があります。

後者を考える場合、PropertyChanged用にはPropertyChangedEventManagerが用意されているので、これを使うとして、ObservableCollectionの要素の追加/削除をBindingOperations.EnableCollectionSynchronizationを使ってバックグラウンドのスレッドプールで行う場合に、PropertyChangedEventManagerが正常に機能するか、ふと気になったので確認してみました。

同様の問題について@dyonedaさんがCollectionChangedEventManagerで検証された例があり、結論はAddListenerを実行したスレッドと同じスレッドでなければRemoveListenerは失敗するというものです。

PropertyChangedEventManagerも同じWeakEventManagerの派生なので、結論は半ば見えているようなものですが、もやもやは解消しておくに如くはなしです。

サンプル

PropertyChangedEventManagerを使うためにIWeakEventListenerを実装したクラスが必要ですが、これは以下のようなPropertyChangedEventListenerとしました。PropertyChangedイベントが発生するとReceiveWeakEventが呼ばれ、コンストラクターで指定したActionが実行されます。

PropertyChangedEventListener.cs
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も行うようにしました。

MainWindowViewModel.cs
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));
        }
    }
}
ItemViewModel.cs
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は実用的に使えないことが確認できました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?