异步编程

针对异步 MVVM 应用程序的模式:数据绑定

Stephen Cleary

使用 async 和 await 关键字的异步代码正在转变程序的编写方式,这一转变有着充分的理由。 尽管 async 和 await 可能对服务器软件很有用,但当前人们主要关注的是具有 UI 的应用程序。 对于这些应用程序,这些关键字可产生更具响应能力的 UI。 然而,如何在 Model-View-ViewModel (MVVM) 等原有模式中使用 async 和 await 并不是显而易见的。 本文是一个简短文章系列的开篇,该系列将探讨一些将 async 和 await 与 MVVM 结合起来的模式。

更清楚地说,我关于 async 的第一篇文章“异步编程的最佳做法” (msdn.microsoft.com/magazine/jj991977) 涵盖了使用 async/await 的所有应用程序,既有客户端,也有服务器。 这个新系列的内容以那篇文章中提到的最佳做法为基础,介绍了专门针对客户端 MVVM 应用程序的模式。 但这些模式也只是模式,并不一定是特定情况的最佳解决方案。 如果您发现了更好的方式,请告诉我!

在写这篇文章时,众多 MVVM 平台均支持 async 和 await 关键字:台式机(Microsoft .NET Framework 4 和更高版本上的 Windows Presentation Foundation [WPF])、iOS/Android (Xamarin)、Windows 应用商店(Windows 8 和更高版本)、Windows Phone(7.1 和更高版本)、Silverlight(4 和更高版本),以及面向这些平台的任何组合的可移植类库 (PCL)(例如 MvvmCross)。 现在,发展“async MVVM”模式的时机已经成熟。

我假设您对 async 和 await 已有一定了解,并且相当熟悉 MVVM。 如果不是这样的话,可以在网上找到很多有帮助的介绍性资料。 我的博客 (bit.ly/19IkogW) 包含了 async/await 介绍,其末尾列出了其他资源,有关 async 的 MSDN 文档也非常不错(搜索“Task-based Asynchronous Programming”)。 有关 MVVM 的更多信息,我强烈推荐大家阅读 Josh Smith 所写的任何文章。

一个简单的应用程序

在这篇文章中,我将构建一个非常简单的应用程序,如图 1 所示。 当该应用程序加载时,它会启动一个 HTTP 请求,并统计返回的字节数。 此 HTTP 请求可能成功完成,也可能出现异常,并且该应用程序将使用数据绑定进行更新。 该应用程序始终充分响应。

The Sample Application
The Sample Application
The Sample Application
图 1 示例应用程序

但首先我想提一下,我在自己的项目中并未严格遵循 MVVM 模式,有时使用了适当的域 Model,但更常用的是一系列服务和数据传输对象(实际上是数据访问层),而不是实际 Model。 对于 View,我同样秉持相当务实的态度;如果替代方案是使用支持类和 XAML 的几十行代码,那么我不会回避采用几行代码隐藏。 因此,当我谈到 MVVM 时,您需要明白,我没有使用此术语的任何特别严格的定义。

在将 async 和 await 引入到 MVVM 模式时,您首先要考虑的事情之一是确定解决方案的哪些部分需要 UI 线程上下文。 有些 UI 组件仅从拥有它们的 UI 线程加以访问,Windows 平台会认真对待这些组件。 很明显,视图被完全绑定到了 UI 上下文。 我在我的应用程序中也证实了通过数据绑定链接到视图的任何内容均被绑定到 UI 上下文。 WPF 的最新版本已放松了这一限制,允许在 UI 线程与后台线程(例如,BindingOperations.EnableCollection­Synchronization)之间实现一定的数据共享。 不过,并非在每个 MVVM 平台(WPF、iOS/Android/Windows Phone、Windows 应用商店)上均保证支持跨线程数据绑定,因此在我自己的项目中,我只是将数据绑定到 UI 的任何内容均视为具有 UI 线程关联性。

因此,我始终将 ViewModel 视为仿佛绑定到 UI 上下文。 在我的应用程序中,ViewModel 与 View 的联系更紧密,而不是 Model — ViewModel 层实质上是用于整个应用程序的 API。 实际上,View 仅提供实际应用程序所处的 UI 元素的外壳。 从概念上来说,ViewModel 层是包含 UI 线程关联性的可测试 UI。 如果您的 Model 是实际域模型(而不是数据访问层),且该 Model 与 ViewModel 之间存在数据绑定,则 Model 本身也具有 UI 线程关联性。 确定了哪些层具有 UI 关联性后,您可以在“与 UI 关联的代码”(View 和 ViewModel,也可能是 Model)和“与 UI 无关的代码”(可能是 Model,并且一定是其他所有层,例如服务和数据访问)之间加以区分。

