异步编程

针对异步 MVVM 应用程序的模式:命令

Stephen Cleary

下载代码示例

本文是关于将 async 和 await 与主流 Model-View-ViewModel (MVVM) 模式相结合的一系列文章中的第二篇。上次,我展示了如何数据绑定到异步操作,并开发了一个名为 NotifyTaskCompletion<TResult> 的键类型,其作用类似一个数据绑定友好型的 Task<TResult>(请参阅 msdn.microsoft.com/magazine/dn605875)。现在我将介绍 ICommand,这是一个 MVVM 应用程序用于定义用户操作(通常被数据绑定到按钮)的 .NET 接口,并探讨创建异步 ICommand 的意义。

此处的这些模式可能不会与所有情景完美契合,因此请根据需要进行调整。实际上,整篇文章以对异步命令类型的一系列改进为主线展开。在这些迭代过程的最后,您将最终获得如图 1 中所示的应用程序。这类似于我在上一篇文章中开发的应用程序,但这次,我为用户提供了要执行的实际命令。当用户单击“开始”按钮时,将从文本框读取 URL,并且该应用程序将对此 URL 上的字节数进行计数(人为设置的延迟之后)。在此操作正在进行时,用户无法启动另一个操作,但他能够取消此操作。

An Application That Can Execute One Command
An Application That Can Execute One Command
An Application That Can Execute One Command
An Application That Can Execute One Command
An Application That Can Execute One Command
图 1:能够执行一个命令的应用程序

然后我将展示如何使用非常类似的方法创建任何数目的操作。图 2 显示了修改后的应用程序,“开始”按钮表示将操作添加到操作集中。

An Application Executing Multiple Commands
图 2:执行多个命令的应用程序

在开发此应用程序过程中,我将进行一些简化,以便将重点始终放在异步命令上,而不是实现细节上。首先,我不会使用命令执行参数。在真实应用程序中我几乎从不需要使用参数;但如果需要,本文中的模式可轻松进行扩展,将其包含在内。其次,我不亲自实现 ICommand.CanExecuteChanged。类似字段的标准事件将在某些 MVVM 平台上泄漏内存(请参阅 bit.ly/1bROnVj)。为使此代码保持简单,我使用 Windows Presentation Foundation (WPF) 内置的 CommandManager 来实现 CanExecuteChanged。

我还使用了简化的“服务层”,目前这只是一个静态方法,如图 3 所示。实际上该服务与我在上一篇文章中的服务相同,但进行了扩展,可支持取消操作。下一篇文章将采用适当的异步服务设计,但目前使用这个简化的服务即可。

图 3:服务层

public static class MyService
{
  // bit.ly/1fCnbJ2
  public static async Task<int> DownloadAndCountBytesAsync(string url,
    CancellationToken token = new CancellationToken())
  {
    await Task.Delay(TimeSpan.FromSeconds(3), token).ConfigureAwait(false);
    var client = new HttpClient();
    using (var response = await client.GetAsync(url, token).ConfigureAwait(false))
    {
      var data = await
        response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
      return data.Length;
    }
  }
}

异步命令

开始前,迅速了解一下 ICommand 接口:

public interface ICommand
{
  event EventHandler CanExecuteChanged;
  bool CanExecute(object parameter);
  void Execute(object parameter);
}

忽略 CanExecuteChanged 和参数,稍微思考一下异步命令将如何使用此接口。CanExecute 方法必是同步的;唯一可为异步的成员是 Execute。Execute 方法是为同步实现设计的,因此其返回 void。正如我在上一篇文章“异步编程的最佳做法”(msdn.microsoft.com/magazine/jj991977)中所提到的,应避免 async void 方法,除非它们是事件处理程序(或者事件处理程序的逻辑对等物)。ICommand.Execute 的实现在逻辑上是事件处理程序,因此可以是 async void。

