非同期プログラミング

非同期 MVVM アプリケーションのパターン: データ バインド

Stephen Cleary

async キーワードと await キーワードを使用する非同期コードによって、プログラムの記述方法が変化しています。これには相応の理由があります。async/await キーワードはサーバー ソフトウェアで役立ちますが、現在最も関心が集まっている分野は、UI を備えたアプリケーションです。そのようなアプリケーションで async/await キーワードを使用すると、UI の応答性を高めることができます。しかし、モデル - ビュー - ビューモデル (MVVM: Model-View-ViewModel) などの確立されたパターンと共に async や await を使用する方法は、わかりやすくありません。この記事は、async や await を MVVM と組み合わせたパターンについて考察する、短期連載の 1 回目です。

誤解のないように述べておくと、非同期に関する私の初めての記事「非同期プログラミングのベスト プラクティス」(msdn.microsoft.com/magazine/jj991977) は、クライアント側とサーバー側両方の、async/await を使用するすべてのアプリケーションを対象としていました。この新連載では、以前の記事のベスト プラクティスを基に、クライアント側 MVVM アプリケーション専用のパターンを紹介します。しかし、紹介するパターンは単なるパターンであり、特定のシナリオに対して最適な解決策とは限りません。より優れた方法を見つけた場合はお知らせください。

この記事の執筆時点で、async/await キーワードは幅広い MVVM プラットフォームでサポートされています。具体的には、デスクトップ (.NET Framework 4 以上の Windows Presentation Foundation (WPF))、iOS/Android (Xamarin)、Windows ストア (Windows 8 以上)、Windows Phone (バージョン 7.1 以上)、Silverlight (バージョン 4 以上)、およびこれらのプラットフォームの組み合わせを対象にした MvvmCross などのポータブル クラス ライブラリ (PCL) が挙げられます。"非同期 MVVM" パターンを開発する機は熟しています。

この記事は、async/await の知識をある程度持っていて、MVVM に関してはかなり詳しい読者を想定しています。知識に不安がある方は、多数の便利な入門資料をオンラインで参照できます。私のブログには、async/await の入門記事 (bit.ly/19IkogW、英語) を掲載しており、記事の最後には参考資料も紹介しています。また、MSDN の非同期関連ドキュメントも非常に役立ちます (「タスクベースの非同期プログラミング」で検索してください)。MVVM の詳細については、Josh Smith が執筆した記事を読むことを強くお勧めします。

単純なアプリケーション

この記事では、図 1に示すような非常に単純なアプリケーションを構築します。アプリケーションが読み込まれたら、HTTP 要求を開始して、返されたバイト数を計測します。HTTP 要求は正常に完了するか例外が発生して完了し、データ バインドを使用してアプリケーションが更新されます。アプリケーションは常に完全な応答性を保っています。




図 1 サンプル アプリケーション

最初にお断りしておきますが、私のプロジェクトの MVVM パターンに対する準拠状況はかなり緩やかです。正式なドメイン モデルを使用することもありますが、多くの場合は、実際のモデルではなく一連のサービスとデータ転送オブジェクト (本質的にはデータ アクセス層) を使用しています。また、ビューに関してはかなり実際的です。サポート クラスや XAML のコードが何十行にも達するのを避けるためなら、数行の分離コードもためらいません。そのため、MVVM の説明では、厳密な定義を使用していないことに留意してください。

MVVM パターンに async と await を取り入れる場合にまず考慮が必要な点の 1 つは、ソリューションのどの部分に UI スレッド コンテキストが必要なのか判断することです。Windows プラットフォームでは、UI コンポーネントにはそのコンポーネントを所有する UI スレッドからのみアクセスできると決まっています。もちろん、ビューは UI コンテキストに完全に結び付いています。もう 1 つ お断りしておきますが、私のアプリケーションでは、データ バインドを使用してビューにリンクしている項目も UI コンテキストに結び付いています。WPF の最近のバージョンではこの制限が緩和され、UI スレッドとバックグラウンド スレッド (BindingOperations.EnableCollectionSynchronization など) の間でデータをある程度共有できるようになっています。しかし、スレッド間データ バインドのサポートはすべての MVVM プラットフォーム (WPF、iOS/Android/Windows Phone、Windows ストア) で保証されているわけではないため、私のプロジェクトでは UI へのデータ バインドを UI スレッド類似性があるものとして扱います。

