次の方法で共有


Silverlight パターン

Silverlight 2 アプリケーションでの Model-View-ViewModel

Shawn Wildermuth

コードは MSDN コード ギャラリーからダウンロードできます。
オンラインでのコードの参照

この記事では、次の内容について説明します。

  • Silverlight 2 の開発
  • Model-View-ViewModel パターン
  • ビューとビュー モデル
  • Silverlight 2 での変更
この記事では、次のテクノロジを使用しています。
Silverlight 2、Visual Studio

目次

問題点
Silverlight 2 でのアプリケーションの階層化
MVVM: チュートリアル
モデルを作成する
ビューとビュー モデル
Silverlight 2 での変更
まとめ

Silverlight 2 がリリースされたことで、Silverlight を基に構築されたアプリケーションの数は増加しており、それに伴って問題も増えています。Silverlight 2 テンプレートによってサポートされる基本構造は、ユーザー インターフェイス (UI) と使用するデータの間の密統合を意味します。このような密接な統合は技術の学習には役立ちますが、テストやリファクタリングやメンテナンスには障害となります。ここでは、アプリケーション デザインの成熟したパターンを使用してデータから UI を分離する方法を説明します。

問題点

問題の核心は密結合であり、これはアプリケーションの層を組み合わせた結果です。ある層が別の層での処理方法を熟知している場合、アプリケーションは密結合されています。ある市で売り出されている家を照会できる簡単なデータ入力アプリケーションについて考えてみます。密結合のアプリケーションでは、ユーザー インターフェイスのボタン ハンドラで検索を実行するクエリを定義できます。スキーマまたは検索のセマンティクスが変化したときは、データ層とユーザー インターフェイス層の両方を更新する必要があります。

これはコードの品質と複雑さの点で問題になります。データ層が変化するたびに、アプリケーションを同期させてからテストし、変更によって変化が断絶しないことを確認する必要があります。すべてのものが密接に結び付けられていると、アプリケーションのある部分での動きが、コードの他の部分に波及的な変化をもたらす可能性があります。ムービー プレーヤーやメニュー ウィジェットのような簡単な何かを Silverlight2 で作成しているときは、アプリケーションのコンポーネントの密結合はあまり問題にはなりません。しかし、プロジェクトの規模が大きくなるに従って、問題もますます大きくなります。

問題となるもう 1 つの部分は単体テストです。アプリケーションが密結合の場合、行うことができるのはアプリケーションの機能 (またはユーザー インターフェイス) テストだけです。この場合も、小さいプロジェクトでは問題になりませんが、プロジェクトの規模と複雑さが増すほど、アプリケーションの各層を個別にテストできることが非常に重要になります。単体テストとは、システム内でユニットを使用したときにユニットが動作することを確認するということだけではなく、ユニットがシステム内で動作し続けることも確認する必要があるということに注意してください。システムを構成する部分の単体テストを行うことで、システムを変更したときに、問題がプロセスの遅い段階 (機能テストなど) ではなく早い段階で明らかになることが保証されます。その場合、システムに加えられた小さい変更がバグを誘発しないことを保証するには、回帰テスト (たとえば、ビルドごとにシステムで単体テストを実行すること) が非常に重要になります。

異なる層を定義してアプリケーションを作成する方法を、過剰なエンジニアリングと考える開発者もいるでしょう。実際には、層を念頭に置いて開発を行うかどうかにかかわらず、作業を行っているのは n 層のプラットフォーム上であり、アプリケーションには層があります。しかし、きちんとした計画を立てないと、非常に緊密に結合した (そして前述のような問題のある) システムができあがるか、またはアプリケーションがスパゲッティ コードだらけになってメンテナンスが頭痛の種になります。

異なるレイヤまたは層を持つアプリケーションを構築すると、アプリケーションをうまく機能させるためには多くのインフラストラクチャが必要であると考えがちですが、実際には、層の単純な分離は簡単に実装できます。制御の反転技法を使用することでさらに複雑な階層化のアプリケーションを設計できますが、それはこの記事で考えているのとは異なる問題に対処する場合に必要になります。

Silverlight 2 でのアプリケーションの階層化

Silverlight 2 では、アプリケーションを階層化する方法を決定するのに役立つ新しい何かを考案する必要はありません。よく知られたいくつかのパターンを設計に使用できます。