但最好尽量减少 async void 方法中的代码,而将一个包含实际逻辑的 async Task 方法公开。这种做法可使代码更容易测试。本着这一宗旨,我建议将以下作为异步命令接口,图 4 中的代码作为基类:

public interface IAsyncCommand : ICommand
{
  Task ExecuteAsync(object parameter);
}

图 4:异步命令的基类型

public abstract class AsyncCommandBase : IAsyncCommand
{
  public abstract bool CanExecute(object parameter);
  public abstract Task ExecuteAsync(object parameter);
  public async void Execute(object parameter)
  {
    await ExecuteAsync(parameter);
  }
  public event EventHandler CanExecuteChanged
  {
    add { CommandManager.RequerySuggested += value; }
    remove { CommandManager.RequerySuggested -= value; }
  }
  protected void RaiseCanExecuteChanged()
  {
    CommandManager.InvalidateRequerySuggested();
  }
}

该基类负责两件事:它将 CanExecuteChanged 实现交给 CommandManager 类完成;通过调用 IAsyncCommand.ExecuteAsync 方法实现 async void ICommand.Execute 方法。它等待结果,以确保异步命令逻辑中的任何异常都将被正确提交到 UI 线程的主循环。

这颇为复杂,但其中每个类型都有一个用途。IAsyncCommand 可用于任何异步 ICommand 实现,其设计初衷是从 ViewModel 公开,供 View 和单元测试使用。AsyncCommandBase 提供所有异步 ICommand 公共的样板代码。

奠定了这一基础,我可以着手开始开发一个有效的异步命令。对于无返回值的同步操作,标准委托类型为 Action。异步对等物为 Func<Task>。图 5 显示了基于委托的 AsyncCommand 的第一次迭代。

图 5:对异步命令的第一次尝试

public class AsyncCommand : AsyncCommandBase
{
  private readonly Func<Task> _command;
  public AsyncCommand(Func<Task> command)
  {
    _command = command;
  }
  public override bool CanExecute(object parameter)
  {
    return true;
  }
  public override Task ExecuteAsync(object parameter)
  {
    return _command();
  }
}

此时,UI 只有显示 URL 的文本框、启动 HTTP 请求的按钮,以及用于显示结果的标签。XAML 和 ViewModel 的基本部分很简单。以下为 Main­Window.xaml(跳过定位属性,例如 Margin):

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" 
      Content="Go" />
  <TextBlock Text="{Binding ByteCount}" />
</Grid>

图 6 显示了 MainWindowViewModel.cs。

图 6:第一个 MainWindowViewModel

public sealed class MainWindowViewModel : INotifyPropertyChanged
{
  public MainWindowViewModel()
  {
    Url = "http://www.example.com/";
    CountUrlBytesCommand = new AsyncCommand(async () =>
    {
      ByteCount = await MyService.DownloadAndCountBytesAsync(Url);
    });
  }
  public string Url { get; set; } // Raises PropertyChanged
  public IAsyncCommand CountUrlBytesCommand { get; private set; }
  public int ByteCount { get; private set; } // Raises PropertyChanged
}

如果您执行该应用程序(示例代码下载中的 AsyncCommands1),您将注意到四种不良行为情况。第一,标签始终显示结果,甚至在单击按钮之前。第二,单击按钮后没有忙碌状态指示器来指示操作正在进行。第三,如果 HTTP 请求失败,则会将异常传递到 UI 主循环,从而导致应用程序崩溃。第四,如果用户做出了多个请求,则该用户无法辨别结果;由于服务器响应时间的不确定性,较早请求的结果可能覆盖较晚请求的结果。

这是一连串的问题!但在我迭代此设计之前,暂时考虑一下引发的问题的类型。当 UI 变成异步时,您不得不考虑 UI 中的额外状态。我建议您至少问自己三个问题:

  1. 此 UI 将如何显示错误?(我希望您的同步 UI 已经对此有了答案!)
  2. 当操作正在进行时,此 UI 的外观应如何?(例如,它是否将通过忙碌状态指示器及时提供反馈?)
  3. 当操作正在进行时,用户受到哪些限制?(例如,按钮是否禁用?)
  4. 当操作正在进行时,用户是否可发出额外命令?(例如,他能否取消操作?)
  5. 如果用户能够启动多个操作,UI 如何为每个操作提供完成或错误详细信息?(例如,UI 将使用“命令队列”样式还是弹出通知?)

