OITA: Oika's Information Technological Activities

@oika 情報技術的活動日誌。

C# (WPF) で算出プロパティ

C# Advent Calendar ぽっかり今日だけ空きができたみたいなので、急きょ参加しておきます。

Webのバインディング系フレームワークでよくある算出(Computed / Calculated)プロパティをC# のMVVMでも使いたいという話。

ちらっと検索してみた感じ、当然のように同じことをやろうとしている人がいっぱいいそうですが、他の実装例は未確認です。

だいたい同じような形になるんじゃないかなーと思いつつも、ちょっと微妙にスッキリと実装できない部分があったので、そこをどう工夫しているかは他のも見てみたいところではあります。

作りたいもの

WindowのDataContextとなるクラス(ViewModel)に、2つのプロパティ Num1, Num2があったとして、このどちらかの値が更新されたときに自動で再計算される合計値 Sumプロパティを作りたいとします。

↓こんなイメージ。
RaisePropertyChangedはPropertyChangedイベントを発生させるメソッドだと思ってください。

private int _num1;  
private int _num2;  

public int Num1  
{  
    get  
    {  
        return _num1;  
    }  
    set  
    {  
        if (_num1 == value) return;  
        _num1 = value;  
        RaisePropertyChanged(nameof(Num1));  
    }  
}  

public int Num2  
{  
    get  
    {  
        return _num2;  
    }  
    set  
    {  
        if (_num2 == value) return;  
        _num2 = value;  
        RaisePropertyChanged(nameof(Num2));  
    }  
}  

//こういうのを作りたい  
public Computed<int> Sum { get; }  

で、画面側で2つのテキストボックスにNum1,Num2をバインド、もういっこ別の表示部品にSumをバインドし、入力値が変わるたびに合計値の表示も更新されるようにしたい。

実装

最初に全体をドン

//PropertyChangedイベントとRaisePropertyChangedメソッドを持つインタフェース  
public interface IPropertyChangedRaisable : INotifyPropertyChanged  
{  
    void RaisePropertyChanged(string propertyName);  
}  

//Computedの基底クラス  
public abstract class ComputedBase  
{  
    public abstract object GetValue();  
}  

//こいつがメイン  
public class Computed<T> : ComputedBase  
{  
    //インスタンスのビルダを返すメソッド  
    public static Builder<TObj, T> Of<TObj>(TObj viewModel, string name) where TObj : IPropertyChangedRaisable  
    {  
        return new Builder<TObj, T>(viewModel, name);  
    }  

    //ビルダクラス  
    public class Builder<TObj, TValue> where TObj : IPropertyChangedRaisable  
    {  
        private Func<IPropertyChangedRaisable, TValue> compute;  
        private readonly List<string> observedPropNameList = new List<string>();  
        private readonly TObj viewModel;  
        private string propertyName;  

        internal protected Builder(TObj viewModel, string name)  
        {  
            if (viewModel == null) throw new ArgumentNullException(nameof(viewModel));  
            if (name == null) throw new ArgumentNullException(nameof(name));  

            this.viewModel = viewModel;  
            this.propertyName = name;  
        }  

        public Builder<TObj, TValue> ComputeAs(Func<TObj, TValue> func)  
        {  
            if (func == null) throw new ArgumentNullException(nameof(func));  

            this.compute = o => func((TObj)o);  
            return this;  
        }  

        public Builder<TObj, TValue> Observe(params string[] propertyNames)  
        {  
            if (propertyNames == null || propertyNames.Any(n => n == null))  
            {  
                throw new ArgumentNullException(nameof(propertyNames));  
            }  

            this.observedPropNameList.AddRange(propertyNames);  
            return this;  
        }  

        public Computed<TValue> Build()  
        {  
            if (this.viewModel == null || this.propertyName == null || this.compute == null)  
            {  
                throw new InvalidOperationException();  
            }  
            return new Computed<TValue>(this.viewModel, this.propertyName, this.observedPropNameList, this.compute);  
        }  
    }  

  
    private readonly IPropertyChangedRaisable viewModel;  
    private readonly IReadOnlyCollection<string> propertyNames;  
    private readonly Func<IPropertyChangedRaisable, T> compute;  

    public string Name { get; }  

    private T _val;  

    public T Value  
    {  
        get  
        {  
            return _val;  
        }  
        private set  
        {  
            if (_val == null && value == null) return;  
            if (_val != null && _val.Equals(value)) return;  
            _val = value;  

            viewModel.RaisePropertyChanged(this.Name);  
        }  
    }  

    public override object GetValue()  
    {  
        return Value;  
    }  