最近聞くことの多いパターンは Model-View-Controller (MVC) パターンです。MVC パターンでは、モデルはデータであり、ビューはユーザー インターフェイスです。そして、コントローラはビューとモデルとユーザー入力の間のプログラム的なインターフェイスです。しかし、このパターンは、Windows Presentation Foundation (WPF) や Silverlight のような宣言型のユーザー インターフェイスでは、これらの技術が使用する XAML が入力とビューの間の一部のインターフェイスを定義できるので (データ バインド、トリガ、および状態は XAML で宣言できます)、うまく機能しません。

アプリケーションを階層化するためによく使われるもう 1 つのパターンは Model-View-Presenter (MVP) です。MVP パターンでは、プレゼンタがビューの状態の設定と管理を行います。MVC と同様に MVP も、XAML に宣言的なデータ バインド、トリガ、および状態管理が含まれる可能性があるため、Silverlight 2 モデルとの適合性は完全ではありません。さて、どうなるでしょうか。

Silverlight 2 にとってさいわいなことに、WPF コミュニティは Model-View-ViewModel (MVVM) という名前のパターンを支持しています。このパターンは MVC および MVP パターンを改造したものであり、ビュー モデルがデータ モデルおよびビューに対する動作を提供しますが、ビューはビュー モデルに宣言的にバインドできます。ビューは (Silverlight 2 のコントロールと同じように) XAML と C# の混合であり、モデルはアプリケーションで使用可能なデータを表し、ビュー モデルはモデルをビューにバインドするためにモデルを準備します。

データへのアクセスが一連の Web サービス、ADO.NET データ サービス、または他の形式のデータ取得のいずれであっても、モデルがアクセスをラップするので、このパターンではモデルが特に重要です。モデルはビュー モデルから分離されるので、ビューのデータ (ビュー モデル) を実際のデータと切り離してテストできます。図 1 に MVVM パターンの例を示します。

fig01.gif

図 1 Model-View-ViewModel パターン

MVVM: チュートリアル

MVVM パターンの実装方法の手順を、例を使って説明します。この例は、必ずしも実際のコードの使用方法を示すものではありません。単にパターンを説明するためのものです。

この例は、1 つの Visual Studio ソリューションに含まれる 5 つの異なるプロジェクトで構成されます (各層を異なるプロジェクトとして作成する必要はありませんが、多くの場合は良い方法です)。さらに、この例ではプロジェクトをクライアントとサーバーのフォルダに置くことで分離します。Server フォルダには 2 つのプロジェクトがあります。ASP.NET Web アプリケーション (MVVMExample) は Silverlight プロジェクトとサービスをホストし、.NET Library プロジェクトはデータ モデルを含みます。

Client フォルダには 3 つのプロジェクトがあります。Silverlight プロジェクト (MVVM.Client) はアプリケーションのメイン UI 用であり、Silverlight クライアント ライブラリ (MVVM.Client.Data) はモデルとビュー モデルおよびサービス参照を含み、Silverlight プロジェクト (MVVM.Client.Tests) は単体テストを含みます。これらのプロジェクトの詳細を図 2 に示します。

fig02.gif

図 2 プロジェクト レイアウト

この例のサーバーでは、ASP.NET、Entity Framework、および ADO.NET Data Services を使用しました。基本的に、サーバーのデータ モデルは REST ベースのサービスを通して公開する簡単なものです。これらの詳細な説明については、Silverlight 2 での ADO.NET Data Services の使用に関する私の 2008 年 9 月号の記事「データ サービス: Silverlight 2 でデータ中心の Web アプリケーションを作成する」を参照してください。

モデルを作成する

Silverlight アプリケーションで階層化を有効にするには、最初に MVVM.Client.Data プロジェクトでアプリケーション データ用のモデルを定義する必要があります。モデルの定義の一部は、アプリケーションの内部で使用することになるエンティティの種類を決定することです。エンティティの種類は、アプリケーションがサーバーのデータを操作する方法によって決まります。たとえば、Web サービスを使用する場合のエンティティはおそらく、Web サービスに対するサービス参照を作成することで生成されるデータ コントラクト クラスになります。または、データ アクセスで未加工の XML を取得する場合は、単純な XML 要素を使用します。ここでは ADO.NET Data Services を使用するので、データ サービスに対するサービス参照を作成すると、一連のエンティティが自動的に作成されます。

この例では、サービス参照は Game、Supplier、GameEntities (データ サービスにアクセスするためのコンテキスト オブジェクト) という 3 つの重要なクラスを作成しました。Game クラスと Supplier クラスはビューとの対話に使用する実際のエンティティで、GameEntities クラスはデータ サービスにアクセスしてデータを取得するために内部的に使用されます。

