sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# CommunityToolkit.Mvvm の学習3 RelayCommand + INotifyPropertyChanged

RelayCommand 属性

RelayCommand は、(属性を設定した)メソッドのリレーコマンドのプロパティを生成するための属性です。目的は、viewmodel の private メソッドをラップするコマンドを定義するために必要な定型文(ボイラープレートコード)を完全に削除することです。

Note: 属性を設定したメソッドを(RelayCommand として)動作させるためには partial クラスである必要があります。

APIs:

  • RelayCommand
  • ICommand
  • IRelayCommand
  • IRelayCommand
  • IAsyncRelayCommand
  • IAsyncRelayCommand
  • Task
  • CancellationToken

How it works(どのように機能するのか)

RelayCommand 属性は、次のように partial 型のメソッドに属性を付けることができます:

[RelayCommand]
private void GreetUser()
{
    Console.WriteLine("Hello!");
}

(上のコードから)次のようなコマンドが生成されます:

private RelayCommand? greetUserCommand;

public IRelayCommand GreetUserCommand => greetUserCommand ??= new RelayCommand(GreetUser);

Note: 生成されるコマンドの名前は、メソッド名に基づいて作成されます。ジェネレーターはメソッド名を利用しつつ、最後に "Command" の文字列を追加します。また "On" の接頭辞がある場合は、これを取り除きます。さらに、非同期メソッドの場合 "Async" 接尾辞も取り除きます。

Command パラメーター

RelayCommand 属性は、パラメーターを持つメソッドの Command の作成をサポートします。生成された Command は自動的に IRelayCommand<T> になり、代わりに同じ型のパラメーターを受け取ります:

[RelayCommand]
private void GreetUser(User user)
{
    Console.WriteLine($"Hello {user.Name}!");
}

以下のコードが生成されます:

private RelayCommand<User>? greetUserCommand;

public IRelayCommand<User> GreetUserCommand => greetUserCommand ??= new RelayCommand<User>(GreetUser);

作成した Command メソッドから、自動的に引数型を持つプロパティを生成します。

非同期コマンド (Asynchronous commands)

RelayCommandIAsyncRelayCommandIAsyncRelayCommand<T> インターフェースを介して、非同期メソッドへのラッピングもサポートしています。メソッドが Task 型を返すと自動的に処理されます。たとえば:

[RelayCommand]
private async Task GreetUserAsync()
{
    User user = await userService.GetCurrentUserAsync();

    Console.WriteLine($"Hello {user.Name}!");
}

以下のコードが生成されます:

private AsyncRelayCommand? greetUserCommand;

public IAsyncRelayCommand GreetUserCommand => greetUserCommand ??= new AsyncRelayCommand(GreetUserAsync);

メソッドがパラメーターを受け取る場合、結果の Command はジェネリックになります。メソッドが CancellationToken を持っている場合は特別で、キャンセルできるように伝搬されます。次のようなケースです:

[RelayCommand]
private async Task GreetUserAsync(CancellationToken token)
{
    try
    {
        User user = await userService.GetCurrentUserAsync(token);

        Console.WriteLine($"Hello {user.Name}!");
    }
    catch (OperationCanceledException)
    {
    }
}

生成されたコマンドはラップされたメソッドをトークンに渡すことになる。これによって、コンシューマーは IAsyncRelayCommand.Cancel を呼び出すだけでそのトークンをシグナルとして送りだすことができるので、保留中の処理を正しく停止することが可能です。

コマンドの有効化と無効化 (Enabling and disabling commands)

Command を無効化できることは、しばしば便利です。Command を無効にしておいて、後からその状態を実行可能かどうかを再度チェックできるようにすると、便利に使えることがよくあります。

これをサポートするために、RelayCommand 属性はプロパティを公開しています。このプロパティは、コマンドを実行できるかどうかを評価するために仕様するターゲット CanExecute プロパティ、または、メソッドを示すために使用できます:

[RelayCommand(CanExecute = nameof(CanGreetUser))]
private void GreetUser(User? user)
{
    Console.WriteLine($"Hello {user!.Name}!");
}

private bool CanGreetUser(User? user)
{
    return user is not null;
}

このメソッドはボタンが最初に UI からバインドされたときに呼び出されます。そのあと Command で IRelayCommand.NotifyCanExecuteChanged が実行されるたびに繰り返し呼び出しがされます。

Command を属性のプロパティにバインドしてその状態を制御する方法は次のとおり:

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SelectedUser))]
private User? selectedUser;
<!-- Note: this example uses traditional XAML binding syntax -->
<Button
    Content="Greet user"
    Command="{Binding GreetUserCommand}"
    CommandParameter="{Binding SelectedUser}"/>

この例では、生成された SelectedUser プロパティは、値が変更されるたびに GreetUserCommand.NotifyCanExecuteChanged() メソッドが呼び出されることになります。

UI (xaml) には、GreetUserCommand にバインドしている Button コントロールがあります。CanExecuteChanged イベントが発生するたびに、CanExecute メソッドが再度呼び出されます。

これによって、ラップされた CanGreetUser メソッドが評価されて、入力される User インスタンスxaml ではバインドした SelectedUser プロパティ)が null かどうかに基づいて、(つぎの新しい)ボタンの状態が返却されます。

つまり、(整理すると)SelectedUser が変更されるたびに、プロパティが値を持つかどうかに基づいて GreetUserCommand は有効になるかどうかの状態が決まります。これが、ここでのシナリオ(機能)の動作になります。

