2018年2月25日日曜日

MVVMにおけるViewModelとModelのプロパティ同期 - NotifyPropertyHelper 1.0.0-beta1

MVVMスタイルでWPFアプリケーションなどを作っていると、ViewModelとModelにやたら同じプロパティが出てきます。

それもそのはず、ViewModelはViewの実装上の制約を吸収する層なので、画面の状態(表示内容など)自体はModelもプロパティとして持っています。そうでなければMVVMではありません。詳しくは以前書いた記事を読んでください。
MVVMとは何か
ですが、その通りに実装すると同じ名前のプロパティがViewModelとModelで大量に出てきてしまうという問題もありました。
MVVMのサンプルプログラム - TwitterViewer  
こちらのページで紹介しているサンプルプログラムは基本的にはTwitterからツイートを持ってきて画面に表示するだけですので、ModelのプロパティもGet-Onlyのプロパティばかりです。ですが、もっと動的に内容が動くような状況の場合は、もっとしっかりViewModelにModelの変更通知を受けてプロパティをコピーするコードや、逆にViewの操作で変化したプロパティをModelにコピーするコードを書かなければなりません。

問題提起

サンプルプログラム

例えば、指定したURLのHTMLを取得するアプリケーションを作るとします。

このアプリケーションはURLを入力しGoボタンを押すとそのサイトのHTMLを取得して画面に表示するシンプルなものです。これをMVVMで作ると次のようなコードになります。

【View】
<Window x:Class="Frontend.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:Frontend.Views"
        xmlns:vm="clr-namespace:Frontend.ViewModels"
        Title="MainWindow" Height="350" Width="525">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>

    <i:Interaction.Triggers>
        <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます-->
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

        <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます-->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction/>
        </i:EventTrigger>

        <!--WindowのCloseキャンセル処理に対応する場合は、WindowCloseCancelBehaviorの使用を検討してください-->

    </i:Interaction.Triggers>
    
    <Grid>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="1*" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="1*" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>
            <TextBlock Grid.Row="0" Grid.Column="0" Text="URL: " VerticalAlignment="Center" />
            <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}" />
            <Button Grid.Row="0" Grid.Column="2" Content="Go" Width="50" Command="{Binding GoCommand}" />
            <TextBox Grid.Row="1" Grid.ColumnSpan="3" IsReadOnly="True" Text="{Binding Html}"
                     ScrollViewer.HorizontalScrollBarVisibility="Visible" ScrollViewer.VerticalScrollBarVisibility="Visible" />
        </Grid>
    </Grid>
</Window>

【ViewModel】
public class MainWindowViewModel : ViewModel
{
    Model model;

    public void Initialize()
    {
        model = Model.GetInstance();

        GoCommand = new ViewModelCommand(() => model.Go(), () => !string.IsNullOrEmpty(Url));
        GoCommand.RaiseCanExecuteChanged();

        this.PropertyChanged += This_PropertyChanged;
        model.PropertyChanged += Model_PropertyChanged;

        this.Url = model.Url;
        this.Html = model.Html;
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Url):
                model.Url = this.Url;
                GoCommand.RaiseCanExecuteChanged();
                break;
        }
    }

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Model.Html):
                this.Html = model.Html;
                break;
        }
    }

    #region Url変更通知プロパティ

    public string Url
    {
        get { return _Url; }
        set
        {
            if(_Url == value)
                return;
            _Url = value;
            RaisePropertyChanged(nameof(Url));
        }
    }
    private string _Url;

    #endregion

    #region Html変更通知プロパティ

    public string Html
    {
        get { return _Html; }
        set
        {
            if(_Html == value)
                return;
            _Html = value;
            RaisePropertyChanged(nameof(Html));
        }
    }
    private string _Html;

    #endregion

    #region GoCommand変更通知プロパティ

    public ViewModelCommand GoCommand
    {
        get { return _GoCommand; }
        set
        {
            if(_GoCommand == value)
                return;
            _GoCommand = value;
            RaisePropertyChanged(nameof(GoCommand));
        }
    }
    private ViewModelCommand _GoCommand;

    #endregion

    protected override void Dispose(bool disposing)
    {
        if(disposing) {
            this.PropertyChanged -= This_PropertyChanged;
            model.PropertyChanged -= Model_PropertyChanged;
        }

        base.Dispose(disposing);
    }
}

