パターン
Model-View-ViewModel デザイン パターンによる WPF アプリケーション
Josh Smith
この記事では、次の内容について説明します。
|
この記事では、次のテクノロジを使用しています。 WPF、データ バインド |
コードは MSDN コード ギャラリーからダウンロードできます。
オンラインでのコードの参照
目次
秩序と混乱
Model-View-ViewModel の進化
WPF 開発者に MVVM が好まれる理由
デモ アプリケーション
中継コマンド ロジック
ViewModel クラス階層
ViewModelBase クラス
CommandViewModel クラス
MainWindowViewModel クラス
ViewModel に View を適用する
データ モデルとリポジトリ
新しい顧客のデータ入力フォーム
All Customers ビュー
まとめ
専門的なソフトウェア アプリケーションのユーザー インターフェイスの開発は容易ではありません。そこでは、データ、対話デザイン、ビジュアル デザイン、接続性、マルチスレッド、セキュリティ、国際化対応、検証、単体テストがブードゥーの魔術であいまいに混在するおそれがあります。ユーザー インターフェイスは、基になるシステムを公開し、ユーザーからの予期しないスタイルの要求を満たす必要があることから、大部分のアプリケーションで最も安定性に欠ける領域になる場合があります。
この扱いづらい性質を緩和するのに役立つ一般的なデザイン パターンがありますが、多くの懸念事項を適切に分離し、これらに対応することは困難です。パターンが複雑になるほど、後に手っ取り早い方法が用いられるようになり、適切な方法で作業するための以前からのすべての努力が根本から損なわれます。
デザイン パターンだけが常に問題なのではありません。私たちは時に複雑なデザイン パターンを使用し、大量のコードを作成する必要が生じますが、これには使用する UI プラットフォームが単純なパターンにあまり適していないという理由があります。単純で、時の試練を経た、開発者が承認するデザイン パターンを使用して UI を簡単に構築できるプラットフォームが必要になります。さいわいなことに、Windows Presentation Foundation (WPF) はまさにこの目的にふさわしい手法です。
ソフトウェアの世界では WPF の採用が急速に進んできているため、WPF コミュニティでは独自のパターンとプラクティスによるエコシステムが確立されています。この記事では、WPF を使用してクライアント アプリケーションを設計および実装するこれらのベスト プラクティスをいくつか紹介します。WPF のいくつかのコア機能を Model-View-ViewModel (MVVM) デザイン パターンと共に利用して、いかに簡単に WPF アプリケーションを "正しい方法" で作成することができるかを示すサンプル プログラムを順を追って説明します。
この記事を読み終わるころには、データ テンプレート、コマンド、データ バインド、リソース システム、および MVVM パターンのすべてが組み合わせられ、任意の WPF アプリケーションが利用することのできる単純で、テスト可能で、堅牢なフレームワークを形成するしくみが明らかになります。この記事に付属のデモ プログラムは、MVVM をコア アーキテクチャとして使用する実際の WPF アプリケーションのテンプレートとしても活用できます。デモ ソリューションの単体テストでは、アプリケーションのユーザー インターフェイス機能が一連の ViewModel クラスに存在している場合に、これらの機能のテストがどれほど簡単になるかを示します。詳細に入る前に、なぜ MVVM のようなパターンを使用する必要があるかということから始めます。
秩序と混乱
単純な "Hello, World!" プログラムでデザイン パターンを使用する必要はなく、生産的ではありません。優秀な開発者は数行のコードをひと目見るだけで理解できます。しかし、プログラムの機能数が増えるにつれて、コード行数や可変部分の数も増えます。最終的に、システムの複雑性およびそこに含まれる繰り返し発生する問題のために、開発者は簡単に理解、議論、拡張、およびトラブルシューティングが行える方法でコードを編成するようになります。複雑なシステムの認識上の混乱は、ソース コードの特定のエンティティに既知の名前を付けることで軽減されます。コードに適用する名前を決定する際は、システム内の機能的な役割を考えます。
開発者はしばしば、パターンを有機的に発生させるのではなく、デザイン パターンに応じてコードを意図的に構造化します。どちらの手法も悪くありませんが、この記事では WPF アプリケーションのアーキテクチャとして MVVM を明示的に使用するメリットを調べます。クラスがビューの抽象化である場合は "ViewModel" で終わるなど、特定のクラスの名前には MVVM パターンの既知の語が含まれています。この手法により、上で述べた認識上の混乱が避けられます。むしろ、混乱は制御されるようになり、ほとんどの専門的なソフトウェア開発プロジェクトにとってはそれが自然な状態です。
Model-View-ViewModel の進化
ソフトウェアのユーザー インターフェイスの作成が開始されてからは、この作業をより簡単にするためのデザイン パターンが普及しました。たとえば、Model-View-Presenter (MVP) パターンは各種の UI プログラミング プラットフォームで広く使われています。MVP は何十年も使用されている Model-View-Controller パターンのバリエーションです。これまで MVP パターンを使用したことがない人のために簡単に説明します。画面上に見えるものが View であり、そこに表示されるデータが Model であり、Presenter が両者を結び付けます。ビューは、Presenter によって実行されるモデル データの作成、ユーザー入力への応答、入力検証の提供 (モデルに委任されることもある) などのタスクに依存します。Model View Presenter に関する詳細については、Jean-Paul Boodhoo による 2006 年 8 月の「設計パターン」コラムをお読みになることをお勧めします。
2004 年に、Martin Fowler により Presentation Model (PM) と名付けられたパターンに関する記事が発表されました。PM パターンは、動作と状態からビューを切り離す点で MVP と同じです。PM パターンの興味深い点は、Presentation Model と呼ばれるビューの抽象化が作成されることです。そして、ビューは単に Presentation Model のレンダリングになります。Fowler 氏の説明では、Presentation Model は頻繁に View を更新し、両者は相互に同期された状態を維持します。この同期ロジックは Presentation Model クラスの中にコードとして存在します。
2005 年に、現在マイクロソフトの WPF および Silverlight アーキテクトの一員である John Gossman が Model-View-ViewModel (MVVM) パターンを自身のブログで発表しました。MVVM は、これらのパターンが両方とも View の抽象化を特徴としており、これが View の状態と動作を保持しているという点で、Fowler の Presentation Model と同じです。Fowler は UI プラットフォームに依存しない View の抽象化を作成する手段として Presentation Model を導入しました。一方で、Gossman はユーザー インターフェイスの作成の簡素化に向けて WPF のコア機能を活用するための標準化された方法として MVVM を導入しました。こうしてみると、MVVM はより汎用的な PM パターンを WPF および Silverlight プラットフォーム向けに特殊化したものであると考えることができます。
Glenn Block による 2008 年 9 月号の優れた記事「Prism: WPF で複合アプリケーションを作成するためのパターン」では、Microsoft Composite Application Guidance for WPF についての説明があります。ViewModel という用語は使われていません。代わりに、ビューの抽象化を示す Presentation Model という用語が使われています。ただし、筆者の記事ではパターンを MVVM と呼び、ビューの抽象化を ViewModel と呼びます。WPF および Silverlight コミュニティではこの用語の方がより広く使われています。
MVP の Presenter とは異なり、ViewModel はビューへの参照を必要としません。ビューは ViewModel のプロパティにバインドされ、ViewModel はモデル オブジェクトに含まれているデータと、ビューに固有の他の状態を公開します。ViewModel オブジェクトはビューの DataContext として設定されているため、ビューと ViewModel の間のバインドは簡単に構築されます。ViewModel のプロパティの値が変化すると、データ バインドを通じてこれらの新しい値が自動的にビューに伝播されます。ユーザーが View でボタンをクリックした場合は、要求された動作を行うために ViewModel のコマンドが実行します。View ではなく、ViewModel がモデル データに加えられたすべての変更を実行します。
ビュー クラスはモデル クラスが存在することを認識しておらず、一方で、ViewModel およびモデルはビューについて認識しません。実際、モデルは ViewModel およびビューが存在することをまったく認識していません。これは疎結合度が非常に高いデザインであり、後述のようにさまざまな利点があります。
WPF 開発者に MVVM が好まれる理由
いったん WPF と MVVM を使い慣れた開発者にとって、両者を区別するのは困難です。MVVM は WPF プラットフォームに適しており、WPF はパターンの中でも特に MVVM パターンを使用してアプリケーションを簡単に構築できるようにデザインされているため、MVVM は WPF 開発者の共通語です。実際、WPF コア プラットフォームは構築中でしたが、マイクロソフトでは内部的に MVVM を使用して Microsoft Expression Blend などの WPF アプリケーションを開発していました。外観なしのコントロール モデルやデータ テンプレートなどの WPF の多くの面で、MVVM によって促進される、状態および動作から表示を強力に分離する機能が利用されています。
MVVM パターンの利用価値を高めている WPF の最も重要な面は、データ バインド インフラストラクチャです。ビューのプロパティを ViewModel にバインドすることで、両者の疎結合が実現し、ViewModel にビューを直接更新するコードを書く必要がなくなりました。データ バインド システムでは入力検証もサポートされ、検証エラーをビューに転送する標準化された方法が提供されます。
このパターンを便利なものにしている WPF の他の 2 つの機能は、データ テンプレートとリソース システムです。データ テンプレートは View をユーザー インターフェイスに表示される ViewModel オブジェクトに適用します。テンプレートは XAML で宣言でき、リソース システムが実行時にこれらのテンプレートを自動的に参照し、適用します。バインドおよびデータ テンプレートの詳細については、2008 年 7 月号の筆者の記事「データと WPF: データ バインドと WPF でデータの表示をカスタマイズする」をご覧ください。
WPF のコマンドをサポートしていなかったら、MVVM パターンはこれほど強力にはならなかったでしょう。この記事では、ViewModel が View にコマンドを公開して、ビューがその機能を利用できるようにするしくみを示します。コマンドの実行についてご存じない場合は、2008 年 9 月号の Brian Noyes による包括的な記事「高度な WPF: WPF におけるルーティング イベントとルーティング コマンドについて」をお読みになることをお勧めします。
MVVM をアプリケーションの構築に適した手法とする WPF (および Silverlight 2) 機能のほかに、ViewModel クラスで単体テストが簡単に実施できることもパターンの普及を後押しする要因です。アプリケーションの対話ロジックが一連の ViewModel クラスに存在する場合、これをテストするコードを簡単に作成できます。ある意味、View と単体テストは 2 種類の異なる ViewModel コンシューマです。アプリケーションの ViewModel に一連のテストを実行することにより自由で迅速な回帰テストが実現し、時間の経過に沿ったアプリケーション管理のコストが軽減されます。
自動化された回帰テストの作成を促進するほか、ViewModel クラスのテスト機能は、簡単にスキンを適用できるユーザー インターフェイスを適切にデザインする場合にも役立ちます。アプリケーションのデザイン時は、多くの場合に、ViewModel を利用する単体テストの作成を想定しながら、ある項目をビューに入れるか、または ViewModel に入れるかを決定できます。UI オブジェクトを作成せずに ViewModel の単体テストを作成できる場合は、固有のビジュアル要素に対する依存関係がないため、ViewModel に完全にスキンを適用できます。
最後に、ビジュアル デザイン担当者と共同で作業する開発者にとっては、MVVM を使用することでデザイナと開発者の円滑なワークフローをより簡単に構築できます。ビューは ViewModel の任意のコンシューマにすぎないため、あるビューを取り去り、ViewModel を表示する新しいビューを簡単に追加できます。この簡単な手順によって、デザイナが作成したユーザー インターフェイスを迅速にプロトタイプ化し、検証することが可能になります。
開発者チームは堅牢な ViewModel クラスの作成に焦点を当て、デザイン チームは使いやすい View の作成に焦点を当てることができます。両チームの成果物を結び付ける場合は、ビューの XAML ファイルでバインドが正しいことを確認するだけで済みます。
デモ アプリケーション
ここまでは MVVM の歴史と運用理論を概説しました。また、WPF 開発者の間で普及している理由についても確認しました。ここからは、パターンの動作を調べていきます。この記事に付属のデモ アプリケーションではさまざまな方法で MVVM を使用しています。意味のあるコンテキストの中で概念を示す豊富なサンプルが提供されます。デモ アプリケーションの作成には、Microsoft .NET Framework 3.5 SP1 に対して Visual Studio 2008 SP1 を使用しました。単体テストは Visual Studio 単体テスト システムで実行します。
アプリケーションには任意の数の "ワークスペース" を含めることができ、これらは左側のナビゲーション領域のコマンド リンクをクリックすることで開きます。すべてのワークスペースはメイン コンテンツ領域の TabControl にあります。ワークスペースを閉じるには、そのワークスペースのタブ項目にある [Close] ボタンをクリックします。アプリケーションには "All Customers" と "New Customer" という 2 つの利用可能なワークスペースがあります。アプリケーションを実行し、いくつかのワークスペースを開くと、図 1 のような UI が表示されます。
図 1 ワークスペース
"All Customers" ワークスペースは同時に 1 つのインスタンスしか開けませんが、"New Customer" ワークスペースは同時に任意の数のインスタンスを開くことができます。新しい顧客を作成する場合は、図 2 に示すデータ入力フォームに入力する必要があります。
図 2 新しい顧客のデータ入力フォーム
データ入力フォームに有効な値を入力し、[Save] ボタンをクリックすると、タブ項目に新しい顧客名が表示され、全顧客の一覧にその顧客が追加されます。既存の顧客の削除または編集機能はアプリケーションでサポートされていませんが、それらの機能や他の多くの類似機能は、既存のアプリケーション アーキテクチャの上に構築することで簡単に実装できます。デモ アプリケーションの概要が理解できたら、デザインと実装がどのように行われたかを調べていきます。
中継コマンド ロジック
クラスのコンストラクタで InitializeComponent を呼び出す標準の定型コードを除いて、アプリケーションの各ビューには空の分離コード ファイルがあります。実際には、プロジェクトからビューの分離コードを削除することができ、その場合でもアプリケーションは正常にコンパイルし、実行します。ビューのイベント処理メソッドが欠けていても、ユーザーがボタンをクリックすると、アプリケーションはユーザー要求に対して応答し、これを実行します。この動作は、UI に表示されている Hyperlink、Button、および MenuItem コントロールの Command プロパティにバインドが確立されているため実現します。これらのバインドにより、ユーザーがコントロールをクリックした場合に ViewModel で公開された ICommand オブジェクトが実行することが保証されます。コマンド オブジェクトは、ViewModel の機能を XAML で宣言されたビューから簡単に利用できるようにするアダプタと考えることができます。
ViewModel が ICommand 型のインスタンス プロパティを公開すると、コマンド オブジェクトは通常その ViewModel オブジェクトを使用してジョブを実行します。可能な 1 つの実装パターンは、ViewModel クラスの内部に入れ子になったプライベート クラスを作成して、コマンドが内包する ViewModel のプライベート メンバにアクセスできるようにし、名前空間が煩雑になるのを避けることです。入れ子になったクラスは ICommand インターフェイスを実装し、内包する ViewModel オブジェクトへの参照がそのクラスのコンストラクタに挿入されます。しかし、ICommand を実装する入れ子になったクラスを ViewModel で公開されたコマンドごとに作成するのは、ViewModel クラスのサイズを膨張させることになります。コードが増えることは潜在的なバグが増えることを意味します。
デモ アプリケーションでは、この問題は RelayCommand クラスによって解消されます。RelayCommand を使用すると、コンストラクタに渡されたデリゲートを通じて、コマンドのロジックを注入することができます。この手法により、ViewModel クラスでコマンドを無駄なく簡潔に実装できます。RelayCommand は、Microsoft Composite Application Library にある DelegateCommand の簡素化されたバリエーションです。図 3 に、RelayCommand クラスを示します。
図 3 RelayCommand クラス
public class RelayCommand : ICommand
{
#region Fields
readonly Action<object> _execute;
readonly Predicate<object> _canExecute;
#endregion // Fields
#region Constructors
public RelayCommand(Action<object> execute)
: this(execute, null)
{
}
public RelayCommand(Action<object> execute, Predicate<object> canExecute)
{
if (execute == null)
throw new ArgumentNullException("execute");
_execute = execute;
_canExecute = canExecute;
}
#endregion // Constructors
#region ICommand Members
[DebuggerStepThrough]
public bool CanExecute(object parameter)
{
return _canExecute == null ? true : _canExecute(parameter);
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public void Execute(object parameter)
{
_execute(parameter);
}
#endregion // ICommand Members
}
CanExecuteChanged イベントは ICommand インターフェイス実装の一部ですが、いくつかの興味深い機能があります。これはイベント サブスクリプションを CommandManager.RequerySuggested イベントに委任します。これにより、WPF コマンド インフラストラクチャが組み込みコマンドにたずねる場合は必ず、すべての RelayCommand オブジェクトに実行可能かどうかをたずねられることが保証されます。CustomerViewModel クラスの以下のコードは、後で詳細を説明しますが、RelayCommand をラムダ式で構成する方法を示します。
RelayCommand _saveCommand;
public ICommand SaveCommand
{
get
{
if (_saveCommand == null)
{
_saveCommand = new RelayCommand(param => this.Save(),
param => this.CanSave );
}
return _saveCommand;
}
}
ViewModel クラス階層
ほとんどの ViewModel クラスは同じ機能を必要とします。多くの場合に INotifyPropertyChanged インターフェイスを実装する必要があり、通常はわかりやすい表示名を付ける必要があり、さらにワークスペースの場合は閉じる機能が必要です (つまり、UI からは削除されます)。新しい ViewModel クラスがすべての共通機能を基本クラスから継承できるように、ViewModel の基本クラスの作成でこれらの問題に対処することになります。図 4 に、ViewModel クラスが形成する継承階層を示します。
図 4 継承階層
自身のすべての ViewModel に対して基本クラスを使用することは必須ではありません。継承を使用せずに、多くの小型クラスを合わせて作成することでクラスの機能を実現する場合は、それでも問題ありません。他のデザイン パターンと同様、MVVM はガイドラインであって規則ではありません。
ViewModelBase クラス
ViewModelBase は階層内のルート クラスであるため、共通に使用される INotifyPropertyChanged インターフェイスを実装し、DisplayName プロパティを含んでいます。INotifyPropertyChanged インターフェイスには PropertyChanged というイベントが含まれます。ViewModel オブジェクトのプロパティが新しい値を得るたびに、PropertyChanged イベントを起動して WPF バインド システムに新しい値を通知できます。この通知を受けると、バインド システムがプロパティを照会し、UI 要素にバインドされたプロパティが新しい値を受け取ります。
ViewModel オブジェクトで変更されたプロパティを WPF が認識できるように、PropertyChangedEventArgs クラスは String 型の PropertyName プロパティを公開します。このイベント引数に正しいプロパティ名を渡すよう注意する必要があります。そうしないと、WPF が間違ったプロパティに新しい値を照会することになります。
ViewModelBase の興味深い一面は、指定の名前を持つプロパティが実際に ViewModel オブジェクトに存在することの検証機能を提供することです。Visual Studio 2008 リファクタリング機能を通じてプロパティ名を変更した場合、そのプロパティ名を含むソース コードの文字列は更新されないため、この機能はリファクタリングで非常に役立ちます。イベント引数に間違ったプロパティ名を使用して PropertyChanged イベントを起動すると、追跡困難で捕らえにくいバグの原因となるおそれがあり、この小さい機能で時間を大きく節約できます。図 5 に、この便利なサポートを追加する ViewModelBase のコードを示します。
図 5 プロパティを検証する
// In ViewModelBase.cs
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
this.VerifyPropertyName(propertyName);
PropertyChangedEventHandler handler = this.PropertyChanged;
if (handler != null)
{
var e = new PropertyChangedEventArgs(propertyName);
handler(this, e);
}
}
[Conditional("DEBUG")]
[DebuggerStepThrough]
public void VerifyPropertyName(string propertyName)
{
// Verify that the property name matches a real,
// public, instance property on this object.
if (TypeDescriptor.GetProperties(this)[propertyName] == null)
{
string msg = "Invalid property name: " + propertyName;
if (this.ThrowOnInvalidPropertyName)
throw new Exception(msg);
else
Debug.Fail(msg);
}
}
CommandViewModel クラス
最も簡素化された具象 ViewModelBase サブクラスは CommandViewModel です。これは Command という ICommand 型のプロパティを公開します。MainWindowViewModel は Commands プロパティを通じてこれらのオブジェクトのコレクションを公開します。メイン ウィンドウの左側のナビゲーション領域には、"View all customers"、"Create new customer" などの MainWindowViewModel で公開されたそれぞれの CommandViewModel へのリンクが表示されます。ユーザーがリンクをクリックすると、いずれかのコマンドが実行されることになり、メイン ウィンドウの TabControl でワークスペースが開きます。以下に、CommandViewModel クラスの定義を示します。
public class CommandViewModel : ViewModelBase
{
public CommandViewModel(string displayName, ICommand command)
{
if (command == null)
throw new ArgumentNullException("command");
base.DisplayName = displayName;
this.Command = command;
}
public ICommand Command { get; private set; }
}
MainWindowResources.xaml ファイルには、キーが "CommandsTemplate" の DataTemplate が存在します。MainWindow はこのテンプレートを使用して、前述の CommandViewModel のコレクションを表示します。テンプレートは単にそれぞれの CommandViewModel オブジェクトを ItemsControl にリンクとして表示します。各ハイパーリンクの Command プロパティは CommandViewModel の Command プロパティにバインドされています。図 6 に、その XAML を示します。
図 6 Command のリストを表示する
<!-- In MainWindowResources.xaml -->
<!--
This template explains how to render the list of commands on
the left side in the main window (the 'Control Panel' area).
-->
<DataTemplate x:Key="CommandsTemplate">
<ItemsControl ItemsSource="{Binding Path=Commands}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Margin="2,6">
<Hyperlink Command="{Binding Path=Command}">
<TextBlock Text="{Binding Path=DisplayName}" />
</Hyperlink>
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
MainWindowViewModel クラス
先に示したクラス ダイアグラムでわかるとおり、WorkspaceViewModel クラスは ViewModelBase から派生し、閉じる機能を追加します。"閉じる" とは、実行時にワークスペースがユーザー インターフェイスから削除されることを意味します。WorkspaceViewModel からは MainWindowViewModel、AllCustomersViewModel、および CustomerViewModel という 3 つのクラスが派生します。図 7 に示すように、MainWindowViewModel を閉じる要求は App クラスで処理され、ここでは MainWindow とその ViewModel が作成されます。
図 7 ViewModel を作成する
// In App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
MainWindow window = new MainWindow();
// Create the ViewModel to which
// the main window binds.
string path = "Data/customers.xml";
var viewModel = new MainWindowViewModel(path);
// When the ViewModel asks to be closed,
// close the window.
viewModel.RequestClose += delegate
{
window.Close();
};
// Allow all controls in the window to
// bind to the ViewModel by setting the
// DataContext, which propagates down
// the element tree.
window.DataContext = viewModel;
window.Show();
}
MainWindow には、Command プロパティが MainWindowViewModel の CloseCommand プロパティにバインドされているメニュー項目があります。ユーザーがそのメニュー項目をクリックすると、App クラスはウィンドウの Close メソッドを呼び出すことでこれに応答します。
<!-- In MainWindow.xaml -->
<Menu>
<MenuItem Header="_File">
<MenuItem Header="_Exit" Command="{Binding Path=CloseCommand}" />
</MenuItem>
<MenuItem Header="_Edit" />
<MenuItem Header="_Options" />
<MenuItem Header="_Help" />
</Menu>
MainWindowViewModel には、ワークスペースと呼ばれる WorkspaceViewModel オブジェクトの識別可能なコレクションが含まれています。メイン ウィンドウにある TabControl の ItemsSource プロパティはそのコレクションにバインドされています。各タブ項目にある [Close] ボタンの Command プロパティは、該当する WorkspaceViewModel の CloseCommand にバインドされています。各タブ項目を構成するテンプレートの簡略版を以下のコードに示します。コードは MainWindowResources.xaml にあり、テンプレートは [Close] ボタンを持つタブ項目を表示する方法を示しています。
<DataTemplate x:Key="ClosableTabItemTemplate">
<DockPanel Width="120">
<Button
Command="{Binding Path=CloseCommand}"
Content="X"
DockPanel.Dock="Right"
Width="16" Height="16"
/>
<ContentPresenter Content="{Binding Path=DisplayName}" />
</DockPanel>
</DataTemplate>
ユーザーがタブ項目の [Close] ボタンをクリックすると、その WorkspaceViewModel の CloseCommand が実行し、RequestClose イベントを起動します。MainWindowViewModel はワークスペースの RequestClose イベントを監視し、要求があると Workspaces コレクションからワークスペースを削除します。MainWindow の TabControl の ItemsSource プロパティは WorkspaceViewModel の識別可能なコレクションにバインドされているため、コレクションのアイテムを削除すると該当するワークスペースが TabControl から削除されます。図 8 に、その MainWindowViewModel のロジックを示します。
図 8 UI からワークスペースを削除する
// In MainWindowViewModel.cs
ObservableCollection<WorkspaceViewModel> _workspaces;
public ObservableCollection<WorkspaceViewModel> Workspaces
{
get
{
if (_workspaces == null)
{
_workspaces = new ObservableCollection<WorkspaceViewModel>();
_workspaces.CollectionChanged += this.OnWorkspacesChanged;
}
return _workspaces;
}
}
void OnWorkspacesChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null && e.NewItems.Count != 0)
foreach (WorkspaceViewModel workspace in e.NewItems)
workspace.RequestClose += this.OnWorkspaceRequestClose;
if (e.OldItems != null && e.OldItems.Count != 0)
foreach (WorkspaceViewModel workspace in e.OldItems)
workspace.RequestClose -= this.OnWorkspaceRequestClose;
}
void OnWorkspaceRequestClose(object sender, EventArgs e)
{
this.Workspaces.Remove(sender as WorkspaceViewModel);
}
UnitTests プロジェクトでは、この機能が正常に動作していることを検証するテスト メソッドが MainWindowViewModelTests.cs ファイルに含まれています。UI を処理するコードを作成せずにアプリケーション機能の単純なテストが可能になるため、ViewModel クラスの単体テストを作成できる簡便性は MVVM パターンの大きなセールス ポイントです。図 9 に、そのテスト メソッドを示します。
図 9 テスト メソッド
// In MainWindowViewModelTests.cs
[TestMethod]
public void TestCloseAllCustomersWorkspace()
{
// Create the MainWindowViewModel, but not the MainWindow.
MainWindowViewModel target =
new MainWindowViewModel(Constants.CUSTOMER_DATA_FILE);
Assert.AreEqual(0, target.Workspaces.Count, "Workspaces isn't empty.");
// Find the command that opens the "All Customers" workspace.
CommandViewModel commandVM =
target.Commands.First(cvm => cvm.DisplayName == "View all customers");
// Open the "All Customers" workspace.
commandVM.Command.Execute(null);
Assert.AreEqual(1, target.Workspaces.Count, "Did not create viewmodel.");
// Ensure the correct type of workspace was created.
var allCustomersVM = target.Workspaces[0] as AllCustomersViewModel;
Assert.IsNotNull(allCustomersVM, "Wrong viewmodel type created.");
// Tell the "All Customers" workspace to close.
allCustomersVM.CloseCommand.Execute(null);
Assert.AreEqual(0, target.Workspaces.Count, "Did not close viewmodel.");
}
ViewModel に View を適用する
MainWindowViewModel はメイン ウィンドウの TabControl で WorkspaceViewModel オブジェクトを間接的に追加または削除します。データ バインドに依存することで、TabItem の Content プロパティは表示する ViewModelBase 派生のオブジェクトを受け取ります。ViewModelBase は UI 要素でないため、自身を表示する固有の機能をサポートしていません。既定では、WPF で非ビジュアル オブジェクトを表示する際は ToString メソッドへの呼び出し結果が TextBlock に表示されます。私たちの ViewModel クラスの型名を表示したいとユーザーが熱望しない限り、これは必要ありません。
型指定された DataTemplates を使用して、ViewModel オブジェクトの表示方法を WPF に簡単に通知できます。型指定された DataTemplate には x:Key 値は割り当てられていませんが、DataType プロパティに Type クラスのインスタンスが設定されています。WPF は、いずれかの ViewModel オブジェクトを表示しようとする際、ViewModel オブジェクトの型と同一 (または型の基本クラス) の DataType を持つ、型指定された DataTemplate がスコープ内でリソース システムに存在するかを確認します。見つかった場合は、そのテンプレートを使用してタブ項目の Content プロパティで参照されている ViewModel オブジェクトを表示します。
MainWindowResources.xaml ファイルには ResourceDictionary が含まれています。その辞書はメイン ウィンドウのリソース階層に追加されますが、これは含まれているリソースがウィンドウのリソース スコープ内にあることを意味します。図 10 に示すように、タブ項目のコンテンツが ViewModel オブジェクトに設定されると、この辞書の型指定された DataTemplate によって表示するビュー (つまり、ユーザー コントロール) が指定されます。
図 10 ビューを指定する
<!--
This resource dictionary is used by the MainWindow.
-->
<ResourceDictionary
xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:DemoApp.ViewModel"
xmlns:vw="clr-namespace:DemoApp.View"
>
<!--
This template applies an AllCustomersView to an instance
of the AllCustomersViewModel class shown in the main window.
-->
<DataTemplate DataType="{x:Type vm:AllCustomersViewModel}">
<vw:AllCustomersView />
</DataTemplate>
<!--
This template applies a CustomerView to an instance
of the CustomerViewModel class shown in the main window.
-->
<DataTemplate DataType="{x:Type vm:CustomerViewModel}">
<vw:CustomerView />
</DataTemplate>
<!-- Other resources omitted for clarity... -->
</ResourceDictionary>
ViewModel オブジェクトで表示するビューを決定するコードを作成する必要はありません。WPF リソース システムによってすべての複雑な処理が行われるので、それらの作業から解放され、重要な項目に焦点を当てることができます。より複雑なシナリオではプログラムによってビューを選択できますが、ほとんどの場合これは必要ありません。
データ モデルとリポジトリ
アプリケーション シェルによる ViewModel オブジェクトの読み込み、表示、および終了のしくみについて見てきました。一般的な機能が準備できたら、今度はアプリケーションのドメインにより特化した実装の詳細を調べることができます。アプリケーションの 2 つのワークスペース "All Customers" および "New Customer" の詳細に入る前に、まずデータ モデルとデータ アクセス クラスについて調べます。これらのクラスのデザインは MVVM パターンとほとんど関係ありませんが、これは任意のデータ オブジェクトが WPF に適応するように ViewModel クラスを作成できるためです。
デモ プログラムの唯一のモデル クラスは Customer です。このクラスには、姓名や電子メール アドレスなど、会社の顧客に関する情報を表すいくつかのプロパティがあります。WPF が出現する数年前から存在する標準の IDataErrorInfo インターフェイスを実装することにより、検証メッセージが提供されます。Customer クラスには MVVM アーキテクチャ、さらには WPF アプリケーションで使用されることを示すものは何もありません。業務用レガシ ライブラリからのクラスを手軽に使用することもできます。
どこかからデータを受け取り、保持する必要があります。このアプリケーションでは、CustomerRepository クラスのインスタンスがすべての Customer オブジェクトを読み込み、保存します。XML ファイルからの顧客データを読み込むことになりますが、外部データ ソースの種類が適切ではありません。データはデータベース、Web サービス、名前付きパイプ、ディスク上のファイル、さらには伝書バトから供給される場合がありますが、問題ではありません。データの供給元にかかわらず、データが含まれている .NET オブジェクトがある限り、MVVM パターンでは画面にデータを表示できます。
CustomerRepository クラスは、利用可能なすべての Customer オブジェクトを取得し、新しい Customer をリポジトリに追加し、Customer がリポジトリに既に存在するかをチェックするためのいくつかのメソッドを公開します。アプリケーションでユーザーが顧客を削除することは許可されていないため、リポジトリで顧客を削除することはできません。AddCustomer メソッドを通じて新しい Customer が CustomerRepository に入ると CustomerAdded イベントが起動します。
実際のビジネス アプリケーションでの要求と比べればこのアプリケーションのデータ モデルが非常に小さいことは明らかですが、これは重要ではありません。重要なのは、ViewModel クラスがどのように Customer と CustomerRepository を利用するかを理解することです。CustomerViewModel は Customer オブジェクトのラッパーであることに注意してください。Customer の状態、および CustomerView コントロールで使用されるその他の状態がプロパティを通じて公開されます。CustomerViewModel によって Customer の状態が重複することはありません。以下のように、デリゲートにより公開されます。
public string FirstName
{
get { return _customer.FirstName; }
set
{
if (value == _customer.FirstName)
return;
_customer.FirstName = value;
base.OnPropertyChanged("FirstName");
}
}
ユーザーが新しい顧客を作成し、CustomerView コントロールの [Save] ボタンをクリックすると、そのビューに関連付けられた CustomerViewModel によって新しい Customer オブジェクトが CustomerRepository に追加されます。これによってリポジトリの CustomerAdded イベントが起動し、新しい CustomerViewModel を AllCustomers コレクションに追加するよう AllCustomersViewModel に通知します。ある意味、CustomerRepository は Customer オブジェクトを扱うさまざまな ViewModel 間の同期メカニズムとして動作します。これについては Mediator (仲介者) デザイン パターンの使用を思い浮かべる人もいるかもしれません。この動作については次のセクションで詳しく述べますが、図 11 に各部分の関係の概要を示します。
図 11 Customer の関係
新しい顧客のデータ入力フォーム
ユーザーが [Create new customer] リンクをクリックすると、MainWindowViewModel によって新しい CustomerViewModel がワークスペースのリストに追加され、CustomerView コントロールがこれを表示します。ユーザーが入力フィールドに有効な値を入力すると、[Save] ボタンが有効な状態になり、新しい顧客情報が保存できるようになります。これは特に変わったところのない、入力検証と [Save] ボタンを備えた通常のデータ入力フォームです。
Customer クラスには組み込みの検証サポートがあり、IDataErrorInfo インターフェイスの実装を通じて使用可能です。この検証では、顧客の名前と整形された電子メール アドレス、さらに顧客が人の場合は姓があることが確認されます。Customer の IsCompany プロパティで true が返された場合、LastName プロパティは値を持つことができません (会社には姓がないと考えます)。この検証ロジックは Customer オブジェクト側から見れば意味のあるものですが、ユーザー インターフェイスの要件は満たしていません。UI はユーザーに新しい顧客が人または会社のどちらであるかを選択するよう求めます。Customer Type セレクタの初期値は "(Not Specified)" です。Customer の IsCompany プロパティで true または false の値しか許可されないとすれば、UI はどのようにして顧客の種類が指定されていないことをユーザーに通知できるでしょうか。
ソフトウェア システム全体を完全に制御できる立場であれば、IsCompany プロパティを "未選択" の値を許可する Nullable<bool> 型に変更できます。しかし、現実にはそれほど簡単に事が運ぶとも限りません。社内の別のチームが所有するレガシ ライブラリから来ているため、Customer クラスは変更できないと想定します。既存のデータベース スキーマがあるため "未選択" の値を保存する簡単な方法がない場合はどうでしょうか。他のアプリケーションが既に Customer クラスを使用していて、プロパティが通常の Boolean 値であることに依存している場合はどうでしょうか。ここでも、救済策として ViewModel を使用します。
図 12 のテスト メソッドは、この機能が CustomerViewModel でどのように動作するかを示します。Customer Type セレクタが表示する 3 つの文字列が保持されるように、CustomerViewModel は CustomerTypeOptions プロパティを公開します。また、セレクタで選択された String を格納する CustomerType プロパティも公開します。CustomerType が設定されると、String 値は基になる Customer オブジェクトの IsCompany プロパティの Boolean 値にマップされます。図 13 に、2 つのプロパティを示します。
図 12 テスト メソッド
// In CustomerViewModelTests.cs
[TestMethod]
public void TestCustomerType()
{
Customer cust = Customer.CreateNewCustomer();
CustomerRepository repos = new CustomerRepository(
Constants.CUSTOMER_DATA_FILE);
CustomerViewModel target = new CustomerViewModel(cust, repos);
target.CustomerType = "Company"
Assert.IsTrue(cust.IsCompany, "Should be a company");
target.CustomerType = "Person";
Assert.IsFalse(cust.IsCompany, "Should be a person");
target.CustomerType = "(Not Specified)";
string error = (target as IDataErrorInfo)["CustomerType"];
Assert.IsFalse(String.IsNullOrEmpty(error), "Error message should
be returned");
}
図 13 CustomerType のプロパティ
// In CustomerViewModel.cs
public string[] CustomerTypeOptions
{
get
{
if (_customerTypeOptions == null)
{
_customerTypeOptions = new string[]
{
"(Not Specified)",
"Person",
"Company"
};
}
return _customerTypeOptions;
}
}
public string CustomerType
{
get { return _customerType; }
set
{
if (value == _customerType ||
String.IsNullOrEmpty(value))
return;
_customerType = value;
if (_customerType == "Company")
{
_customer.IsCompany = true;
}
else if (_customerType == "Person")
{
_customer.IsCompany = false;
}
base.OnPropertyChanged("CustomerType");
base.OnPropertyChanged("LastName");
}
}
CustomerView コントロールには、以下のように、これらの 2 つのプロパティにバインドされた ComboBox が含まれています。
<ComboBox
ItemsSource="{Binding CustomerTypeOptions}"
SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}"
/>
この ComboBox での選択項目が変更されると、新しい値が有効であるかどうかをチェックするためにデータソースの IDataErrorInfo インターフェイスが照会されます。SelectedItem プロパティのバインドで ValidatesOnDataErrors が true に設定されているため、この動作が行われます。データ ソースは CustomerViewModel オブジェクトであるため、バインド システムはその CustomerViewModel に CustomerType プロパティに対する検証エラーについてたずねます。多くの場合、CustomerViewModel は検証エラーに対するすべての要求を内包する Customer オブジェクトに委任します。ただし、Customer では IsCompany プロパティが未選択の状態であることは認識されないため、CustomerViewModel クラスは ComboBox コントロールで選択された新しい項目の検証を処理する必要があります。図 14 に、そのコードを示します。
図 14 CustomerViewModel オブジェクトを検証する
// In CustomerViewModel.cs
string IDataErrorInfo.this[string propertyName]
{
get
{
string error = null;
if (propertyName == "CustomerType")
{
// The IsCompany property of the Customer class
// is Boolean, so it has no concept of being in
// an "unselected" state. The CustomerViewModel
// class handles this mapping and validation.
error = this.ValidateCustomerType();
}
else
{
error = (_customer as IDataErrorInfo)[propertyName];
}
// Dirty the commands registered with CommandManager,
// such as our Save command, so that they are queried
// to see if they can execute now.
CommandManager.InvalidateRequerySuggested();
return error;
}
}
string ValidateCustomerType()
{
if (this.CustomerType == "Company" ||
this.CustomerType == "Person")
return null;
return "Customer type must be selected";
}
このコードで重要な点は、CustomerViewModel の IDataErrorInfo の実装では ViewModel に固有のプロパティに対する検証要求を処理することができ、その他の要求については Customer オブジェクトに委任できることです。これにより、Model クラスの検証ロジックを利用し、ViewModel クラスにとってのみ意味のあるプロパティの検証を追加できます。
CustomerViewModel を保存する機能は、SaveCommand プロパティを通じてビューに対し利用可能になります。このコマンドは前述の RelayCommand クラスを使用して、CustomerViewModel が自身を保存できるかどうか、および状態を保存するよう通知された場合の処理内容を決定できるようにします。このアプリケーションでは、新しい顧客の保存は単に CustomerRepository への追加を意味します。新しい顧客を保存する準備ができたかどうかを決定するには、2 つの部分による同意が必要です。Customer オブジェクトに自身が有効であるかどうかをたずねる必要があり、CustomerViewModel は顧客が有効であるかどうかを決定する必要があります。前述の ViewModel に固有のプロパティと検証を処理するため、これら 2 つの部分による決定が必要です。図 15 に、CustomerViewModel の保存ロジックを示します。
図 15 CustomerViewModel の保存ロジック
// In CustomerViewModel.cs
public ICommand SaveCommand
{
get
{
if (_saveCommand == null)
{
_saveCommand = new RelayCommand(
param => this.Save(),
param => this.CanSave
);
}
return _saveCommand;
}
}
public void Save()
{
if (!_customer.IsValid)
throw new InvalidOperationException("...");
if (this.IsNewCustomer)
_customerRepository.AddCustomer(_customer);
base.OnPropertyChanged("DisplayName");
}
bool IsNewCustomer
{
get
{
return !_customerRepository.ContainsCustomer(_customer);
}
}
bool CanSave
{
get
{
return
String.IsNullOrEmpty(this.ValidateCustomerType()) &&
_customer.IsValid;
}
}
このような方法で ViewModel を使用することにより、Customer オブジェクトの表示可能なビューを作成し、Boolean プロパティの "未選択" 状態を処理することなどがはるかに簡単になりました。また、状態を保存するよう顧客に簡単に通知する機能も提供されます。ビューが直接 Customer オブジェクトにバインドされていたとしたら、これを正しく動作させるために大量のコードが必要になります。適切にデザインされた MVVM アーキテクチャでは、ほとんどの View の分離コードは空であるか、またはせいぜいそのビュー内部のコントロールやリソースを操作するコードのみを含んでいる必要があります。時には、通常は ViewModel 自身から呼び出すことが非常に困難な、イベントのフックやメソッドの呼び出しなどの ViewModel オブジェクトと対話するコードを View の分離コードに作成することも必要になります。
All Customers ビュー
デモ アプリケーションには、ListView 内のすべての顧客を表示するワークスペースも含まれています。リスト内の顧客は会社または個人でグループ化されています。ユーザーは 1 つ以上の顧客を同時に選択し、売上額の合計を右下隅に表示できます。
UI には AllCustomersView コントロールを使用し、これは AllCustomersViewModel オブジェクトを表示します。それぞれの ListViewItem は、AllCustomerViewModel オブジェクトによって公開される AllCustomers コレクションの CustomerViewModel オブジェクトを表します。前のセクションでは CustomerViewModel がデータ入力フォームとして表示されるしくみを調べましたが、今度はその CustomerViewModel オブジェクトが ListView の項目として表示されます。CustomerViewModel クラスは表示するビジュアル要素について認識しておらず、そのことが再利用を可能にしています。
AllCustomersView は ListView で表示されるグループを作成します。これは、ListView の ItemsSource を図 16 のように構成された CollectionViewSource にバインドすることで実現します。
図 16 CollectionViewSource
<!-- In AllCustomersView.xaml -->
<CollectionViewSource
x:Key="CustomerGroups"
Source="{Binding Path=AllCustomers}"
>
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="IsCompany" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<!--
Sort descending by IsCompany so that the ' True' values appear first,
which means that companies will always be listed before people.
-->
<scm:SortDescription PropertyName="IsCompany" Direction="Descending" />
<scm:SortDescription PropertyName="DisplayName" Direction="Ascending" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
ListViewItem と CustomerViewModel オブジェクトの間の関連付けは ListView の ItemContainerStyle プロパティによって確立されます。このプロパティに割り当てられた Style はそれぞれの ListViewItem に適用され、ListViewItem のプロパティが CustomerViewModel のプロパティにバインドできるようになります。この Style での重要なバインドの 1 つにより、ListViewItem の IsSelected プロパティと CustomerViewModel の IsSelected プロパティの間に以下のようなリンクが作成されます。
<Style x:Key="CustomerItemStyle" TargetType="{x:Type ListViewItem}">
<!-- Stretch the content of each cell so that we can
right-align text in the Total Sales column. -->
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<!--
Bind the IsSelected property of a ListViewItem to the
IsSelected property of a CustomerViewModel object.
-->
<Setter Property="IsSelected" Value="{Binding Path=IsSelected,
Mode=TwoWay}" />
</Style>
CustomerViewModel が選択されたり、または選択が解除されると、選択されたすべての顧客の売上額の合計が変わります。ListView の下の ContentPresenter が正しい数を表示できるように、AllCustomersViewModel クラスはこの値を維持する必要があります。図 17 に、AllCustomersViewModel が各顧客の選択または選択解除を監視し、表示の値を更新する必要があることをビューに通知する方法を示します。
図 17 選択または選択解除を監視する
// In AllCustomersViewModel.cs
public double TotalSelectedSales
{
get
{
return this.AllCustomers.Sum(
custVM => custVM.IsSelected ? custVM.TotalSales : 0.0);
}
}
void OnCustomerViewModelPropertyChanged(object sender,
PropertyChangedEventArgs e)
{
string IsSelected = "IsSelected";
// Make sure that the property name we're
// referencing is valid. This is a debugging
// technique, and does not execute in a Release build.
(sender as CustomerViewModel).VerifyPropertyName(IsSelected);
// When a customer is selected or unselected, we must let the
// world know that the TotalSelectedSales property has changed,
// so that it will be queried again for a new value.
if (e.PropertyName == IsSelected)
this.OnPropertyChanged("TotalSelectedSales");
}
UI は TotalSelectedSales プロパティにバインドされ、通貨 (貨幣) の書式設定が値に適用されます。TotalSelectedSales プロパティから Double ではなく String の値を返すことで、ビューの代わりに ViewModel オブジェクトによって通貨の書式設定を適用することもできます。.NET Framework 3.5 SP1 で ContentPresenter の ContentStringFormat プロパティが追加されたため、古いバージョンの WPF を対象とする必要がある場合は、通貨の書式設定をコードで適用する必要があります。
<!-- In AllCustomersView.xaml -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="Total selected sales: " />
<ContentPresenter
Content="{Binding Path=TotalSelectedSales}"
ContentStringFormat="c"
/>
</StackPanel>
まとめ
WPF はアプリケーション開発者に多くのメリットを提供し、この機能を活用するための学習には思考の変化が要求されます。Model-View-ViewModel パターンは、WPF アプリケーションをデザインおよび実装するための簡素で効果的な一連のガイドラインです。これにより、データ、動作、表現を強力に分離することが可能になり、混沌としたソフトウェア開発をより簡単に管理できるようになります。
この記事の執筆にご協力いただいた John Gossman 氏に感謝します。
Josh Smith は、WPF を使用して優れたユーザー エクスペリエンスを実現することに情熱を注いでおり、WPF コミュニティにおける活動に対して Microsoft MVP の称号を授与されました。Josh は、Experience Design Group の Infragistics に勤めています。コンピュータの前にいないときは、ピアノを弾いたり、歴史書を読んだり、ガールフレンドとニューヨーク シティを散策したりしています。Josh は joshsmithonwpf.wordpress.com でブログを公開しています。