    //プロテクトコンストラクタ  
    protected Computed(IPropertyChangedRaisable viewModel, string name, IReadOnlyCollection<string> propertyNames, Func<IPropertyChangedRaisable, T> compute)  
    {  
        if (viewModel == null) throw new ArgumentNullException(nameof(viewModel));  
        if (name == null) throw new ArgumentNullException(nameof(name));  
        if (propertyNames == null) throw new ArgumentNullException(nameof(propertyNames));  
        if (compute == null) throw new ArgumentNullException(nameof(compute));  

        this.Name = name;  
        this.viewModel = viewModel;  
        this.propertyNames = propertyNames;  
        this.compute = compute;  

        this._val = compute(viewModel);  

        viewModel.PropertyChanged += OnViewModelPropertyChanged;  
    }  

    private void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)  
    {  
        if (!this.propertyNames.Contains(e.PropertyName)) return;  

        this.Value = compute(viewModel);  
    }  

    public override string ToString()  
    {  
        return this.Value == null ? "" : this.Value.ToString();  
    }  
}  

もういっこ、画面にバインドする際はコンバータが必要になります。このへんが惜しい。

public class ComputedValueConverter : IValueConverter  
{  
    //Computedオブジェクトをその内部の値にコンバート  
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)  
    {  
        var computed = value as ComputedBase;  
        if (computed == null) return value;  

        return computed.GetValue();  
    }  

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)  
    {  
        throw new NotImplementedException();  
    }  
}  

ソース全体はこちらにも置いてあります。

解説

使いやすいようにビルダクラスを作っていますが、Computedオブジェクトに渡す必要がある情報は、

  • そのComputedオブジェクトをプロパティとして持つViewModelオブジェクト(親ViewModel)
  • このComputedオブジェクトに付けられるプロパティ名
  • 変更を監視するプロパティ名リスト
  • 算出ロジック

の4つです。

親ViewModelのPropertyChangedイベントを購読し、監視対象のプロパティに変更を検知したら、算出ロジックで値を再計算、その後、親ViewModel経由で自身のPropertyChangedを発生させる、という仕組みになっています。
(コンストラクタで親ViewModelのPropertyChangedに直接ハンドラを突っ込んでいますが、ちゃんとやるなら弱参照で購読したほうが安全かも)

ですが、これだけだとViewに更新が通知されるのは Computedオブジェクトそのものなので、中身の値を画面に出すためにはConverterが必要になります。
Binding Sum.Value とかはやりたくない)

ただ、実は今回の例のようにただ文字列を表示する程度であれば、 ToString() をoverrideするだけでもよかったりします。

public override string ToString()  
{  
    return this.Value == null ? "" : this.Value.ToString();  
}  

使い方

Computedはこんなふうに初期化します。

    this.Sum = Computed<int>.Of(this, nameof(Sum))  
                            .Observe(nameof(Num1), nameof(Num2))  
                            .ComputeAs(me => me.Num1 + me.Num2)  
                            .Build();  

nameofのおかげでタイポの心配がありません。ありがたい。

ViewModelではIPropertyChangedRaisableを実装する必要があります。

class MainViewModel : IPropertyChangedRaisable  
{  
    public event PropertyChangedEventHandler PropertyChanged;  

    public void RaisePropertyChanged(string propertyName)  
    {  
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));  
    }  

    ...  
}  

View側

<Window.Resources>  
    <comp:ComputedValueConverter x:Key="convComputed" />  
</Window.Resources>  

<StackPanel Orientation="Horizontal" Height="Auto" Width="Auto">  
    <TextBox Width="60" Text="{Binding Num1, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />  
    <Label Content="+" />  
    <TextBox Width="60" Text="{Binding Num2, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>  
    <Label Content="=" />  
    <TextBox IsReadOnly="True" Width="80" Text="{Binding Sum, Mode=OneWay, Converter={StaticResource convComputed}}" />  
</StackPanel>  

Prismとの併用も可能です。
Viewは上とおんなし。ViewModelはこんな感じ。

class MainViewModel : BindableBase, IPropertyChangedRaisable  
{  
    private int _num1;  
    private int _num2;  

    public int Num1  
    {  
        get  
        {  
            return _num1;  
        }  
        set  
        {  
            if (_num1 == value) return;  
            SetProperty(ref _num1, value);  
        }  
    }  
    public int Num2  
    {  
        get  
        {  
            return _num2;  
        }  
        set  
        {  
            if (_num2 == value) return;  
            SetProperty(ref _num2, value);  
        }  
    }  

    public Computed<int> Sum { get; }  

    public MainViewModel()  
    {  
        this.Sum = Computed<int>.Of(this, nameof(Sum))  
                                .Observe(nameof(Num1), nameof(Num2))  
                                .ComputeAs(me => me.Num1 + me.Num2)  
                                .Build();  
    }  

    public new void RaisePropertyChanged(string propertyName)  
    {  
        base.RaisePropertyChanged(propertyName);  
    }  
}  

BindableBaseも同名のRaisePropertyChangedを持ってるんですが、protectedなメソッドなので、publicで定義し直してやる必要があります。

以上

以上です。まあまあ便利っちゃ便利、かな。