【Model】
public class Model : NotificationObject
{
    #region Singleton

    static Model Instance;
    public static Model GetInstance()
    {
        if(Instance == null)
            Instance = new Model();
        return Instance;
    }

    #endregion

    private Model()
    {
        Url = @"https://www.google.co.jp/";
    }

    #region Url変更通知プロパティ

    public string Url
    {
        get { return _Url; }
        set
        {
            if(_Url == value)
                return;
            _Url = value;
            RaisePropertyChanged(nameof(Url));
        }
    }
    private string _Url;

    #endregion

    #region Html変更通知プロパティ

    public string Html
    {
        get { return _Html; }
        set
        {
            if(_Html == value)
                return;
            _Html = value;
            RaisePropertyChanged(nameof(Html));
        }
    }
    private string _Html;

    #endregion

    public async void Go()
    {
        using(WebClient wc = new WebClient()) {
            try {
                Html = await wc.DownloadStringTaskAsync(Url);
            }
            catch(Exception e) {
                Html = e.Message;
            }
        }
    }
}
例によってLivetを使っています。

大まかな流れとしては

ViewでURLを入力→ViewModel→Modelへ入力値が伝搬する

ViewでGoボタンを押す→ViewModel→Modelへメソッドの呼び出しが伝搬する

ModelがGoメソッド内でUrlプロパティの値を使ってHTMLをダウンロードし、Htmlプロパティにセットする

ModelがHtmlプロパティが変化したことをイベントで通知する

Model→ViewModel→ViewへHtmlプロパティが伝搬する。この時、ViewModelはViewの制約である「単一スレッドでしか動作をしない」を受け、UIスレッドでHtmlプロパティの値を更新する。

ViewにHTMLが表示される

となります。まさに厳密なMVVMです。ViewModelはViewの実装上の制約を吸収するためのみの仕事に徹し、ModelはViewの実装上の制約などは何も気にせずのびのびと.NETの機能を使っています。

さて、ですが実際にこのコードを書いていると、問題点も見えてきます。

ViewModelとModelのプロパティ同期関係のコードが散在する

当然、ModelとViewModelを同期するのはViewModelの仕事となるわけですが、この同期作業は割と面倒です。
やることとしては、
  1. 初期化時に値を同期する作業
  2. Modelのプロパティ変化時にViewModelのプロパティを更新する作業
  3. ViewModelのプロパティ変化時にModelのプロパティを更新する作業
  4. View消滅時にModelの監視をやめる作業
の3つになります。
実際に書いたコードがこんな感じになります。

この画像を見れば一目瞭然です。あちこちにプロパティ同期のコードが散らばっています。
ViewModelの仕事がプロパティ同期だけならまだいいのですが、他にもCommandの管理など、ModelとViewを繋ぐ仕事は沢山あります。そうすると、それぞれの仕事があちこちに散在してとても見にくいプログラムになりますし、あとからプロパティを増やすときなんかは変更忘れの原因にもなってしまいます。

正直言ってこんなのやってられません。何かもっと簡単に同期作業ができるライブラリが必要です。
ですが、たいていのMVVMライブラリの関心ごとは「ViewとViewModelをいかにして連携させるか」です。ViewModelとModelは通常のC#の言語機能を用いて連携できますから、勝手にやってくださいどうぞというスタンスになってしまうのもある程度しかたのないことなのでしょうが…。

NotifyPropertyHelper

さて、というわけで、ModelとViewModelの連携に的を絞ったライブラリを書いてみました。

1. プロパティの同期

流れとしては、
  1. 同期元の同期したいプロパティにPropertySync属性を付ける
  2. PropertySyncServiceで同期元と同期先のクラスを指定する
  3. 同期元がDisposeされたときにPropertySyncServiceをDisposeする
だけになります。あまり変わらないじゃん!って思うかもしれませんが、プロパティを追加したときに、プロパティの追加と、その追加したプロパティに属性を追加するだけですものは結構でかいです。

