Windows Forms で MVVM

2021/09/02に公開

前書き

MVVM

MVVM は ソフトウェアを Model-View-ViewModel の三つに分けて実装する設計パターンです。
Model はデータを、View はユーザーインターフェースを、ViewModel は View と Model の橋渡しをそれぞれ受け持ちます。
MVC との相違点は、View と ViewModel がデータバインディングされる、データの可視化とも言うべき設計をする事です。
この設計パターンを用いることにより、ソフトウェアの外観とロジックが入り混じった従来の Windows Forms の設計に比べて部品化の進んだ整ったソースを書くことができます。

Windows Forms

Windows Forms は Windows アプリを作成するためのプラットフォームです。
学習コストが低く資産も豊富で、技術も枯れて安定しています。
半面、Windows に依存しており、将来を見据えると不安が残ります。

WPF

WPF は Windows Forms に代わる次世代の UI フレームワークです。
Windows Forms より Windows への依存度が低く、マルチプラットフォーム開発に向いています。

MVVM と Windows Forms

MVVM は Windows Forms のユーザーインターフェースが主導する設計の反省から生まれました。
WPF では MVVM が推奨されています。
そのため Windows Forms では従来の手法で、WPF では MVVM で開発することが多いようです。

しかし、MVVM は WPF の専売特許ではありません。
Windows Forms でも十分にその恩恵を得ることができます。
また Windows Forms で MVVM パターンに慣れておけば WPF への移行も容易になるでしょう。

まずは結果から

image.png
image.png
image.png

作成したのは上図のようなカウンターです。
▲ボタンを押すと数字が増え、▼ボタンを押すと減ります。
数字は 0 から 10 までの値を取り、その範囲を超えそうな時にはボタンが使用不能になります。
これを実現するのが次の二つのソースです。

Form1.cs

using FormsMvvm;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
	public partial class Form1 : Form
	{
		protected ViewModel ViewModel { get; private set; } = new ViewModel();

		public Form1()
		{
			InitializeComponent();
			label1.Bind(() => ViewModel.Counter);
			button1.Bind(ViewModel.UpCommand);
			button2.Bind(ViewModel.DownCommand);
		}
	}
}

ViewModel.cs

using FormsMvvm;
using System.ComponentModel;
using System.Linq;
using System.Reactive.Linq;

namespace WindowsFormsApp1
{
	public class ViewModel : INotifyPropertyChanged
	{
		#region INotifyPropertyChanged
		public event PropertyChangedEventHandler PropertyChanged;

		protected virtual void OnPropertyChanged(string propertyName)
		{
			PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
		}

		public PropertySetter PropertySetter { get; private set; }
		#endregion

		private int counter;
		public int Counter
		{
			get => counter;
			set => PropertySetter.Set(ref counter, value);
		}

		public Command UpCommand { get; private set; }
		public Command DownCommand { get; private set; }

		public ViewModel()
		{
			PropertySetter = new PropertySetter(this, OnPropertyChanged);
			var observeCounter = PropertySetter.ObserveChanged(nameof(Counter));
			UpCommand = observeCounter.Select(_ => Counter < 10).ToCommand(() => Counter++);
			DownCommand = observeCounter.Select(_ => Counter > 0).ToCommand(() => Counter--);
		}
	}
}

クリックイベントでカウンタを増減させる従来のプログラミング手法とはかなり違うことがおわかりになると思います。
Form1.cs では ViewModel を作成してバインドしているだけで、実際にデータを操作しているのは ViewModel です。
ユーザーインターフェースはロジックのことを気にしないし、ロジックはユーザーインターフェースを気にしません。
これによって再利用性が高まり、開発やメンテナンスが容易になります。

もちろん、上のソースだけでは動きません。
コンパイルも通りません。
記述が簡単になるようにヘルパークラスをいくつか作成しています。

ヘルパーライブラリを作る

Reactive Extensions のインストール

Reactive Extensions はマイクロソフトの作成した、イベントを LINQ で扱えるライブラリです。
現在はまだオプション扱いですが、将来は標準ライブラリに組み込まれるものと思われます。
プロジェクトを作成して保存し、[ツール]→[NuGet パッケージマネージャー]→[パッケージマネージャーコンソール] でパッケージマネージャーコンソールを開き、「Install-Package System.Reactive」とコマンドを実行してください。

PropertySetter

PropertySetter.cs

using System;
using System.ComponentModel;
using System.Linq;
using System.Reactive.Linq;
using System.Runtime.CompilerServices;

