LoginSignup
37
40

More than 1 year has passed since last update.

CommunityToolkit.Mvvm V8 入門

Last updated at Posted at 2022-12-13
  • 2022/12/14
    • 「WeakとStrong」の節に少し追記

はじめに

.NET Community Toolkit の v8.0.0 が8月にリリースされました。
これは雑に言うと、Windows開発用のWindows Community ToolkitからWindows 固有でないAPIが独立して作成された最初のバージョンです。
上記Toolkitに、CommunityToolkit.Mvvmが含まれています。

.NET 6 で Incremental Source Generator(ISG)が登場し、多くのボイラープレートコードが高速に生成できるようになりました。
CommunityToolkit.Mvvmでも同様にISGを活用し、多くのコード生成を実現しています。
※ちなみにコード生成機能自体は7.1.0でもあったようです。

本稿ではv8のうち、特にコード生成に関連する部分(の一部)を紹介します。
自動生成コードとある程度格闘できる人向けの記事ということで、記事内ではXamlは原則省略します。

基本的な使い方

NotifyPropertyChangedを実装したViewModelを作成する

ViewModelとしたいクラスにINotifyPropertyChanged属性とpartialを付与するだけです。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
}

監視可能なプロパティを作成する

バッキングフィールドを先に記述し、そのバッキングフィールドへObservableProperty属性を付与します。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
+   [ObservableProperty]
+   private string _text = string.Empty; //Text というプロパティが生成される
}

生成されるプロパティは以下の通り1です。
inheritdocが付与されるのはかなり嬉しいポイントです。

クリックして展開
generated
partial class MainWindowViewModel
{
    /// <inheritdoc cref="_text"/>
    public string Text
    {
        get => _text;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_text, value))
            {
                _text = value;
                OnPropertyChanged(new PropertyChangedEventArgs("Text"));
            }
        }
    }
}

生成されるプロパティの名前

プロパティの名前は、バッキングフィールドの名前を元に以下の通り決定されます。

  1. プレフィックスの削除
    • m_で始まる場合はm_を削除
    • _で始まる場合は一連のアンダーバーを削除
  2. 残った文字列の先頭1文字を大文字に

つまり、以下のフィールドに対してはTextというプロパティが生成されます。
m_text, _text, ____text, text

監視可能なプロパティに依存したプロパティの変更を通知する

NotifyPropertyChangedFor属性を利用することで、自身が更新された際に他のプロパティの更新通知を投げることができます。
タイトル含め少しわかりにくいですが、コードを見ればシンプルです。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    [ObservableProperty]
+   [NotifyPropertyChangedFor(nameof(TextUpper))] //Textの変更時に、OnPropertyChanged("TextUpper")がコールされる
    private string _text = string.Empty; //Text というプロパティが生成される

+   public string TextUpper => _text.ToUpper();
}

生成されるプロパティは以下の通り1です。

クリックして展開

TextUpperへの変更通知が追加されています。

generated
partial class MainWindowViewModel
{
    /// <inheritdoc cref="_text"/>
    public string Text
    {
        get => _text;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_text, value))
            {
                _text = value;
                OnPropertyChanged(new PropertyChangedEventArgs("Text"));
+               OnPropertyChanged(new PropertyChangedEventArgs("TextUpper"));
            }
        }
    }
}

コマンドを作成する

コマンドにしたい関数へ、RelayCommand属性を付与します。
引数にはCommandParameterで指定した値が利用されます。
なお、返り値はvoidである必要があります。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    //...

+   [RelayCommand]
+   private void SyncMethod() //SyncMethodCommand が生成される
+   {        
+       //...
+   }

+   [RelayCommand]
+   private void SyncMethodWithParam(string str) //SyncMethodWithParamCommand が生成される
+   {        
+       //...
+   }
}

生成されるコマンドは以下の通り1です。

