MVVM

MVVM アプリにおけるマルチスレッドとディスパッチ

Laurent Bugnion

コード サンプルのダウンロード

MSDN マガジンでモデル - ビュー - ビューモデル (MVVM: Model-View-ViewModel) パターンに関する連載を始めてからちょうど 1 年になります。これまでの連載記事は、すべて is.gd/mvvmmsdn (英語) で確認できます。これまでの連載では、MVVM Light Toolkit のコンポーネントを使ってこのパターンに沿った疎結合アプリをビルドする方法、依存関係の挿入 (DI) や制御の反転 (IOC) といったコンテナー パターン (MVVM Light の SimpleIoc など)、Messenger、ビュー サービス (Navigation、Dialog など) を取り上げてきました。また、デザイン時データを作成して Blend などのビジュアル デザイナーを最大限に活用する方法や、ビューとビューモデルの関係をこれまでよりも分離する場合にイベント ハンドラーに代わる、RelayCommand コンポーネントと EventToCommand コンポーネントについても紹介しました。

今回は、最新のクライアント アプリでよく目にするもう 1 つのシナリオ、マルチスレッド処理とスレッド間の相互コミュニケーションについて詳しく説明します。マルチスレッド処理は、Windows 8、Windows Phone、Windows Presentation Foundation (WPF)、Silverlight といった最新のアプリ フレームワークで、ますます重要性が高まっています。これらのどのプラットフォームでも、バックグラウンド スレッドを開始して管理する必要があります。最も処理能力が制限されているプラットフォームでもこれは同じです。ユーザー エクスペリエンス (UX) を向上するには、実のところ、処理能力が制限される小規模プラットフォームこそ、マルチスレッド処理が重要だと言えます。

Windows Phone プラットフォームはその良い例です。最初のバージョン (Windows Phone 7) では、特に項目テンプレートに画像が含まれている場合、長い一覧を滑らかにスクロールできるようにすることはかなり困難でした。ただし、以降のバージョンでは、画像や一部のアニメーションのデコードが専用のバックグラウンド スレッドに渡されます。その結果、メイン スレッドに影響することなく画像を読み込んで、滑らかなスクロールを実現できます。

今回は、上記の例で示している重要な考え方をいくつか説明します。まず、一般的な XAML ベースのアプリでのマルチスレッドのしくみを復習します。

簡単に言えば、スレッドはアプリの小さな実行単位と考えることができます。各アプリには、少なくともスレッドが 1 つあります。このスレッドをメイン スレッドと呼びます。アプリ起動時にアプリのメイン メソッドが呼び出されるときに、OS がこのスレッドを開始します。このシナリオは、多かれ少なかれ、スレッドをサポートするすべてのプラットフォームで発生します。処理能力の高いコンピューター上で実行している WPF でも、処理能力が限られた Windows Phone ベースのデバイスでも発生する頻度は変わりません。

メソッドが呼び出されるときに、メソッドの操作がキューに追加されます。各操作は、キューに追加された順序に従ってシーケンシャルに実行されます (ただし、操作に優先度を割り当てて、実行順序を変えることができます)。このキューを管理するオブジェクトをスレッドのディスパッチャーと呼びます。このオブジェクトは、WPF、Silverlight、および Windows Phone では Dispatcher クラスのインスタンスです。Windows 8 では、このディスパッチャー オブジェクトを CoreDispatcher と呼び、API がやや異なります。

アプリのニーズに応じて、新しいスレッドは、コードから明示的に開始することも、ライブラリや OS によって暗黙のうちに開始することもできます。ほとんどの場合、新しいスレッドを開始する目的は、他のアプリをブロックすることなく操作を実行すること (または操作の結果を待機すること) です。コンピューター処理を集中的に実行する操作や入出力操作などがこの対象になります。つまり、最新のアプリでは UX の要件が増えているため、マルチスレッド処理が増えることになります。アプリが複雑になるほど、開始するスレッド数が多くなります。この傾向をよく表しているのが、Windows ストア アプリで使用されている Windows ランタイム フレームワークです。このような最新クライアント アプリでは、非同期操作 (バックグラウンド スレッドで実行する操作) がごく一般的です。たとえば、Windows 8 ではファイル アクセスがすべて非同期処理になります。以下は WPF におけるファイルの読み取り (同期) です。

 