通过数据绑定完成异步命令

第一个 Async­Command 迭代中的大部分问题与如何处理结果有关。真正需要的是找到某种类型,该类型包装 Task<T> 并提供一些数据绑定功能,使应用程序能够顺畅响应。幸好,在我的上一篇文章中开发的 NotifyTaskCompletion<T> 类型几乎完美地符合这些需求。我将在该类型中添加一个成员,以简化一些 Async­Command 逻辑:TaskCompletion 属性,表示操作完成,但不传播异常(或返回结果)。以下是对 NotifyTaskCompletion<T> 的修改:

public NotifyTaskCompletion(Task<TResult> task)
{
  Task = task;
  if (!task.IsCompleted)
    TaskCompletion = WatchTaskAsync(task);
}
public Task TaskCompletion { get; private set; }

AsyncCommand 的下一迭代使用 NotifyTaskCompletion 来表示实际操作。这样一来,XAML 能够直接数据绑定到操作的结果和错误消息,并且在操作正在进行的过程中,还可使用数据绑定来显示相应的消息。新 AsyncCommand 现在具有表示实际操作的属性,如图 7 所示。

图 7:异步命令的第二次尝试

public class AsyncCommand<TResult> : AsyncCommandBase, INotifyPropertyChanged
{
  private readonly Func<Task<TResult>> _command;
  private NotifyTaskCompletion<TResult> _execution;
  public AsyncCommand(Func<Task<TResult>> command)
  {
    _command = command;
  }
  public override bool CanExecute(object parameter)
  {
    return true;
  }
  public override Task ExecuteAsync(object parameter)
  {
    Execution = new NotifyTaskCompletion<TResult>(_command());
    return Execution.TaskCompletion;
  }
  // Raises PropertyChanged
  public NotifyTaskCompletion<TResult> Execution { get; private set; }
}

注意,AsyncCommand.ExecuteAsync 使用 TaskCompletion,而不是 Task。我不想将异常传播回 UI 主循环(如果其等待 Task 属性,则会发生这种情况);而是通过数据绑定返回 TaskCompletion 并处理异常。我还在项目中添加了一个简单的 NullToVisibilityConverter,这样,忙碌状态指示器、结果和错误消息在单击按钮前都是隐藏的。图 8 显示了更新的 ViewModel 代码。

图 8:第二个 MainWindowViewModel

public sealed class MainWindowViewModel : INotifyPropertyChanged
{
  public MainWindowViewModel()
  {
    Url = "http://www.example.com/";
    CountUrlBytesCommand = new AsyncCommand<int>(() => 
      MyService.DownloadAndCountBytesAsync(Url));
  }
  // Raises PropertyChanged
  public string Url { get; set; }
  public IAsyncCommand CountUrlBytesCommand { get; private set; }
}

图 9 显示了新 XAML 代码。

图 9:第二个 MainWindow XAML

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" Content="Go" />
  <Grid Visibility="{Binding CountUrlBytesCommand.Execution,
    Converter={StaticResource NullToVisibilityConverter}}">
    <!--Busy indicator-->
    <Label Visibility="{Binding CountUrlBytesCommand.Execution.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"
      Content="Loading..." />
    <!--Results-->
    <Label Content="{Binding CountUrlBytesCommand.Execution.Result}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}" />
    <!--Error details-->
    <Label Content="{Binding CountUrlBytesCommand.Execution.ErrorMessage}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}" Foreground="Red" />
  </Grid>
</Grid>