此外,View 层之外的所有代码(即,ViewModel 和 Model 层、服务等)都不应依赖绑定到特定 UI 平台的任何类型。 不要直接使用 Dispatcher (WPF/Xamarin/Windows Phone/Silverlight)、CoreDispatcher(Windows 应用商店)或 ISynchronizeInvoke(Windows 窗体)。 (SynchronizationContext 略微好些,但也仅仅是勉强好些。)例如,Internet 上有许多代码进行一些异步工作,然后使用 Dispatcher 更新 UI;更便捷且不太繁琐的解决方案是使用 await 进行异步工作,然后在不使用 Dispatcher 的情况下更新 UI。

ViewModel 是最有趣的层,因为它们具有 UI 关联性,但不依赖特定 UI 上下文。 在该系列中,我将 async 和 MVVM 结合起来,在避免特定 UI 类型的同时还遵循 async 最佳做法;第一篇文章着重说明异步数据绑定。

异步数据绑定属性

术语“异步属性”实际上是一种矛盾。 属性 getter 应立即执行并检索当前值,而不是启动后台操作。 这可能是在属性 getter 上不能使用 async 关键字的原因之一。 如果您发现您的设计需要异步属性,则首先考虑一些替代选择。 尤其是,该属性实际上是否应是一个方法(或命令)? 如果每次访问属性 getter 时其都需要启动一个新异步操作,则这根本不是属性。 异步方法直截了当,我将在另一个文章中涉及异步命令。

在该文章中,我将开发一个异步数据绑定属性;即,我利用异步操作的结果更新的数据绑定属性。 一个常见情况是 ViewModel 需要从某个外部源检索数据。

如前所述,我的示例应用程序中将定义一个统计网页中的字节数的服务。 为展示 async/await 的响应能力,此服务还将延迟几秒钟。 我将在后续文章中介绍更现实的异步服务;目前,此“服务”只是图 2 中所示的单一方法。

图 2 MyStaticService.cs

using System;
using System.Net.Http;
using System.Threading.Tasks;
public static class MyStaticService
{
  public static async Task<int> 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)(如在我的其他文章“异步编程中的最佳做法”中所讨论的)。

我们添加一个简单 View 和 ViewModel,在启动时发起 HTTP 请求。 该示例代码使用 WPF 窗口,View 在构建时创建它们的 ViewModel。 这只是为了简单;该系列文章中探讨的异步原则和模式在所有 MVVM 平台、框架和库间均适用。 目前,View 包含一个带有单标签的单个主窗口。 用于主 View 的 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>

主窗口的代码隐藏创建 ViewModel:

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

常见错误

您可能注意到 ViewModel 类型称为 BadMainViewModelA。 这是因为我将首先查看与 ViewModel 相关的一些常见错误。 一个常见错误是在该操作上同步阻塞,如下所示:

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

这违反了“始终使用异步”的异步指导原则,但有时开发人员出于无奈会尝试这样做。 如果您执行此代码,您会看到这在某种程度上是有效的。 使用 Task.Wait 或 Task<T>.Result 而不是 await 的代码会在该操作上同步阻塞。

同步阻塞有几个问题。 最明显的是此代码现在正在采取异步操作,但又在该操作上阻塞;结果会失去异步的所有好处。 如果您执行当前代码,您将看到应用程序僵住几秒钟,然后 UI 窗口中会瞬间迸发已填充了结果的视图。 此问题是应用程序不响应,这对许多新式应用程序来说是不可接受的。 此示例代码具有故意延迟,以突出这种不响应问题;在真实应用程序中,此问题在开发过程中可能不会引起注意,仅在“异常”客户端情况(例如失去网络连接)下才会出现。

另一个同步阻塞问题更加微妙:此代码更加脆弱。 我的示例服务正确使用了 ConfigureAwait(false),就像服务应该做的那样。 但这容易忘记,尤其是如果您(或您的同事)不经常使用异步。 请考虑随时间的推移,在维护此服务代码时会发生什么。 维护开发人员可能会忘记 ConfigureAwait,此时 UI 线程的阻塞会变成 UI 线程的死锁。 (在我有关异步最佳做法的上一篇文章中更详细地描述了这种情况。)

好了,因此您应“始终使用异步”。但许多开发人员采用图 3 所示的第二个错误方法。

图 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 方法时,异步操作引发的任何错误在默认情况下均会导致应用程序崩溃。 这是在开发过程中容易疏忽的另一种情况,其仅在客户端设备上的“怪异”情况下出现。 甚至将图 3 中的代码从 async void 更改为 async Task 也几乎不会改进此应用程序;所有错误均将被默默地忽视,从而使用户想知道发生了什么。 任何一种错误处理方法都不适用。 尽管可通过捕获异步操作中的异常并更新其他数据绑定属性来处理这种情况,但这将导致大量冗长代码。