public string ReadFile(FileInfo file)
{
  using (var reader = new StreamReader(file.FullName))
  {
    return reader.ReadToEnd();
  }
}

以下は、Windows 8 における同じ操作 (非同期) です。

 

public async Task<string> ReadFile(IStorageFile file)
{
  var content = await FileIO.ReadTextAsync(file);
  return content;
}

Windows 8 の非同期処理では、await キーワードと async キーワードが使用されています。これらのキーワードの目的は、非同期処理でコールバックの使用を避け、コードを読みやすくすることです。これらのキーワードは、ファイルの操作を非同期に行うために必要です。これに対し、WPF の例は同期処理です。そのため、読み取っているファイルが長ければメイン スレッドをブロックするリスクがあります。これは、アニメーションがコマ送りになったり UI が更新されないなど、UX が不安定になる要因になります。

同様に、アプリで時間のかかる操作を行い、その結果 UI が不安定になるおそれがある場合は、そのような操作をバックグラウンド スレッドで実行します。たとえば、WPF、Silverlight、および Windows Phone の図 1 のコードでは、時間のかかるループを実行するバックグラウンド操作を開始しています。ここでは、毎回のループでスレッドを短時間のスリープ状態にして、他のスレッドが自身のスレッド操作を実行する時間を生み出しています。

図 1 Microsoft .NET Framework での非同期操作

public void DoSomethingAsynchronous()
{
  var loopIndex = 0;
  ThreadPool.QueueUserWorkItem(
    o =>
    {
      // This is a background operation!
      while (_condition)
      {
        // Do something
        // ...
        // Sleep for a while
        Thread.Sleep(500);
      }
  });
}

スレッド間で通信する

スレッドが別のスレッドと通信しなければならない場合は、いくつか準備が必要になります。たとえば、毎回ループするたびにユーザーに状態メッセージを表示するように図 1 のコードを変更します。そのため、while ループの中にコードを 1 行追加して、XAML にある StatusTextBlock コントロールの Text プロパティを設定します。

while (_condition)
{
  // Do something
  // Notify user
  StatusTextBlock.Text = 
    string.Format("Loop # {0}", loopIndex++);
  // Sleep for a while
  Thread.Sleep(500);
}

この記事付属の SimpleMultiThreading というアプリでこの例を示しています。[Start (crashes the app)] (開始 (アプリがクラッシュする)) というラベルのボタンを使ってアプリを実行すると、アプリが実際にクラッシュします。何が起きているのでしょう。オブジェクトを作成すると、オブジェクトはそのオブジェクトを作成するコンストラクター メソッドを呼び出したスレッドに所属します。UI 要素の場合、XAML パーサーが XAML ドキュメントを読み込むときにオブジェクトを作成します。パーサーによる読み込みはメイン スレッドで実行されるため、結果として、すべての UI 要素がメイン スレッド (UI スレッドとも呼びます) に所属することになります。そのため、上記のコードのバックグラウンド スレッドが StatusTextBlock の Text プロパティの変更を試みると、不適切なスレッド間アクセスが行われます。その結果例外がスローされます。これは、デバッガーでコードを実行すると確認できます。図 2 に、例外ダイアログ ボックスを示します。このダイアログ ボックスでは、問題の根源を示す "追加情報" というメッセージが表示されます。

Cross-Thread Exception Dialog
図 2 スレッド間例外のダイアログ ボックス

このコードを機能させるには、バックグラウンド スレッドからディスパッチャーにアクセスして、メイン スレッドでの操作をキューに登録する必要があります。さいわい、図 3 の .NET クラス階層からわかるように、各 FrameworkElement も DispatcherObject です。各 DispatcherObject は、親となるディスパッチャーにアクセスできるようにする Dispatcher プロパティを公開しているため、このコードを図 4 のように変更できます。