现在,此代码匹配示例代码中的 AsyncCommands2 项目。此代码负责解决初始解决方案中提及的所有问题:在第一个操作开始前,标签是隐藏的;有一个直接的忙碌状态指示器为用户提供反馈;捕获了异常,并且通过数据绑定更新了 UI;多个请求不再互相干扰。每个请求均创建一个新的 NotifyTaskCompletion 包装,其具有自己的独立 Result 和其他属性。NotifyTaskCompletion 作为异步操作的可数据绑定抽象。这允许多个请求,同时 UI 始终绑定到最新请求。但在许多真实情况中,相应解决方案是禁用多个请求。即,要在操作正在进行时让命令从 CanExecute 返回 false。对 AsyncCommand 进行些许修改即可,如图 10 所示。

图 10:禁用多个请求

public class AsyncCommand<TResult> : AsyncCommandBase, INotifyPropertyChanged
{
  public override bool CanExecute(object parameter)
  {
    return Execution == null || Execution.IsCompleted;
  }
  public override async Task ExecuteAsync(object parameter)
  {
    Execution = new NotifyTaskCompletion<TResult>(_command());
    RaiseCanExecuteChanged();
    await Execution.TaskCompletion;
    RaiseCanExecuteChanged();
  }
}

现在,此代码匹配示例代码中的 AsyncCommands3 项目。此按钮在操作进行过程中被禁用。

添加取消

许多异步操作所用的时间量可能不同。例如,HTTP 请求通常可能非常快速地做出响应,甚至快于用户响应。但如果网络较慢,或者服务器繁忙,同一 HTTP 请求可能会导致相当长的延迟。设计异步 UI 的部分原因就是针对这种情况。当前解决方案已经具有忙碌状态指示器。设计异步 UI 时,您可能还选择为用户提供更多选择,取消操作是一个常见选择。

取消本身始终是同步操作 — 请求取消的行为需立即执行。取消的最棘手部分是它何时运行;应仅在异步命令正在进行时才能够执行。对图 11 中 AsyncCommand 的修改提供了嵌套的取消命令,并且在异步命令开始和结束时会发出该取消命令的通知。

图 11:添加取消

public class AsyncCommand<TResult> : AsyncCommandBase, INotifyPropertyChanged
{
  private readonly Func<CancellationToken, Task<TResult>> _command;
  private readonly CancelAsyncCommand _cancelCommand;
  private NotifyTaskCompletion<TResult> _execution;
  public AsyncCommand(Func<CancellationToken, Task<TResult>> command)
  {
    _command = command;
    _cancelCommand = new CancelAsyncCommand();
  }
  public override async Task ExecuteAsync(object parameter)
  {
    _cancelCommand.NotifyCommandStarting();
    Execution = new NotifyTaskCompletion<TResult>(_command(_cancelCommand.Token));
    RaiseCanExecuteChanged();
    await Execution.TaskCompletion;
    _cancelCommand.NotifyCommandFinished();
    RaiseCanExecuteChanged();
  }
  public ICommand CancelCommand
  {
    get { return _cancelCommand; }
  }
  private sealed class CancelAsyncCommand : ICommand
  {
    private CancellationTokenSource _cts = new CancellationTokenSource();
    private bool _commandExecuting;
    public CancellationToken Token { get { return _cts.Token; } }
    public void NotifyCommandStarting()
    {
      _commandExecuting = true;
      if (!_cts.IsCancellationRequested)
        return;
      _cts = new CancellationTokenSource();
      RaiseCanExecuteChanged();
    }
    public void NotifyCommandFinished()
    {
      _commandExecuting = false;
      RaiseCanExecuteChanged();
    }
    bool ICommand.CanExecute(object parameter)
    {
      return _commandExecuting && !_cts.IsCancellationRequested;
    }
    void ICommand.Execute(object parameter)
    {
      _cts.Cancel();
      RaiseCanExecuteChanged();
    }
  }
}

将“取消”按钮(和取消的标签)添加到 UI 十分简单,如图 12 所示。