更有效的方法

理想的情况下,我真正想要的是就像 Task<T> 这样的类型,这种类型具有用于获得结果或错误详细信息的属性。 不幸的是,由于两个原因,Task<T> 没有友好地进行数据绑定:它没有实现 INotify­PropertyChanged,并且其 Result 属性会导致阻塞。 但您可以定义各种“任务观察器”,例如图 4 中的类型。

图 4 NotifyTaskCompletion.cs

using System;
using System.ComponentModel;
using System.Threading.Tasks;
public sealed class NotifyTaskCompletion<TResult> : INotifyPropertyChanged
{
  public NotifyTaskCompletion(Task<TResult> 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<TResult> 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<T>.WatchTaskAsync。 此方法接受呈现异步操作的任务,并且(异步)等待其完成。 注意,await 不使用 ConfigureAwait(false);我想在引发 PropertyChanged 通知前返回到 UI 上下文。 此方法在这里违反了常见编码指导原则:它有一个空的常规 Catch 子句。 但在这种情况下,这恰恰是我想要的。 我不想将异常直接传播回到主 UI 循环;我想捕获任何异常并设置属性,以便通过数据绑定执行错误处理。 当该任务完成时,此类型会引发针对所有适用属性的 PropertyChanged 通知。

使用 NotifyTaskCompletion<T> 的已更新 ViewModel 将如下所示:

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

此 ViewModel 将立即启动操作,然后为最终任务创建数据绑定的“观察器”。 View 数据绑定代码需要进行更新才能显式绑定到此操作的结果,如下所示:

<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>

注意,标签内容被数据绑定到了 NotifyTask­Completion<T>.Result,而不是 Task<T>.Result。 NotifyTaskCompletion<T>.Result 正在进行友好地数据绑定:它没有阻塞,并且将在任务完成时通知绑定。 如果您现在运行此代码,您将发现其行为就像先前示例:UI 具有响应性,并且立即加载(显示默认值“0”),然后在几秒钟内更新为实际结果。

NotifyTaskCompletion<T> 的好处是它还具有许多其他属性,因此您能够使用数据绑定来显示忙碌状态指示器或错误详细信息。 使用这些便捷属性来创建完全位于 View 中的忙碌状态指示器或错误详细信息并不难,例如图 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>

有了最新版本的 View,应用程序会显示几秒钟“正在加载…”(同时保持响应性),然后更新为此操作的结果,或以红色背景显示的错误消息。

NotifyTaskCompletion<T> 处理一种用例:当您有一个异步操作并且想对结果进行数据绑定时。 当执行数据锁定或在启动过程中进行加载时,这种情况很常见。 但当您具有异步的实际命令,例如“保存当前记录”时,这没有太多帮助。(我将在我的下一篇文章中考虑异步命令。)

乍一看,这似乎像是构建异步 UI 需要大量工作,并且在某种程度上确实如此。 正确使用 async 和 await 关键字会极大促使您设计更出色的 UX。 当您移动到异步 UI 时,您发现当异步操作正在进行时,您不再阻塞 UI。 您必须考虑在加载过程中 UI 应具有的外观,并且有目的地针对这种外观进行设计。 这需要更多工作,但对于大多数新式应用程序来说,这是应完成的工作。 这是像 Windows 应用商店等更新的平台仅支持异步 API 的一个原因:鼓励开发人员设计更具响应性的 UX。

总结

将代码库从同步转换为异步时,通常服务或数据访问组件会首先更改,并且异步会从那里延伸到 UI。 当您实现了几次之后,将方法从同步转变到异步会变得相当简单。 我期望(并且希望)未来能通过工具自动完成这种转换。 但当异步触及 UI 时,此时需要进行真正的更改。

当 UI 变成异步时,您必须通过增强应用程序的 UI 设计来解决应用程序没有响应的情况。 这最终将实现更具响应性、更加现代化的应用程序。 “快而流畅”,如果您愿意那样说的话。

本文介绍了一个简单类型,可将其概括为用于数据绑定的 Task<T>。 下次,我将探讨异步命令,以及探究实质上为“用于异步的 ICommand”的概念。然后在该系列的最后一篇文章中,我将通过考虑异步服务进行总结。 记住,这些模式仍在发展中;请随时针对您的特定需求调整它们。

Stephen Cleary 生活在密歇根州北部,他是一位丈夫、父亲和程序员。他已从事了 16 年的多线程和异步编程工作,自第一个 CTP 以来便在使用 Microsoft .NET Framework 中的异步支持。他的主页(包括博客)位于 stephencleary.com

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McCaffrey 和 Stephen Toub