このため、いつも私はビューモデルが UI コンテキストに結び付いているものとして扱っています。私のアプリケーションでは、ビューモデルはモデルよりもビューとの関係が深く、ビューモデル層は本質的にはアプリケーション全体の API です。ビューは、文字どおり、実際のアプリケーションが存在している UI 要素のシェルを提供します。ビューモデル層は、概念上は、UI スレッド類似性があるテスト可能な UI です。モデルが (データ アクセス層ではなく) 実際のドメイン モデルで、モデルとビューモデルの間にデータ バインドが存在する場合は、モデル自体にも UI スレッド類似性があります。UI 類似性のある層を特定すれば、"UI 類似コード" (ビューとビューモデル、および場合によってはモデル) と "UI 非依存コード" (おそらくモデルと、確実にサービスやデータ アクセスなどその他すべての層) を頭の中で線引きできるようになります。

さらに、ビュー層の外部にあるすべてのコード (ビューモデル層、モデル層、サービスなど) は、特定の UI プラットフォームに結び付いた型に依存してはなりません。Dispatcher (WPF/Xamarin/Windows Phone/Silverlight)、CoreDispatcher (Windows ストア)、または ISynchronizeInvoke (Windows Forms) の直接使用はお勧めできません (SynchronizationContext はかろうじて使用できますが、ほとんど役立ちません)。たとえば、非同期操作を行ってから Dispatcher を使用して UI を更新するコードは、インターネットで多数見つかります。しかし、非同期操作に await を使用して、Dispatcher を使用せずに UI を更新する方が、移植性が高く面倒が少なくなります。

ビューモデルは、UI 類似性がありながら特定の UI コンテキストに依存しないため、最も興味深い層です。この連載では、非同期のベスト プラクティスに従いながらも特定の UI 型を使用しない方法で、非同期と MVVM を組み合わせます。この 1 回目の記事のテーマは、非同期データ バインドです。

非同期データ バインド プロパティ

"非同期プロパティ" という用語は、矛盾しています。プロパティの get アクセス操作子は、すぐに実行され、現在の値を取得します。バックグラウンド操作を開始することはありません。これが、プロパティの get アクセス操作子に async キーワードを使用できない理由の 1 つになっていると言えそうです。非同期プロパティが設計上必要な場合は、まず代替手段を考えてください。特に、そのプロパティが実際にはメソッド (またはコマンド) として機能しているかどうか考えてください。プロパティの get アクセス操作子にアクセスするたびに新しい非同期操作を開始する必要がある場合、それはプロパティとは言えません。非同期メソッドは単純です。また、非同期コマンドについては別の記事で取り上げます。

この記事では、非同期データ バインド プロパティを開発します。非同期データ バインド プロパティとは、非同期操作の結果で更新するデータ バインド プロパティです。一般的なシナリオは、外部ソースからビューモデルにデータを取得する必要がある状況です。

既に説明したように、サンプル アプリケーションでは、Web ページのバイト数を計測するサービスを定義します。async/await の応答性に関する性質を示すために、このサービスでは数秒の遅れを生じさせます。次回以降の記事ではより現実的な非同期サービスを取り上げますが、今回の "サービス" は図 2 に示すメソッド 1 つだけです。

図 2 MyStaticService.cs

using System;
 using System.Net.Http;
 using System.Threading.Tasks;
 public static class MyStaticService
 {
   public static async Task CountBytesInUrlAsync(string url)
   {
     // Artificial delay to show responsiveness.
     await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
     // Download the actual data and count it.
     using (var client = new HttpClient())
     {
       var data = await client.GetByteArrayAsync(url).ConfigureAwait(false);
       return data.Length;
     }
   }
 }

このメソッドは、サービスと見なしているので UI 非依存です。サービスは UI 非依存なので、待機のたびに ConfigureAwait(false) を使用します (これについては「非同期プログラミングのベスト プラクティス」で説明しました)。

起動時に HTTP 要求を開始する、単純なビューとビューモデルを追加しましょう。サンプル コードでは、構築時にビューモデルを作成するビューを備えた、WPF ウィンドウを使用してします。これは簡略化だけを目的としており、この連載記事で説明する非同期の原則とパターンは、すべての MVVM プラットフォーム、フレームワーク、およびライブラリに適用されます。今のところ、ビューはラベルが 1 つ含まれた 1 つのメイン ウィンドウで構成されています。メイン ビューの XAML は、UrlByteCount メンバーにバインドしています。