Window Class Hierarchy
図 3 Window のクラス階層

図 4 UI スレッドへの呼び出しのディスパッチ

while (_condition)
{
  // Do something
  Dispatcher.BeginInvoke(
    (Action)(() =>
    {
      // Notify user
      StatusTextBlock.Text = 
        string.Format("Loop # {0}", loopIndex++);
    }));
  // Sleep for a while
  Thread.Sleep(500);
}

MVVM アプリでのディスパッチ

ビューモデルからバックグラウンド操作が実行されると、状況が少し変わります。一般に、ビューモデルは DispatcherObject から継承されません。ビューモデルは、INotifyPropertyChanged インターフェイスを実装する単純な従来の CLR オブジェクト (POCO) です。例として、MVVM Light の ViewModelBase クラスから派生したビューモデルを図 5 に示します。実際の MVVM の慣習に倣って、PropertyChanged イベントが発生する、Status という監視可能なプロパティを追加します。次に、バックグラウンド スレッド コードでこのプロパティに情報メッセージを設定します。

図 5 ビューモデルにおけるバインドされたプロパティの更新

public class MainViewModel : ViewModelBase
{
  public const string StatusPropertyName = "Status";
  private bool _condition = true;
  private RelayCommand _startSuccessCommand;
  private string _status;
  public RelayCommand StartSuccessCommand
  {
    get
    {
      return _startSuccessCommand
        ?? (_startSuccessCommand = new RelayCommand(
          () =>
          {
            var loopIndex = 0;
            ThreadPool.QueueUserWorkItem(
              o =>
              {
                // This is a background operation!
                while (_condition)
                {
                  // Do something
                  DispatcherHelper.CheckBeginInvokeOnUI(
                    () =>
                    {
                      // Dispatch back to the main thread
                      Status = string.Format("Loop # {0}", 
                         loopIndex++);
                    });
                  // Sleep for a while
                  Thread.Sleep(500);
                }
              });
          }));
    }
  }
  public string Status
  {
    get
    {
      return _status;
    }
    set
    {
      Set(StatusPropertyName, ref _status, value);
    }
  }
}

Windows Phone または Silverlight でこのコードを実行すると、XAML フロントエンドの TextBlock に Status プロパティをデータ バインドするまでは正常に動作します。操作をもう一度実行すると、アプリはクラッシュします。前回同様、バックグラウンド スレッドから別のスレッドに属している要素にアクセスしようとして、例外がスローされます。これは、データ バインドを使ってアクセスしている場合でも発生します。

WPF では状況が異なり、図 5 のコードは Status プロパティが TextBlock にデータ バインドされても動作します。他のすべての XAML フレームワークとは違って、WPF は PropertyChanged イベントをメイン スレッドに自動的にディスパッチするためです。他のすべてのフレームワークでは、ディスパッチのためのソリューションが必要になります。実際に必要なのは、必要な場合にのみ呼び出しをディスパッチするシステムです。WPF などの複数のフレームワークでビューモデルのコードを共有するためには、開発者ではなく、オブジェクトでディスパッチが必要かどうかを自動的に判断できると便利です。

ビューモデルは POCO なので、Dispatcher プロパティにアクセスできません。そのため、メイン スレッドにアクセスして操作をキューに登録するには別の手段が必要です。これが、MVVM Light の DispatcherHelper コンポーネントの目的です。つまりこのクラスは、メイン スレッドの Dispatcher を静的プロパティに格納し、いくつかのユーティリティ メソッドを公開して、利便性と一貫性のある方法で Dispatcher にアクセスします。クラスが機能するには、メイン スレッドでクラスを初期化する必要があります。この初期化は、アプリの起動時から機能にアクセスできるように、アプリの有効期間の早い段階で行うのが理想的です。通常、MVVM Light アプリでは、アプリのスタートアップ クラスを定義する App.xaml.cs ファイルで DispatcherHelper を初期化します。Windows Phone では、アプリのメイン フレーム作成直後に InitializePhoneApplication メソッドで DispatcherHelper.Initialize を呼び出します。WPF では、このクラスは App コンストラクターで初期化されます。Windows 8 では、ウィンドウをアクティブにした直後に OnLaunched で Initialize メソッドを呼び出します。