ただし、モデルを作成する前に、モデルとビュー モデルの間の通信用にインターフェイスを作成する必要があります。通常、このインターフェイスにはデータへのアクセスに必要なすべてのメソッド、プロパティ、およびイベントが含まれます。この一連の機能が 1 つのインターフェイスによって表され、必要に応じて (たとえばテストのために) 他の実装に置き換えることができます。次に示すこの例のモデル インターフェイスは、IGameCatalog という名前です。

public interface IGameCatalog
{
  void GetGames();
  void GetGamesByGenre(string genre);
  void SaveChanges();

  event EventHandler<GameLoadingEventArgs> GameLoadingComplete;
  event EventHandler<GameCatalogErrorEventArgs> GameLoadingError;
  event EventHandler GameSavingComplete;
  event EventHandler<GameCatalogErrorEventArgs> GameSavingError;
}

IGameCatalog インターフェイスには、データを取得および保存するためのメソッドが含まれます。ただし、どの操作も実際のデータは返しません。代わりに、成功と失敗に対応するイベントがあります。この動作により、非同期ネットワーク アクティビティに対する Silverlight 2 の要件に対応するための非同期実行が有効になります。WPF での非同期デザインが推奨されることはよくありますが、Silverlight 2 での非同期性が必要なので、この特定のデザインは Silverlight 2 でうまく動作するようになっています。

インターフェイスの呼び出し元に結果を通知できるようにするため、この例では GameLoadingEventArgs クラスを実装し、イベントでそれを使用して要求の結果を送信します。次のコードでわかるように、このクラスは呼び出し元が要求する結果を含むエンティティの列挙可能な一覧として、エンティティ型 (Game) を公開します。

public class GameLoadingEventArgs : EventArgs
{
  public IEnumerable<Game> Results { get; private set; }

  public GameLoadingEventArgs(IEnumerable<Game> results)
  {
    Results = results;
  }
}

インターフェイスを定義したので、IGameCatalog インターフェイスを実装するモデル クラス (GameCatalog) を作成できます。GameCatalog クラスは ADO.NET Data Services を単純にラップしており、データの要求を受け取ると (GetGames または GetGamesByGenre)、要求を実行してデータを含むイベント (またはエラーが発生した場合はエラー) をスローします。このコードは、呼び出し元に具体的な知識を実装することなくデータに簡単にアクセスできるようにすることを意図して作られています。このクラスにはサービスの URI を指定するためのオーバーロードされたコンストラクタが含まれていますが、常に必要なわけではなく、代わりに構成要素として実装できます。図 3 に、GameCatalog クラスのコードを示します。

図 3 GameCatalog クラス

public class GameCatalog : IGameCatalog
{
  Uri theServiceRoot;
  GamesEntities theEntities;
  const int MAX_RESULTS = 50;

  public GameCatalog() : this(new Uri("/Games.svc", UriKind.Relative))
  {
  }

  public GameCatalog(Uri serviceRoot)
  {
    theServiceRoot = serviceRoot;
  }

  public event EventHandler<GameLoadingEventArgs> GameLoadingComplete;
  public event EventHandler<GameCatalogErrorEventArgs> GameLoadingError;
  public event EventHandler GameSavingComplete;
  public event EventHandler<GameCatalogErrorEventArgs> GameSavingError;

  public void GetGames()
  {
    // Get all the games ordered by release date
    var qry = (from g in Entities.Games
               orderby g.ReleaseDate descending
               select g).Take(MAX_RESULTS) as DataServiceQuery<Game>;

    ExecuteGameQuery(qry);
  }

  public void GetGamesByGenre(string genre)
  {
    // Get all the games ordered by release date
    var qry = (from g in Entities.Games
               where g.Genre.ToLower() == genre.ToLower()
               orderby g.ReleaseDate
               select g).Take(MAX_RESULTS) as DataServiceQuery<Game>;

    ExecuteGameQuery(qry);
  }

  public void SaveChanges()
  {
    // Save Not Yet Implemented
    throw new NotImplementedException();
  }

  // Call the query asynchronously and add the results to the collection
  void ExecuteGameQuery(DataServiceQuery<Game> qry)
  {
    // Execute the query
    qry.BeginExecute(new AsyncCallback(a =>
    {
      try
      {
        IEnumerable<Game> results = qry.EndExecute(a);

        if (GameLoadingComplete != null)
        {
          GameLoadingComplete(this, new GameLoadingEventArgs(results));
        }
      }
      catch (Exception ex)
      {
        if (GameLoadingError != null)
        {
          GameLoadingError(this, new GameCatalogErrorEventArgs(ex));
        }
      }

    }), null);
  }