上記のViewModelを書き換えてみます。

public class MainWindowViewModel : ViewModel
{
    Model model;
    PropertySyncService propsync;

    public void Initialize()
    {
        model = Model.GetInstance();

        GoCommand = new ViewModelCommand(() => model.Go(), () => !string.IsNullOrEmpty(Url));
        GoCommand.RaiseCanExecuteChanged();

        this.PropertyChanged += This_PropertyChanged;

        propsync = new PropertySyncService(this, model);
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Url):
                GoCommand.RaiseCanExecuteChanged();
                break;
        }
    }

    #region Url変更通知プロパティ

    [PropertySync(PropertySyncMode.TwoWay, Direction.TargetToSource)]
    public string Url
    {
        get { return _Url; }
        set
        {
            if(_Url == value)
                return;
            _Url = value;
            RaisePropertyChanged(nameof(Url));
        }
    }
    private string _Url;

    #endregion

    #region Html変更通知プロパティ

    [PropertySync(PropertySyncMode.OneWayToSource, Direction.TargetToSource)]
    public string Html
    {
        get { return _Html; }
        set
        {
            if(_Html == value)
                return;
            _Html = value;
            RaisePropertyChanged(nameof(Html));
        }
    }
    private string _Html;

    #endregion

    #region GoCommand変更通知プロパティ
    private ViewModelCommand _GoCommand;

    public ViewModelCommand GoCommand
    {
        get
        { return _GoCommand; }
        set
        { 
            if(_GoCommand == value)
                return;
            _GoCommand = value;
            RaisePropertyChanged(nameof(GoCommand));
        }
    }
    #endregion

    protected override void Dispose(bool disposing)
    {
        if(disposing) {
            propsync.Dispose();
        }

        base.Dispose(disposing);
    }
}
同期したいプロパティにはPropertySyncという属性を付加します。
public PropertySyncAttribute(PropertySyncMode Mode, Direction InitializeCopyDirection, Type PropertyConverter = null);
public PropertySyncAttribute(string TargetPropertyName, PropertySyncMode Mode, Direction InitializeCopyDirection, Type PropertyConverter = null);
PropertySync属性はこのような2つのコンストラクタを持っています。前者はTargetPropertyNameが省略されていますが、省略された場合は同名のプロパティを当たります。
PropertySyncModeは同期方向で、InitializeCopyDirectionが初期化時(PropertySyncServiceインスタンス生成時)にコピーをする方向です。注意点としては、同期元、同期先ともに、これらの同期方向に支障が無いようなアクセサビリティ・型のプロパティにする必要があるということです。
PropertyConverterはプロパティを変換するクラスです。後述します。

後は、クラスの生成時(コンストラクタ等)でPropertySyncServiceのインスタンスを生成し、そこで同期元と同期先のインスタンスを設定してあげます。不要になったらDisposeを呼び出すことで、同期処理を打ち切ります。

2. 型の違うプロパティの同期

ときどき型の違うプロパティを同期したくなります。
例えば、ModelではReadOnlyObservableCollectionだけど、ViewModelはUIスレッドに同期したReadOnlyObservableCollectionにしたいときなどです。
そのようなときは、IPropertyConverter<TSource, TTarget>を継承した変換クラスを作り、PropertySync属性のコンストラクタに指定してあげるとそれが実現できます。

例えば、ListViewExtensionsではModel用にSortableObservableCollection、ViewModel用にListViewViewModelを用意していますが、それらを変換するには次のようなコードを書けばよいでしょう。
class PersonListViewConverter : IPropertyConverter<ListViewViewModel<PersonViewModel, Person>, SortableObservableCollection<Person>>
{
    public ListViewViewModel<PersonViewModel, Person> ConvertToSource(ListViewViewModel<PersonViewModel, Person> OldSourceValue, SortableObservableCollection<Person> NewTargetValue)
    {
        if(OldSourceValue != null)
            OldSourceValue.Dispose();

        if(NewTargetValue == null)
            return null;
        else
            return new ListViewViewModel<PersonViewModel, Person>(NewTargetValue, person => new PersonViewModel(person), DispatcherHelper.UIDispatcher);
    }

