【WPF】コンストラクタ内で非同期の初期化処理を行いたい

【WPF】コンストラクタ内で非同期の初期化処理を行いたい

はじめに

Prism を使用して開発していると、よくViewModelのコンストラクタにRepositoryを指定して依存注入するコードを書くことが多いです。これは、MVVM パターンを採用した WPF アプリケーションにおいて、ビューモデルとモデル(またはリポジトリ)の結びつけを行う一般的な方法です。

例えば、ユーザー管理システムを開発する場合、以下のようなシナリオが考えられます。

  1. アプリケーション起動時に、ユーザーリストをデータベースから取得して表示したい
  2. ユーザーの詳細情報を編集する画面を開いたとき、そのユーザーの最新データを取得したい
  3. 設定画面を開いたとき、アプリケーションの設定情報をロードしたい

これらのシナリオでは、多くの場合データベースや API との通信が必要となり通常非同期で行われます。一方で、ViewModel のコンストラクタは同期的に実行されるため、ここで問題が発生します。

以下のようなコードをコンストラクタ内で使用することは、直感的には自然に思えます。

public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
{
   _usersRepository = usersRepository;

   // Bad Code
   InitializeAsync();
}

private async void InitializeAsync()
{
   var enumerableData = await _usersRepository.GetUsersAsync();

   FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);
}

上記のようなコードでも問題なく動いているように見えます。

initialize-async.gif

このコードは一見問題なく動作するように見えます。

しかし、この実装には複数の問題点があります。

本記事では、これらの問題点を詳しく分析し、適切な解決策を探っていきます。

開発環境

  • Windows11
  • .NET8
  • Visual Studio 2022(Version 17.9.6)
  • Prism(8.1.97)

現在の問題点

冒頭で提示したコードには以下の問題点があります。

  • コンストラクタ内の処理は同期的に実行される
  • InitializeAsyncメソッドの呼び出し元(コンストラクタ)でawaitが使用できないので、非同期のスレッドがいつ終了したのか判断する明確な方法がない
  • try catch文で例外を捕捉出来ない

コンストラクタ内の処理は同期的に実行される

まず、コンストラクタ内の処理は同期的に実行され、非同期操作をawaitすることが出来ません。

そのため先程のコードも以下のように修正してもコンパイルエラーになります。

public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
{
   _usersRepository = usersRepository;

   // コンパイルエラー
   await InitializeAsync();
}

awaitを指定することが出来ないため、InitializeAsyncメソッドのでは戻り値にTaskオブジェクトでは無くvoidを指定するしかありません(Taskを指定してもコンパイルエラーにはなりません)

public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
{
   _usersRepository = usersRepository;

   // Bad Code
   InitializeAsync();
}

// 以下のように記述してもコンパイルエラーにはならないが警告が表示さる
// また、呼び出し元で await で待機することが出来ないので、動作は void を指定したときと同じ
// private async Task InitializeAsync()
// { }

// void を指定するしか無い
private async void InitializeAsync()
{
   var enumerableData = await _usersRepository.GetUsersAsync();

   FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);
}

await 出来ないのでいつ終了したか判断できない

2 つ目の問題点として、このInitializeAsyncメソッドはコンストラクタが終了した時点でまだ初期化が終わっていません。

コードを以下のように修正して処理の動きを見ていきます。

// 省略

public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
{
   _usersRepository = usersRepository;

   Debug.WriteLine("Initialize Start");

   InitializeAsync();

   Debug.WriteLine("Initialize End");

}

private async Task InitializeAsync()
{
   Debug.WriteLine("InitializeAsyncMethod Start");

   var enumerableData = await _usersRepository.GetUsersAsync();

   FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);

   Debug.WriteLine("InitializeAsyncMethod Finish");
}

プログラムを実行してみるとDebug.WriteLineで記述した出力の順番は以下の通りになります。

  1. Constructor Initialize Start
  2. InitializeAsyncMethod Start
  3. Constructor Initialize End
  4. InitializeAsyncMethod Finish

InitializeAsyncメソッドが終了する前にコンストラクタの処理が先に終了していることが分かります。

このようにInitializeAsyncメソッドはコンストラクタ内でawaitを指定出来ないため、非同期で実行したスレッドがいつ終了したのか判断できません。

さらにこの記述は例外処理で不都合が発生します。

例外が捕捉出来ない

Taskオブジェクトを返す非同期処理のメソッドは、例外が発生した場合にTaskオブジェクトに捕捉された例外が格納されるためtry catch文で処理することが出来ます。

反対にTaskオブジェクトを返さないasync voidが指定されたメソッドはtry catch文で例外を捕捉することが出来ません。ここで発生した例外は、このメソッドが開始された時点のSynchronizationContextに直接投げられます。

Async void methods have different error-handling semantics. When an exception is thrown out of an async Task or async Task method, that exception is captured and placed on the Task object. With async void methods, there is no Task object, so any exceptions thrown out of an async void method will be raised directly on the SynchronizationContext that was active when the async void method started.

Async/Await - Best Practices in Asynchronous Programming より引用