クリックして展開
generated
partial class MainWindowViewModel
{
    /// <summary>The backing field for <see cref="SyncMethodCommand"/>.</summary>
    private RelayCommand? syncMethodCommand;
    /// <summary>Gets an <see cref="IRelayCommand"/> instance wrapping <see cref="SyncMethod"/>.</summary>
    public IRelayCommand SyncMethodCommand
        => syncMethodCommand ??= new RelayCommand(new Action(SyncMethod));

    /// <summary>The backing field for <see cref="SyncMethodWithParamCommand"/>.</summary>
    private RelayCommand<string>? syncMethodWithParamCommand;
    /// <summary>Gets an <see cref="IRelayCommand{T}"/> instance wrapping <see cref="SyncMethodWithParam"/>.</summary>
    public IRelayCommand<string> SyncMethodWithParamCommand
        => syncMethodWithParamCommand ??= new RelayCommand<string>(new Action<string>(SyncMethodWithParam));
}

生成されるコマンドの名前

コマンドの名前は、関数の名前を元に以下の通り決定されます。

  1. プレフィックスの削除
    • Onで始まる場合はOnを削除
  2. 残った文字列の末尾にCommandを付与

非同期コマンドを作成する

基本的に、同期コマンドと同じです。
返り値はTaskTaskを継承した型である必要があります。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    //...

+   [RelayCommand]
+   private async Task AsyncMethodAsync() //AsyncMethodCommand が生成される
+   {
+   }

+   [RelayCommand]
+   private async Task AsyncMethodWithParamAsync(string str) //AsyncMethodWithParamCommand が生成される
+   {
+   }
}

生成される非同期コマンドは以下の通り1です。

クリックして展開
generated
partial class MainWindowViewModel
{
    /// <summary>The backing field for <see cref="AsyncMethodCommand"/>.</summary>
    private AsyncRelayCommand? asyncMethodCommand;
    /// <summary>Gets an <see cref="IAsyncRelayCommand"/> instance wrapping <see cref="AsyncMethodAsync"/>.</summary>
    public IAsyncRelayCommand AsyncMethodCommand
        => asyncMethodCommand ??= new AsyncRelayCommand(new Func<Task>(AsyncMethodAsync));

    /// <summary>The backing field for <see cref="AsyncMethodWithParamCommand"/>.</summary>
    private AsyncRelayCommand<string>? asyncMethodWithParamCommand;
    /// <summary>Gets an <see cref="IAsyncRelayCommand{T}"/> instance wrapping <see cref="AsyncMethodWithParamAsync"/>.</summary>
    public IAsyncRelayCommand<string> AsyncMethodWithParamCommand
        => asyncMethodWithParamCommand ??= new AsyncRelayCommand<string>(new Func<string, Task>(AsyncMethodWithParamAsync));
}

生成される非同期コマンドの名前

非同期コマンドの名前は、関数の名前を元に以下の通り決定されます。

  1. プレフィックスの削除
    • Onで始まる場合はOnを削除
  2. サフィックスの削除
    • Asyncで終わる場合はAsyncを削除
  3. 残った文字列の末尾にCommandを付与

非同期コマンドのキャンセル

非同期コマンドにするメソッドの引数にCancellationTokenを指定することで、実行中のコマンドを容易にキャンセルすることができます。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    //...

+   [RelayCommand]
+   private async Task CancelableAsyncMethodAsync(CancellationToken token) //CancelableAsyncMethodCommand が生成される
+   {
+   }

+   [RelayCommand]
+   private async Task CancelableAsyncMethodWithParamAsync(string str, CancellationToken token) //CancelableAsyncMethodWithParamCommand が生成される
+   {
+       //tokenをチェックしながらの重い処理...
+   }
}

実際にキャンセルするには対応するCommandのCancelを呼び出します。
例えばキャンセルボタンを設ける場合、以下のように記述します。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    //...

    [RelayCommand]
    private async Task CancelableAsyncMethodWithParamAsync(string str, CancellationToken token) //CancelableAsyncMethodWithParamCommand が生成される
    {
        //tokenをチェックしながらの重い処理...
    }