    public SortableObservableCollection<Person> ConvertToTarget(SortableObservableCollection<Person> OldTargeteValue, ListViewViewModel<PersonViewModel, Person> NewSourceValue)
    {
        throw new InvalidOperationException("Not defined.");
    }
}
2つのConvertToSourceとConvertToTargetの2つのメソッドは古い値も引き渡してくれるため、Disposeが必要なインスタンスに対しても適切な措置を取ることができます。

3.  ReadOnlySynchronizationContextCollection

さて、今回のブログ記事のテーマとはちょっと違うのですが、ViewModelとModelの連携を取るためのライブラリということで、このようなコレクションも用意しています。

WPFではUIが単一スレッドからしかアクセスできません。そのような制約の吸収はViewModelがやることです。外から変更させる気が無いリストの場合は、ModelがReadOnlyCollectionでリストを公開し、それをViewModelがUIのスレッドに合わせながらUIに橋渡ししてあげる必要があります。それをやってくれるのがReadOnlySyncronizationContextCollectionです。

使い方は使ってみればわかると思います(雑)。

ライセンス

以下の各項目をお守りください
  • このライブラリを利用する方は自己責任でお願いします。いかなる問題が起きても作者は責任を負いません。
  • このソフトを悪用しないでください。
  • このソフトウェアを無断で単体での転載、再配布しないでください。ただし、このライブラリを参照しているソフトウェアと一緒に配布する場合を除きます。
  • 作者は使用方法やバグに関するサポートをする義務を負いません。
  • 有償アプリケーションには使用してはならない。
  • 完成したソフトウェアのどこか(ヘルプ、バージョン情報など)と、ReadMeなどのドキュメンテーションに私のライブラリを使用したことを明記すること。ただし、作者(私)がこのライブラリを自分のソフトで使用するときはその限りではない。

公開

Nugetにて公開しています。
NotifyPropertyHelper - Nuget
なお、 プレリリース版ですので、VisualStudioからの検索時にはプレリリースもヒットするオプションを指定するようにしてください。

2018年2月20日火曜日

WPFにおけるINotifyDataErrorInfoを使った値の検証 属性版

2年ちょっと前にINotifyDataErrorInfoを使った値の検証の方法を記事にしました。

これはViewModelを継承したクラスにエラー情報を蓄積する機能を持たせ、任意のタイミングでエラーを登録したり削除したりすることができるというものでした。
実際、それでも使い物にはなるのですが、しっかりとMVVMを実装しようとすると妙に不便に思えてきました。

簡単な値の検証(例えばnullじゃなければ受け付ける、など)ならまだしも、複雑な判定ロジックになるとModel側にその判定機能を持たせたくなります。なぜならば、入力値を受けて何かしらの処理を行うのはModelの仕事だからです。何かしらの処理を行う前には、渡されたパラメーターが正しいものかどうかを判定し、間違っていれば何かしらのエラー通知(例外を吐く等々)をするかと思いますが、値の検証をViewModelでやっていたとすると、ViewModelとModelの両方に複雑な判定ロジックを持たせるメリットがありません。
入力された値の正当性も状態の1つだと考えると、Modelがその状態を持っていても変ではありません。すなわち、ViewModelは入力された値をそのままModelに横流ししておき、Modelがその横流しされた値の正当性を検証して状態として公開し、それをViewModelが監視してUIに何かしらの表示を行うといった流れになれば、正当性の判断をすべてModelがすることができますし、ViewModelもそれを監視して入力欄のエラー表示やボタンの有効/無効などを切り替えればいいわけです。

そうすると、前回の記事で書いたクラスでは多少の不満が出てきます。ViewModelがModelの正当性を表すプロパティを監視し、そのプロパティの変化に合わせてエラーをセットするロジックをViewModelに書くことになります。それ自体はMVVMの考え方には反しないのですが、「あるプロパティの値の正当性を他のとあるプロパティが示している」というシンプルな関係なのに、なぜわざわざプロパティの変更通知を購読し、値が変化したタイミングでメソッドを呼び出して…なんて面倒くさい処理を書かなきゃいけないのかと。実際書いてみると、誰もが「もっとシンプルにプロパティの関係性を書きたい!」と思うはずです。

