【C#】ViewModelの実装をスニペットで軽減する

WPF/UWP などの XAML 系実装で使用する ViewModel は OSS(Livet, Prism ReactiveProperty) などを使用しない場合、INotifyPropertyChanged 周りの実装が冗長で、繰り返しが面倒なので軽減策の紹介したいと思います。

アプローチ方法は以下の通りです。

  • 共通基底クラスを使用
  • プロパティはスニペット化

確認環境

  • VisualStudio2019
  • C# 9.0

Bindableクラス

まず ViewModel の共通基底クラスとして Bindableクラスを以下のように宣言します。

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

/// <summary>
/// Modelの基底クラスを表します。
/// </summary>
public abstract class Bindable : IDisposable, INotifyPropertyChanged , INotifyDataErrorInfo
{
    /// <summary>
    /// オブジェクトがガベージコレクションによって破棄される際に、非マネージリソースを解放します。
    /// </summary>
    ~Bindable() => Dispose(false);

    /// <summary>
    /// 指定した変数を更新し通知を行います。
    /// </summary>
    /// <remarks>
    /// このメソッドを以下のようなバインドされるプロパティで使用すると記述を単純化省略できます。
    /// private string _name;
    /// public string Name
    /// {
    ///     get { return _name; }
    ///     set { SetProperty(ref _name, value); }
    /// }
    /// </remarks>
    protected bool SetProperty<T>(ref T storage,
                                  T value,
                                  [CallerMemberName] string propertyName = null)
    {
        if (Equals(storage, value))
        {
            return false;
        }
        storage = value;
        RaisePropertyChanged(propertyName);
        return true;
    }

    //
    // IDisposable の実装
    //
    #region...

    // このオブジェクトが破棄されたかどうかを表すフラグ
    // true: された / false: まだ
    bool _isDisposed;

    /// <summary>
    /// 派生クラスでオーバーライドされリソースを解放します。
    /// </summary>
    protected virtual void Dispose(bool disposing)
    {
        if (!_isDisposed)
        {
            PropertyChanged = null;
            ErrorsChanged = null;
            _isDisposed = true;
        }
    }

    /// <summary>
    /// 使用しているリソースを解放しオブジェクトの利用を終了します。
    /// </summary>
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    #endregion

    //
    // INotifyDataErrorInfo の実装
    //
    #region...

    /// <summary>
    /// INotifyPropertyChanged の実装 
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// INotifyPropertyChanged.PropertyChanged イベントを発生させます。
    /// </summary>
    protected void RaisePropertyChanged([CallerMemberName] string propertyName = "")
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        handler?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    #endregion

    //
    // INotifyDataErrorInfo の実装
    //
    #region...

    // 以下のようにバインドするプロパティで'ValidatesOnNotifyDataErrors=True'を指定する
    // <TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged,
    //                               ValidatesOnNotifyDataErrors=True}">
    //     <TextBox.ToolTip>
    //         <Binding Path="(Validation.Errors)[0].ErrorContent"
    //                        RelativeSource="{RelativeSource Self}" />
    //     </TextBox.ToolTip>
    // </TextBox>

    // エラーを管理するテーブル
    //  TKey:   プロパティ名
    //  TValue: エラーメッセージ
    readonly Dictionary<string, List<string>> _errors = new();

    /// <summary>
    /// INotifyDataErrorInfo の実装
    /// </summary>
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    /// <summary>
    /// エラーが発生しているかを取得します。true : エラーが存在する / false: 存在しない
    /// </summary>
    public bool HasErrors => _errors.Count > 0;

    /// <summary>
    /// INotifyDataErrorInfo.EventHandler{DataErrorsChangedEventArgs} イベントを発生させます。
    /// </summary>
    protected void RaiseErrorsChanges([CallerMemberName] string propertyName = "")
    {
        EventHandler<DataErrorsChangedEventArgs> handler = ErrorsChanged;
        handler?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
    }

    /// <summary>
    /// 指定したプロパティのエラーを取得します。
    /// </summary>
    public IEnumerable GetErrors(string propertyName)
    {
        if (string.IsNullOrEmpty(propertyName) && !_errors.ContainsKey(propertyName))
        {
            return null;
        }
        return _errors[propertyName];
    }

    /// <summary>
    /// エラーを追加します。
    /// </summary>
    private void AddError(string error, [CallerMemberName] string propertyName = "")
    {
        if (!_errors.ContainsKey(propertyName))
        {
            _errors[propertyName] = new List<string>();
        }

        if (!_errors[propertyName].Contains(error))
        {
            _errors[propertyName].Add(error);
            RaiseErrorsChanges(propertyName);
        }
    }

    /// <summary>
    /// エラーをクリアします。
    /// </summary>
    private void ClearErrors([CallerMemberName] string propertyName = "")
    {
        if (!_errors.ContainsKey(propertyName))
        {
            return;
        }
        _errors.Remove(propertyName);
        RaiseErrorsChanges(propertyName);
    }

    #endregion
}

スニペットの作成

次に以下のコードを propvm.snippet としてファイルに保存し、以下のメニューからスニペットを VisualStudio にインポートします。

// インポート方法
> 画面上部の ツール > コード スニペット マネージャー
  > [言語] > CSharp を選択 > インポートボタンを押す

スニペットは以下の通りです。

<?xml version="1.0" encoding="utf-8" ?>
<CodeSnippets  xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
  <CodeSnippet Format="1.0.0">
    <Header>
      <Title>Bindable Property</Title>
      <Shortcut>propvm</Shortcut>
      <Description>Property for INotifyPropertyChanged</Description>
      <Author>Takap</Author>
    </Header>
    <Snippet>
      <Declarations>
        <Literal>
          <ID>valueType</ID>
          <Default>int</Default>
          <ToolTip>公開する変数の型で置き換えます。</ToolTip>
        </Literal>
        <Literal>
          <ID>propName</ID>
          <Default>Sample</Default>
          <ToolTip>公開するプロパティの名前で置き換えます。</ToolTip>
        </Literal>
        <Literal>
          <ID>fieldName</ID>
          <Default>sample</Default>
          <ToolTip>内部で使用する変数の名前で置き換えます。</ToolTip>
        </Literal>
      </Declarations>
      <Code Language="csharp"><![CDATA[private $valueType$ _$fieldName$;
public $valueType$ $propName$
{
    get => _$fieldName$;
    set
    {
        if ($valueType$.Equals(_$fieldName$, value))
        {
            _$fieldName$ = value;
            this.RaisePropertyChanged();
        }
    }
}$end$]]></Code>
    </Snippet>
  </CodeSnippet>
</CodeSnippets>

使い方

Bindable が継承されているクラス上で使用する前提です。

インポート後にエディター上で propvm > Tab > Tab と入力すると以下のようにコードが展開されるので必要個所を入力します。

public class MyViewModel : Bindable // Bindable を継承していること
{ 

    // propvm > Tab > Tab で展開されるコード
    
    // 型、フィールド名、プロパティ名の順に入力する
    private int _sample;
    public int Sample
    {
        get => _sample;
        set
        {
            if (int.Equals(_sample, value))
            {
                _sample = value;
                this.RaisePropertyChanged(); // Bindable を継承してないとエラー
            }
        }
    }

以上