wpf 异步命令



异步编程

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

Stephen Cleary

下载代码示例

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

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






图 1:能够执行一个命令的应用程序

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


图 2:执行多个命令的应用程序

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

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

图 3:服务层

  1. public static class MyService
  2. {
  3.   // bit.ly/1fCnbJ2
  4.   public static async Task<int> DownloadAndCountBytesAsync(string url,
  5.     CancellationToken token = new CancellationToken())
  6.   {
  7.     await Task.Delay(TimeSpan.FromSeconds(3), token).ConfigureAwait(false);
  8.     var client = new HttpClient();
  9.     using (var response = await client.GetAsync(url, token).ConfigureAwait(false))
  10.     {
  11.       var data = await
  12.         response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
  13.       return data.Length;
  14.     }
  15.   }
  16. }

异步命令

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

  1. public interface ICommand
  2. {
  3.   event EventHandler CanExecuteChanged;
  4.   bool CanExecute(object parameter);
  5.   void Execute(object parameter);
  6. }

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

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

  1. public interface IAsyncCommand : ICommand
  2. {
  3.   Task ExecuteAsync(object parameter);
  4. }

图 4:异步命令的基类型

  1. public abstract class AsyncCommandBase : IAsyncCommand
  2. {
  3.   public abstract bool CanExecute(object parameter);
  4.   public abstract Task ExecuteAsync(object parameter);
  5.   public async void Execute(object parameter)
  6.   {
  7.     await ExecuteAsync(parameter);
  8.   }
  9.   public event EventHandler CanExecuteChanged
  10.   {
  11.     add { CommandManager.RequerySuggested += value; }
  12.     remove { CommandManager.RequerySuggested -= value; }
  13.   }
  14.   protected void RaiseCanExecuteChanged()
  15.   {
  16.     CommandManager.InvalidateRequerySuggested();
  17.   }
  18. }

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

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

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

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

  1. public class AsyncCommand : AsyncCommandBase
  2. {
  3.   private readonly Func _command;
  4.   public AsyncCommand(Func command)
  5.   {
  6.     _command = command;
  7.   }
  8.   public override bool CanExecute(object parameter)
  9.   {
  10.     return true;
  11.   }
  12.   public override Task ExecuteAsync(object parameter)
  13.   {
  14.     return _command();
  15.   }
  16. }

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


  
  

图 6 显示了 MainWindowViewModel.cs。

图 6:第一个 MainWindowViewModel

  1. public sealed class MainWindowViewModel : INotifyPropertyChanged
  2. {
  3.   public MainWindowViewModel()
  4.   {
  5.     Url = "http://www.example.com/";
  6.     CountUrlBytesCommand = new AsyncCommand(async () =>
  7.     {
  8.       ByteCount = await MyService.DownloadAndCountBytesAsync(Url);
  9.     });
  10.   }
  11.   public string Url { get; set; } // Raises PropertyChanged
  12.   public IAsyncCommand CountUrlBytesCommand { get; private set; }
  13.   public int ByteCount { get; private set; } // Raises PropertyChanged
  14. }

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

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

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

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

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

  1. public NotifyTaskCompletion(Task task)
  2. {
  3.   Task = task;
  4.   if (!task.IsCompleted)
  5.     TaskCompletion = WatchTaskAsync(task);
  6. }
  7. public Task TaskCompletion { get; private set; }

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

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

  1. public class AsyncCommand : AsyncCommandBase, INotifyPropertyChanged
  2. {
  3.   private readonly Func> _command;
  4.   private NotifyTaskCompletion _execution;
  5.   public AsyncCommand(Func> command)
  6.   {
  7.     _command = command;
  8.   }
  9.   public override bool CanExecute(object parameter)
  10.   {
  11.     return true;
  12.   }
  13.   public override Task ExecuteAsync(object parameter)
  14.   {
  15.     Execution = new NotifyTaskCompletion(_command());
  16.     return Execution.TaskCompletion;
  17.   }
  18.   // Raises PropertyChanged
  19.   public NotifyTaskCompletion Execution { get; private set; }
  20. }

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

图 8:第二个 MainWindowViewModel

  1. public sealed class MainWindowViewModel : INotifyPropertyChanged
  2. {
  3.   public MainWindowViewModel()
  4.   {
  5.     Url = "http://www.example.com/";
  6.     CountUrlBytesCommand = new AsyncCommand<int>(() => 
  7.       MyService.DownloadAndCountBytesAsync(Url));
  8.   }
  9.   // Raises PropertyChanged
  10.   public string Url { get; set; }
  11.   public IAsyncCommand CountUrlBytesCommand { get; private set; }
  12. }

