導入
前回、ViewModel が INotifyPropertyChanged を実装して変更を通知するしくみを見ました。でもプロパティが増えるたびに set の中で PropertyChanged?.Invoke(...) を書くのは面倒で、書き忘れも起きます。実際のアプリでは、この通知の共通処理を基底クラス ViewModelBase にまとめ、各画面の ViewModel はそれを継承します。ここでは ViewModelBase・ViewModel・View の3つに分けて、データバインディングの現実的な形を作ります。
図解
classDiagram
class ViewModelBase {
<<abstract>>
+PropertyChanged
#OnPropertyChanged(name)
#SetProperty(field, value) bool
}
class UserViewModel {
+Name
+Age
}
class View {
購読して表示を更新
}
ViewModelBase <|-- UserViewModel : 継承
View --> UserViewModel : バインド(購読)
説明
役割を3つに分けます。
- ViewModelBase(土台):
INotifyPropertyChangedを実装し、通知の共通処理OnPropertyChangedとSetPropertyを提供します。[CallerMemberName]を使うと、呼び出し元のプロパティ名が自動で入るのでnameof(...)すら書かずに済みます。 - ViewModel(画面用の状態):
ViewModelBaseを継承し、画面固有のプロパティをSetPropertyで書くだけ。通知のことは基底に任せます。 - View(画面):ViewModel を購読し、変更が来たら表示を更新します。実アプリでは XAML の
{Binding}がこの購読・更新を自動でやってくれます。
ViewModelBase(ViewModelBase.cs) ― 通知の共通処理をまとめた土台です。
using System.ComponentModel;
using System.Runtime.CompilerServices;
abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
// 呼び出し元のプロパティ名が [CallerMemberName] で自動的に入る
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
// 値が変わったときだけ field を更新し、変更を通知する共通ヘルパー
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;
}
}
ViewModel(UserViewModel.cs) ― 基底を継承し、SetProperty を呼ぶだけです。
class UserViewModel : ViewModelBase
{
private string _name = "";
public string Name
{
get => _name;
set => SetProperty(ref _name, value); // 変わったら自動で通知
}
private int _age;
public int Age
{
get => _age;
set => SetProperty(ref _age, value);
}
}
View(画面のかわり/Program.cs) ― ViewModel を購読し、変化を受け取って表示します。
var vm = new UserViewModel();
// 実アプリの XAML {Binding} の代わりに、変更通知を購読して表示を更新する
vm.PropertyChanged += (_, e) => Console.WriteLine($"表示更新: {e.PropertyName}");
vm.Name = "田中"; // 表示更新: Name
vm.Age = 20; // 表示更新: Age
vm.Age = 20; // 同じ値なので通知は出ない(SetProperty が弾く)
- 通知の共通処理を
ViewModelBaseに集約すると、各 ViewModel はSetProperty(ref _x, value)だけで済む [CallerMemberName]により、通知に乗るプロパティ名は自動で入る(nameof(Name)を書かなくてよい)SetPropertyは「値が変わったときだけ」通知するので、同じ値の再代入では画面を無駄に更新しない- 下のエディタで動かすときは、using を先頭にまとめ、View(トップレベル文)→ ViewModelBase → ViewModel の順に並べて貼り付けます(C#はトップレベル文をクラス定義より前・
usingをすべての先頭に置く決まりのため)。下の「演習」がそのまとめた形です
やってみよう
UserViewModel に IsAdult(Age >= 18 を返す読み取り専用プロパティ)を足し、Age の set で SetProperty が変化したときだけ OnPropertyChanged(nameof(IsAdult)) も呼んでみましょう。1回の代入で複数の表示更新が飛ぶ様子を確認できます。
演習
ViewModelBase を継承した CounterViewModel を作り、Count プロパティを SetProperty で実装してください。
using System.ComponentModel;
using System.Runtime.CompilerServices;
var vm = new CounterViewModel();
vm.PropertyChanged += (_, e) => Console.WriteLine($"changed: {e.PropertyName}");
vm.Count = 1; // changed: Count
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;
}
}
class CounterViewModel : ViewModelBase
{
private int _count;
public int Count
{
get => _count;
// TODO: SetProperty を使って _count を更新してください
set => ___;
}
}
- 期待される出力:
changed: Count
ヒント1を見る
set => SetProperty(ref _count, value);
ヒント2を見る
SetProperty がプロパティ名(Count)を通知に自動で載せるので、nameof を書く必要はありません
まとめ
ViewModelBaseに「変更通知の共通処理(OnPropertyChanged/SetProperty)」を集約する- 各 ViewModel は基底を継承し、
SetProperty(ref _field, value)だけで通知つきプロパティになる - View は ViewModel を購読して表示を更新する(実アプリの
{Binding}の役割) [CallerMemberName]でプロパティ名は自動、SetPropertyは無駄な通知を防ぐ
次回: 読み取りと書き込みを分ける CQRS です。