  GamesEntities Entities
  {
    get
    {
      if (theEntities == null)
      {
        theEntities = new GamesEntities(theServiceRoot);
      }
      return theEntities;
    }
  }
}

ExecuteGameQuery メソッドに注目してください。このメソッドは ADO.NET Data Services のクエリを受け取ってそれを実行します。このメソッドは結果を非同期に実行して呼び出し元に返します。

このモデルはクエリを実行しますが、完了したときにはイベントを発生させるだけです。イベントが Silverlight 2 のユーザー インターフェイス スレッドに呼び出しをマーシャリングするようになっていないことを不思議に思うかもしれません。このようになっているのは、Silverlight は (Windows フォームや WPF などの他のユーザー インターフェイス形式のように) メイン スレッドまたは UI スレッドからのみユーザー インターフェイスを更新できるためです。しかし、そのマーシャリングをこのコードで行うと、モデルがユーザー インターフェイスと結びついてしまい、この例の (関係を切り離すという) 意図に反することになります。データは UI スレッドで返す必要があると思いこんでいると、このクラスをユーザー インターフェイスの呼び出しにバインドしてしまいますが、それはアプリケーションで異なる層を使用する理由と正反対のことです。

ビューとビュー モデル

ビュー モデルを作成してビュー クラスにデータを直接公開することは当然のことのように思えるかもしれません。この方法の問題は、ビュー モデルはビューで直接必要なデータのみを公開する必要があり、ビューが必要とするものを理解する必要があることです。多くの場合、ビュー モデルとビューを並行して作成し、ビューで新しい要件が発生するとビュー モデルをリファクタリングします。ビュー モデルがビューにデータを公開しますが、ビューはエンティティ クラスとも対話します (モデルのエンティティはビュー モデルによってビューに渡されるので、間接的な対話になります)。

図 4 に示すように、この例では、XBox 360 のゲーム データを参照するために使用される単純なデザインを使用します。このデザインは、(ドロップダウン リストによって選択された) ジャンルでフィルタされたモデルの Game エンティティのリストが必要であることを意味します。この要件を満たすには、次のものを公開するビュー モデルが必要です。

  • 現在選択されているジャンルのデータバインド可能な Game リスト。
  • 選択されたジャンルの要求を行うメソッド。
  • ゲームのリストが更新されたことを UI に警告するイベント (このデータ要求は非同期であるため)。

fig04.gif

図 4 ユーザー インターフェイスの例

この一連の要件をビュー モデルがサポートしていれば、GameView.XAML (MVVM.Client プロジェクトにあります) で示されているように、ビュー モデルを XAML に直接バインドできます。このバインドは、ビューの Resources でビュー モデルの新しいインスタンスを作成し、メイン コンテナ (この場合は Grid) をビュー モデルにバインドすることで実装します。これは、XAML ファイル全体がビュー モデルに基づいて直接データ バインドされることを意味します。図 5 に GameView.XAML のコードを示します。

図 5 GameView.XAML

// GameView.XAML
<UserControl x:Class="MVVM.Client.Views.GameView"
             xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:data="clr-namespace:MVVM.Client.Data;assembly=MVVM.Client.Data">

  <UserControl.Resources>
    <data:GamesViewModel x:Key="TheViewModel" />
  </UserControl.Resources>

  <Grid x:Name="LayoutRoot"
        DataContext="{Binding Path=Games, Source={StaticResource TheViewModel}}">
    ...
  </Grid>
</UserControl>

このビュー モデルでは、IGameCatalog インターフェイスを使用してこれらの要件を満たす必要があります。一般に、ビュー モデルの既定のコンストラクタで既定のモデルを作成して XAML に簡単にバインドできるようにすると便利ですが、テストなどのシナリオ用にモデルを提供するコンストラクタのオーバーロードも含める必要があります。この例のビュー モデル (GameViewModel) は図 6 のようになります。

図 6 GameViewModel クラス

public class GamesViewModel
{
  IGameCatalog theCatalog;
  ObservableCollection<Game> theGames = new ObservableCollection<Game>();

  public event EventHandler LoadComplete;
  public event EventHandler ErrorLoading;

 public GamesViewModel() : 
    this(new GameCatalog())
  {
  }

  public GamesViewModel(IGameCatalog catalog)
  {
    theCatalog = catalog;
    theCatalog.GameLoadingComplete += 
      new EventHandler<GameLoadingEventArgs>(games_GameLoadingComplete);
    theCatalog.GameLoadingError += 
      new EventHandler<GameCatalogErrorEventArgs>(games_GameLoadingError);
  }