そこで、そのような機能を実現するViewModelを作ってみました。
public abstract class NotifyDataErrorViewModel : ViewModel, INotifyDataErrorInfo
{
    Dictionary<string, NotifyErrorAttribute[]> properties;

    public NotifyDataErrorViewModel()
    {
        properties = this
            .GetType()
            .GetProperties()
            .Select(p => new KeyValuePair<string, NotifyErrorAttribute[]>(
                p.Name,
                p.GetCustomAttributes(typeof(NotifyErrorAttribute), true)
                    .Cast<NotifyErrorAttribute>()
                    .Where(p1 => p1.IsValidProperty(this))
                    .ToArray()))
            .Where(p => p.Value.Length > 0)
            .ToDictionary(p => p.Key, p => p.Value);

        this.PropertyChanged += This_PropertyChanged;

        foreach(var p in properties.Where(p => p.Value.Any(p1 => !p1.GetValidityValue(this))).Select(p => p.Key))
            RaiseErrorsChanged(p);
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        foreach(var p in properties.Where(p => p.Value.Any(p1 => p1.ValidityPropertyName == e.PropertyName)))
            RaiseErrorsChanged(p.Key);
    }

    /// <summary>
    /// エラーがあるかどうかを示すプロパティ
    /// </summary>
    public bool HasErrors => properties.Values.SelectMany(p => p).Any(p => !p.GetValidityValue(this));

    /// <summary>
    /// エラー情報を返すメソッド
    /// </summary>
    /// <param name="propertyName">エラーがあるか調べたいプロパティ名</param>
    /// <returns>エラーがあればエラーテキストの配列、無ければ空の配列</returns>
    public IEnumerable GetErrors(string propertyName)
    {
        if(properties.ContainsKey(propertyName))
            return properties[propertyName].Where(p => !p.GetValidityValue(this)).Select(p => p.ErrorText).ToArray();
        else
            return Enumerable.Empty<string>();
    }

    /// <summary>
    /// エラー状態が変わった時に発生するイベント
    /// </summary>
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

    /// <summary>
    /// ErrorsChangedイベントを発生させるメソッド
    /// </summary>
    /// <param name="PropertyName">プロパティ名</param>
    protected void RaiseErrorsChanged(string PropertyName)
    {
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(PropertyName));
    }
}
ViewModelを継承した抽象クラスで、INotifyDataErrorInfoを実装しています。
ここまでは前回と同じなのですが、エラーを登録/削除するメソッドは用意していません。さて、ではどうやってエラーの登録/削除をするのでしょう。

その答えは属性です。
public class NotifyErrorAttribute : Attribute
{
    public NotifyErrorAttribute(string ValidityPropertyName, string ErrorText)
    {
        this.ValidityPropertyName = string.IsNullOrEmpty(ValidityPropertyName) ? throw new ArgumentNullException(nameof(ValidityPropertyName)) : ValidityPropertyName;
        this.ErrorText = string.IsNullOrEmpty(ErrorText) ? throw new ArgumentNullException(nameof(ErrorText)) : ErrorText;
    }

    public string ValidityPropertyName { get; }

    public string ErrorText { get; }

    /// <summary>
    /// この属性で与えられたプロパティ名が、指定したオブジェクトで値の正当性を示しているプロパティとして正当かを検証します。
    /// </summary>
    /// <param name="Source">オブジェクト</param>
    /// <returns>指定したオブジェクトがbool型の読み取り可能なこの名前のプロパティを持っていた場合true、そうでなければfalse</returns>
    internal bool IsValidProperty(object Source)
    {
        var property = Source.GetType().GetProperty(ValidityPropertyName);
        return property != null && property.CanRead && property.PropertyType == typeof(bool);
    }