+   [RelayCommand]
+   private void Cancel()
+   {
+       CancelableAsyncMethodWithParamCommand.Cancel(); //これ
+   }
}

やや複雑な使い方

コマンドの実行可否を制御する

いわゆるCanExecuteの使い方です。
「基本的な」に書くか少し迷いましたが、ぱっと見では属性間の関係性が分かりづらいため、こちらに書きます。

  1. まず、コマンドの実行可否を返すプロパティ、またはメソッドを定義します
    ⇒ 下記の例だとCanExecuteSyncMethod
  2. 対象コマンドのRelayCommand属性に1.で定義したプロパティ or メソッドを指定します
    ⇒ 同、[RelayCommand(CanExecute = nameof(CanExecuteSyncMethod))]
  3. 最後に、実行可否に影響するプロパティにNotifyCanExecuteChangedFor属性を付与します
    ⇒ 同、[NotifyCanExecuteChangedFor(nameof(SyncMethodCommand))]
MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TextUpper))]
+   [NotifyCanExecuteChangedFor(nameof(SyncMethodCommand))] //3. このプロパティが影響するCommandを指定する
    private string _text = string.Empty;

+   private bool CanExecuteSyncMethod => Text.Length > 0; //1. メソッドでも可。フィールドは不可
+   //private bool CanExecuteSyncMethod() => Text.Length > 0; //メソッド版

-   [RelayCommand]
+   [RelayCommand(CanExecute = nameof(CanExecuteSyncMethod))] //2. 実行可否のプロバイダを指定する
    private void SyncMethod() //SyncMethodCommand が生成される
    {
    }
}

生成されるプロパティ及びコマンドは以下の通り1変化します。

クリックして展開

プロパティにはNotifyCanExecuteChangedが追加されています。

generated
partial class MainWindowViewModel
{
    /// <inheritdoc cref="_text"/>
    public string Text
    {
        get => _text;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_text, value))
            {
                //...
+               SyncMethodCommand.NotifyCanExecuteChanged();
            }
        }
    }
}

コマンドの生成時に、CanExecuteSyncMethodが引き渡されるようになります。

generated
partial class MainWindowViewModel
{
    /// <summary>The backing field for <see cref="SyncMethodCommand"/>.</summary>
    private RelayCommand? syncMethodCommand;
    /// <summary>Gets an <see cref="IRelayCommand"/> instance wrapping <see cref="SyncMethod"/>.</summary>
    public IRelayCommand SyncMethodCommand
-       => syncMethodCommand ??= new RelayCommand(new Action(SyncMethod));
+       => syncMethodCommand ??= new RelayCommand(new Action(SyncMethod), () => CanExecuteSyncMethod);
}

プロパティの変更前後に処理を挟む

ObservablePropertyを用いて作成した監視可能なプロパティが、バッキングフィールドへ値を反映する前後に処理を差し込むことができます。

「監視可能なプロパティの作成」では省略しましたが、以下のような監視可能プロパティに対し、、、

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    [ObservableProperty]
    private string _text = string.Empty;
}

実装を持たないpartialメソッドが2つ作成されます。(以下だと、OnTextChangingOnTextChanged

generated
internal partial class MainWindowViewModel
{
    public string Text
    {
        get => _text;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_text, value))
            {
+               OnTextChanging(value); //ここで呼び出し
                _text = value;
+               OnTextChanged(value); //ここで呼び出し
                OnPropertyChanged(new PropertyChangedEventArgs("Text"));
            }
        }
    }

    /// <summary>Executes the logic for when <see cref="Text"/> is changing.</summary>
+   partial void OnTextChanging(string value); //こいつと
    /// <summary>Executes the logic for when <see cref="Text"/> just changed.</summary>
+   partial void OnTextChanged(string value); //こいつ
}

これらpartialメソッドを実装することで、処理を差し込みます。

