January 2011
Volume 26 Number 01
Silverlight の公開 - MEF を使用して Silverlight MVVM アプリケーションのインターフェイスを公開する
Sandrino Di Di | January 2011
Silverlight を Web 中心のテクノロジとして考えている開発者が多いかもしれませんが、実際には、さまざまな種類のアプリケーションの構築に適したプラットフォームに進化しています。Silverlight には、データ バインド、値コンバーター、ナビゲーション、ブラウザー外実行、COM 相互運用機能といった概念のサポートが組み込まれているため、さまざまな種類のアプリケーションを比較的簡単に作成できます。ここで言う "さまざまな種類" のアプリケーションには、エンタープライズ アプリケーションも含まれます。
モデル ビュー ビューモデル (MVVM: Model-View-ViewModel) パターンを使用して Silverlight アプリケーションを作成すると、Silverlight に既に備わっている機能に加えて、保守やテストが容易になるだけでなく、UI とそれを支えるロジックを簡単に分離できるメリットも得られます。もちろん、すべての手法を独自に考案する必要はありません。入門用に役立つ情報やツールが多数公開されています。たとえば、MVVM Light Toolkit (mvvmlight.codeplex.com、英語) は、Silverlight と Windows Presentation Foundation (WPF) で MVVM を実装するための簡易フレームワークです。また、WCF RIA Services (silverlight.net/getstarted/riaservices、英語) のコード生成を利用すれば、Windows Communication Foundation (WCF) サービスとデータベースに簡単にアクセスできます。
Managed Extensibility Framework (MEF、mef.codeplex.com、英語) を使用すると、Silverlight アプリケーションをさらに進化させることができます。このフレームワークでは、コンポーネントと構成を使用して拡張可能なアプリケーションを作成するためのプラミングが提供されます。
今回の記事では、MEF を使用してビューとビューモデルの作成を一元管理する方法を説明します。この方法を習得すると、ビューモデルをビューの DataContext に配置するだけだった状態から大幅に前進できます。実際の作業は、組み込みの Silverlight ナビゲーションをカスタマイズするだけです。ユーザーが特定の URL に移動すると、MEF がこの要求をインターセプトし、ルートを確認します (ASP.NET MVC に少し似ています)。続いて、対応するビューとビューモデルを探して、何が行われるかをビューモデルに通知し、ビューを表示します。
MEF 入門
MEF はこの例のすべてのパーツを結び付けるエンジンの役割を果たすため、MEF から説明を始めるのが一番でしょう。MEF についてあまり馴染みのない方は、最初に MSDN マガジン 2010 年 2 月号の Glenn Block による記事「Managed Extensibility Framework による .NET 4 で構成可能なアプリケーションの構築」(msdn.microsoft.com/magazine/ee291628) をお読みください。
まず、App クラスの Startup イベントを処理することで、アプリケーションの起動時に MEF を正しく構成します。
private void OnStart(object sender, StartupEventArgs e) {
// Initialize the container using a deployment catalog.
var catalog = new DeploymentCatalog();
var container = CompositionHost.Initialize(catalog);
// Export the container as singleton.
container.ComposeExportedValue<CompositionContainer>(container);
// Make sure the MainView is imported.
CompositionInitializer.SatisfyImports(this);
}
配置カタログは、エクスポートのためにすべてのアセンブリをスキャンします。その後このカタログを使用して CompositionContainer を作成します。ナビゲーションは以降の処理にこの CompositionContainer を必要とするため、このコンテナーのインスタンスをエクスポートされる値として登録することが重要です。このようにすると、必要なときはいつでも同じコンテナーをインポートできます。
コンテナーを静的オブジェクトとして格納する方法もありますが、クラスどうしが密結合されるためこの方法はお勧めしません。
Silverlight ナビゲーションを拡張する
Silverlight ナビゲーション アプリケーションは Visual Studio テンプレートの 1 つで、コンテンツをホストする Frame コントロールを使用してナビゲーションをサポートするアプリケーションを、簡単に作成できるようにします。Frame コントロールの長所は、ブラウザーの [戻る] ボタンと [進む] ボタンと連携し、ディープ リンクの設定をサポートしている点です。次のコードをご覧ください。
<navigation:Frame x:Name="ContentFrame"
Style="{StaticResource ContentFrameStyle}"
Source="Customers"
NavigationFailed="OnNavigationFailed">
<i:Interaction.Behaviors>
<fw:CompositionNavigationBehavior />
</i:Interaction.Behaviors>
</navigation:Frame>
これは、Customers に移動することによって開始される標準のフレームです。ご覧のとおり、この Frame コントロールには UriMapper (/Views/Customers.aspx など、Customers を XAML ファイルにリンクできるクラス) が含まれていません。含まれているのは、カスタム ビヘイビアの CompositionNavigationBehavior だけです。ビヘイビア (System.Windows.Interactivity アセンブリ内) を使用すると、既存のコントロール (この場合の Frame など) を拡張できます。
図 1 にビヘイビアを示します。この CompositionNavigationBehavior の処理を見てみましょう。まず、Import 属性により、CompositionContainer と CompositionNavigationContentLoader (このクラスについては後で説明します) を必要とすることがわかります。次に、コンストラクターでは CompositionInitializer の SatisfyImports メソッドを使用して Import 属性を反映します。このメソッドを実行すると実際にコードが MEF に結合されるので、他の方法を採用できない場合にのみこのメソッドを使用するようにしてください。
図 1 CompositionNavigationBehavior
public class CompositionNavigationBehavior : Behavior<Frame> {
private bool processed;
[Import]
public CompositionContainer Container {
get; set;
}
[Import]
public CompositionNavigationContentLoader Loader {
get; set;
}
public CompositionNavigationBehavior() {
if (!DesignerProperties.IsInDesignTool)
CompositionInitializer.SatisfyImports(this);
}
protected override void OnAttached() {
base.OnAttached();
if (!processed) {
this.RegisterNavigationService();
this.SetContentLoader();
processed = true;
}
}
private void RegisterNavigationService() {
var frame = AssociatedObject;
var svc = new NavigationService(frame);
Container.ComposeExportedValue<INavigationService>(svc);
}
private void SetContentLoader() {
var frame = AssociatedObject;
frame.ContentLoader = Loader;
frame.JournalOwnership = JournalOwnership.Automatic;
}
}
Frame をアタッチするときに、NavigationService を作成してその Frame をラップします。ComposeExportedValue を使用して、このラッパーのインスタンスをコンテナーに登録します。
コンテナーを作成したときに、このコンテナーのインスタンスもコンテナー自体に登録されています。そのため、CompositionContainer の Import 属性では常に同じオブジェクトが返されます。App クラスの Startup イベントで ComposeExportedValue を使用したのはこのためです。ここで、CompositionNavigationBehavior で Import 属性を使用して CompositionContainer を要求すると、SatisfyImports メソッドの実行後に CompositionContainer が返されます。
INavigationService のインスタンスを登録するときにも同様の処理が行われます。これで、アプリケーションの任意の場所から (Frame をラップする) INavigationService を要求できるようになります。つまり、ビューモデルをフレームに結び付けなくても、次のコードにアクセスできます。
public interface INavigationService {
void Navigate(string path);
void Navigate(string path, params object[] args);
}
では、すべての顧客を表示するビューモデルがあり、このビューモデルから特定の顧客についてのページを開くことができるとしましょう。次のコードを使用すると、この処理を実行できます。
[Import]
public INavigationService NavigationService {
get; set;
}
private void OnOpenCustomer() {
NavigationService.Navigate(
"Customer/{0}", SelectedCustomer.Id);
}
先へ進む前に、CompositionNavigationBehavior の SetContentLoader メソッドについて説明しておきましょう。このメソッドは Frame の ContentLoader を変更します。これは、Silverlight で拡張性がサポートされていることを示す良い例です。独自の ContentLoader (INavigationContentLoader インターフェイスを実装) を指定すると、Frame に表示するコンテンツを実際に指定できます。
これで、Silverlight ナビゲーションが機能するしくみを理解できたので、次の話題、つまり MEF の拡張がわかりやすくなります。
MEF の拡張に戻る
ここでの目的は、特定のパスに移動できるようにし (ビューモデルまたはブラウザーのアドレス バーから移動します)、他の処理は CompositionNavigationContentLoader で実行することです。つまり、CompositionNavigationContentLoader では URI を解析し、対応するビューモデルと対応するビューを見つけ、これらを組み合わせます。
通常は、次のようなコードを作成します。
[Export(typeof(IMainViewModel))]
public class MainViewModel
ここでは、Export 属性に、メタデータと呼ばれる構成を追加して使用するとおもしろいでしょう。メタデータ属性の例を図 2 に示します。
図 2 ViewModelExportAttribute の作成
[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewModelExportAttribute :
ExportAttribute, IViewModelMetadata {
..public Type ViewModelContract { get; set; }
public string NavigationPath { get; set; }
public string Key { get; set; }
public ViewModelExportAttribute(Type viewModelContract,
string navigationPath) : base(typeof(IViewModel)) {
this.NavigationPath = navigationPath;
this.ViewModelContract = viewModelContract;
if (NavigationPath != null &&
NavigationPath.Contains("/")) {
// Split the path to get the arguments.
var split = NavigationPath.Split(new char[] { '/' },
StringSplitOptions.RemoveEmptyEntries);
// Get the key.
Key = split[0];
}
else {
// No arguments, use the whole key.
Key = NavigationPath;
}
}
}
この属性では、特殊な処理は行っていません。まず、ビューモデルのインターフェイスに加えて、Customer/{Id} などのナビゲーション パスを定義できるようにします。続いて、Customer を Key プロパティ、{Id} を引数の 1 つとして使用して、このパスを処理します。次にこの属性の使用例を示します。
[ViewModelExport(typeof(ICustomerDetailViewModel),
"Customer/{id}")]
public class CustomerDetailViewModel
: ICustomerDetailViewModel
話を進める前に、注意しなければならない重要な点がいくつかあります。まず、属性が正しく機能するように [MetadataAttribute] で修飾します。次に、属性では、メタデータとして公開する値を使用して、インターフェイスを実装します。最後に、属性のコンストラクターに留意します。コンストラクターでは、型を基本コンストラクターに渡します。この属性で修飾されたクラスは、基本コンストラクターに渡した型を使用して公開されます。今回の例の場合、この型は IViewModel です。
ビューモデルのエクスポートについては以上です。任意の場所でビューモデルをインポートする場合は、次のようなコードを作成します。
[ImportMany(typeof(IViewModel))]
public List<Lazy<IViewModel, IViewModelMetadata>> ViewModels {
get;
set;
}
このようにすると、エクスポートされたすべてのビューモデルと、ビューモデルごとのメタデータが含まれた一覧が返され、一覧を列挙できるようになり、場合によっては必要なビューモデルだけを (メタデータに基づいて) 選択できるようになります。実際、Lazy オブジェクトによって、必要なビューモデルのインスタンスだけが作成されます。
次のように、ビューでも同様の処理が必要です。
[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewExportAttribute :
ExportAttribute, IViewMetadata {
public Type ViewModelContract { get; set; }
public ViewExportAttribute() : base(typeof(IView)) {
}
}
この例にも、特殊な処理はありません。この属性を使用すると、ビューモデルのコントラクトをビューのリンク先に設定できます。
次に AboutView の例を示します。
[ViewExport(ViewModelContract = typeof(IAboutViewModel))]
public partial class AboutView : Page, IView {
public AboutView() {
InitializeComponent();
}
}
カスタム INavigationContentLoader
全体的なアーキテクチャを設定したので、次はユーザーが移動すると読み込まれるコンテンツを制御する方法について説明しましょう。カスタム コンテンツ ローダーを作成するには、次のインターフェイスを実装する必要があります。
public interface INavigationContentLoader {
IAsyncResult BeginLoad(Uri targetUri, Uri currentUri,
AsyncCallback userCallback, object asyncState);
void CancelLoad(IAsyncResult asyncResult);
bool CanLoad(Uri targetUri, Uri currentUri);
LoadResult EndLoad(IAsyncResult asyncResult);
}
このインターフェイスで最も重要なのは、BeginLoad メソッドです。このメソッドが、Frame コントロールに表示する項目を含む AsyncResult を返すためです。カスタム INavigationContentLoader の実装を図 3 に示します。
図 3 カスタム INavigationContentLoader
[Export] public class CompositionNavigationContentLoader :
INavigationContentLoader {
[ImportMany(typeof(IView))]
public IEnumerable<ExportFactory<IView, IViewMetadata>>
ViewExports { get; set; }
[ImportMany(typeof(IViewModel))]
public IEnumerable<ExportFactory<IViewModel, IViewModelMetadata>>
ViewModelExports { get; set; }
public bool CanLoad(Uri targetUri, Uri currentUri) {
return true;
}
public void CancelLoad(IAsyncResult asyncResult) {
return;
}
public IAsyncResult BeginLoad(Uri targetUri, Uri currentUri,
AsyncCallback userCallback, object asyncState) {
// Convert to a dummy relative Uri so we can access the host.
var relativeUri = new Uri("http://" + targetUri.OriginalString,
UriKind.Absolute);
// Get the factory for the ViewModel.
var viewModelMapping = ViewModelExports.FirstOrDefault(o =>
o.Metadata.Key.Equals(relativeUri.Host,
StringComparison.OrdinalIgnoreCase));
if (viewModelMapping == null)
throw new InvalidOperationException(
String.Format("Unable to navigate to: {0}. " +
"Could not locate the ViewModel.",
targetUri.OriginalString));
// Get the factory for the View.
var viewMapping = ViewExports.FirstOrDefault(o =>
o.Metadata.ViewModelContract ==
viewModelMapping.Metadata.ViewModelContract);
if (viewMapping == null)
throw new InvalidOperationException(
String.Format("Unable to navigate to: {0}. " +
"Could not locate the View.",
targetUri.OriginalString));
// Resolve both the View and the ViewModel.
var viewFactory = viewMapping.CreateExport();
var view = viewFactory.Value as Control;
var viewModelFactory = viewModelMapping.CreateExport();
var viewModel = viewModelFactory.Value as IViewModel;
// Attach ViewModel to View.
view.DataContext = viewModel;
viewModel.OnLoaded();
// Get navigation values.
var values = viewModelMapping.Metadata.GetArgumentValues(targetUri);
viewModel.OnNavigated(values);
if (view is Page) {
Page page = view as Page;
page.Title = viewModel.GetTitle();
}
else if (view is ChildWindow) {
ChildWindow window = view as ChildWindow;
window.Title = viewModel.GetTitle();
}
// Do not navigate if it's a ChildWindow.
if (view is ChildWindow) {
ProcessChildWindow(view as ChildWindow, viewModel);
return null;
}
else {
// Navigate because it's a Control.
var result = new CompositionNavigationAsyncResult(asyncState, view);
userCallback(result);
return result;
}
}
private void ProcessChildWindow(ChildWindow window,
IViewModel viewModel) {
// Close the ChildWindow if the ViewModel requests it.
var closableViewModel = viewModel as IClosableViewModel;
if (closableViewModel != null) {
closableViewModel.CloseView += (s, e) => { window.Close(); };
}
// Show the window.
window.Show();
}
public LoadResult EndLoad(IAsyncResult asyncResult) {
return new LoadResult((asyncResult as
CompositionNavigationAsyncResult).Result);
}
}
ご覧のとおり、このクラスではさまざまな処理を行っていますが、実際には単純なクラスです。まず、Export 属性に注目してください。Export 属性は、このクラスを CompositionNavigationBehavior にインポートできるようにするために必要です。
このクラスで最も重要なのは、ViewExports プロパティと ViewModelExports プロパティです。これらの列挙には、ビューとビューモデルのすべてのエクスポートが、それぞれのメタデータと共に含まれています。これらのプロパティでは、Lazy オブジェクトを使用せずに ExportFactory オブジェクトを使用しています。これは大きな違いです。どちらのクラスも要求した場合にだけインスタンスが作成されますが、Lazy クラスではオブジェクトのインスタンスを 1 つだけ作成できる点が異なります。これに対し、ExportFactory (ファクトリ パターンにちなんで命名) は、任意のタイミングでオブジェクト型の新しいインスタンスを要求できるクラスです。
最後に、このクラスには BeginLoad メソッドがあります。このメソッドこそ、重要な処理を実行する箇所です。このメソッドでは、指定された URI に移動した後に表示するコンテンツを Frame コントロールに提供します。
オブジェクトを作成して処理する
フレームに対し、Customers に移動するよう指示するとします。移動先についての情報は、BeginLoad メソッドの targetUri 引数に格納されます。この情報を取得すれば、移動を処理できます。
まず、正しいビューモデルを見つけます。ViewModelExports プロパティは、すべてのエクスポートとそのメタデータが含まれている列挙です。ラムダ式を使用すると、ビューモデルのキーに基づいて正しいビューモデルを見つけることができます。次のコードを思い出してください。
[ViewModelExport(typeof(ICustomersViewModel), "Customers")]
public class CustomersViewModel :
ContosoViewModelBase, ICustomersViewModel
では、Customers に移動するようすを想像してください。移動したら、次のコードで適切なビューモデルを見つけます。
var viewModelMapping = ViewModelExports.FirstOrDefault(o => o.Metadata.Key.Equals("Customers",
StringComparison.OrdinalIgnoreCase));
ExportFactory が見つかったら、ビューについても同じことを行います。ただし、ナビゲーション キーを参照するのではなく、ViewModelExportAttribute と ViewModelAttribute で次のように定義されているとおり、ViewModelContract を参照します。
[ViewExport(ViewModelContract = typeof(IAboutViewModel))
public partial class AboutView : Page
両方の ExportFactory オブジェクトが見つかれば、難しい部分は完了です。ここで CreateExport メソッドを使用すると、ビューとビューモデルの新しいインスタンスを作成できます。
var viewFactory = viewMapping.CreateExport();
var view = viewFactory.Value as Control;
var viewModelFactory = viewModelMapping.CreateExport();
var viewModel = viewModelFactory.Value as IViewModel;
ビューとビューモデルの両方を作成したら、ビューモデルをビューの DataContext に格納し、必要なデータ バインドを開始します。ビューモデルの OnLoaded メソッドを呼び出して、複雑な作業がすべて完了したこと、および Import 属性が指定されたすべてのクラス (存在する場合) がインポートされたことをビューモデルに通知します。
Import 属性と ImportMany 属性を使用する際は、この最後の段階を軽視しないでください。たいていは、ビューモデルの作成時に、すべてが正常に読み込まれた場合のみ特定の処理を実行します。ImportingConstructor を使用しているときは、Import 属性が指定されたすべてのクラスがインポートされたタイミング (おそらく、コンストラクターが呼び出されるタイミング) を確実に把握できます。しかし、Import 属性や ImportMany 属性を操作しているときは、すべてのプロパティがインポートされたタイミングを把握するために、すべてのプロパティにコードを記述してフラグを設定します。
このようなときは、OnLoaded メソッドを使用すると問題が解決します。
ビューモデルに引数を渡す
IViewModel インターフェイスを調べ、次に示す OnNavigated メソッドを探してください。
public interface IViewModel {
void OnLoaded();
void OnNavigated(NavigationArguments args);
string GetTitle();
}
たとえば、Customers/1 に移動すると、このパスが解析されて引数が NavigationArguments クラスにまとめられます (このクラスは、GetInt や GetString などのメソッドが追加された単なる Dictionary クラスです)。各ビューモデルは必ず IViewModel インターフェイスを実装するため、次のようビューモデルの解決後に OnNavigated メソッドを呼び出すことができます。
// Get navigation values.
var values = viewModelMapping.Metadata.GetArgumentValues(targetUri); viewModel.OnNavigated(values);
CustomersViewModel から CustomerDetailViewModel を開くと、次の処理が実行されます。
NavigationService.Navigate("Customer/{0}", SelectedCustomer.Id);
これらの引数はいずれ CustomerDetailViewModel に渡されます。、たとえば次のように DataService に渡して使用できます。
public override void OnNavigated(NavigationArguments args) {
var id = args.GetInt("Id");
if (id.HasValue) {
Customer = DataService.GetCustomerById(id.Value);
}
}
引数を探すため、2 つの拡張メソッドを備えたクラスを作成しました。これらの拡張メソッドは、ビューモデルのメタデータに含まれる情報に基づいて処理を実行します (図 4 参照)。これも、MEF におけるメタデータの概念が非常に役に立つことを証明しています。
図 4 ナビゲーション引数の拡張メソッド
最後の作業
ビューが Page コントロールや ChildWindow コントロールの場合、このコントロールのタイトルも IViewModel オブジェクトから抽出されます。そのため、現在の顧客に合わせて Page や ChildWindow のタイトルを動的に設定できます (図 5 参照)。
図 5 カスタム ウィンドウ タイトルの設定
重要ながらも細かい作業がすべて完了したら、最後に 1 つ作業が残っています。ビューが ChildWindow の場合、そのウィンドウを表示します。しかし、ビューモデルが IClosableViewModel を実装している場合は、このビューモデルの CloseView イベントを ChildWindow の Close メソッドにリンクする必要があります。
次のように、IClosableViewModel インターフェイスは単純です。
public interface IClosableViewModel : IViewModel {
event EventHandler CloseView;
}
次に示すように、ChildWindow の処理も簡単です。ビューモデルで CloseView イベントが発生したら、ChildWindow の Close メソッドを呼び出します。これにより、間接的にビューモデルをビューに接続できます。
// Close the ChildWindow if the ViewModel requests it.
var closableViewModel = viewModel as IClosableViewModel;
if (closableViewModel != null) {
closableViewModel.CloseView += (s, e) => {
window.Close();
};
}
// Show the window.
window.Show();
ビューが ChildWindow でなければ、ビューを IAsyncResult で使用可能にするだけです。このようにすると、ビューが Frame 内に表示されます。
これで、ビューとビューモデルを構築する方法のすべての作業についての説明は完了です。
サンプル コードを使用する
この記事のコード ダウンロードには、ここで説明した MEF によるカスタム ナビゲーションを使用する MVVM アプリケーションが含まれています。ソリューションには次の例が含まれています。
- 標準 UserControl へのナビゲーション
- 引数 (.../#Employee/DiMattia など) を渡すことによる標準 UserControl へのナビゲーション
- 引数 (.../#Customer/1 など) を渡すことによる ChildWindow へのナビゲーション
- INavigationService、IDataService などのインポート
- ViewExport と ViewModelExport の構成例
今回は、サンプルの動作については詳しく説明しませんでした。理解を深めるために、コードを実行したり、独自のアプリケーション向けにカスタマイズしたりしてみてください。MEF が強力で柔軟なことを実感していただけると思います。
Sandrino Di Mattia は、RealDolmen でソフトウェア エンジニアとして働いており、マイクロソフトに関するあらゆるものに情熱を注いでいます。また、ユーザー グループに参加し、ブログ (blog.sandrinodimattia.net、英語) で記事を公開しています。
この記事のレビューに協力してくれた技術スタッフの Glenn Block と Daniel Plaisted に心より感謝いたします。