图 12:添加“取消”按钮

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" Content="Go" />
  <Button Command="{Binding CountUrlBytesCommand.CancelCommand}" Content="Cancel" />
  <Grid Visibility="{Binding CountUrlBytesCommand.Execution,
    Converter={StaticResource NullToVisibilityConverter}}">
    <!--Busy indicator-->
    <Label Content="Loading..."
      Visibility="{Binding CountUrlBytesCommand.Execution.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}" />
    <!--Results-->
    <Label Content="{Binding CountUrlBytesCommand.Execution.Result}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}" />
    <!--Error details-->
    <Label Content="{Binding CountUrlBytesCommand.Execution.ErrorMessage}"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}" Foreground="Red" />
    <!--Canceled-->
    <Label Content="Canceled"
      Visibility="{Binding CountUrlBytesCommand.Execution.IsCanceled,
      Converter={StaticResource BooleanToVisibilityConverter}}" Foreground="Blue" />
  </Grid>
</Grid>

现在,如果您执行该应用程序(示例代码中的 AsyncCommands4),您将发现“取消”按钮最初被禁用。当您单击“开始”按钮时会启用该按钮,并且其启用状态会一直保持到操作完成为止(无论成功、失败还是取消)。您现在拥有了可以说是完整的异步操作 UI。

简单工作队列

到目前为止,我一直在着重探讨一次只针对一个操作的 UI。在许多情况下这些都是必要的,但有时您需要能够启动多个异步操作。我认为,作为一个社区,我们尚未拥有用于处理多个异步操作的真正好的 UX。两个常用方法是使用工作队列或通知系统,但这两个方法都不理想。

工作队列显示集合中的所有异步操作;这会为用户提供最大的可见性和控制,但对于典型最终用户来说处理起来太过复杂。通知系统会在操作正在运行时隐藏它们,如果其中任何一个操作失败,该系统都会弹出通知(当它们成功完成时也可能弹出通知)。通知系统更便于用户使用,但它不提供全面可见性和工作队列的功能)(例如,难以将取消插入基于通知的系统中)。我必须找到可处理多个异步操作的理想 UX。

也就是说,此时可扩展示例代码,以便不太困难地支持多操作情况。在现有代码中,“开始”按钮和“取消”按钮在概念上均与单一异步操作相关。新 UI 将更改“开始”按钮,使其表示“启动一个新异步操作并将其添加到操作列表中”。这意味着“开始”按钮现在实际上是同步的。我将一个简单(同步)的 DelegateCommand 添加到了解决方案中,现在可更新 ViewModel 和 XAML,如图 13图 14 所示。

图 13:用于多个命令的 ViewModel

public sealed class CountUrlBytesViewModel
{
  public CountUrlBytesViewModel(MainWindowViewModel parent, string url,
    IAsyncCommand command)
  {
    LoadingMessage = "Loading (" + url + ")...";
    Command = command;
    RemoveCommand = new DelegateCommand(() => parent.Operations.Remove(this));
  }
  public string LoadingMessage { get; private set; }
  public IAsyncCommand Command { get; private set; }
  public ICommand RemoveCommand { get; private set; }
}
public sealed class MainWindowViewModel : INotifyPropertyChanged
{
  public MainWindowViewModel()
  {
    Url = "http://www.example.com/";
    Operations = new ObservableCollection<CountUrlBytesViewModel>();
    CountUrlBytesCommand = new DelegateCommand(() =>
    {
      var countBytes = new AsyncCommand<int>(token =>
        MyService.DownloadAndCountBytesAsync(
        Url, token));
      countBytes.Execute(null);
      Operations.Add(new CountUrlBytesViewModel(this, Url, countBytes));
    });
  }
  public string Url { get; set; } // Raises PropertyChanged
  public ObservableCollection<CountUrlBytesViewModel> Operations
    { get; private set; }
  public ICommand CountUrlBytesCommand { get; private set; }
}