DispatcherHelper.Initialize メソッドの呼び出しが完了すると、DispatcherHelper クラスの UIDispatcher プロパティに、メイン メソッドのディスパッチャーへの参照が格納されます。あまり使いませんが、必要であればこのプロパティを直接使ってもかまいません。ですが、直接使用するよりも CheckBeginInvokeOnUi メソッドを使用することをお勧めします。このメソッドは、パラメーターとしてデリゲートを受け取ります。通常は図 6 のようにラムダ式を使用しますが、名前付きのメソッドでもかまいません。

図 6 DispatcherHelper を使用したクラッシュの回避

while (_condition)
{
  // Do something
  DispatcherHelper.CheckBeginInvokeOnUI(
    () =>
    {
      // Dispatch back to the main thread
      Status = string.Format("Loop # {0}", loopIndex++);
    });
  // Sleep for a while
  Thread.Sleep(500);
}

このメソッドでは、名前のとおり最初にチェックが実行されます。メソッドの呼び出し元が既にメイン メソッドで実行されていればディスパッチは不要です。この場合は、すぐにメイン メソッドで直接デリゲートが実行されます。ただし、呼び出し元がバックグラウンド スレッド上にある場合は、ディスパッチを実行します。

このメソッドはディスパッチの前にチェックを実行するため、呼び出し元では、このコードが常に最適な呼び出しを使用すると想定できます。クロスプラットフォームのコードを記述する場合、異なるプラットフォームの小さな違いにマルチスレッドで対処することになるため、これは特に便利です。この場合も、図 6 のビューモデル コードは、Status プロパティを設定しているコードを変更することなく共有できます。

さらに、DispatcherHelper により、XAML プラットフォーム間のディスパッチャー API における違いが抽象化されます。Windows 8 では、CoreDispatcher の主要メンバーは RunAsync メソッドと HasThreadAccess プロパティですが、他の XAML フレームワークでは、それぞれ BeginInvoke メソッドと CheckAccess メソッドを使用します。DispatcherHelper を使用すると、これらの違いを考慮する必要がなくなり、コードを共有しやすくなります。

現実のディスパッチ: センサー

Windows Phone アプリのコンパス センサーをビルドしながら、DispatcherHelper の使用方法について説明します。

この記事付属のサンプル コードには、CompassSample - Start というドラフト アプリを含めています。このアプリを Visual Studio で開くと、MainViewModel からコンパス センサーへのアクセスが、ISensorService インターフェイスの実装である SensorService というサービスにカプセル化されているのがわかります。これら 2 つの要素は、Model フォルダーに格納されています。

MainViewModel は、コンストラクターで ISensorService への参照を受け取り、SensorService の RegisterForHeading メソッドを使用してコンパスの変化をすべて登録します。このメソッドには、センサーが Windows Phone ベース デバイスの向きが変わったことを報告するたびに実行するコールバックが必要です。MainViewModel の既定のコンストラクターを次のコードに置き換えます。

sensorService.RegisterForHeading(
  heading =>
  {
    Heading = string.Format("{0:N1}°", heading);
    Debug.WriteLine(Heading);
  });

残念ながら、Windows Phone エミュレーターでデバイスのコンパスのシミュレーションを行うことはできません。コードをテストするには、物理デバイスでアプリを実行する必要があります。開発者デバイスを接続し、F5 キーを押してコードをデバッグ モードで実行します。Visual Studio で出力コンソールを確認すると、コンパスの出力が一覧されるのがわかります。デバイスを動かすと、北の方位を見つけて、値が更新され続ける過程を観察できます。

