sh1’s diary

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

C# CommunityToolkit.Mvvm の学習4 ObservableObject

ObservableObject は、INotifyPropertyChanged と INotifyPropertyChanging インターフェースを実装することによって observable(監視)できるオブジェクトの base クラスです。プロパティの変更通知をサポートする必要のある、あらゆるオブジェクトの起点として使用することができます。

APIs:

  • ObservableObject
  • TaskNotifier
  • TaskNotifier

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

ObservableObject の主な特徴はつぎのとおりです:

  • INotifyPropertyChanged と INotifyPropertyChanging の基本実装を提供して、PropertyChanged と PropertyChanging イベントを公開します。
  • ObservableObject を継承した型のプロパティの値を簡単に設定して、適切なイベントを自動的に発生させるために使用できる SetProperty メソッドを提供します。
  • SetPropertyAndNotifyOnCompletion メソッドを提供します。このメソッドは SetProperty に似ています。Task プロパティを設定して、割り当てられたタスクが完了したときに通知イベントを発生させる機能を備えています。
  • OnPropertyChanged および OnPropertyChanging メソッドを公開しています。派生型を override することで、通知イベントの発生方法をカスタマイズすることができます。

単純な例

以下は、カスタムプロパティに通知のサポートを実装する例です:

public class User : ObservableObject
{
    private string name;

    public string Name
    {
        get => name;
        set => SetProperty(ref name, value);
    }
}

提供される SetProperty(ref T, T, string) メソッドは、プロパティの現在の値をチェックして、値が異なる場合は、値の更新+関連するイベントを自動的に発生させます。プロパティの名前は [CallerMemberName] 属性の使用によって、自動的に取得されるため、更新されるプロパティを手動で(記述するコード上で)指定する必要はありません。

サンプル

private string name;

/// <summary>
/// Gets or sets the name to display.
/// </summary>
public string Name
{
    get => name;
    set => SetProperty(ref name, value);
}
<StackPanel Spacing="8">
    <TextBox
        PlaceholderText="Type here to update the text below"
        Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
    <TextBlock Text="{x:Bind ViewModel.Name, Mode=OneWay}"/>
</StackPanel>

non-observable モデルのラッピング

例えば、データベースのアイテムを扱う際によくあるシナリオを挙げます。データベースモデルのプロパティをリレーして、プロパティが変更されたことを通知する "bindable" な(バインディング可能な)ラップモデルを作成します。

これは、INotifyPropertyChanged インターフェースを実装していないモデルに通知機能のサポートを追加したい場合に必要なラッピングです。ObservableObject は、この処理を簡単にするための専用メソッドを提供します。

以下の例では、User は ObservableObject を継承せずに、データベースのテーブルを直接にマッピングしたモデルになります:

public class ObservableUser : ObservableObject
{
    private readonly User user;

    public ObservableUser(User user) => this.user = user;

    public string Name
    {
        get => user.Name;
        set => SetProperty(user.Name, value, user, (u, n) => u.Name = n);
    }
}

この場合、SetProperty<TModel, T>(T, T, TModel, Action<TModel, T>, string) の overload を使用します。これは、以前のシナリオのように backing field にアクセスできなくても、コードを効率的にするために必要なことです。このメソッドのシグネチャの各部分を詳しく見て、コンポーネントの役割を理解しましょう:

  • TModel は型引数で、ラップする(ターゲットの)モデル型のことを示しています。この例だと User クラスのことです。C# コンパイラーは、SetProperty メソッドをどのように呼び出しているのかによって、自動的に判断します。
  • T は設定したいプロパティの型です。TModel と同様に判断されます。
  • T.oldValue が最初の引数で、この例ではプロパティの現在値として user.Name を渡している。
  • T.newValue はプロパティに設定をする新しい値です。例では プロパティの入力値である value を渡しています。
  • TModel は、ラップする対象のモデルで、例では user インスタンスを渡します。
  • Action<TModel, T> callback は、プロパティに与えられた新しい値が現在値と違うとき、プロパティに新しい値を設定する際に呼び出される Action です。この callback 関数は、ターゲットのモデル TModel と設定する新しい値を引数として受け取っています。(入力値 n を Name プロパティに代入してるだけ)ここで重要なことは、現在のスコープから値の読み取りを避け、callback の引数として与えられた値のみと対話することです。この理由は、C# コンパイラが callback 関数をキャッシュして、多くのパフォーマンス改善ができるようになります。なので、ここではフィールドや setter のパラメーターにアクセスするのではなくて、ラムダ式の入力パラメーター(引数)だけを使用しています。

SetProperty<TModel, T>(T, T, TModel, Action<TModel, T>, string) メソッドは、非常にコンパクトな API を提供しながら、ターゲット+プロパティの取得と設定の両方ができるので、ラッピング プロパティの作成をかなり簡単にしてくれます。

Note: LINQ 式を使ったメソッドの実装に比べると、特に callback パラメーターの代わりに Expression<Func> 型のパラメーターを使用することで達成できるパフォーマンスの向上は大きいです。特にこのバージョンは LINQ 式を使ったものよりも ~200 倍高速で、メモリ割り当てを全くしない。

Task の扱い (Handling Task properties)

もしも、プロパティが Task なら、タスクが完了したときに通知イベントを発生させ、binding を適切なタイミングで更新する必要があります。(例えば、タスクに紐づいた読み込みのインジケーター、または、その他のステータス情報の表示)ObservableObject はこのシナリオのための API を持っています:

public class MyModel : ObservableObject
{
    private TaskNotifier<int>? requestTask;

    public Task<int>? RequestTask
    {
        get => requestTask;
        set => SetPropertyAndNotifyOnCompletion(ref requestTask, value);
    }

    public void RequestValue()
    {
        RequestTask = WebService.LoadMyValueAsync();
    }
}

SetPropertyAndNotifyOnCompletion<T>(ref TaskNotifier<T>, Task<T>, string) メソッドが、フィールドの更新、新しいタスクの監視、タスクが完了したときの通知イベントを行います。タスクプロパティに binding するだけで、そのステータスが更新されたときに通知を受け取ることができます。

TaskNotifier<T> は ObservableObject によって公開される特別な型で、ターゲットの Task インスタンスをラップして、このメソッドに必要な通知ロジックを有効化してくれます。TaskNotifier 型は、一般的なタスクのみを持っている場合、直接利用することもできます。

Note: SetPropertyAndNotifyOnCompletion メソッドは、Microsoft.Toolkit パッケージの NotifyTaskCompletion 型を置き換えるためのものです。この型が使用されていた場合、内部の Task or Task プロパティを置き換えることができ、SetPropertyAndNotifyOnCompletion メソッドを使用して値を設定、値の変更通知を発生させることができます。NotifyTaskCompletion 型によって公開されるすべてのプロパティは、Task インスタンスで直接利用できます。

Sample

private TaskNotifier myTask;

/// <summary>
/// Gets or sets the name to display.
/// </summary>
public Task MyTask
{
    get => myTask;
    private set => SetPropertyAndNotifyOnCompletion(ref myTask, value);
}

/// <summary>
/// Simulates an asynchronous method.
/// </summary>
public void ReloadTask()
{
    MyTask = Task.Delay(3000);
}
<StackPanel Spacing="8">
    <Button
        Content="Click me to load a Task to await"
        Click="{x:Bind ViewModel.ReloadTask}"/>
    <TextBlock Text="{x:Bind ViewModel.MyTask.Status, Mode=OneWay}"/>
</StackPanel>

参考