今回のコードのようにコンストラクタ内で非同期初期化を行うと、コンストラクタの実行が完了した後もバックグラウンドで非同期操作が継続します。この非同期操作が完了したとき、SynchronizationContextは例外を UI スレッドに直接スローします。

しかし、この時点ではコンストラクタの実行は既に完了しているため、例外を適切に処理することができません。

そのため、コードを以下のように書き換えても例外をキャッチすることは出来ず、アプリケーションは終了してしまいます。

public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
{
   _usersRepository = usersRepository;

   try
   {
      InitializeAsync();
   }
   catch (Exception ex)
   {
      MessageBox.Show($"初期化中にエラーが発生しました。: {ex.Message}");
   }
}

private async void InitializeAsync()
{
   // 手動で例外を発生
   throw new Exception("InitializeAsyncメソッド内でエラー");

   var enumerableData = await _usersRepository.GetUsersAsync();

   FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);
}

非同期で初期化処理をするための改善案

安全に初期化処理を行うために色々な方法が考えられますが、Microsoft.Xaml.Behaviors.Wpf(verson 1.1.135) を使用して実装してみます。

Microsoft.Xaml.Behaviors.Wpf を Nuget よりインストール後、XAML に Microsoft.Xaml.Behaviors.Wpf を適応してLoadedイベントで指定した Command 内で非同期処理が実行されるように書き換えます。

"MainWindow.xaml"
<Window x:Class="MVVM.AsyncConstructorInitialization.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:Icon="http://metro.mahapps.com/winfx/xaml/iconpacks"
+       xmlns:bh="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:prism="http://prismlibrary.com/"
        Title="{Binding Title}"
        Width="1080"
        Height="720"
        prism:ViewModelLocator.AutoWireViewModel="True"
        AllowsTransparency="True"
        Background="Transparent"
        WindowStartupLocation="CenterScreen"
        WindowStyle="None">
+  <bh:Interaction.Triggers>
+     <bh:EventTrigger EventName="Loaded">
+        <bh:InvokeCommandAction Command="{Binding InitializeAsyncCommand}" />
+     </bh:EventTrigger>
+  </bh:Interaction.Triggers>

<!-- 省略 -->

次に ViewModel でInitializeAsyncCommandを実装してInitializeAsyncメソッドを指定します。

"MainWindowViewModel.cs"
namespace MVVM.AsyncConstructorInitialization.ViewModels
{
   public class MainWindowViewModel : BindableBase
   {
      // 省略
+     public DelegateCommand InitializeAsyncCommand =>
+        _initializeAsyncCommand ?? (new DelegateCommand(async() => await InitializeAsync()));
+     private DelegateCommand _initializeAsyncCommand;

      /// <summary>
      /// コンストラクタ
      /// </summary>
      /// <param name="usersRepository">Userリポジトリインターフェース</param>
      public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
      {
         _usersRepository = usersRepository;
      }

-     // private async void InitializeAsync()
+     private async Task InitializeAsync()
      {
         try
         {
               throw new Exception("InitializeAsync メソッド内でエラー");

               var enumerableData = await _usersRepository.GetUsersAsync();

               FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);
         }
         catch (Exception ex)
         {
               MessageBox.Show($"初期化中にエラーが発生しました。: {ex.Message}");
         }
      }
   }
}

ポイントとしてはDelegateCommandで非同期メソッドを指定する際はnew DelegateCommand(async() => await InitializeAsync())のように記述します。

アプリケーションを実行すると手動で発生させた例外がキャッチ出来ていることが分かります。

initialize-async-try-catch.png

このようにasync voidメソッドは待機が出来ないため、テストや例外処理の観点から今回はasync Taskを指定したメソッドを使用することが推奨されています。

async void はイベントハンドラで指定する

ガイドラインでは非同期メソッドはasync Taskを指定するよう推奨されていますが、イベントハンドラではvoid を返すメソッドを指定するよう設計されています。

Void-returning async methods have a specific purpose: to make asynchronous event handlers possible. It is possible to have an event handler that returns some actual type, but that doesn’t work well with the language; invoking an event handler that returns a type is very awkward, and the notion of an event handler actually returning something doesn’t make much sense. Event handlers naturally return void, so async methods return void so that you can have an asynchronous event handler. However, some semantics of an async void method are subtly different than the semantics of an async Task or async Task method.

Avoid Async Void より引用及び訳

そのため、先程Loadedイベントで実行される Command に指定したメソッドはvoidにしても例外をキャッチできます。

以下の箇所を今まで通りvoidを指定したメソッドに変更して実行してみてください。同じように例外がキャッチできると思います。

  • new DelegateCommand(InitializeAsync)
  • private async void InitializeAsync()

最後に

今回はLoadedイベントを使用しましたが、Prism で用意されているViewModelLocatorの処理をConfigureViewModelLocatorでカスタマイズする方法もありそうです。

ただ、動作をカスタマイズしたい ViewModel が増えるとコードが増えて後々大変そうなのでLoadedイベントで非同期処理を指定したほうが楽だと感じました。

使用したサンプルアプリケーションのリポジトリはこちらです。

参考