图 14:用于多个命令的 XAML

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" Content="Go" />
  <ItemsControl ItemsSource="{Binding Operations}">
    <ItemsControl.ItemTemplate>
      <DataTemplate>
        <Grid>
          <!--Busy indicator-->
          <Label Content="{Binding LoadingMessage}"
            Visibility="{Binding Command.Execution.IsNotCompleted,
            Converter={StaticResource BooleanToVisibilityConverter}}" />
          <!--Results-->
          <Label Content="{Binding Command.Execution.Result}"
            Visibility="{Binding Command.Execution.IsSuccessfullyCompleted,
            Converter={StaticResource BooleanToVisibilityConverter}}" />
          <!--Error details-->
          <Label Content="{Binding Command.Execution.ErrorMessage}"
            Visibility="{Binding Command.Execution.IsFaulted,
            Converter={StaticResource BooleanToVisibilityConverter}}"
            Foreground="Red" />
          <!--Canceled-->
          <Label Content="Canceled"
            Visibility="{Binding Command.Execution.IsCanceled,
            Converter={StaticResource BooleanToVisibilityConverter}}"
            Foreground="Blue" />
          <Button Command="{Binding Command.CancelCommand}" Content="Cancel" />
          <Button Command="{Binding RemoveCommand}" Content="X" />
        </Grid>
      </DataTemplate>
    </ItemsControl.ItemTemplate>
  </ItemsControl>
</Grid>

此代码等同于示例代码中的 AsyncCommandsWithQueue 项目。当用户单击“开始”按钮时,将创建一个新 AsyncCommand,并且会将其包装到子 ViewModel (CountUrlBytesViewModel) 中。然后该子 ViewModel 实例会被添加到操作列表中。与这个特殊操作相关联的所有信息(各个标签和“取消”按钮)显示在工作队列的数据模板中。我还添加了一个简单的按钮“X”,该按钮会将项目从队列中移除。

这是一个非常基本的工作队列,我做出了一些有关设计的假设。例如,在将操作从队列中移除时,不会自动取消此操作。当您开始使用多个异步操作时,我建议您最少问自己三个额外问题:

  1. 用户如何知道哪个通知或工作项针对哪个操作?(例如,在该工作队列示例中的忙碌状态指示器包含其正在下载的 URL)。
  2. 用户是否需要知道每个结果?(例如,仅通知用户错误即可,或者自动将成功操作从工作队列中移除)。

总结

目前对于异步命令还没有适合每个人需求的通用解决方案。开发者社区仍在探索异步 UI 模式。在本文中,我的目标是展示如何在 MVVM 应用程序上下文中考虑异步命令,尤其是考虑在 UI 变为异步时必须解决的 UX 问题。但记住,本文以及示例代码中的模式只是范例,应根据应用程序的需求调整它们。

特别要指出的是,关于多个异步操作还没有完美案例。工作队列和通知都有弊端,在我看来今后应有通用的 UX。当更多 UI 变为异步时,将会有更多人思考该问题,革命性的突破可能会马上出现。亲爱的读者,请谈谈您对该问题的想法。或许您将是新 UX 的发现者。

另外可别忘了发布。在本文中,我从最基本的异步 ICommand 实现开始,然后逐渐添加功能,最后得到适用于大多数新型应用程序的结果。其结果还完全可进行单元测试;由于 async void ICommand.Execute 方法仅调用返回任务的 IAsyncCommand.ExecuteAsync 方法,因此您可以在单元测试中直接使用 ExecuteAsync。

在我的上一篇文章中,我开发了 NotifyTaskCompletion<T>,这是围绕 Task<T> 的一个数据绑定包装。在这篇文章中,我展示了如何开发 AsyncCommand<T> 的一个类型,即,ICommand 的异步实现。在我的下一篇文章中,我将涉及异步服务。请记住,异步 MVVM 模式仍是非常新的概念;不要担心违背它们,革新您自己的解决方案。

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

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