本文へスキップ
BecomeCoder

C# MVVMコース · 第6章 ViewModel ― 画面の状態と操作 · レッスン20

ViewModelBase ― 通知の重複をなくす

ブラウザで完結

導入

ViewModelのプロパティが変わったとき、画面(View)に「変わったよ」と伝える仕組みがINotifyPropertyChangedです。これがないと、コードの上では値が変わっていても画面表示は古いままになります。ただし、プロパティのsetterひとつひとつに通知処理を書くのは面倒で、コピペミス(プロパティ名の書き間違い)も起きやすい作業です。共通の土台ViewModelBaseを作り、この面倒を1箇所に押し込みましょう。

図解

classDiagram
    class INotifyPropertyChanged {
        <<interface>>
        +PropertyChanged event
    }
    class ViewModelBase {
        +PropertyChanged event
        #OnPropertyChanged(name)
        #SetProperty~T~(ref field, value, name) bool
    }
    class TaskListViewModel {
        +Tasks
        +NewTaskTitle
    }
    INotifyPropertyChanged <|.. ViewModelBase
    ViewModelBase <|-- TaskListViewModel

サンプル

using System.ComponentModel;
using System.Runtime.CompilerServices;

// ViewModelBase を継承するだけで、プロパティ変更通知が使えるようになる
int changedCount = 0;
string? lastPropertyName = null;
var person = new PersonViewModel();
person.PropertyChanged += (sender, e) => { changedCount++; lastPropertyName = e.PropertyName; };

person.Name = "Alice";
Console.WriteLine($"{person.Name} / 通知{changedCount}回 / プロパティ名={lastPropertyName}");
// Alice / 通知1回 / プロパティ名=Name

class PersonViewModel : ViewModelBase
{
    private string _name = "";
    public string Name
    {
        get => _name;
        set => SetProperty(ref _name, value);   // 変更判定+通知をまとめて委譲する
    }
}

abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;   // 同じ値なら何もしない
        field = value;
        OnPropertyChanged(name);
        return true;
    }
}
  • [CallerMemberName]は、呼び出し元のプロパティ名(この場合"Name")をコンパイラが自動で埋め込む属性。文字列を手で渡さないのでタイプミスによるバグを防げる
  • SetProperty<T>は「値が変わっていなければ何もしない」判定と、通知の発行を1行にまとめる
  • ViewModelBase継承するだけで、以降のViewModelはSetPropertyを呼ぶだけでよくなる

演習

using System.ComponentModel;
using System.Runtime.CompilerServices;

int changedCount = 0;
string? lastPropertyName = null;
var person = new PersonViewModel();
person.PropertyChanged += (sender, e) => { changedCount++; lastPropertyName = e.PropertyName; };

person.Name = "Alice";
Assert(person.Name == "Alice", "値が変わる");
Assert(changedCount == 1, "1回だけ通知される");
Assert(lastPropertyName == "Name", "プロパティ名がNameで通知される");

person.Name = "Alice";   // 同じ値を再セット
Assert(changedCount == 1, "同じ値なら通知は増えない");

Console.WriteLine("全テスト成功");

class PersonViewModel : ViewModelBase
{
    private string _name = "";
    public string Name
    {
        get => _name;
        set => SetProperty(ref _name, value);
    }
}

// TODO: INotifyPropertyChanged を実装する ViewModelBase を完成させてください。
//       ・PropertyChanged イベントを持つ
//       ・SetProperty<T> は、値が変わっていれば field を更新して true を、
//         変わっていなければ何もせず false を返す
//       ・プロパティ名は呼び出し元から渡させず [CallerMemberName] で自動取得する
abstract class ViewModelBase : INotifyPropertyChanged
{
    ___
}

void Assert(bool condition, string name)
{
    if (!condition) throw new Exception($"FAIL: {name}");
}
  • 期待される出力: 全テスト成功
ヒント1を見る

public event PropertyChangedEventHandler? PropertyChanged; を宣言し、protected void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); を用意します

ヒント2を見る

protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null) { if (EqualityComparer<T>.Default.Equals(field, value)) return false; field = value; OnPropertyChanged(name); return true; }

まとめ

  • INotifyPropertyChangedは「プロパティが変わったこと」を画面へ知らせる仕組み
  • 通知処理はViewModelBaseに共通化し、SetPropertyを呼ぶだけにする
  • [CallerMemberName]でプロパティ名の手入力(=タイプミスの温床)をなくす

次回: この土台の上に、実際に状態を持つTaskListViewModelを作ります。

実際に動かしてみよう

本文のサンプルや演習のコードは、コードブロック右上の「コピー」ボタンでコピーして、下のエディタに貼り付ければそのまま実行できます。

C# — ブラウザ内で実行

ブラウザ内でC#を動かす環境を読み込みます(初回のみ数秒)。
スクロールして表示された時点でも自動で読み込まれます。