値変更前後でデバッグ出力をするには、以下のように実装します。

手書きコード
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    [ObservableProperty]
    private string _text = string.Empty;

    //このメソッドをコールすると、
    private void Test()
    {
        Text = "After";
    }

+   partial void OnTextChanging(string value)
+   {
+       System.Diagnostics.Debug.WriteLine($"Changing: Text = {Text}, value = {value}");
+       // => Changing: Text = , value = After
+   }

+   partial void OnTextChanged(string value)
+   {
+       System.Diagnostics.Debug.WriteLine($"Changed: Text = {Text}, value = {value}");
+       // => Changed: Text = After, value = After
+   }
}

ちなみにpartialメソッドを実装しない場合、プロパティ更新時のpartialメソッド呼び出しはコンパイラーによって削除されます。

値のバリデーション

バリデーションを行うには、ViewModelにObservableValidatorを継承させます。
これによりINotifyPropertyChangedインターフェースが重複してしまうため、INotifyPropertyChanged属性は削除します。

さらにバリデーション対象となるプロパティ(のバッキングフィールド)へNotifyDataErrorInfo属性を付与し、チェックしたい内容に応じてRequired属性などのValidationAttributeを継承した属性を付与します。

とはいえ、コード生成に関連するのはNotifyDataErrorInfo属性のみで、その他は従来の(or WPF本来の)ものです。

手書きコード
- [INotifyPropertyChanged]
- internal partial class MainWindowViewModel
+ internal partial class MainWindowViewModel : ObservableValidator
{
+   [Required]
+   [NotifyDataErrorInfo]
    [ObservableProperty]
    private string _text = string.Empty;
}

生成されるプロパティは以下の通り1変化します。

クリックして展開

NotifyDataErrorInfo属性を付けたことにより、ValidatePropertyが呼び出されています。
また、バッキングフィールドへ付与したRequiredがそのまま転記されています。

partial class MainWindowViewModel
{
    /// <inheritdoc cref="_text"/>
+   [RequiredAttribute()]
    public string Text
    {
        get => _text;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_text, value))
            {
                OnTextChanging(value);
                _text = value;
+               ValidateProperty(value, "Text");
                OnTextChanged(value);
                OnPropertyChanged(new PropertyChangedEventArgs("Text"));
            }
        }
    }
}

従来のバリデーションと同様にValidationAttributeを継承した独自属性を定義するか、CustomValidation属性を用いて独自のバリデーションを仕込むこともできます。

プロパティの変更をブロードキャストする

ViewModelにObservableRecipient属性を付与し、変更をブロードキャストしたいプロパティにNotifyPropertyChangedRecipients属性を付与します。

手書きコード
+ [ObservableRecipient]
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
+   [NotifyPropertyChangedRecipients]
    [ObservableProperty]
    private string _text = string.Empty;
}

生成されるプロパティは以下の通り1変化します。

クリックして展開

Broadcastの呼び出しが追加されます。

partial class MainWindowViewModel
{
    /// <inheritdoc cref="_text"/>
    public string Text
    {
        get => _text;
        set
        {
            if (!EqualityComparer<string>.Default.Equals(_text, value))
            {
                OnTextChanging(value);
                _text = value;
                OnTextChanged(value);
                OnPropertyChanged(new PropertyChangedEventArgs("Text"));
+               Broadcast(__oldValue, value, "Text");
            }
        }
    }
}

ブロードキャストは後述するメッセンジャーを用いて行われますが、このメッセンジャーは自動生成されるコンストラクタで初期化されます。
具体的には、以下のような1コンストラクタが生成されます。

generated
partial class MainWindowViewModel
{
    public MainWindowViewModel() : this(WeakReferenceMessenger.Default)
    {
    }

    public MainWindowViewModel(IMessenger messenger)
    {
        Messenger = messenger;
    }
}

