はじめに
C#をはじめて1箇月。とりあえずWPFのMVVMを学習したのですが、結局Formアプリケーションで開発することになりました。
最初に遭遇したのが下の絵のようなパターン。
Startボタンを押したらStopボタンがEnabled=trueになり、StartボタンはEnabled=falseにしたいのですが、これ普通どうやるんでしょうか。クリックイベントで直接Enabledを操作するのはここでは除外しますね。
Formにもデータバインドがあるので、これでできるかなと「c# form データバインド bool 反転」でググってもXAMLのConverterしか出てきません。どうやったらできるのでしょうか。それともできないのでしょうか。
#ラムダExpression
c#にはExpressionという式木を作るクラスがあります。
Expression<Func<T1,T2>> e = x => "Input=" + x;
のようにラムダ式をExpressionで包んでやると、デリゲートではなくExpressionとして評価することができます。
記述できるのは単一式(代入とかはダメ)だけみたいですがコンパイルすると以下のように実行(式を評価)することができます。
Expression<Func<string, string>> exp = x => "Input=" + x;
var dele = exp.Compile();
Console.WriteLine(dele("Hoge"));
//Input=Hoge とコンソール出力される。
Expressionにプロパティを記述すればデータバインドのソース側が抽出できるはずなので、コードを少し変えて以下のように呼び出すメソッドを考えました。
AddBindMember(buttonStart, "Enabled", () => _model.Running.Not());
buttonStart
はターゲットのコントロール。
"Enabled"
は操作するコントロールのプロパティ名。
() => _model.Runinng.Not()
はRunningプロパティ値を反転して返すラムダ式。
Not()はbool値を反転する自前の拡張メソッドです。
このメソッド本体は別クラス(本例ではBindmanager.cs)で定義しました。
public void AddBindMember<T>(object control, string memberName, Expression<Func<T>> expression)
ここでexpressionの中身を調べるのですが、最終的にはExpressionVisitorを継承して以下のようなクラスを作りました。
MemberExpressionとMethodCallExpressionからPropertyInfoを抽出しています。他のバリエーションは見ていません。
public class ExVisitor : ExpressionVisitor
{
public List<PropertyInfo> Properties = new List<PropertyInfo>();
protected override Expression VisitMember(MemberExpression node)
{
if (node.Member is PropertyInfo propInfo)
Properties.Add(propInfo);
return base.VisitMember(node);
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
if (node.Method is MethodInfo methodInfo)
{
if (methodInfo.Name.StartsWith("get_"))
{
string candProp = methodInfo.Name.Substring(4);
var props = methodInfo.DeclaringType.GetProperties()
.Where(prop => prop.Name == candProp);
PropertyInfo itemProp = props.FirstOrDefault();
if (itemProp != null)
Properties.Add(itemProp);
}
}
return base.VisitMethodCall(node);
}
}
Expressionバインド?
式形式のラムダ式はプロパティだけでなく三項演算とかメソッドとか書けるので「Expressionバインド?」としてみました。
全体構成は以下のようになります。
BinManager.csはビジネスロジックを持たない共通のクラスです。Disposeの実装など省略していますが、参考にしたサイトに詳しく書かれていますのでそちらをご覧ください。
public class BindManager
{
private Dictionary<(Type type, string triggerName), List<BindInfo>> _dict;
public BindManager()
{
_dict = new Dictionary<(Type type, string triggerName), List<BindInfo>>();
}
public void AddViewModel(INotifyPropertyChanged viewModel)
{
viewModel.PropertyChanged += BindEventHandler;
}
public void RemoveViewModel(INotifyPropertyChanged viewModel)
{
viewModel.PropertyChanged -= BindEventHandler;
}
//トリガープロパティに値がセットされたとき動作するハンドラー
private void BindEventHandler(object sender, PropertyChangedEventArgs e)
{
if (_dict.ContainsKey((sender.GetType(), e.PropertyName)))
foreach (BindInfo b in _dict[(sender.GetType(), e.PropertyName)])
b.SetValue();
}
//バインド定義を処理
public void AddBindMember<T>(object control, string memberName, Expression<Func<T>> expression)
{
Delegate deleg = expression.Compile();
BindInfo bindInfo = new BindInfo(control, memberName, deleg);
//コントロールのプロパティに初期値がセットされる
bindInfo.SetValue();
//PropertyInfoを収集する
var visitor = new ExVisitor();
visitor.Visit(expression);
//プロパティ変更イベント発生時参照するディクショナリを作る
foreach (PropertyInfo info in visitor.Properties.Distinct())
_dict.GetWithNew((info.DeclaringType, info.Name)).Add(bindInfo);
}
//バインド情報を持つクラス
public class BindInfo
{
private object _target;
private string _memberName;
private Delegate _deleg;
public BindInfo(object control, string memberName, Delegate deleg)
{
_target = control;
_memberName = memberName;
_deleg = deleg;
}
public void SetValue()
{
PropertyInfo p = _target.GetType().GetProperty(_memberName);
p.SetValue(_target, _deleg.DynamicInvoke());
}
}
}
DictionaryのキーでtypeはViewModelのTypeで、triggerNameはプロパティ名です。
本当はBindEventHandlerのsenderと照合したかったのですが、expressionからはViewModelのインスタンスは見えないはずなので、typeとsender.GetType()を照合することにしました。このためViewModelはシングルトンとしています。
あと、
Delegate deleg = expression.Compile();
は
Func<T> deleg = expression.Compile();
のように定義したかったのですが、BindInfoにdelegを詰め込もうとするとnew BindInfo<?>
をどう書けば判らなかったので諦めました。型が静的に解決できないときの書き方なんてあるのでしょうか?ジェネリックについてはまだ理解不足です。
サンプル
以下のような画面で確認しました。
startボタン、stopボタン、PlaceIdの操作で表示が変わります。
確認したのは、プロパティ単体、プロパティと拡張メソッドの組み合わせ、インデクサ、メソッド、四則演算を含む式です。
以下ソースを掲載します。
public partial class Form1 : Form
{
private ViewModel _model = ViewModel.Instance;
private BindManager _bm;
public Form1()
{
InitializeComponent();
//バインド定義
_bm = new BindManager();
_bm.AddViewModel(_model);
_bm.AddBindMember(buttonStart, "Enabled", () => _model.Running.Not());
_bm.AddBindMember(buttonStop, "Enabled", () => _model.Running);
_bm.AddBindMember(labelIndexer, "Text", () => _model.PlaceNames[_model.PlaceId]);
_bm.AddBindMember(textBoxStatus, "Text", () => JoinWord(_model.PlaceNames[_model.PlaceId]," ",_model.Running == true ? "走行中" : "休憩中"));
_bm.AddBindMember(textBoxStatus2, "Text", () => _model.PlaceNames[_model.PlaceId]+ " "+ (_model.Running == true ? "runnig" : "break"));
}
private string JoinWord(params string[] words)
{
StringBuilder sb = new StringBuilder();
foreach ( string s in words)
sb.Append(s);
return sb.ToString();
}
private void buttonStart_Click(object sender, EventArgs e)
{
_model.Running = true;
}
private void buttonStop_Click(object sender, EventArgs e)
{
_model.Running = false;
}
private void numericIndex_ValueChanged(object sender, EventArgs e)
{
_model.PlaceId = (int)numericIndex.Value;
}
}
public class ViewModel : INotifyPropertyChanged
{
private static ViewModel instance = new ViewModel();
private bool _running = false;
private int _placeId = 0;
public static ViewModel Instance
{
get
{
return instance;
}
}
public Places PlaceNames;
private ViewModel()
{
PlaceNames = new Places();
}
public bool Running
{
get
{
return _running;
}
set
{
SetProperty(this, ref _running, value);
}
}
public int PlaceId
{
get
{
return _placeId;
}
set
{
SetProperty(this, ref _placeId, value);
}
}
public class Places
{
private string[] names = { "公園", "グラウンド", "堤防","山道","一般道"};
public string this[int index]
{
get
{
return names[index];
}
set
{
ViewModel.Instance.SetProperty(this,ref names[index], value);
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
// プロパティ変更通知
public void SetProperty<T>(object sender,ref T target, T value, [CallerMemberName] string caller = "")
{
target = value;
if (PropertyChanged == null)
return;
PropertyChangedEventArgs arg = new PropertyChangedEventArgs(caller);
PropertyChanged.Invoke(sender, arg);
}
}
public static class ExtMethods
{
public static TValue GetWithNew<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key) where TValue : new()
{
if (dict.TryGetValue(key, out TValue result))
return result;
else
{
TValue v = new TValue();
dict.Add(key, v);
return v;
}
}
public static bool Not(this bool value) => !value;
}
#まとめ
とりあえず目的は達したのですが、コントロールのイベント処理など改善したい気持ちはあります。でも最初からXAML使えと突っ込みがありそうですね。
ほとんど先人のノウハウを元にした内容ですが、自分としては非常に勉強になったと感じています。
主な参考サイト
- Dictionaryの拡張メソッド 36選
https://qiita.com/soi/items/6ce0e0ddefdd062c026a - Recognize indexer in LINQ expression
https://stackoverflow.com/questions/31413822/recognize-indexer-in-linq-expression - 自前でC#の変更通知クラスを作って勉強になったことまとめ(前編)(後編)
https://qiita.com/nossey/items/7c415799bc6fda45f94e - Expression を使ってラムダ式のメンバー名を取得する
http://blog.shos.info/archives/2012/12/cexpression_expression_2.html
環境
- 開発環境 --- Visual Studio Community 2017
- タプルを使用しているため、プロジェクトのコンテキストメニューから「NuGetパッケージの管理」を選択してSystem.ValueTupleをインストールしました。