<Window x:Class="MainWindow"
        xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <Label Content="{Binding UrlByteCount}"/>
  </Grid>
</Window>

メイン ウィンドウの次のような分離コードで、ビューモデルを作成します。

public partial class MainWindow
 {
   public MainWindow()
   {
     DataContext = new BadMainViewModelA();
     InitializeComponent();
   }
 }

よくある誤り

このビューモデル型を BadMainViewModelA という名前にしていることにお気付きでしょう。その理由は、まずはビューモデルに関する 2 つのよくある誤りについて説明するためです。よくある誤りの 1 つは、次のように同期的に操作をブロックすることです。

public class BadMainViewModelA
 {
   public BadMainViewModelA()
   {
     // BAD CODE!!!
     UrlByteCount =
       MyStaticService.CountBytesInUrlAsync("http://www.example.com").Result;
   }
   public int UrlByteCount { get; private set; }
 }

これは "常に非同期" のガイドラインに違反していますが、開発者は手詰まりになったと感じるとこのような方法を取ることがあります。このコードを実行してみると、それなりに機能することがわかります。await ではなく Task.Wait または Task.Result を使用するコードは、その操作を同期的にブロックします。

同期ブロックにはいくつかの問題があります。最もわかりやすい問題は、コードで非同期操作の実行もブロックも行っていることです。このようにすると、非同期性のメリットがまったくなくなります。現在のコードを実行すると、アプリケーションでは何も起きずに数秒間経過してから、結果が既に入力された完全なビューの形式で UI ウィンドウを表示します。問題は、アプリケーションに応答性がないことです。これは多くの最新アプリケーションでは許容されません。サンプル コードでは、応答性がないことを強調するために意図的に遅延を生じさせています。実際のアプリケーションでは、このような問題が開発中に検出されないまま、"まれな" クライアント シナリオ (ネットワーク接続の切断など) でようやく明らかになることもあります。

同期ブロックに関する別の問題は、もっとわかりにくい問題です。それはコードの脆弱性です。サンプル サービスでは、ConfigureAwait(false) プロパティを適切に使用しています。これはサービスのあるべき姿ですが、忘れられがちでもあります。非同期を普段から使用していない人にとってはなおさら忘れやすい処理です。サービス コードを保守していくうちに何が起きるか考えてみてください。保守担当の開発者が ConfigureAwait を忘れると、その時点で UI スレッドのブロックが UI スレッドのデッドロックに変わってしまいます (これについては、非同期のベスト プラクティスに関する以前の記事で詳しく説明しました)。

このため、"常に非同期" を使用する必要があります。しかし、多くの開発者は、図 3 に示すような 2 つ目の誤ったアプローチに陥ります。

図 3 BadMainViewModelB.cs

using System.ComponentModel;
 using System.Runtime.CompilerServices;
 public sealed class BadMainViewModelB : INotifyPropertyChanged
 {
   public BadMainViewModelB()
   {
     Initialize();
   }
   // BAD CODE!!!
   private async void Initialize()
   {
     UrlByteCount = await MyStaticService.CountBytesInUrlAsync(
       "http://www.example.com");
   }
   private int _urlByteCount;
   public int UrlByteCount
   {
     get { return _urlByteCount; }
     private set { _urlByteCount = value; OnPropertyChanged(); }
   }
   public event PropertyChangedEventHandler PropertyChanged;
   private void OnPropertyChanged([CallerMemberName] string propertyName = null)
   {
     PropertyChangedEventHandler handler = PropertyChanged;
     if (handler != null)
         handler(this, new PropertyChangedEventArgs(propertyName));
   }
 }

このコードも、実行すれば機能することがわかります。今回は UI がすぐに表示され、数秒間ラベルに 0 が表示されてから適切な値に更新されます。UI には応答性があり、まったく問題ないように見えます。しかし、この場合の問題はエラーの処理です。async void メソッドを使用する場合、既定では、非同期操作によってエラーが発生するとアプリケーションがクラッシュします。これも、開発中に見落とされたままクライアント デバイスの "特殊な" 条件でようやく明らかになりやすい、もう 1 つの状況です。図 3 のコードを async void から async Task に変更しても、アプリケーションはほとんど改善されません。すべてのエラーは暗黙的に無視され、ユーザーには何が起きたかわからないでしょう。どちらのエラー処理方法も適切ではありません。しかも、非同期操作の例外をキャッチして他のデータ バインド プロパティを更新すれば解決はできますが、そのコードは非常に長くなります。