namespace FormsMvvm
{
	public class PropertySetter
	{
		public PropertySetter(INotifyPropertyChanged owner, Action<string> OnPropertyChanged = null, Action<string> OnPropertyChanging = null)
		{
			Owner = owner ?? throw new ArgumentNullException(nameof(owner));
			PropertyChangedHandler = OnPropertyChanged;
			PropertyChangingHandler = OnPropertyChanging;
		}

		protected INotifyPropertyChanged Owner { get; private set; }

		protected Action<string> PropertyChangedHandler;
		protected Action<string> PropertyChangingHandler;

		public bool Set<T>(ref T backStore, T value, [CallerMemberName]string propertyName = null)
		{
			if (Equals(backStore, value)) return false;
			PropertyChangingHandler?.Invoke(propertyName);
			backStore = value;
			PropertyChangedHandler?.Invoke(propertyName);
			return true;
		}

		private IObservable<string> observeChanged;
		public IObservable<string> ObserveChanged()
		{
			if (observeChanged == null)
			{
				observeChanged = Observable
					.FromEvent<PropertyChangedEventHandler, string>(
						h => (sender, args) => h(args.PropertyName),
						h => Owner.PropertyChanged += h,
						h => Owner.PropertyChanged -= h
					);
			}
			return observeChanged;
		}

		private IObservable<string> observeChanging;
		public IObservable<string> ObserveChanging()
		{
			if (observeChanging == null)
			{
				var owner = Owner as INotifyPropertyChanging;
				if (owner == null)
				{
					observeChanging = Observable.Empty<string>();
				}
				else
				{
					observeChanging = Observable
						.FromEvent<PropertyChangingEventHandler, string>(
							h => (sender, args) => h(args.PropertyName),
							h => owner.PropertyChanging += h,
							h => owner.PropertyChanging -= h
						);
				}
			}
			return observeChanging;
		}

		public IObservable<string> ObserveChanged(string propertyName)
		{
			return Observable
				.Return(propertyName)
				.Merge(ObserveChanged())
				.Where(a => a == propertyName);
		}

		public IObservable<string> ObserveChanging(string propertyName)
		{
			return Observable
				.Return(propertyName)
				.Merge(ObserveChanging())
				.Where(a => a == propertyName);
		}
	}
}

PropertySetterINotifyPropertyChanged を簡単に実装するためのクラスです。
Prism では ViewModel は BindableBase という専用のクラスから派生させますが、それだと他のクラスから派生させた ViewModel を使いにくいので、ここではプロパティのセットとイベントの送出に特化させています。
PropertySetter は次のように使います。

public class ViewModel : INotifyPropertyChanged

まずここまで打ち込み、[Alt]+[Enter] を押します。
するとコンテキストメニューが出てくるので「インターフェースを実装します」を選択します。
コードは次のようになるはずです。

public class ViewModel : INotifyPropertyChanged
{
	public event PropertyChangedEventHandler PropertyChanged;
}

ここにイベントを発生させるためのメソッドと PropertySetter を保持するためのプロパティを追加します。
#region で囲んでおけば折りたたむことができて見た目がすっきりします。

public class ViewModel : INotifyPropertyChanged
{
	#region INotifyPropertyChanged
	public event PropertyChangedEventHandler PropertyChanged;

	protected virtual void OnPropertyChanged(string propertyName)
	{
		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
	}

	public PropertySetter PropertySetter { get; private set; }
	#endregion
}

なぜ PropertySetter プロパティを public にしているかと言うと、ほかのクラスから ViewModel のプロパティの変化を簡単に監視できるようにするためです。
必要なければ Protected で構いません。
ここにプロパティを追加し、コンストラクタで PropertySetter のインスタンスを作れば完成です。

public class ViewModel : INotifyPropertyChanged
{
	#region INotifyPropertyChanged
	public event PropertyChangedEventHandler PropertyChanged;

	protected virtual void OnPropertyChanged(string propertyName)
	{
		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
	}

	public PropertySetter PropertySetter { get; private set; }
	#endregion

	private int counter;
	public int Counter
	{
		get => counter;
		set => PropertySetter.Set(ref counter, value);
	}

	public ViewModel()
	{
		PropertySetter = new PropertySetter(this, OnPropertyChanged);
	}
}

これでプロパティ Counter の値が変化した時に INotifyPropertyChanged が発行されるようになりました。

Command

Command.cs

using System;
using System.ComponentModel;

namespace FormsMvvm
{
	public class Command : INotifyPropertyChanged
	{
		public event PropertyChangedEventHandler PropertyChanged;

