設計パターン
モデル - ビュー - ビューモデル (MVVM: Model-View-ViewModel) の問題点とその解決策
Robert McCarter
Windows Presentation Foundation (WPF) と Silverlight には最新のアプリケーションを構築するための高度な API が用意されていますが、WPF のさまざまな機能を理解し、互いに調和させて適用し、設計が適切で保守しやすいアプリケーションを構築することは、難しくなる場合があります。このような問題を解決する糸口はどこにあるでしょう。また、アプリケーションの適切な構築方法とはどのようなものでしょう。
モデル - ビュー - ビューモデル (MVVM: Model-View-ViewModel) 設計パターンは、WPF アプリケーションおよび Silverlight アプリケーションを構築する際によく使われる手法です。MVVM 設計パターンは、強力なアプリケーション構築ツールであり、開発者がアプリケーションの設計について議論する際に土台となる共通認識でもあります。MVVM は非常に有用なパターンですが、その歴史はまだかなり浅く、誤解もあります。
どのような場合に MVVM 設計パターンが適していて、どのような場合は必要ないのでしょう。アプリケーションの構造はどのようにすべきでしょう。ビューモデル層の作成と保守にかかる作業はどの程度で、ビューモデル層のコード量を削減するための代替手段にはどのようなものがあるでしょう。モデルに含まれる関連プロパティを巧みに処理する方法とはどのようなものでしょう。モデルに含まれるコレクションをどのような方法でビューに公開すればよいでしょう。どこでビューモデル オブジェクトのインスタンスを作成してモデル オブジェクトにフックすべきでしょう。
今回のコラムでは、ビューモデルのしくみについて説明し、コードでのビューモデルの実装に関わるメリットと問題点をいくつか紹介します。また、モデル オブジェクトをビュー層で公開するために、ビューモデルをドキュメント マネージャーとして使用する具体例を調査します。
モデル、ビューモデル、およびビュー
私がこれまでに携わってきたすべての WPF および Silverlight アプリケーションのコンポーネントでは、基本的に同じ設計を採用してきました。つまり、モデルをアプリケーションの中核とし、オブジェクト指向分析設計 (OOAD) のベスト プラクティスに従ってアプリケーションを設計するために多くの労力を費やしてきました。
私にとって、モデルがアプリケーションの心臓部であり、最も重要なビジネス資産を表しています。なぜなら、すべての複雑なビジネス エンティティ、エンティティの関係、およびエンティティの機能がモデルで把握されるためです。
モデル層の上位に位置するのが、ビューモデル層です。ビューモデルの 2 つの主な目標は、WPF/XAML ビューでモデルを簡単に使用できるようにすることと、モデルとビューとを分離してカプセル化することです。どちらもすばらしい目標ですが、実用的な理由から達成できないこともあります。
ビューモデルを構築する際、開発者はユーザーがアプリケーションと対話する方法を大まかに認識しているものです。しかし、MVVM 設計パターンで重要なことは、ビューモデルはビューについて何も認識しないことです。このため、対話のデザイナーやグラフィック アーティストは、ビューモデルの上位に美しく実用的な UI を作成できるだけでなく、開発者と緊密に連携して、UI の効果を高める適切なビューモデルを設計することもできます。また、ビューとビューモデルが分離されているため、ビューモデルの単体テストや再利用がさらに容易になります。
モデル層、ビュー層、およびビューモデル層を厳密に分離するには、各層を別々の Visual Studio プロジェクトとして構築します。再利用可能なユーティリティ、メインの実行可能アセンブリ、および (膨大な数になるはずの) 単体テスト プロジェクトが組み合わさり、こうしたソリューションではプロジェクトとアセンブリの数が非常に多くなることがあります (図 1 参照)。
図 1 MVVM アプリケーションのコンポーネント
プロジェクト数が多くなることを考えれば、このように厳密に分離する手法が適しているのは、もちろん大規模プロジェクトです。1 ~ 2 人だけの開発者で作成する小規模アプリケーションの場合、このように厳密に分離する手法を採用しても複数のプロジェクトを作成、構成、および保守する手間をかけるほどのメリットを得られないことがあります。そのため、同じプロジェクトの中で異なる名前空間にコードを分離するだけで十分な場合もあります。
ビューモデルの作成と保守は簡単ではないため、慎重に行ってください。ただし、「MVVM 設計パターンの検討が必要なときと必要ではないとき」という最も基本的な問題点は、ドメイン モデルで解決されることがよくあります。
大規模プロジェクトでは、ドメイン モデルが非常に複雑になり、さまざまな種類のアプリケーション (Web サービス、WPF アプリケーション、ASP.NET アプリケーションなど) と洗練された方法で連携するよう注意深く設計された数百ものクラスが存在することがあります。連携する複数のアセンブリからモデルが構成されていることもあります。また、非常に大きな組織では、専門の開発チームがドメイン モデルを構築して保守していることもあります。
ドメイン モデルが大規模で複雑になるときは、ビューモデル層を導入すると必ずと言ってよいほど有効に機能します。
一方、ドメイン モデルが単純で、データベースを覆う薄い層にすぎない場合もあります。このようなモデル クラスは自動生成されることがあり、多くの場合 INotifyPropertyChanged インターフェイスが実装されます。通常、UI はリストやグリッドのコレクションで、基になるデータをユーザーが操作できる編集フォームを備えています。マイクロソフトのツールセットは、この種のアプリケーションをすばやく容易に構築することに優れていました。
構築中のモデルやアプリケーションがこのカテゴリに該当する場合にビューモデルを使用すると、おそらく許容できないほど高いオーバーヘッドが発生し、アプリケーション設計に十分なメリットを得られないでしょう。
ただし、このような状況でもビューモデルが役に立つことがあります。たとえば、ビューモデルは元に戻す機能の実装に適しています。また、アプリケーションの一部 (後で説明するドキュメント管理など) で MVVM を利用し、状況に応じてモデルをビューに直接公開することもできます。
ビューモデルを使用する理由
ビューモデルがアプリケーションに適していると思える場合でも、コーディングを始める前にいくつか問題を解決しておく必要があります。最初の 1つは、プロキシ プロパティの数を削減する方法です。
MVVM 設計パターンによってビューとモデルの分離が促進されることは、このパターンの重要で価値ある側面です。このように分離が促進される結果として、ビューで公開する必要があるプロパティが 10 個モデル クラスに含まれるとすると、通常、基になるモデル インスタンスの呼び出しのプロキシとして機能する同等のプロパティがビューモデルにも 10 個含まれることになります。一般に、これらのプロキシ プロパティを設定するとプロパティ変更イベントが発生し、変更されたことがビューに通知されます。
必ずしもすべてのモデル プロパティがビューモデル プロキシ プロパティを必要とするとは限りませんが、ビューで公開する必要があるすべてのモデル プロパティには、通常はプロキシ プロパティが存在します。一般的なプロキシ プロパティは次のようになります。
public string Description {
get {
return this.UnderlyingModelInstance.Description;
}
set {
this.UnderlyingModelInstance.Description = value;
this.RaisePropertyChangedEvent("Description");
}
}
複雑なアプリケーションになると、このようにビューモデル経由でユーザーに公開する必要があるモデル クラスの数が数十にも数百にもなります。これは、MVVM パターンによる分離では避けて通れない問題です。
このようなプロキシ プロパティの作成は面倒で、間違いを起こしやすい作業です。特に、プロパティ変更イベントを発生させるにはそのプロパティ名に一致する文字列が必要になるため (しかも自動的なコード リファクタリングの対象にはならないため)、間違いが起こります。これらのプロキシ イベントを取り除く一般的な解決策は、次のように直接ビューモデルのラッパーからモデル インスタンスを公開し、ドメイン モデルで INotifyPropertyChanged インターフェイスを実装することです。
public class SomeViewModel {
public SomeViewModel( DomainObject domainObject ) {
Contract.Requires(domainObject!=null,
"The domain object to wrap must not be null");
this.WrappedDomainObject = domainObject;
}
public DomainObject WrappedDomainObject {
get; private set;
}
...
このようにすると、ビューモデルはビューに必要なコマンドや追加プロパティを公開でき、モデル プロパティが重複することも、大量のプロキシ プロパティが作成されることもありません。この手法は確かに魅力的で、とりわけモデル クラスで既に INotifyPropertyChanged インターフェイスを実装している場合は魅力が高まります。モデルがこのインターフェイスを実装することは必ずしも不適切ではありません。むしろ、Microsoft .NET Framework 2.0 と Windows Forms フォーム アプリケーションでは一般的でさえありました。しかし、ドメイン モデルが複雑になるため、ASP.NET アプリケーションやドメイン サービスでは有効ではありません。
この手法を採用するとビューがモデルに依存しますが、これはデータ バインドを通じた間接的な依存関係でしかないので、ビュー プロジェクトからモデル プロジェクトへのプロジェクト参照を追加する必要はありません。このため、完全に実用的な理由からこの手法が役に立つことがあります。
しかし、この手法は MVVM 設計パターンの精神から外れているうえに、ビューモデル固有の新機能 (元に戻す機能など) を導入しにくくなります。私は、この手法を使用したシナリオで大幅にやり直さなければならなくなったことが何度もあります。幾重にも入れ子になったプロパティにデータ バインドしている状況を想像してください。現在のデータ コンテキストが Person ビューモデルで、Person ビューモデルに Address プロパティがあるとすると、データ バインドは次のようになります。
{Binding WrappedDomainObject.Address.Country}
Address オブジェクトにビューモデルの追加機能を導入する必要が生じたら、WrappedDomainObject.Address へのデータ バインド参照を削除して、新しいビューモデル プロパティを使用する必要があります。このような状況が問題になるのは、XAML のデータ バインド (おそらくデータ コンテキストも) の更新をテストするのが難しいためです。ビューは、自動化された包括的な回帰テストが存在しないコンポーネントの 1 つです。
動的プロパティ
プロキシ プロパティの増加に対処する私の解決策は、.NET Framework 4 と WPF で新しくサポートされるようになった動的オブジェクトと動的メソッドのディスパッチを使用することです。動的メソッドのディスパッチを使用すると、実際はクラスに存在しないプロパティの読み取りや書き込みの処理方法を実行時に決定できます。つまり、基になるモデルをカプセル化したまま、ビューモデルに直接記述していたプロキシ プロパティをすべて取り除くことができます。ただし、Silverlight 4 では動的プロパティへのバインドがサポートされないことに注意してください。
この機能の最も単純な実装方法は、ビューモデル基本クラスで新しい System.Dynamic.DynamicObject クラスを拡張して、TryGetMember メソッドと TrySetMember メソッドをオーバーライドすることです。参照先プロパティがクラスに存在しないときは、動的言語ランタイム (DLR) によってこれら 2 つのメソッドが呼び出されるため、不足しているプロパティの実装方法を実行時に判断できます。簡単なリフレクションと組み合わせると、次のようなわずか数行のコードで、プロパティから基になるモデル インスタンスへのアクセスをビューモデル クラスで動的にプロキシ化できます。
public override bool TryGetMember(
GetMemberBinder binder, out object result) {
string propertyName = binder.Name;
PropertyInfo property =
this.WrappedDomainObject.GetType().GetProperty(propertyName);
if( property==null || property.CanRead==false ) {
result = null;
return false;
}
result = property.GetValue(this.WrappedDomainObject, null);
return true;
}
このメソッドでは、まず、リフレクションを使用して基になるモデル インスタンスのプロパティを検出します (詳細については、2009 年 6 月号の「CLR 徹底解剖」コラム「リフレクションについての考察」を参照してください)。モデルにそのようなプロパティが存在しなければ、メソッドは false を返して失敗するため、データ バインドが失敗します。プロパティが存在すれば、メソッドはプロパティ情報を使用して、モデルのプロパティ値を取得して返します。従来のプロキシ プロパティの get メソッドよりもメソッド内の処理は増加しますが、この実装を作成するだけですべてのモデルとプロパティに使用できます。
動的プロキシ プロパティの手法は、プロパティ セッターで真価を発揮します。TrySetMember メソッドには、プロパティ変更イベントの発生など、共通ロジックも含めることができます。コードは次のようになります。
public override bool TrySetMember(
SetMemberBinder binder, object value) {
string propertyName = binder.Name;
PropertyInfo property =
this.WrappedDomainObject.GetType().GetProperty(propertyName);
if( property==null || property.CanWrite==false )
return false;
property.SetValue(this.WrappedDomainObject, value, null);
this.RaisePropertyChanged(propertyName);
return true;
}
このメソッドでも、まず、リフレクションを使用して基になるモデル インスタンスのプロパティを検出します。プロパティが存在しないか読み取り専用であれば、メソッドは false を返して失敗します。プロパティがドメイン オブジェクトに存在すれば、プロパティ情報を使用してモデル プロパティを設定します。その後、すべてのプロパティ セッターに共通する任意のロジックを含めることができます。このサンプル コードでは、このメソッドで設定したプロパティのプロパティ変更イベントが発生するだけですが、他の処理も簡単に実行できます。
モデルのカプセル化に関する課題の 1 つは、統一モデリング言語で派生プロパティと呼ばれるプロパティがモデルに含まれているのが多いことです。たとえば、Person クラスには、BirthDate プロパティと Age 派生プロパティが含まれています。Age プロパティは読み取り専用で、次のように生年月日と現在日付に基づいて年齢を自動計算します。
public class Person : DomainObject {
public DateTime BirthDate {
get; set;
}
public int Age {
get {
var today = DateTime.Now;
// Simplified demo code!
int age = today.Year - this.BirthDate.Year;
return age;
}
}
...
計算によって生年月日から年齢を導き出しているため、BirthDate プロパティが変更されると Age プロパティも暗黙のうちに変更されます。このため、BirthDate プロパティが設定されたら、ビューモデル クラスでは BirthDate プロパティと Age プロパティの両方に対してプロパティ変更イベントを発生させる必要があります。動的ビューモデル手法では、モデル内でこのプロパティ間の関係を明示的に指定することで、イベントを自動的に発生させることができます。
この手法では、まず、プロパティの関係を把握する次のカスタム属性が必要です。
[AttributeUsage(AttributeTargets.Property, AllowMultiple=true)]
public sealed class AffectsOtherPropertyAttribute : Attribute {
public AffectsOtherPropertyAttribute(
string otherPropertyName) {
this.AffectsProperty = otherPropertyName;
}
public string AffectsProperty {
get;
private set;
}
}
この属性では、AllowMultiple プロパティを true に設定して、1 つのプロパティが複数の他のプロパティに影響を及ぼすシナリオをサポートします。この属性を適用して BirthDate プロパティと Age プロパティの関係をモデルに直接記述する方法は、次のように簡単です。
[AffectsOtherProperty("Age")]
public DateTime BirthDate { get; set; }
続いて、動的ビューモデル クラスでこの新しいモデルのメタデータを使用するには、TrySetMember メソッドに次のように 3 行のコードを追加するだけです。
public override bool TrySetMember(
SetMemberBinder binder, object value) {
...
var affectsProps = property.GetCustomAttributes(
typeof(AffectsOtherPropertyAttribute), true);
foreach(AffectsOtherPropertyAttribute otherPropertyAttr
in affectsProps)
this.RaisePropertyChanged(
otherPropertyAttr.AffectsProperty);
}
リフレクションを行ったプロパティ情報を既に取得しているので、GetCustomAttributes メソッドではモデル プロパティのすべての AffectsOtherProperty 属性を返すことができます。その後は、AffectsOtherProperty 属性をループ処理し、属性ごとにプロパティ変更イベントを発生させます。これで、ビューモデルを通じて BirthDate プロパティを変更すると、BirthDate プロパティと Age プロパティの両方のプロパティ変更イベントを自動的に発生することができるようになります。
動的ビューモデル クラス (または、モデル固有のビューモデル派生クラス) のプロパティを明示的にプログラミングすると、DLR から TryGetMember メソッドと TrySetMember メソッドが呼び出されず、プロパティが直接呼び出されることに注意してください。プロパティが直接呼び出されると、この自動機能が失われます。ただし、カスタム プロパティでもこの機能が使用されるよう、簡単にコードをリファクタリングできます。
次のような、(ビューモデルが現在の WPF データ コンテキストになる) 幾重にも入れ子になったプロパティにデータ バインドしている問題についてもう一度考えてみましょう。
{Binding WrappedDomainObject.Address.Country}
動的プロキシ プロパティを使用すると、基になるラップされたドメイン オブジェクトが公開されなくなるため、データ バインドは次の形式になります。
{Binding Address.Country}
この手法でも、Address プロパティは基になるモデルの Address インスタンスに直接アクセスしています。しかし、Person ビューモデル クラスに新しいプロパティを追加するだけで Address プロパティにビューモデルを導入できるようになります。新しい Address プロパティは次のように非常に単純です。
public DynamicViewModel Address {
get {
if( addressViewModel==null )
addressViewModel =
new DynamicViewModel(this.Person.Address);
return addressViewModel;
}
}
private DynamicViewModel addressViewModel;
プロパティ名が Address のままなので XAML のデータ バインドを変更する必要はありませんが、DLR では動的な TryGetMember メソッドではなく新しい具象プロパティが呼び出されます (この Address プロパティでの遅延インスタンス作成はスレッド セーフではありません。しかし、ビューだけがビューモデルにアクセスすべきで、WPF や Silverlight のビューは単一スレッドのため、この点について考慮する必要はありません)。
この手法は、モデルで INotifyPropertyChanged を実装している場合でも使用できます。ビューモデルでは、INotifyPropertyChanged の実装を認識して、プロパティ変更イベントがプロキシ化しないようにできます。そのためには、基になるモデル インスタンスのプロパティ変更イベントをビューモデルでリッスンし、ビューモデルのイベントとして再度発生させます。動的ビューモデル クラスのコンストラクターでは、次のように確認を行ってその結果を覚えています。
public DynamicViewModel(DomainObject model) {
Contract.Requires(model != null,
"Cannot encapsulate a null model");
this.ModelInstance = model;
// Raises its own property changed events
if( model is INotifyPropertyChanged ) {
this.ModelRaisesPropertyChangedEvents = true;
var raisesPropChangedEvents =
model as INotifyPropertyChanged;
raisesPropChangedEvents.PropertyChanged +=
(sender,args) =>
this.RaisePropertyChanged(args.PropertyName);
}
}
プロパティ変更イベントが重複して発生しないよう、次のように TrySetMember メソッドを少し変更する必要もあります。
if( this.ModelRaisesPropertyChangedEvents==false )
this.RaisePropertyChanged(property.Name);
したがって、動的プロキシ プロパティを使用すると、標準のプロキシ プロパティを取り除いてビューモデル層を大幅に簡略化できます。この結果、コーディング、テスト、ドキュメント作成、および長期保守作業が大きく軽減されます。新しいプロパティ向けに非常に特殊なビュー ロジックが存在しない限り、新しいプロパティをモデルに追加する際にビューモデル層を更新する必要がなくなります。また、この手法を使用すると、関連プロパティなどの難しい問題を解決できます。ユーザーによるプロパティの変更がすべて TrySetMember 経由で伝播するため、共通の TrySetMember メソッドは元に戻す機能の実装にも役に立ちます。
長所と短所
多くの開発者は、パフォーマンスへの懸念から、リフレクション (および DLR) に不信感を抱いています。私の経験では、パフォーマンスの低下が問題になったことはありません。UI にプロパティを 1 つ設定する際にユーザーがパフォーマンスの低下に気が付くことはあまりありません。ただし、マルチタッチのデザイン サーフェイスなど、対話性に優れた UI の場合はパフォーマンスの低下を無視できないでしょう。
大きなパフォーマンスの問題は、多数のフィールドが存在するビューを初めて読み込むときに発生するだけです。ユーザビリティに関する懸念事項から、画面に表示するフィールド数は当然制限されるため、この DLR の手法による初期データ バインドでパフォーマンスが低下するかどうかは検出できません。
しかし、ユーザー エクスペリエンスに関連している場合は、必ずパフォーマンスを慎重に監視して把握しておく必要があります。このコラムで説明した簡単な手法は、リフレクションをキャッシュするように書き直すことができます。詳細については、2005 年 7 月号の MSDN Magazine の Joel Pobar の記事 (英語) を参照してください。
「リフレクションを使用すると、ビュー層ではビューモデルには実際に存在しないプロパティを参照しているように見えるため、読みやすさと保守性が低下する」という主張には、ある程度の正当性があります。しかし、私の考えでは、手作業で記述するプロキシ プロパティの大半を取り除けることによるメリットは、このようなデメリットをはるかに上回ります。特に、ビューモデルについて適切なドキュメントが存在する場合はメリットが大きくなります。
動的プロキシ プロパティの手法を使用する場合、XAML 内でモデルのプロパティを名前で参照できるようになるため、モデル層を隠蔽する機能が制限されたり使用できなくなったりします。従来のプロキシ プロパティを使用する場合、プロパティは直接参照されてアプリケーションの他の部分から隠蔽されるため、モデル層を隠蔽する機能は制限されません。しかし、ほとんどの隠蔽ツールは XAML や BAML にまだ対応していないので、概してこの問題は重要ではありません。どちらの場合でも、コード クラッカーが XAML や BAML から侵入してモデル層を攻撃するおそれがあります。
最後に、セキュリティ関連のメタデータを含むモデル プロパティを備えることで、ビューモデルでセキュリティを適用していると考えていると、この手法が悪用される可能性があります。セキュリティはビュー固有の役割のようには思えないことから、私はこのような想定によってビューモデルの機能が多くなりすぎると考えています。このようにセキュリティを想定する場合は、モデル内でにアスペクト指向の手法を適用する方がより適しています。
コレクション
コレクションは、MVVM 設計パターンの最も複雑で不十分な側面の 1 つです。モデルで基になるモデルのコレクションを変更する場合、変更を公開してビューを適切に更新できるようにするのはビューモデルの役割です。
残念ながら、モデルでは INotifyCollectionChanged インターフェイスを実装するコレクションはほぼ確実に公開されません。.NET Framework 3.5 では、INotifyCollectionChanged インターフェイスは System.Windows.dll に含まれていたため、このインターフェイスをモデルで使用しないことが強く勧められていました。さいわい、.NET Framework 4 では、このインターフェイスが System.dll に移動され、ずっと自然にモデル内で識別可能なコレクションを使用できるようになりました。
モデルで識別可能なコレクションを使用すると、モデル開発の新しい可能性が開け、このようなコレクションは Windows フォーム アプリケーションや Silverlight アプリケーションでも使用できます。他のあらゆる手法より簡単なので、現在私はこの手法を気に入っており、NotifyCollectionChanged インターフェイスがより一般的なアセンブリに移動したことを喜ばしく思っています。
モデルに識別可能なコレクションがない場合の最高の手法は、他のメカニズム (たいていはカスタム イベント) をモデルで公開して、コレクションが変更されるタイミングを通知することです。この処理は、モデル固有の方法で行います。たとえば、Person クラスに住所のコレクションがある場合、このクラスでは次のようなイベントを発生させることができます。
public event EventHandler<AddressesChangedEventArgs>
NewAddressAdded;
public event EventHandler<AddressesChangedEventArgs>
AddressRemoved;
このようなイベントの発生は、WPF のビューモデル専用に設計されたカスタム コレクション イベントの発生よりもお勧めです。しかし、この手法でも、ビューモデルでコレクションの変更を公開することは困難です。おそらく唯一の手段は、ビューモデルのコレクション プロパティ全体でプロパティ変更イベントを発生させることです。これはせいぜい不十分な解決策でしかありません。
コレクションのもう 1 つの問題は、ビューモデル インスタンス内のコレクションで各モデル インスタンスをラップするタイミングやラップするかどうかを判断することです。サイズが比較的小さいコレクションの場合、新しい識別可能なコレクションをビューモデルで公開して、基になるモデル コレクションの全項目をビューモデルのこの識別可能なコレクションにコピーし、同時にコレクションの各モデル項目を対応するビューモデル インスタンスでラップできます。ビューモデルでは、コレクション変更イベントをリッスンして、ユーザーによる変更を基になるモデルに送信する必要があることもあります。
一方、サイズが非常に大きいコレクションがなんらかの形式の仮想化パネルで公開される場合、最も簡単で実用的な手法は、モデル オブジェクトを直接公開することです。
ビューモデルのインスタンスを作成する
MVVM 設計パターンにおいてめったに取り上げられることがない問題は、ビューモデル インスタンスを作成するタイミングと場所です。この問題は、MVC など、同様の設計パターンの話題でも見過ごされがちです。
私のお気に入りの手法は、ビューモデルのシングルトンを記述し、このシングルトンに、他のあらゆるビューモデル オブジェクトを必要に応じて簡単にビューで取得できる窓口となるメインのビューモデル オブジェクトを用意することです。多くの場合、このマスター ビューモデル オブジェクトには、ビューがドキュメントを開くことができるようにコマンドの実装を用意します。
しかし、私がこれまでに携わってきたほとんどのアプリケーションでは、ドキュメント中心のインターフェイスを備えており、通常は Visual Studio と同様のタブ付きワークスペースを使用しています。このため、ビューモデル層ではドキュメントを中心に考えることにし、ドキュメントでは特定のモデル オブジェクトをラップする少なくとも 1 つのビューモデル オブジェクトを公開します。このようにすると、ビューモデル層の標準的な WPF コマンドで、永続化層を使用して必要なオブジェクトを取得し、取得したオブジェクトをビューモデル インスタンスでラップし、ビューモデルのドキュメント マネージャーを作成してオブジェクトを表示できます。
このコラムに付属するサンプル アプリケーションの場合、新しい Person オブジェクトを作成するビューモデル コマンドは次のとおりです。
internal class OpenNewPersonCommand : ICommand {
...
// Open a new person in a new window.
public void Execute(object parameter) {
var person = new MvvmDemo.Model.Person();
var document = new PersonDocument(person);
DocumentManager.Instance.ActiveDocument = document;
}
}
最後の行で参照しているビューモデルのドキュメント マネージャーは、開いているすべてのビューモデル ドキュメントを管理するシングルトンです。問題は、ビューモデル ドキュメントのコレクションをビューで公開する方法です。
組み込みの WPF タブ コントロールには、ユーザーが期待するような強力なマルチ ドキュメント インターフェイスが用意されていませんが、さいわいなことに、ドッキング機能やタブ付きワークスペースを備えたサードパーティ製の製品を利用できます。ほとんどの製品では、ドッキング可能なツール ウィンドウ、分割ビュー、Ctrl キーを押しながら Tab キーを押すと表示されるポップアップ ウィンドウ (ミニドキュメント ビュー付き) など、Visual Studio のタブ付きドキュメントと同じ外観のエミュレーションを行おうとしています。
残念ながら、このようなコンポーネントの大部分では MVVM 設計パターンの組み込みサポートが提供されていません。しかし、アダプター (Adapter) 設計パターンを適用すればビューモデルのドキュメント マネージャーをサードパーティ製のビュー コンポーネントに簡単にリンクできるので、問題ありません。
ドキュメント マネージャーのアダプター
図 2 のようなアダプターの設計では、ビューモデルからビューを参照する必要がまったくないため、MVVM 設計パターンの主要目標が尊重されます (ただし、この例ではドキュメントの概念は完全に UI 上の概念なので、モデル層ではなくビューモデル層で定義されています)。
図 2 ビューに存在するドキュメント マネージャーのアダプター
ビューモデルのドキュメント マネージャーは、開いているビューモデル ドキュメントのコレクションを管理して、現在アクティブなドキュメントを認識します。このように設計すると、ビューモデル層でドキュメント マネージャーを使用してドキュメントを開いたり閉じたりでき、ビューについてまったく認識することなくアクティブなドキュメントを変更できます。この手法のビューモデル部分はかなり単純です。サンプル アプリケーションのビューモデル クラスを図 3 に示します。
図 3 ビューモデル層のドキュメント マネージャー クラスとドキュメント クラス
Document 基本クラスでは、操作に合わせてドキュメントを最新の状態に保つためにドキュメント マネージャーから呼び出されるいくつかの内部ライフサイクル メソッド (Activated、LostActivation、および DocumentClosed) を公開します。Document クラスでは、INotifyPropertyChanged インターフェイスも実装しているためデータ バインドをサポートできます。たとえば、アダプターでは、ビュー ドキュメントの Title プロパティをビューモデルの DocumentTitle プロパティにデータ バインドします。
この手法の最も複雑な部分はアダプター クラスです。このため、このコラムに付属するプロジェクトに作業用のコピーを用意しています。アダプターでは、ドキュメント マネージャーのイベントをサブスクライブし、サブスクライブ対象のイベントを使用してタブ付きワークスペース コントロールを最新の状態に保ちます。たとえば、新しいドキュメントを開いたことがドキュメント マネージャーから通知されると、アダプターではイベントを受け取り、ビューモデル ドキュメントを必要な任意の WPF コントロールでラップして、ラップしたコントロールをタブ付きワークスペースで公開します。
アダプターの機能はもう 1 つあります。それは、ビューモデルのドキュメント マネージャーとユーザー操作の同期を保つことです。したがって、ユーザーがアクティブなドキュメントを変更したりドキュメントを閉じたりした場合にアダプターからドキュメント マネージャーに通知できるように、アダプターではタブ付きワークスペース コントロールのイベントもリッスンする必要があります。
このロジックには特に複雑な箇所はありませんが、いくつか注意が必要な点があります。一部のシナリオではコードが再入可能になるため、このようなシナリオを適切に処理する必要があります。たとえば、ビューモデルでドキュメント マネージャーを使用してドキュメントを閉じると、アダプターはドキュメント マネージャーからイベントを受け取り、ビューの物理ドキュメント ウィンドウを閉じます。この結果、ドキュメントを閉じたことを示すイベントがタブ付きワークスペース コントロールでも発生し、このイベントもアダプターが受け取るので、当然ながら、アダプターのイベント ハンドラーではドキュメント マネージャーにドキュメントを閉じるよう通知します。既にドキュメントは閉じているため、ドキュメント マネージャーではこのような動作を問題なく実行できるよう十分に配慮する必要があります。
もう 1 つの問題は、ビューのアダプターでビューのタブ付きドキュメント コントロールをビューモデルの Document オブジェクトにリンクできる必要があることです。最も堅牢な解決策は、WPF の添付依存関係プロパティを使用することです。アダプターでは、プライベートな添付依存関係プロパティを宣言し、このプロパティを使用して、ビューのウィンドウ コントロールをビューモデル ドキュメントのインスタンスにリンクします。
このコラムのサンプル プロジェクトでは AvalonDock という、オープン ソースのタブ付きワークスペース コンポーネントを使用しているため、添付依存関係プロパティは図 4 のコードのようになります。
図 4 ビューのコントロールとビューモデル ドキュメントのリンク
private static readonly DependencyProperty
ViewModelDocumentProperty =
DependencyProperty.RegisterAttached(
"ViewModelDocument", typeof(Document),
typeof(DocumentManagerAdapter), null);
private static Document GetViewModelDocument(
AvalonDock.ManagedContent viewDoc) {
return viewDoc.GetValue(ViewModelDocumentProperty)
as Document;
}
private static void SetViewModelDocument(
AvalonDock.ManagedContent viewDoc, Document document) {
viewDoc.SetValue(ViewModelDocumentProperty, document);
}
アダプターでは、新しいビューのウィンドウ コントロールの作成時に、新しいウィンドウ コントロールの添付プロパティを基になるビューモデル ドキュメントに設定します (図 5 参照)。このコードでは、タイトルのデータ バインドを構成する方法や、アダプターでデータ コンテキストとビューのドキュメント コントロールに表示するコンテンツの両方を構成する方法も示しています。
図 5 添付プロパティの設定
private AvalonDock.DocumentContent CreateNewViewDocument(
Document viewModelDocument) {
var viewDoc = new AvalonDock.DocumentContent();
viewDoc.DataContext = viewModelDocument;
viewDoc.Content = viewModelDocument;
Binding titleBinding = new Binding("DocumentTitle") {
Source = viewModelDocument };
viewDoc.SetBinding(AvalonDock.ManagedContent.TitleProperty,
titleBinding);
viewDoc.Closing += OnUserClosingDocument;
DocumentManagerAdapter.SetViewModelDocument(viewDoc,
viewModelDocument);
return viewDoc;
}
ビューのドキュメント コントロールのコンテンツを設定することで、この特定の種類のビューモデル ドキュメントを表示する方法の決定という厄介な作業を WPF で実行できるようにしています。ビューモデル ドキュメントの実際のデータ テンプレートは、メインの XAML ウィンドウに含まれているリソース ディクショナリに存在しています。
このビューモデルのドキュメント マネージャーによる手法を使用する際に、私は WPF と Silverlight の両方を問題なく併用できました。ビュー層のコードはアダプターだけで容易にテストでき、テスト後はそのままにできます。この手法では、ビューモデルが完全にビューから分離されます。また、私はアダプター クラスに最小限の変更を加えただけでビューモデルやモデルをまったく変更せずに、タブ付きワークスペース コンポーネントのベンダーを切り替えたことがあります。
ビューモデル層のドキュメント操作機能の使用感は洗練されており、ここで説明したようなビューモデル コマンドは簡単に実装できます。ビューモデルのドキュメント クラスは、ドキュメントに関連する ICommand のインスタンスを公開するわかりやすい場所にもなります。
ビューではこのようなコマンドをフックするので、MVVM 設計パターンの長所が際立ちます。また、ユーザーがドキュメントを作成する前にデータを (折りたたみ可能なツール ウィンドウなどで) 公開する必要がある場合、ビューモデルのドキュメント マネージャーによる手法をシングルトンの手法と組み合わせて使用することもできます。
まとめ
MVVM 設計パターンは強力で便利なパターンですが、すべての問題を解決できる設計パターンは存在しません。ここで説明したように、MVVM パターンと他のパターン (アダプターやシングルトンなど) の目標を組み合わせると同時に、動的ディスパッチなどの .NET Framework 4 の新機能も活用すると、MVVM 設計パターンの実装に関する一般的な懸念事項の多くに対処するうえで役に立ちます。MVVM を適切な方法で利用すると、WPF および Silverlight アプリケーションがより洗練され、保守しやすくなります。MVVM の詳細については、2009 年 2 月号の MSDN Magazine の Josh Smith の記事を参照してください。
Robert McCarter は、カナダのフリーのソフトウェア開発者であり、アーキテクトであり、企業家でもあります。ブログは robertmccarter.wordpress.com (英語) です。
この記事のレビューに協力してくれた技術スタッフの Josh Smith に心より感謝いたします。