より良いアプローチ

理想的な目標は、Task のような型を使用して、そのプロパティで結果やエラー詳細を取得することです。残念ながら、2 つの理由から Task はデータ バインドに適していません。それは、INotifyPropertyChanged を実装していないことと、Result プロパティによってブロックが発生することです。ただし、図 4 の型のような、ある種の "タスク監視機能" は定義できます。

図 4 NotifyTaskCompletion.cs

using System;
 using System.ComponentModel;
 using System.Threading.Tasks;
 public sealed class NotifyTaskCompletion : INotifyPropertyChanged
 {
   public NotifyTaskCompletion(Task task)
   {
     Task = task;
     if (!task.IsCompleted)
     {
       var _ = WatchTaskAsync(task);
     }
   }
   private async Task WatchTaskAsync(Task task)
   {
     try
     {
       await task;
     }
     catch
     {
     }
     var propertyChanged = PropertyChanged;
     if (propertyChanged == null)
         return;
     propertyChanged(this, new PropertyChangedEventArgs("Status"));
     propertyChanged(this, new PropertyChangedEventArgs("IsCompleted"));
     propertyChanged(this, new PropertyChangedEventArgs("IsNotCompleted"));
     if (task.IsCanceled)
     {
       propertyChanged(this, new PropertyChangedEventArgs("IsCanceled"));
     }
     else if (task.IsFaulted)
     {
       propertyChanged(this, new PropertyChangedEventArgs("IsFaulted"));
       propertyChanged(this, new PropertyChangedEventArgs("Exception"));
       propertyChanged(this,
         new PropertyChangedEventArgs("InnerException"));
       propertyChanged(this, new PropertyChangedEventArgs("ErrorMessage"));
     }
     else
     {
       propertyChanged(this,
         new PropertyChangedEventArgs("IsSuccessfullyCompleted"));
       propertyChanged(this, new PropertyChangedEventArgs("Result"));
     }
   }
   public Task Task { get; private set; }
   public TResult Result { get { return (Task.Status == TaskStatus.RanToCompletion) ?
     Task.Result : default(TResult); } }
   public TaskStatus Status { get { return Task.Status; } }
   public bool IsCompleted { get { return Task.IsCompleted; } }
   public bool IsNotCompleted { get { return !Task.IsCompleted; } }
   public bool IsSuccessfullyCompleted { get { return Task.Status ==
     TaskStatus.RanToCompletion; } }
   public bool IsCanceled { get { return Task.IsCanceled; } }
   public bool IsFaulted { get { return Task.IsFaulted; } }
   public AggregateException Exception { get { return Task.Exception; } }
   public Exception InnerException { get { return (Exception == null) ?
     null : Exception.InnerException; } }
   public string ErrorMessage { get { return (InnerException == null) ?
     null : InnerException.Message; } }
   public event PropertyChangedEventHandler PropertyChanged;
 }

中核となる NotifyTaskCompletion.WatchTaskAsync メソッドを見てみましょう。このメソッドは、非同期操作を表すタスクを受け取り、(非同期に) タスクの完了を待機します。await に ConfigureAwait(false) を使用していないことに注意してください。ここでは PropertyChanged 通知を生成する前に UI コンテキストに戻ります。このメソッドには、一般的なコーディング ガイドラインに反して空の汎用 catch 句があります。しかし、このサンプルでは意図的にこの手法を採用しています。例外をメインの UI ループに直接反映するつもりはありません。データ バインドを使用してエラーを処理できるように、すべての例外をキャッチしてプロパティを設定します。タスクが完了したら、該当するすべてのプロパティに対する PropertyChanged 通知を型から生成します。

NotifyTaskCompletion を使用した更新版ビューモデルは、次のとおりです。

public class MainViewModel
 {
   public MainViewModel()
   {
     UrlByteCount = new NotifyTaskCompletion(
       MyStaticService.CountBytesInUrlAsync("http://www.example.com"));
   }
   public NotifyTaskCompletion UrlByteCount { get; private set; }
 }