    /// <summary>
    /// 値の正当性を取得します。
    /// </summary>
    /// <param name="Source">オブジェクト</param>
    /// <returns>値の正当性</returns>
    internal bool GetValidityValue(object Source)
    {
        return (bool)Source.GetType().GetProperty(ValidityPropertyName).GetValue(Source);
    }
値の正当性情報を付加したいViewModelのプロパティにこの属性を付け、値の正当性を示しているプロパティの名前とエラーが起きたときのメッセージを渡してあげます。そうすることで、NotifyDataErrorViewModelクラスは値の正当性を示しているプロパティを監視して、エラー状態が変化すると自動的にErrorsChangedイベントを発生させます。
NotifyErrorAttributeを1つのプロパティに複数付けた場合、1つでもエラーが起こるとエラーとみなします。GetErrorsメソッドが返すエラーメッセージもそのエラーの内容によって変わるので、エラーになる条件が複数ある場合なんかは属性をいくつか付けるといいでしょう。


さて、せっかくですので以前の記事の時に作ったサンプルプログラムと同じような動きをするサンプルプログラムを今回のクラスを使って作ってみました。
Modelは次の通りです。
public class Model : NotificationObject
{
    #region Singleton

    static Model Instance;

    public static Model GetInstance()
    {
        if(Instance == null)
            Instance = new Model();
        return Instance;
    }

    #endregion

    private Model()
    {
        this.PropertyChanged += This_PropertyChanged;

        CheckValidity();
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Number):
            case nameof(Digits):
                CheckValidity();
                break;
        }
    }

    void CheckValidity()
    {
        NumberIsANumber = int.TryParse(Number, out int number);
        DigitsIsANumber = int.TryParse(Digits, out int digits);

        if(NumberIsANumber && DigitsIsANumber) {
            if(number == 0)
                NumberHasTheDigits = digits == 1;
            else
                NumberHasTheDigits = (int)Math.Log10(Math.Abs(number)) + 1 == digits;
        } else
            NumberHasTheDigits = true;
    }

    #region Number変更通知プロパティ

    public string Number
    {
        get { return _Number; }
        set
        {
            if(_Number == value)
                return;
            _Number = value;
            RaisePropertyChanged(nameof(Number));
        }
    }
    private string _Number;

    #endregion

    #region Digits変更通知プロパティ

    public string Digits
    {
        get { return _Digits; }
        set
        {
            if(_Digits == value)
                return;
            _Digits = value;
            RaisePropertyChanged(nameof(Digits));
        }
    }
    private string _Digits;

    #endregion

    #region NumberHasTheDigits変更通知プロパティ

    public bool NumberHasTheDigits
    {
        get { return _NumberHasTheDigits; }
        set
        {
            if(_NumberHasTheDigits == value)
                return;
            _NumberHasTheDigits = value;
            RaisePropertyChanged(nameof(NumberHasTheDigits));
        }
    }
    private bool _NumberHasTheDigits;

    #endregion

    #region DigitsIsANumber変更通知プロパティ

    public bool DigitsIsANumber
    {
        get { return _DigitsIsANumber; }
        set
        {
            if(_DigitsIsANumber == value)
                return;
            _DigitsIsANumber = value;
            RaisePropertyChanged(nameof(DigitsIsANumber));
        }
    }
    private bool _DigitsIsANumber;

    #endregion

    #region NumberIsANumber変更通知プロパティ

    public bool NumberIsANumber
    {
        get { return _NumberIsANumber; }
        set
        {
            if(_NumberIsANumber == value)
                return;
            _NumberIsANumber = value;
            RaisePropertyChanged(nameof(NumberIsANumber));
        }
    }
    private bool _NumberIsANumber;

    #endregion
}
NumberとDigitsというプロパティを持っており、これらの値が変更されるたびにCheckValidityメソッドを呼び出しています。ここからNumberHasTheDigitsプロパティ、DigitsIsANumberプロパティ、NumberIsANumberプロパティの3つを変更しています。NumberIsANumberはなんか哲学的な名前になってしまっていますが、xxxxIsANumberのプロパティがxxxxのプロパティが値であるかどうか、NumberHasTheDigitsはNumberがDigitsの桁数を持っているかを判定するフラグです。ただ、NumberとDigitsのどちらかのみが値だった場合にfalseにしてしまうと芸がない(というか、xxxxIsANumberプロパティの存在価値がなくなる)ので、片側が入力されている状態ではtrueにしてみました。