		protected virtual void OnPropertyChanged(string propertyName)
		{
			PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
		}

		public PropertySetter PropertySetter { get; private set; }

		public Command(Action execute)
		{
			PropertySetter = new PropertySetter(this, OnPropertyChanged);
			this.execute = execute;
		}

		public Command(Action execute, bool canExecute)
			:this(execute)
		{
			CanExecute = canExecute;
		}

		public Command(Action execute, IObservable<bool> canExecute)
			:this(execute)
		{
			canExecute.Subscribe(a => CanExecute = a);
		}

		private Action execute;
		public void Execute()
		{
			if (!CanExecute) return;
			execute?.Invoke();
		}

		private bool canExecute;
		public bool CanExecute
		{
			get => canExecute;
			set => PropertySetter.Set(ref canExecute, value);
		}
	}

	public static class CommandExtension
	{
		public static Command ToCommand(this IObservable<bool> source, Action execute = null)
		{
			return new Command(execute, source);
		}
	}
}

Command はボタンを押した時などに実行するロジックを保持します。
CanExecute プロパティはコマンドが実行可能な時に true を、不可能な時に false を返します。
Execute() メソッドは紐づけられたアクションを実行します。
また簡単に作成できるように拡張メソッドを定義しています。

Command は次のように作成します。

var command = new Command(() => MessageBox.Show("Hello World!"), true);

第一引数の () => MessageBox.Show("Hello World!") がコマンドを実行した時のアクションです。
この場合はメッセージボックスを表示しています。
第二引数の true が、コマンドが実行可能か否かを示す値です。
この場合は true を指定しているので、コマンドは実行可能です。
また IObservable<bool> を指定することもできます。

var command = new Command(() => MessageBox.Show("Hello World!"), Observable.Return(true));

さらに IObservable<bool> から簡単に作れるよう拡張メソッドが作られています。

var command = Observable.Return(true).ToCommand(() => MessageBox.Show("Hello World!"));

Counter が 0 より大きい時だけ実行できる Command を作るには次のようにします。

var command = new Command(
	() => MessageBox.Show("Hello World!"),
	PropertySetter.ObserveChanged(nameof(Counter)).Select(_ => Counter > 0)
);

または拡張メソッドを使って次のようにします。

var command = PropertySetter
	.ObserveChanged(nameof(Counter))
	.Select(_ => Counter > 0)
	.ToCommand(() => MessageBox.Show("Hello World!"));

Binder

Binder.cs

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using System.Windows.Forms;

namespace FormsMvvm
{
	public static class Binder
	{
		public static void Bind<T, U>(Expression<Func<T>> item1, Expression<Func<U>> item2)
		{
			Tuple<object, string> ResolveLambda<V>(Expression<Func<V>> expression)
			{
				var lambda = expression as LambdaExpression;
				if (lambda == null) throw new ArgumentException();
				var property = lambda.Body as MemberExpression;
				if (property == null) throw new ArgumentException();
				var members = new List<MemberInfo>();
				var parent = property.Expression;
				return new Tuple<object, string>(Expression.Lambda(parent).Compile().DynamicInvoke(), property.Member.Name);
			}
			var tuple1 = ResolveLambda(item1);
			var tuple2 = ResolveLambda(item2);
			var control = tuple1.Item1 as Control;
			if (control == null) throw new ArgumentException();
			control.DataBindings.Add(new Binding(tuple1.Item2, tuple2.Item1, tuple2.Item2));
		}

		public static void Bind(this Button button, Command command)
		{
			Bind(() => button.Enabled, () => command.CanExecute);
			button.Click += (sender, args) => command.Execute();
		}

		public static void Bind<T>(this Label label, Expression<Func<T>> expression)
		{
			Bind(() => label.Text, expression);
		}
	}
}

Binder はコントロールとデータとのバインドを容易にします。
例えば ButtonTextLabelText をバインドするには次のようにします。

Binder.Bind(() => button1.Text, () => label1.Text);

ボタンとコマンドをバインドするにはもっと簡単な拡張メソッドが容易されています。

button1.Bind(command1);

これで button1.Enabled と command1.CanExecute が同期し、ボタンクリックでコマンドが実行されます。

最後に

以上のように簡単なヘルパークラスを用意するだけで容易に MVVM パターンが実装できました。
今回は理解を深めるために車輪の再発明のようなことをしましたが、ReactiveProperty など WPF 用と思われているものでも Windows Forms で十分使えますので利用してみてはいかがでしょうか。

執筆日: 2017/12/19

GitHubで編集を提案

Discussion