Note: Command は、CanExecute メソッド、または、プロパティの戻り値がいつ変更されたのかを自動的に認識していません。なので、IRelayCommand.NotifyCanExecuteChanged を呼び出して Command を無効化すること、CanExecute メソッドの再評価を要求すること、これらのコマンドにバインドされているコントロールの知覚的な状態を更新することは、開発者の責任です。

コマンドの同時実行の処理 (Handling concurrent executions)

コマンドが非同期である場合は、同時実行を許可するかどうかを決定する構成を常にできます。RelayCommand 属性を使う場合は AllowConcurrentExecutions プロパティを介して設定することができます。

この設定は、デフォルトでは false です。(この設定は)実行が保留されるまで、Command はその状態が無効であると通知することを意味しています。もしも、代わりに true を設定すると、任意の数の同時呼び出しを queue に設定することができます。

非同期処理の例外のハンドリング (Handling asynchronous exceptions)

非同期の RelayCommand が例外を処理する方法は2つあります。

  • await and rethrow (default): コマンド呼び出しの完了を await するとき、例外は同じ同期コンテキスト内で(そのまま)throw されます。これは通常、例外が throw されるとアプリケーションがクラッシュするだけで、通常の(同期をする)RelayCommand の例外動作と同じ挙動です。(例外が throw されると、アプリケーションは try-catch でもしない限りは、普通にクラッシュするということ)
  • flow exceptions to task scheduler(タスクスケジューラーに例外を流す): タスクスケジューラーに例外を渡すように RelayCommand を設定されている場合、例外は throw されてもアプリケーションはクラッシュしません。その代わりに、IAsyncRelayCommand.ExecutionTask を通じて TaskScheduler.UnobservedTaskException にバブリングされます。これによって、より高度なシナリオ(UI コンポーネントがタスクにバインドされ、操作の結果に応じて異なる結果を表示するなど)が可能になります。しかし、正しく(この機能を)使うには、さらに複雑になってしまいます。

デフォルトの振る舞いは、Command が例外を待ち受けて、再 throw をすることです。これは FlowExceptionsToTaskScheduler プロパティで(2つ目の)構成できます。

[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task GreetUserAsync(CancellationToken token)
{
    User user = await userService.GetCurrentUserAsync(token);

    Console.WriteLine($"Hello {user.Name}!");
}

この場合は、例外がアプリケーションをクラッシュさせることは無くなるので try-catch は必要ありません。他の無関係な例外も自動的に再 throw されなくなってしまうので、個々のシナリオごとにどのようなアプローチをするのかを注意深く決定して、適切なコードにする必要があります。

非同期処理のキャンセル (Cancel commands for asynchronous operations)

非同期 Command の最後のオプションは、キャンセルを要求する機能です。これは、非同期の RelayCommand をラップした ICommand の操作のキャンセル要求に使用できます。この Command は任意のタイミングで使用可能かどうかの反映をするために、状態を自動的に通知します。例えば、リンクされている Command が実行中でなければ、状態も実行可能ではないことを報告します。次のとおりです:

[RelayCommand(IncludeCancelCommand = true)]
private async Task DoWorkAsync(CancellationToken token)
{
    // Do some long running work...
}

DoWorkCancelCommand プロパティが生成されています。これを他の UI コンポーネントに bind することで、保留中の非同期処理を簡単にキャンセルすることができるようになります。

INotifyPropertyChanged 属性

INotifyPropertyChanged は、既存の型に MVVM サポートコードの利用を可能する属性です。

他の関連する属性 (ObservableObject, ObservableRecipient) と共にこの属性の目的は、これらの型の機能が必要な場合に開発者をサポートすることですが、ターゲットの型はすでに別の型から実装されているとします。

C# は多重継承ができないので、これらの属性は、(継承の)代わりに MVVM Toolkit のジェネレーターが(継承の)制限を回避して、別の型にコードを追加するために使用することができます。

Note: partial クラスの中である必要があります。これらの属性は、ターゲットの型が藤堂の型 (e.g. from) から継承できない場合のみに使用することを目的としています。継承が可能なら継承することをお勧めします。最終的なアセンブリに重複したコードが作成されることを避けられるので、バイナリサイズを削減できます。

APIs:

  • INotifyPropertyChanged
  • ObservableObject
  • ObservableRecipient

使い方 (How to use them)

この属性を使うことはとても簡単。partial クラスに追加するだけで、対応するコードが自動的にその型に対して生成されます。例えば:

[INotifyPropertyChanged]
public partial class MyViewModel : SomeOtherType
{    
}

これは、MyViewModel クラスに完全な INotifyPropertyChanged の実装を生成します。加えて、冗長性を減らすために仕様できる追加のヘルパー (SetProperty など) も共に生成します。以下、さまざまな属性の簡単な概要を挙げます:

  • INotifyPropertyChanged: インターフェースを実装し、プロパティを設定したり、イベントを発生させたりするためのヘルパーメソッドを追加します。
  • ObservableObject: ObservableObject 型のすべてのコードを追加します。概念的には INotifyPropertyChanged と等価で、主な違いは INotifyPropertyChanging を実装していることです。
  • ObservableRecipient: ObservableRecipient 型のすべてのコードを追加します。特に ObservableValidator を継承した型に追加することで、お互いを組み合わせることができます。

参考