はじめに
Prism を使用して開発していると、よくViewModel
のコンストラクタにRepository
を指定して依存注入するコードを書くことが多いです。これは、MVVM パターンを採用した WPF アプリケーションにおいて、ビューモデルとモデル(またはリポジトリ)の結びつけを行う一般的な方法です。
例えば、ユーザー管理システムを開発する場合、以下のようなシナリオが考えられます。
- アプリケーション起動時に、ユーザーリストをデータベースから取得して表示したい
- ユーザーの詳細情報を編集する画面を開いたとき、そのユーザーの最新データを取得したい
- 設定画面を開いたとき、アプリケーションの設定情報をロードしたい
これらのシナリオでは、多くの場合データベースや 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);
}
上記のようなコードでも問題なく動いているように見えます。
このコードは一見問題なく動作するように見えます。
しかし、この実装には複数の問題点があります。
本記事では、これらの問題点を詳しく分析し、適切な解決策を探っていきます。
開発環境
- 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
で記述した出力の順番は以下の通りになります。
- Constructor Initialize Start
- InitializeAsyncMethod Start
- Constructor Initialize End
- 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 Taskmethod, 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 内で非同期処理が実行されるように書き換えます。
<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
メソッドを指定します。
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())
のように記述します。
アプリケーションを実行すると手動で発生させた例外がキャッチ出来ていることが分かります。
このように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 Taskmethod. Avoid Async Void より引用及び訳
そのため、先程Loaded
イベントで実行される Command に指定したメソッドはvoid
にしても例外をキャッチできます。
以下の箇所を今まで通りvoid
を指定したメソッドに変更して実行してみてください。同じように例外がキャッチできると思います。
new DelegateCommand(InitializeAsync)
private async void InitializeAsync()
最後に
今回はLoaded
イベントを使用しましたが、Prism で用意されているViewModelLocator
の処理をConfigureViewModelLocator
でカスタマイズする方法もありそうです。
ただ、動作をカスタマイズしたい ViewModel が増えるとコードが増えて後々大変そうなのでLoaded
イベントで非同期処理を指定したほうが楽だと感じました。
使用したサンプルアプリケーションのリポジトリはこちらです。