肝心のViewModelはこちらです。
public class MainWindowViewModel : NotifyDataErrorViewModel
{
    Model model;

    public void Initialize()
    {
        model = Model.GetInstance();

        this.PropertyChanged += This_PropertyChanged;
        model.PropertyChanged += Model_PropertyChanged;
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Number):
                model.Number = Number;
                break;
            case nameof(Digits):
                model.Digits = Digits;
                break;
            case nameof(NumberHasTheDigits):
                model.NumberHasTheDigits = NumberHasTheDigits;
                break;
            case nameof(DigitsIsANumber):
                model.DigitsIsANumber = DigitsIsANumber;
                break;
            case nameof(NumberIsANumber):
                model.NumberIsANumber = NumberIsANumber;
                break;
        }
    }

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(Model.Number):
                Number = model.Number;
                break;
            case nameof(Model.Digits):
                Digits = model.Digits;
                break;
            case nameof(Model.NumberHasTheDigits):
                NumberHasTheDigits = model.NumberHasTheDigits;
                break;
            case nameof(Model.DigitsIsANumber):
                DigitsIsANumber = model.DigitsIsANumber;
                break;
            case nameof(Model.NumberIsANumber):
                NumberIsANumber = model.NumberIsANumber;
                break;
        }
    }

    #region Number変更通知プロパティ

    [NotifyError(nameof(NumberHasTheDigits), "The Number does not match to the Digits.")]
    [NotifyError(nameof(NumberIsANumber), "The Number is not a number.")]
    public string Number
    {
        get { return _Number; }
        set
        {
            if(_Number == value)
                return;
            _Number = value;
            RaisePropertyChanged(nameof(Number));
        }
    }
    private string _Number;

    #endregion

    #region Digits変更通知プロパティ

    [NotifyError(nameof(NumberHasTheDigits), "The Digits does not match to the Number.")]
    [NotifyError(nameof(DigitsIsANumber), "The Digits is not a number.")]
    public string Digits
    {
        get { return _Digits; }
        set
        {
            if(_Digits == value)
                return;
            _Digits = value;
            RaisePropertyChanged(nameof(Digits));
        }
    }
    private string _Digits;

    #endregion

    #region NumberHasTheDigits変更通知プロパティ

    public bool NumberHasTheDigits
    {
        get { return _NumberHasTheDigits; }
        set
        {
            if(_NumberHasTheDigits == value)
                return;
            _NumberHasTheDigits = value;
            RaisePropertyChanged(nameof(NumberHasTheDigits));
        }
    }
    private bool _NumberHasTheDigits;

    #endregion

    #region DigitsIsANumber変更通知プロパティ

    public bool DigitsIsANumber
    {
        get { return _DigitsIsANumber; }
        set
        {
            if(_DigitsIsANumber == value)
                return;
            _DigitsIsANumber = value;
            RaisePropertyChanged(nameof(DigitsIsANumber));
        }
    }
    private bool _DigitsIsANumber;

    #endregion

    #region NumberIsANumber変更通知プロパティ

    public bool NumberIsANumber
    {
        get { return _NumberIsANumber; }
        set
        {
            if(_NumberIsANumber == value)
                return;
            _NumberIsANumber = value;
            RaisePropertyChanged(nameof(NumberIsANumber));
        }
    }
    private bool _NumberIsANumber;

    #endregion
}
前半にあるのは自身とModel両方の値の変化をウォッチして同期するプログラムです。
NumberとDigitsにはNotifyError属性を付けて、どのプロパティが自身の正当性を示しているかを記述しています。これだけでコントロールに正当性表示が行われるなんて夢の酔うでしょ?

ん?DataAnnotations
…知らね…そんなの…。
(マジレスするとDataAnnotationsはCustomValidationAttributeが任意のメソッドを判定に指定できてしまうため、INotifyPropertyChangedの監視とリフレクションだけじゃ対応しきれずここまでスマートな実装はできないのです…。また、Modelでの値の検証を反映するという考えからも外れてしまいます…。)