このビューモデルでは、すぐに操作を開始して、結果のタスクに対するデータ バインド "監視機能" を作成します。ビュー データ バインド コードは、次のように、操作結果に明示的にバインドするよう更新する必要があります。

<Window x:Class="MainWindow"
        xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <Label Content="{Binding UrlByteCount.Result}"/>
  </Grid>
</Window>

ラベルの内容が、Task.Result ではなく NotifyTaskCompletion.Result にデータ バインドしていることに注意してください。NotifyTaskCompletion.Result は、ブロックを引き起こさず、タスクが完了したらバインドを通知するため、データ バインドに適しています。このコードを実行すると、前の例と同じように動作することがわかります。UI の応答性が高く、すぐに読み込まれ (既定値 "0" が表示されます)、数秒してから実際の結果に更新されます。

NotifyTaskCompletion のメリットは、他にも多くのプロパティがあるため、データ バインドを使用してビジー状態インジケーターやエラー詳細を表示できることです。図 5 の更新版データ バインド コードのように、こうした便利なプロパティをいくつか利用して、ビジー状態インジケーターやエラー詳細を完全にビュー内で作成することも難しくありません。

図 5 MainWindow.xaml

<Window x:Class="MainWindow"
        xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml">
  <Window.Resources>
    <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
  </Window.Resources>
  <Grid>
    <!-- Busy indicator -->
    <Label Content="Loading..." Visibility="{Binding UrlByteCount.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <!-- Results -->
    <Label Content="{Binding UrlByteCount.Result}" Visibility="{Binding
      UrlByteCount.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <!-- Error details -->
    <Label Content="{Binding UrlByteCount.ErrorMessage}" Background="Red"
      Visibility="{Binding UrlByteCount.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
  </Grid>
</Window>

この最後の更新では、ビューだけを変更しています。アプリケーションには、(応答性を保ったまま) 数秒間 "Loading…" と表示されてから、操作結果または赤い背景のエラー メッセージに更新されます。

NotifyTaskCompletion は、非同期操作があって結果をデータ バインドが必要な、1 つのユース ケースに対処します。これは、データ参照や起動時の読み込みに多いシナリオです。しかし、たとえば "現在のレコードを保存する" など、非同期的な事実上のコマンドがある場合には役立ちません (非同期コマンドについては次回の記事で考察します)。

一見すると非同期 UI の構築は大変そうに思えるうえ、ある程度はそのとおりです。async/await キーワードを適切に使用すれば、UX 設計の向上が大きく促進されます。非同期 UI に移行すると、非同期操作の進行中に UI をブロックできなくなることがわかります。読み込み処理中の UI の状態を考え、その状態を目的として設計する必要があります。このようにすると手間が増えますが、多くの最新アプリケーションには必要な手間です。また、Windows ストアなどの新しいプラットフォームで非同期 API だけがサポートされる理由の 1 つでもあります。つまり、応答性の高い UX の開発を開発者に促すことが目的です。

まとめ

コード ベースを同期から非同期に変換する場合、通常はまずサービスやデータ アクセス コンポーネントを変更し、そこから UI に向けて非同期処理を拡大します。この作業を何度か行うと、同期メソッドから非同期メソッドへの変換が非常に簡単になります。将来のツールでは、この変換が自動的に行われるようになると予想 (および期待) しています。ただし、非同期が UI にまで及んだときは、本質的な変化が必要です。

UI が非同期になったら、UI 設計を強化して、アプリケーションが応答しない状況に対処する必要があります。このようにすると、アプリケーションの応答性が高まり、より現代的になります。言うなれば "高速で滑らか" になります。

この記事では、データ バインド用 Task と要約できる、単純な型を紹介しました。次回は、非同期コマンドを紹介し、"非同期用 ICommand" を本質とする概念について説明します。この連載の最後の記事では、まとめとして非同期サービスについて考察します。なお、これらのパターンは現在もコミュニティで開発中です。実際のニーズに応じて、自由に調整してみてください。

Stephen Clearyはミシガン北部在住の夫、父親兼プログラマです。彼は、マルチスレッドと非同期プログラミングに 16 年間取り組み、最初の CTP から Microsoft .NET Framework の非同期サポートを使ってきました。彼のホーム ページとブログは、stephencleary.com(英語) から利用できます。

この記事のレビューに協力してくれた技術スタッフの James McCaffrey と Stephen Toub に心より感謝いたします。