次に、XAML の TextBlock を、MainViewModel の Heading プロパティにバインドします。MainPage.xaml を開いて ContentPanel 内の TextBlock を見つけます。Text プロパティの "Nothing yet" を、"{Binding Heading}" に置き換えます。アプリをもう一度デバッグ モードで実行すると、前述したメッセージと同様のエラー メッセージが表示されアプリがクラッシュするのを確認できます。ここでも、スレッド間例外が発生しています。

このエラーがスローされるのは、コンパス センサーをバックグラウンド スレッドで実行しているためです。コールバック コードが呼び出されると、Heading プロパティのセッターと同様にコールバック コードもバックグラウンド スレッドで実行されます。TextBlock はメイン メソッドに属しているため、例外がスローされます。ここでも、メイン スレッドに操作をディスパッチするための "安全圏" を作る必要があります。これを行うには SensorService クラスを開きます。コールバック メソッドが実行される CompassCurrentValueChanged メソッドで、CurrentValueChanged イベントを処理します。このコードを、DispatcherHelper を使用する次のコードと置き換えます。

 

void CompassCurrentValueChanged(
  object sender,
  SensorReadingEventArgs<CompassReading> e)
{
  if (_orientationCallback != null)
  {
    DispatcherHelper.CheckBeginInvokeOnUI(
      () => _orientationCallback(e.SensorReading.TrueHeading));
  }
}

 

ここで、DispatcherHelper を初期化する必要があります。そのためには、App.xaml.cs を開き、InitializePhoneApplication というメソッドを見つけます。このメソッドの最後に、DispatcherHelper.Initialize(); を追加します。この時点でコードを実行すると、想定どおりに Windows Phone ベース デバイスの正しい方位が表示されます。

Windows Phone では、すべてのセンサーのイベントがバックグラウンド スレッドで発生するわけではありません。たとえば、携帯電話の地理位置情報を監視するのに使用する GeoCoordinateWatcher センサーは、便宜のために既にメイン スレッドに戻っています。DispatcherHelper を使用すると、このようなことを考慮しなくても、メイン スレッドのコールバックを常に同じ方法で呼び出すことができます。

まとめ

今回は、Microsoft .NET Framework でのスレッドのしくみと、メイン スレッドで作成したオブジェクトをバックグラウンド スレッドに移すために必要な準備について説明しました。また、このときにどのようにクラッシュが発生し、このクラッシュを防ぐにはメイン スレッドの Dispatcher を使用して適切に処理を行う必要があることも示しました。

次に、この知識を MVVM アプリに当てはめ、MVVM Light Toolkit の DispatcherHelper コンポーネントを紹介しました。バックグラウンド スレッドからの通信時に DispatcherHelper コンポーネントを使用して問題を回避する方法と、DispatcherHelper コンポーネントによりこのアクセスを最適化し、WPF フレームワークとその他の XAML ベース フレームワーク間の違いが抽象化されることを説明しました。このようにして、DispatcherHelper コンポーネントにより、ビューモデルのコードを共有しやすくなり、作業が容易になります。

最後に、バックグラウンド スレッドでイベントが発生する特定のセンサーを使用している場合に、Windows Phone アプリで DispatcherHelper を使用して問題を回避する現実的な例もデモしました。

次回は、MVVM Light の Messenger コンポーネントについて詳しく説明します。このコンポーネントを使用して、互いのオブジェクトに関する情報を認識しない完全に切り離した方法で、オブジェクト間の通信を簡単に行う方法を紹介します。

Laurent Bugnion は、Windows Presentation Foundation、Silverlight、Pixelsense、Kinect、 Windows 8、Windows Phone、UX などのテクノロジーに携わる Microsoft パートナーの IdentityMine Inc で、シニア ディレクターを務めています。彼は、スイスのチューリッヒを拠点に活動している Microsoft MVP および Microsoft Regional Director です。

この記事のレビューに協力してくれた技術スタッフの Thomas Petchel (マイクロソフト) に心より感謝いたします。