ObservableObjectとDiffDetectableObjectで「変更された?」を自動判定する
2026-04-14
はじめに
楽譜エディタを作っていて、地味に困るのが「未保存の変更があるかどうか」の判定です。タイトルバーに*を出したり、保存ボタンの活性を切り替えたり、タブを閉じようとしたら「未保存の変更があります」と警告したい。これをやるには、現在のデータが初期状態から変更されているかを常に知っておく必要があります。
PICOMでは、この判定をObservableObjectとDiffDetectableObject<T>という2つの小さなクラスで実装しました。この記事では、その設計と、MVVM Community Toolkitとの違いについて書きます。
INotifyPropertyChangedを実装したObservableObjectをベースに、初期スナップショットと現在のオブジェクトを持つDiffDetectableObject<T>を被せることで、差分の有無をイベントで通知できるようにしました。保存ボタンの活性判定や、変更通知バッジの表示など、UI側は状態フラグをそのままバインドするだけで済みます。
ObservableObject:最小限のプロパティ変更通知
まずは下敷きとなるObservableObjectです。中身はINotifyPropertyChangedの実装だけで、めちゃくちゃ小さいです。
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Sample;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void SetProperty<T>(ref T prop, T value, [CallerMemberName] string? name = null)
{
if (name is null) throw new Exception();
prop = value;
NotifyPropertyChanged(name);
}
protected void NotifyPropertyChanged(string name)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
[CallerMemberName]でプロパティ名を自動取得しているので、派生クラスでは次のように書けます。
public class MusicMetadata : ObservableObject
{
private string _title = "";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
}
MVVM Community Toolkit ではダメなのか
同じことはMVVM Community Toolkitの[ObservableProperty]でソースジェネレータ任せにできます。実際、本格的な.NETアプリならそれを使う方が記述量は少なくなります。
PICOMでToolkitを使わなかった理由は、この程度であれば自前実装した方が良いと判断したからです。実際、PICOMのObservableObjectは20行程度で書き切れています。
- 依存がゼロ(Blazor WASMのバイナリサイズに寄与しない)
- 派生クラスから見たAPIも自分で設計できる
- ライブラリの管理コスト削減
SetPropertyを手動で呼ぶ分、記述が冗長になる- あくまで低機能前提
DiffDetectableObject:差分の有無をイベントで通知する
本題のDiffDetectableObject<T>は、ObservableObjectを2つ(初期スナップショットと編集中のもの)保持するラッパーです。
public class DiffDetectableObject<T> where T : ObservableObject, IDifferenceComparable<T>
{
public delegate void DiffStateChangedEventHandler(T editable, bool hasDiff);
public event DiffStateChangedEventHandler DiffStateChanged;
public bool HasDiff { get; private set; } = false;
private T _initObj;
private T _editableObj;
public DiffDetectableObject(T init, T editable)
{
_initObj = init;
_editableObj = editable;
_editableObj.PropertyChanged += Editable_PropertyChanged;
}
// ...
}
肝は_initObjと_editableObjの2本持ちです。_initObjは「最後に保存した時点のデータ」、_editableObjは「編集中のデータ」を指し、どちらも同じ型Tで持ちます。where T : ObservableObject, IDifferenceComparable<T>という型制約で、Tは必ず「変更通知できる」「比較できる」の2つを満たすことを強制しています。
IDifferenceComparable<T>は自前の最小インターフェースで、中身は次のようにCompareDifferenceメソッド1本だけです。
public interface IDifferenceComparable<TSelf>
{
/// <summary>差分がある場合:true</summary>
bool CompareDifference(TSelf other);
}
プロパティ変更を検知してフラグを切り替える
PropertyChangedイベントをフックして、そのたびに差分状態を再計算します。
private void Editable_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
// 既に差分がある状態で、差分がなくなった場合
if (!_initObj.CompareDifference(_editableObj) && HasDiff)
{
HasDiff = false;
DiffStateChanged?.Invoke(_editableObj, false);
}
// 差分がない状態で差分が発生した場合
else if (_initObj.CompareDifference(_editableObj) && !HasDiff)
{
HasDiff = true;
DiffStateChanged?.Invoke(_editableObj, true);
}
}
ここで工夫している点は、「フラグが変化した瞬間だけ」イベントを発火している点です。毎回PropertyChangedのたびにイベントを流してしまうと、Blazorのレンダリングループに過剰な負荷がかかります。PICOMの楽譜エディタはピアノロールをドラッグするだけで毎秒数十回のプロパティ変更が走るので、イベントが暴れ出さないように「差分あり↔差分なし」の遷移時だけに絞っています。
実際の使われ方:保存ボタンの活性判定
ViewModel側では次のような感じで使います。
var initial = LoadFromStorage(); // 初期データ
var editable = initial.Clone(); // 編集可能なコピー
_diff = new DiffDetectableObject<Music>(initial, editable);
_diff.DiffStateChanged += (music, hasDiff) =>
{
CanSave = hasDiff;
TitleBarMark = hasDiff ? "*" : "";
StateHasChanged();
};
UI側はCanSaveプロパティをそのままボタンの活性(enabled)にバインドすれば終わりです。ユーザーが何か変更したらHasDiffがtrueになり、保存後にEditableToInitを呼んで初期状態を「今」に巻き戻します。
public void EditableToInit(T newEditable)
{
_initObj = _editableObj;
Value = newEditable;
HasDiff = false;
DiffStateChanged?.Invoke(_editableObj, false);
}
注意点:PropertyChanged解除忘れでメモリリーク
プロパティに新しいオブジェクトをセットしたときのイベント解除を忘れると、イベント購読がどんどん溜まる現象が発生します。
public T Value
{
get => _editableObj;
set
{
_editableObj.PropertyChanged -= Editable_PropertyChanged; // ← 忘れがち
_editableObj = value;
_editableObj.PropertyChanged += Editable_PropertyChanged;
}
}
PropertyChangedはC#のイベントの中でも特にリークしやすいパターンで、「古いインスタンスを参照しっぱなし」の状態を作りやすいです。Blazor WASMはアプリ全体で1つのプロセスなので、ここでリークするとセッション中にずっと残ります。
IDisposableの実装も本来は検討すべきですが、PICOMではViewModelの寿命がアプリ起動時〜終了時と長いため、デタッチはすべてセッター内で完結させて簡潔にしました。よりシビアな環境(コンポーネントが頻繁に作り直されるケース)では、IDisposableを明示的に実装した方が安全です。
まとめ
ObservableObjectは20行程度で書けるので、小規模アプリならMVVM Toolkitを避けて自前で書くのも選択肢DiffDetectableObject<T>で初期スナップショットと編集中オブジェクトの差分をイベント通知に変換できる- イベントは「差分フラグが変化した瞬間だけ」に絞ると、レンダリングループに優しい
PropertyChangedの購読解除は徹底する。忘れるとメモリリークの温床になる