これまでの説明でも用語として少し出てきたが、WPFによるGUIアプリケーション開発では(特に、アプリケーション規模が大きい場合)、Figure 4に示すような、ビューとモデルの間に「ビューモデル」と呼ばれるものを挟んだ3階層アーキテクチャで作成する場合が多い。このようなアーキテクチャ・パターンを、「MVVM(Model-View-ViewModel)パターン」と呼ぶ。
MVVMパターンは、有名なMVC(Model-View-Controller)パターンと同種のアーキテクチャ・パターンで、WPFの強力なデータ・バインディング機能の利用を前提とした亜種だと考えればいいだろう。
●ビューモデルの役割
MVVMパターンにおいて、ビューモデルは以下の2つの役割を担う。
○モデルをラッピング
前回で説明したように、WPFのデータ・バインディングの機能を最大限享受するためには、データ・ソースがINotifyPropertyChangedインターフェイス(System.ComponentModel名前空間)を実装している必要がある。また、前節での説明のとおり、ユーザーの操作に応じて何らかの処理を行う場合には、ICommandインターフェイスを実装したコマンドを利用することになる。さらに、ユーザーからの入力に不整合がないかなどのデータ検証を行うためには、IDataErrorInfoインターフェイス(System.ComponentModel名前空間)を実装する。
これらのインターフェイスはWPF専用というわけではなく、実際、WindowsフォームやASP.NETでも使われているものではあるが、それでもすべてのモデルがこれらを実装しているわけではない。そこで、モデルをラッピングして、INotifyPropertyChangedインターフェイスによるプロパティ値の変更通知などを実装するのが、ビューモデルの役割の1つである。
○ビューから状態を分離
一般に、GUI部分(=ビュー)のテストは手間がかかりがちなため、ビューは極力小さくとどめたいという要求があり、「ビューから状態を分離する」ということがよく行われる。
「状態の分離」といわれてもピンと来ないかもしれないが、WPFのコマンドの仕組みを前提として説明するなら、以下のような2つのテストをそれぞれ別のクラスに分離したいということである。
この分離が不明瞭(ふめいりょう)な場合、考え得るすべての状態(仮にm個とする)のときに、考え得るすべての入力(仮にn個)を与えてテストを行う必要が生じ、テスト・ケースが膨大な数(m×n個)となる。これに対して、明確に分離すれば、テスト・ケースを少なく(m+n個)抑えられる。
もともと、「ビューとモデルの疎結合」という考え方も、ビューを小さくとどめるための工夫の1つである。しかし、モデルはさまざまなビューでの利用が(場合によってはコンソール・アプリケーションでも)可能な形で作られるもので、ある特定のビュー上でしか必要のないような状態を記録しておく場所ではない。そこで、このような特定のビュー上でのみ利用する状態を保持する場所として、ビューモデルを利用することになる。
●データ・バインディングによる疎結合
「ビューとモデルの疎結合」や、「ビューからの状態の分離」が可能なのも、Figure 5に示すように、INotifyPropertyChangedインターフェイスやICommandインターフェイスなどの標準的なインターフェイスを介した通知機構のおかげである。
このようなインターフェイスを介した通知機構を利用する際、最後に残る問題は、コマンドの登録やPropertyChangedイベントに対するハンドラの登録を誰がどこで行うかという部分である(Figure 5中の赤い枠線内のような処理)。WPFのデータ・バインディングでは、この手の登録作業を、
View.DataContext = ViewModel;
などと書くだけで、フレームワークの内部ですべて自動的に行ってくれるため、非常に手軽になっている(※ここでいう「View」とはビューとなるWPFウィンドウなどのことで、「ViewModel」とはビューモデルとなるオブジェクトのこと。詳しくは前回のデータ・バインディングに関する説明を参照されたい)。
●ビューモデルの作成方法
それでは、INotifyPropertyChanged、ICommand、IDataErrorInfoなどのインターフェイスの実装方法について見ていこう。なお、ビューモデルとビューをデータ・バインディングするための前提条件として、ビュー側のコンストラクタに、
this.DataContext = new ViewModel(); // C#の場合
Me.DataContext = New ViewModel() ' VBの場合
というコードが用意され、XAMLコードに、
<StackPanel>
<TextBox Text="{Binding X, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
ToolTip="{Binding RelativeSource ={x:Static RelativeSource.Self},
Path=(Validation.Errors)[0].ErrorContent}"/>
<Button Content="OK" Command="{Binding OkCommand}" />
</StackPanel>
というコードがあらかじめ記述されているものとする(このコードにある「Bindingマークアップ拡張」の書き方については、第5回の記事を参照してほしい)。
○INotifyPropertyChangedインターフェイスの実装
INotifyPropertyChangedインターフェイスは、PropertyChangedイベントを持っていて、プロパティの値が変化した際には、このイベントを起こすことになる。例えば、int/Integer型の「X」というプロパティを実装するなら、List 4に示すようなコードを書く。
using System.ComponentModel;
public class ViewModel : INotifyPropertyChanged
{
private int _x;
public int X
{
get { return _x; }
set
{
if (_x != value)
{
_x = value;
RaisePropertyChanged("X");
}
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected void RaisePropertyChanged(string propertyName)
{
var d = PropertyChanged;
if (d != null)
d(this, new PropertyChangedEventArgs(propertyName));
}
}
Imports System.ComponentModel
Public Class ViewModel : Implements INotifyPropertyChanged
Private _x As Integer
Public Property X As Integer
Get
Return _x
End Get
Set(ByVal value As Integer)
If _x <> value Then
_x = value
RaisePropertyChanged("X")
End If
End Set
End Property
Public Event PropertyChanged(ByVal sender As Object, ByVal e As PropertyChangedEventArgs) Implements INotifyPropertyChanged.PropertyChanged
Protected Sub RaisePropertyChanged(ByVal propertyName As String)
RaiseEvent PropertyChanged( _
Me, New PropertyChangedEventArgs(propertyName))
End Sub
End Class
また、「X+Y」の値を返すような、Xプロパティに依存する別のプロパティ(今回の例では「Sum」というプロパティ)を作る場合には、List 5に示すようなコードを書く(追記した個所は太字で示している)。
……省略……
public int Sum { get { return X + Y; } }
// ToDo: ここにYプロパティも実装する必要がある
private int _x;
public int X
{
get { return _x; }
set
{
if (_x != value)
{
_x = value;
RaisePropertyChanged("X");
RaisePropertyChanged("Sum");
}
}
}
……省略……
……省略……
Public ReadOnly Property Sum As Integer
Get
Return X + Y
End Get
End Property
' ToDo: ここにYプロパティも実装する必要がある
Private _x As Integer
Public Property X As Integer
Get
Return _x
End Get
Set(ByVal value As Integer)
If _x <> value Then
_x = value
RaisePropertyChanged("X")
RaisePropertyChanged("Sum")
End If
End Set
End Property
……省略……
○ICommandインターフェイス(=コマンド)の実装
前述のとおり、コマンドの仕組みを使う際にはICommandインターフェイスを実装したクラスを作成する。ただ、コマンド1つ1つに対して毎度クラスを作成するのは手間がかかるので、ヘルパー的なクラスを作っておく場合が多い。
特によく使われるパターンは、List 6に示すような、ExecuteメソッドやCanExecuteメソッド内ではデリゲートを呼び出すだけというクラスである(DelegateCommandという名前を付けることが多い)。
using System;
using System.Windows.Input;
public class DelegateCommand : ICommand
{
public Action<object> ExecuteHandler { get; set; }
public Func<object, bool> CanExecuteHandler { get; set; }
#region ICommand メンバー
public bool CanExecute(object parameter)
{
var d = CanExecuteHandler;
return d == null ? true : d(parameter);
}
public void Execute(object parameter)
{
var d = ExecuteHandler;
if (d != null)
d(parameter);
}
public event EventHandler CanExecuteChanged;
public void RaiseCanExecuteChanged()
{
var d = CanExecuteChanged;
if (d != null)
d(this, null);
}
#endregion
}
Public Class DelegateCommand : Implements ICommand
Public Property ExecuteHandler As Action(Of String)
Public Property CanExecuteHandler As Func(Of Object, Boolean)
#Region "ICommand メンバー"
Public Function CanExecute(ByVal parameter As Object) As Boolean Implements ICommand.CanExecute
Dim d = CanExecuteHandler
Return IIf(d = Nothing, True, d(parameter))
End Function
Public Sub Execute(ByVal parameter As Object) Implements ICommand.Execute
Dim d = ExecuteHandler
If d <> Nothing Then
d(parameter)
End If
End Sub
Public Event CanExecuteChanged(ByVal sender As Object, ByVal e As EventArgs) Implements ICommand.CanExecuteChanged
Public Sub RaiseCanExecuteChanged()
RaiseEvent CanExecuteChanged(Me, Nothing)
End Sub
#End Region
End Class
このDelegateCommandクラスを用いて、「Xプロパティの値が正のときだけ実行ができ、実行時にはメッセージボックスを表示する」というようなコマンドを実装するなら、ビューモデルのコードはList 7のようになる(List 4から追記した個所は太字で示している。List 5で追記した部分は削除した)。
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
public class ViewModel : INotifyPropertyChanged
{
private int _x;
public int X
{
get { return _x; }
set
{
if (_x != value)
{
_x = value;
RaisePropertyChanged("X");
((DelegateCommand)OkCommand).
RaiseCanExecuteChanged();
}
}
}
private void OkCommandExecute(object parameter)
{
MessageBox.Show("コマンドが実行されました。");
}
private bool OkCommandCanExecute(object parameter)
{
return X > 0;
}
private ICommand _okCommand;
public ICommand OkCommand
{
get
{
if (_okCommand == null)
_okCommand = new DelegateCommand
{
ExecuteHandler = OkCommandExecute,
CanExecuteHandler = OkCommandCanExecute,
};
return _okCommand;
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected void RaisePropertyChanged(string propertyName)
{
var d = PropertyChanged;
if (d != null)
d(this, new PropertyChangedEventArgs(propertyName));
}
}
Imports System.ComponentModel
Public Class ViewModel : Implements INotifyPropertyChanged
Private _x As Integer
Public Property X As Integer
Get
Return _x
End Get
Set(ByVal value As Integer)
If _x <> value Then
_x = value
RaisePropertyChanged("X")
CType(OkCommand, DelegateCommand).
RaiseCanExecuteChanged()
End If
End Set
End Property
Private Sub OkCommandExecute(ByVal parameter As Object)
MessageBox.Show("コマンドが実行されました。")
End Sub
Private Function OkCommandCanExecute(ByVal parameter As Object) As Boolean
Return X > 0
End Function
Private _okCommand As ICommand
Public ReadOnly Property OkCommand As ICommand
Get
If _okCommand Is Nothing Then
_okCommand = New DelegateCommand With
{
.ExecuteHandler = _
AddressOf OkCommandExecute,
.CanExecuteHandler = _
AddressOf OkCommandCanExecute
}
End If
Return _okCommand
End Get
End Property
Public Event PropertyChanged(ByVal sender As Object, ByVal e As PropertyChangedEventArgs) Implements INotifyPropertyChanged.PropertyChanged
Protected Sub RaisePropertyChanged(ByVal propertyName As String)
RaiseEvent PropertyChanged( _
Me, New PropertyChangedEventArgs(propertyName))
End Sub
End Class
○IDataErrorInfoインターフェイス(=データ検証)の実装
前回説明したように、データ・バインディング時にValidatesOnDataErrorsプロパティに「True」を設定することで(Bindingマークアップ拡張は前述のコード例を参照)、IDataErrorInfoインターフェイス(System.ComponentModel名前空間)を用いたデータ検証が有効になる。
例えば、List 8のようなコードを書くことで、Xプロパティの値が正の数でないときに検証エラーを表示できる。
public class ViewModel : INotifyPropertyChanged, IDataErrorInfo
{
// XプロパティやPropertyChangedイベントの実装はList 4と同じ
……省略……
#region IDataErrorInfo メンバー
public string Error
{
get { return X <= 0 ? "X は正の数" : null; }
}
public string this[string columnName]
{
get
{
switch(columnName)
{
case "X": return X <= 0 ? "X は正の数" : null;
}
return null;
}
}
#endregion,
}
Imports System.ComponentModel
Public Class ViewModel : Implements INotifyPropertyChanged, IDataErrorInfo
' XプロパティやPropertyChangedイベントの実装はList 4と同じ
……省略……
#Region "IDataErrorInfo メンバー"
Public ReadOnly Property [Error] As String Implements IDataErrorInfo.Error
Get
Return IIf(X <= 0, "X は正の数", Nothing)
End Get
End Property
Default Public ReadOnly Property Item(ByVal columnName As String) As String Implements IDataErrorInfo.Item
Get
Select Case columnName
Case "X"
Return IIf(X <= 0, "X は正の数", Nothing)
End Select
Return Nothing
End Get
End Property
#End Region
End Class
ただし、この例のような、インデクサの中で「switch(columnName)」/「Select Case columnName」で分岐するような実装では、プロパティの数が増えるにつれてコードの管理が大変になる。そこで、例えば以下のリンク先の参考ページのように、データ検証属性(=System.ComponentModel.DataAnnotations名前空間にあるRequired属性などを用いて実装を簡素化するというような方法がよく用いられる(リンク先の参考ページにおけるサンプル・コードはSilverlight 4向けで、そのままではWPFで利用できないが、ほぼ同様の実装が可能である)。
ちなみに、ここで出てきたINotifyDataErrorInfoインターフェイスは、Silverlightにだけ存在し、データ検証状態が変化したことを通知する仕組みが入ったインターフェイスである(IDataErrorInfoインターフェイスには通知の仕組みがなく、プロパティをまたいだ検証がしづらくなっている)。
○自動生成
List 4〜8のコードを見てのとおり、ビューモデルには定型的なコードが大量に並ぶことになるため、実装が非常に面倒という問題がある。そこで、コードの自動生成などに頼り、開発を効率化するための手段がいくつか提供されている。
参考:
○デザイン時属性
WPFのデータ・バインディングの仕組みは、リフレクションなどを使って動的に動作しているため、ビルド時にスペルミスなどの人的エラーに悩まされたり、Visual Studioの人気機能の1つであるコード補完が働かなかったりといった問題もある。
そこでVisual Studio 2010やExpression Blendでは、デザイン時限定で適用され、ビルド結果の実行可能ファイル中には情報が残らない「d:DataContext」というプロパティ(=デザイン時DataContext)を使って、データ・バインディングの入力補助を行う仕組みを備えている。
参考:
今回で、長かったXAML/WPFの基礎や仕組みについて一通り説明が終わった。次回からは個々のUI要素について説明していく。
具体的には、GridやStackPanelなどのレイアウト用のパネルについて説明を行っていく予定だ。
「連載:WPF入門」
Copyright© Digital Advantage Corp. All Rights Reserved.