ここには少し罠があって、独自で実装したコンストラクタが存在する場合、上記のコンストラクタは生成されません。
そのため、コンストラクタを自力実装する場合(≒たいていの場合)、Messengerプロパティにインスタンスをセットしてあげる必要があります。

なおこの罠は、ObservableRecipientを継承することでも回避できます。

さて、最終的にメッセージは以下のよう1に送信されます。(Tは通知されるプロパティの型)

generated
void Broadcast()
{
    PropertyChangedMessage<T> message = new(this, propertyName, oldValue, newValue);
    Messenger.Send<PropertyChangedMessage<T>>(message);
}

詳細は後述しますが、受信は以下のように行います。
ここではPropertyChangedMessage<T>が送受信されることが分かれば十分です。

MainWindowViewModel.cs
Messenger.Register<MainWindowViewModel, PropertyChangedMessage<string>>(this, static (recipient, message) =>
{
    //NotifyPropertyChangedを購読するのと同じように書ける
    switch (message.PropertyName)
    {
        case nameof(Text):
            //なんかする
            break;
    }
});

その他

メッセンジャー

コード生成を利用しないAPIで、IMessengerインターフェースとして提供されており、オブジェクト間のメッセージ交換をサポートします。
組み込みのIMessengerを利用する場合、WeakReferenceMessengerクラスかStrongReferenceMessengerクラスのDefault静的プロパティから取得できます。
必要に応じて独自にインスタンス化(new)したものを利用することもできます。

受信と送信

送受信するメッセージの定義

まず、必要に応じてメッセージを定義します。
CommunityToolkit.Mvvm.Messaging.Messages.ValueChangedMessage<T>を継承すると簡単です。

MyMessage.cs
internal class MyMessage : ValueChangedMessage<string>
{
    public MyMessage(string value) : base(value) { } //インテリセンス任せ
}

送信

ボタンクリックでメッセージを送信には、以下のようにすればOKです。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    [RelayCommand]
    private void SendMessage()
    {
+       WeakReferenceMessenger.Default.Send<MyMessage>(new("Mesage from MainWindowViewModel")); //送信!
    }
}

受信

上記メッセージを別ウィンドウで受信する場合、以下のように記述します。

SubWindow.xaml.cs
public partial class SubWindow : Window
{
    public SubWindow()
    {
        InitializeComponent();

+       WeakReferenceMessenger.Default.Register<SubWindow, MyMessage>(this, static (s, e) =>
+       {
+           //SendMessageCommandをExecuteすると、
+           //「Received: Mesage from MainWindowViewModel」と出力
+           System.Diagnostics.Debug.WriteLine($"Received: {e.Value}");
+       });
    }
}
他のパターン

手間ですが、IRecipient<T>を実装したクラスを受信者として登録することもできます。

MyRecipient.cs
internal class MyRecipient : IRecipient<MyMessage>
{
    public void Receive(MyMessage message)
    {
        Debug.WriteLine($"MyRecipient Received: {message.Value}");
    }
}
SubWindow.xaml.cs
public partial class SubWindow : Window
{
    //WeakRefの場合、MyRecipient参照を握っておく必要あり
+   private readonly MyRecipient _recipient = new();

    public SubWindow()
    {
        InitializeComponent();

        //ここでMyRecipientをnewした場合、WeakReferenceMessengerが強い参照を持たないため
        //コンストラクタを抜けたタイミングでMyRecipientがGCの対象となってしまう。
        //WeakReferenceMessenger.Default.Register(new MyRecipient()); ダメ!
+       WeakReferenceMessenger.Default.Register(_recipient);

        WeakReferenceMessenger.Default.Register<SubWindow, MyMessage>(this, static (s, e) =>
        {
            //SendMessageCommandをExecuteすると、
            //「Message: Mesage from MainWindowViewModel」と出力
            System.Diagnostics.Debug.WriteLine($"Message: {e.Value}");
        });
    }
}

コメントに書いていますが、WeakReferenceMessengerの場合は後述の理由で少し注意が必要です。