图 9 显示了新 XAML 代码。

图 9:第二个 MainWindow XAML


  
  

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

图 10:禁用多个请求

  1. public class AsyncCommand : AsyncCommandBase, INotifyPropertyChanged
  2. {
  3.   public override bool CanExecute(object parameter)
  4.   {
  5.     return Execution == null || Execution.IsCompleted;
  6.   }
  7.   public override async Task ExecuteAsync(object parameter)
  8.   {
  9.     Execution = new NotifyTaskCompletion(_command());
  10.     RaiseCanExecuteChanged();
  11.     await Execution.TaskCompletion;
  12.     RaiseCanExecuteChanged();
  13.   }
  14. }

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

添加取消

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

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

图 11:添加取消

  1. public class AsyncCommand : AsyncCommandBase, INotifyPropertyChanged
  2. {
  3.   private readonly Func> _command;
  4.   private readonly CancelAsyncCommand _cancelCommand;
  5.   private NotifyTaskCompletion _execution;
  6.   public AsyncCommand(Func> command)
  7.   {
  8.     _command = command;
  9.     _cancelCommand = new CancelAsyncCommand();
  10.   }
  11.   public override async Task ExecuteAsync(object parameter)
  12.   {
  13.     _cancelCommand.NotifyCommandStarting();
  14.     Execution = new NotifyTaskCompletion(_command(_cancelCommand.Token));
  15.     RaiseCanExecuteChanged();
  16.     await Execution.TaskCompletion;
  17.     _cancelCommand.NotifyCommandFinished();
  18.     RaiseCanExecuteChanged();
  19.   }
  20.   public ICommand CancelCommand
  21.   {
  22.     get { return _cancelCommand; }
  23.   }
  24.   private sealed class CancelAsyncCommand : ICommand
  25.   {
  26.     private CancellationTokenSource _cts = new CancellationTokenSource();
  27.     private bool _commandExecuting;
  28.     public CancellationToken Token { get { return _cts.Token; } }
  29.     public void NotifyCommandStarting()
  30.     {
  31.       _commandExecuting = true;
  32.       if (!_cts.IsCancellationRequested)
  33.         return;
  34.       _cts = new CancellationTokenSource();
  35.       RaiseCanExecuteChanged();
  36.     }
  37.     public void NotifyCommandFinished()
  38.     {
  39.       _commandExecuting = false;
  40.       RaiseCanExecuteChanged();
  41.     }
  42.     bool ICommand.CanExecute(object parameter)
  43.     {
  44.       return _commandExecuting && !_cts.IsCancellationRequested;
  45.     }
  46.     void ICommand.Execute(object parameter)
  47.     {
  48.       _cts.Cancel();
  49.       RaiseCanExecuteChanged();
  50.     }
  51.   }
  52. }

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

图 12:添加“取消”按钮


  
  

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

简单工作队列

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

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

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

图 13:用于多个命令的 ViewModel

  1. public sealed class CountUrlBytesViewModel
  2. {
  3.   public CountUrlBytesViewModel(MainWindowViewModel parent, string url,
  4.     IAsyncCommand command)
  5.   {
  6.     LoadingMessage = "Loading (" + url + ")...";
  7.     Command = command;
  8.     RemoveCommand = new DelegateCommand(() => parent.Operations.Remove(this));
  9.   }
  10.   public string LoadingMessage { get; private set; }
  11.   public IAsyncCommand Command { get; private set; }
  12.   public ICommand RemoveCommand { get; private set; }
  13. }
  14. public sealed class MainWindowViewModel : INotifyPropertyChanged
  15. {
  16.   public MainWindowViewModel()
  17.   {
  18.     Url = "http://www.example.com/";
  19.     Operations = new ObservableCollection();
  20.     CountUrlBytesCommand = new DelegateCommand(() =>
  21.     {
  22.       var countBytes = new AsyncCommand<int>(token =>
  23.         MyService.DownloadAndCountBytesAsync(
  24.         Url, token));
  25.       countBytes.Execute(null);
  26.       Operations.Add(new CountUrlBytesViewModel(this, Url, countBytes));
  27.     });
  28.   }
  29.   public string Url { get; set; } // Raises PropertyChanged
  30.   public ObservableCollection Operations
  31.     { get; private set; }
  32.   public ICommand CountUrlBytesCommand { get; private set; }
  33. }

图 14:用于多个命令的 XAML


  
  

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

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

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

 

你可能感兴趣的:(wpf 异步命令)