  void games_GameLoadingError(object sender, GameCatalogErrorEventArgs e)
  {
    // Fire Event on UI Thread
    Application.Current.RootVisual.Dispatcher.BeginInvoke(() =>
      {
        if (ErrorLoading != null) ErrorLoading(this, null);
      });
  }

  void games_GameLoadingComplete(object sender, GameLoadingEventArgs e)
  {
    // Fire Event on UI Thread
    Application.Current.RootVisual.Dispatcher.BeginInvoke(() =>
      {
        // Clear the list
        theGames.Clear();

        // Add the new games
        foreach (Game g in e.Results) theGames.Add(g);

        if (LoadComplete != null) LoadComplete(this, null);
      });
  }

  public void LoadGames()
  {
    theCatalog.GetGames();
  }

  public void LoadGamesByGenre(string genre)
  {
    theCatalog.GetGamesByGenre(genre);
  }

  public ObservableCollection<Game> Games
  {
    get
    {
      return theGames;
    }
  }
}

ビュー モデルで特に興味深いのは、GameLoadingComplete (および GameLoadingError) 用のハンドラです。これらのハンドラは、モデルからのイベントを受け取り、ビューに対するイベントを生成します。ここでおもしろいのは、モデルはビュー モデルに結果のリストを渡しますが、ビュー モデルは基になっているビューに結果を直接渡すのではなく、独自のバインド可能なリスト (ObservableCollection<Game>) に結果を格納することです。

このような動作になるのは、ビュー モデルはビューに直接バインドされているため、結果はデータ バインドによってビューで示されるためです。ビュー モデルは (目的が UI の要求を満たすことなので) ユーザー インターフェイスについての知識を持っているので、自分が生成するイベントを UI スレッドで発生させることができます (ここでは Dispatcher.BeginInvoke を使用しますが、好みで他のメソッドを UI スレッドでの呼び出しに使用してもかまいません)。

Silverlight 2 での変更

MVVM パターンは多くの WPF プロジェクトで使用されて成功しています。このパターンを Silverlight 2 で使用する場合の問題は、このパターンを簡単でシームレスなものにするには、Silverlight 2 がコマンドとトリガをサポートする必要があることです。そのような場合、ユーザーがアプリケーションと対話するときに、XAML でビュー モデルのメソッドを直接呼び出していました。

Silverlight 2 ではこの動作にはもう少し処理が必要ですが、さいわい作成する必要があるコードは少しだけです。たとえば、ユーザーがドロップダウン リストを使用して別のジャンルを選択したときには、GameViewModel.GetGameByGenre メソッドを実行するコマンドが必要です。必要なインフラストラクチャが利用できないので、コードを使用して同じことを行う必要があります。例では、コンボ ボックス (genreComboBox) の選択が変化したときは、コマンドではなくコードでビュー モデルから Games を手動で読み込んでいます。ここで必要なことは、データを読み込む要求が発生することだけです。Games のリストにバインドされているので、基になっているビュー モデルがバインド対象のコレクションを変更するだけで、更新されたデータが自動的に表示されます。図 7 にこのコードを示します。

図 7 UI でのデータの更新

void genreComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
  string item = genreComboBox.SelectedItem as string;
  if (item != null)
  {
    LoadGames(item);
  }
}

void LoadGames(string genre)
{
  loadingBar.Visibility = Visibility.Visible;
  if (genre == "(All)")
  {
    viewModel.LoadGames();
  }
  else
  {
    viewModel.LoadGamesByGenre(genre);
  }

}

いくつかの場面で、要素のバインドとコマンドがないために Silverlight 2 開発者はこの動作をコードで処理することを強いられます。コードはビューの一部なので、これによってアプリケーションの階層化が壊れることはありません。WPF で目にするすべての XAML の例のように簡単ではないだけです。

まとめ

Silverlight 2 では単一層のアプリケーションを作成する必要はありません。WPF から借用した Model-View-ViewModel パターンを使用して、Silverlight 2 アプリケーションを簡単に階層化できます。さらに、この階層化方法を使用するとアプリケーションの機能を疎結合にすることができ、保守、拡張、テスト、展開が容易になります。

この記事の執筆に協力してくれた Laurent Bugnion (『Silverlight 2 Unleashed』の著者) および WPF Disciples メーリング リストのメンバに感謝します。blog.galasoft.ch の Laurent のブログを参照してください。

Shawn Wildermuth は Microsoft MVP (C#) であり、Wildermuth Consulting Services の創立者でもあります。彼は数冊の著書と多数の記事を執筆しています。また、現在は各地で Silverlight 2 を教える Silverlight ツアーを実施しています。連絡先は shawn@wildermuthconsulting.com です。