リクエスト

単方向の送受信だけでなく、レスポンスを要求することもできます。

送受信するメッセージの定義

こちらはCommunityToolkit.Mvvm.Messaging.Messages.RequestMessage<T>を継承すると簡単です。
※非同期版のAsyncRequestMessage<T>も存在します。

MyRequestMessage.cs
internal class MyRequestMessage : RequestMessage<string>
{
    public DateTime RequestedAt { get; }

    //例ではDateTimeを渡す
    public MyRequestMessage(DateTime requestedAt)
    {
        RequestedAt = requestedAt;
    }
}

リクエスト

ボタンクリックでリクエストするには、以下のようにすればOKです。

MainWindowViewModel.cs
[INotifyPropertyChanged]
internal partial class MainWindowViewModel
{
    //.. 

    [RelayCommand]
    private void SendRequest()
    {
        //リクエスト送信
        MyRequestMessage response = Messenger.Send<MyRequestMessage>(new(DateTime.Parse("2022/12/13")));

        Debug.WriteLine($"RequestedAt = {response.RequestedAt}, Response = {response.Response}");
        // => 「RequestedAt = 2022/12/13 0:00:00, Response = Response from SubWindow.」
    }
}

レスポンス

上記メッセージを別ウィンドウで受信しレスポンスを返すには、以下のように記述します。

SubWindow.xaml.cs
public partial class SubWindow : Window
{
    public SubWindow()
    {
        //...

+       WeakReferenceMessenger.Default.Register<SubWindow, MyRequestMessage>(this, static (recipient, message) =>
+       {
+           //RequestMessage<T>のTに対応する型を返す
+           message.Reply("Response from SubWindow.");
+       });
    }
}

WeakとStrong

IMessengerの実装にはWeakReferenceMessengerStrongReferenceMessengerの二つが存在します。
WeakReferenceMessengerは名前の通り弱参照を利用しており、受信者がGCされることを阻害しません。

例えば以下のようにWindowのコードビハインドでメッセージを受信するケースだと、StrongReferenceMessengerの場合はWindowがクローズされたあともSubWindowのデストラクタは呼ばれません。一方、WeakReferenceMessengerの場合はデストラクタが呼ばれます。

この性質は、小規模ダイアログを後始末する手間や、ViewModelの後始末のし辛さに対しては便利です。

SubWindow.xaml.cs
public partial class SubWindow : Window
{
    public SubWindow()
    {
        InitializeComponent();

        //thisへの参照をやんわり掴む(GCを阻害しない)
        //WeakReferenceMessenger.Default.Register<SubWindow, PropertyChangedMessage<string>>(this, static (_, _) => { });

        //thisへの参照をがっちり掴む
        StrongReferenceMessenger.Default.Register<SubWindow, PropertyChangedMessage<string>>(this, static (_, _) => { });
    }

    //StrongReferenceMessengerを用いた場合、Windowをクローズしても以下は実行されない(当然、GC.Collect()を呼んでもダメ)
    //WeakReferenceMessengerだと、GCが走ったタイミングで"Destructed"と表示される
    ~SubWindow()
    {
        System.Diagnostics.Debug.WriteLine("Destructed");
    }
}

注意点

確かに便利なのですが、上の方で触れたIRecipient<T>を利用して別途受信者を準備するケースなどでは、意図せず受信者が消滅してしまうこともあり取扱いには注意が必要です。
「起動してしばらくは動くのに、しばらくすると(≒GCのタイミングが来ると)受信しなくなる」といった、問題に気が付きづらい不具合に繋がります。
(まぁ、StrongReferenceMessengerによるメモリリークもそれはそれで気付きにくいのですが。。。)

参考リンク

Announcing .NET Community Toolkit 8.0! MVVM, Diagnostics, Performance, and more!

  1. 見易さのため一部省略、改変しています。 2 3 4 5 6 7 8 9

37
40
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
37
40