异步编程
针对异步 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 所示。实际上该服务与我在上一篇文章中的服务相同,但进行了扩展,可支持取消操作。下一篇文章将采用适当的异步服务设计,但目前使用这个简化的服务即可。
- 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);
- }
- 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。图 5 显示了基于委托的 AsyncCommand 的第一次迭代。
- public class AsyncCommand : AsyncCommandBase
- {
- private readonly Func _command;
- public AsyncCommand(Func command)
- {
- _command = command;
- }
- public override bool CanExecute(object parameter)
- {
- return true;
- }
- public override Task ExecuteAsync(object parameter)
- {
- return _command();
- }
- }
此时,UI 只有显示 URL 的文本框、启动 HTTP 请求的按钮,以及用于显示结果的标签。XAML 和 ViewModel 的基本部分很简单。以下为 MainWindow.xaml(跳过定位属性,例如 Margin):
图 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 中的额外状态。我建议您至少问自己三个问题:
- 此 UI 将如何显示错误?(我希望您的同步 UI 已经对此有了答案!)
- 当操作正在进行时,此 UI 的外观应如何?(例如,它是否将通过忙碌状态指示器及时提供反馈?)
- 当操作正在进行时,用户受到哪些限制?(例如,按钮是否禁用?)
- 当操作正在进行时,用户是否可发出额外命令?(例如,他能否取消操作?)
- 如果用户能够启动多个操作,UI 如何为每个操作提供完成或错误详细信息?(例如,UI 将使用“命令队列”样式还是弹出通知?)
通过数据绑定完成异步命令
第一个 AsyncCommand 迭代中的大部分问题与如何处理结果有关。真正需要的是找到某种类型,该类型包装 Task 并提供一些数据绑定功能,使应用程序能够顺畅响应。幸好,在我的上一篇文章中开发的 NotifyTaskCompletion 类型几乎完美地符合这些需求。我将在该类型中添加一个成员,以简化一些 AsyncCommand 逻辑:TaskCompletion 属性,表示操作完成,但不传播异常(或返回结果)。以下是对 NotifyTaskCompletion 的修改:
- public NotifyTaskCompletion(Task task)
- {
- Task = task;
- if (!task.IsCompleted)
- TaskCompletion = WatchTaskAsync(task);
- }
- public Task TaskCompletion { get; private set; }
AsyncCommand 的下一迭代使用 NotifyTaskCompletion 来表示实际操作。这样一来,XAML 能够直接数据绑定到操作的结果和错误消息,并且在操作正在进行的过程中,还可使用数据绑定来显示相应的消息。新 AsyncCommand 现在具有表示实际操作的属性,如图 7 所示。
- public class AsyncCommand : AsyncCommandBase, INotifyPropertyChanged
- {
- private readonly Func> _command;
- private NotifyTaskCompletion _execution;
- public AsyncCommand(Func> command)
- {
- _command = command;
- }
- public override bool CanExecute(object parameter)
- {
- return true;
- }
- public override Task ExecuteAsync(object parameter)
- {
- Execution = new NotifyTaskCompletion(_command());
- return Execution.TaskCompletion;
- }
- // Raises PropertyChanged
- public NotifyTaskCompletion 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 代码。
现在,此代码匹配示例代码中的 AsyncCommands2 项目。此代码负责解决初始解决方案中提及的所有问题:在第一个操作开始前,标签是隐藏的;有一个直接的忙碌状态指示器为用户提供反馈;捕获了异常,并且通过数据绑定更新了 UI;多个请求不再互相干扰。每个请求均创建一个新的 NotifyTaskCompletion 包装,其具有自己的独立 Result 和其他属性。NotifyTaskCompletion 作为异步操作的可数据绑定抽象。这允许多个请求,同时 UI 始终绑定到最新请求。但在许多真实情况中,相应解决方案是禁用多个请求。即,要在操作正在进行时让命令从 CanExecute 返回 false。对 AsyncCommand 进行些许修改即可,如图 10 所示。
- public class AsyncCommand : AsyncCommandBase, INotifyPropertyChanged
- {
- public override bool CanExecute(object parameter)
- {
- return Execution == null || Execution.IsCompleted;
- }
- public override async Task ExecuteAsync(object parameter)
- {
- Execution = new NotifyTaskCompletion(_command());
- RaiseCanExecuteChanged();
- await Execution.TaskCompletion;
- RaiseCanExecuteChanged();
- }
- }
现在,此代码匹配示例代码中的 AsyncCommands3 项目。此按钮在操作进行过程中被禁用。
添加取消
许多异步操作所用的时间量可能不同。例如,HTTP 请求通常可能非常快速地做出响应,甚至快于用户响应。但如果网络较慢,或者服务器繁忙,同一 HTTP 请求可能会导致相当长的延迟。设计异步 UI 的部分原因就是针对这种情况。当前解决方案已经具有忙碌状态指示器。设计异步 UI 时,您可能还选择为用户提供更多选择,取消操作是一个常见选择。
取消本身始终是同步操作 — 请求取消的行为需立即执行。取消的最棘手部分是它何时运行;应仅在异步命令正在进行时才能够执行。对图 11 中 AsyncCommand 的修改提供了嵌套的取消命令,并且在异步命令开始和结束时会发出该取消命令的通知。
- public class AsyncCommand : AsyncCommandBase, INotifyPropertyChanged
- {
- private readonly Func> _command;
- private readonly CancelAsyncCommand _cancelCommand;
- private NotifyTaskCompletion _execution;
- public AsyncCommand(Func> command)
- {
- _command = command;
- _cancelCommand = new CancelAsyncCommand();
- }
- public override async Task ExecuteAsync(object parameter)
- {
- _cancelCommand.NotifyCommandStarting();
- Execution = new NotifyTaskCompletion(_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 所示。
现在,如果您执行该应用程序(示例代码中的 AsyncCommands4),您将发现“取消”按钮最初被禁用。当您单击“开始”按钮时会启用该按钮,并且其启用状态会一直保持到操作完成为止(无论成功、失败还是取消)。您现在拥有了可以说是完整的异步操作 UI。
简单工作队列
到目前为止,我一直在着重探讨一次只针对一个操作的 UI。在许多情况下这些都是必要的,但有时您需要能够启动多个异步操作。我认为,作为一个社区,我们尚未拥有用于处理多个异步操作的真正好的 UX。两个常用方法是使用工作队列或通知系统,但这两个方法都不理想。
工作队列显示集合中的所有异步操作;这会为用户提供最大的可见性和控制,但对于典型最终用户来说处理起来太过复杂。通知系统会在操作正在运行时隐藏它们,如果其中任何一个操作失败,该系统都会弹出通知(当它们成功完成时也可能弹出通知)。通知系统更便于用户使用,但它不提供全面可见性和工作队列的功能)(例如,难以将取消插入基于通知的系统中)。我必须找到可处理多个异步操作的理想 UX。
也就是说,此时可扩展示例代码,以便不太困难地支持多操作情况。在现有代码中,“开始”按钮和“取消”按钮在概念上均与单一异步操作相关。新 UI 将更改“开始”按钮,使其表示“启动一个新异步操作并将其添加到操作列表中”。这意味着“开始”按钮现在实际上是同步的。我将一个简单(同步)的 DelegateCommand 添加到了解决方案中,现在可更新 ViewModel 和 XAML,如图 13 和图 14 所示。
- 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();
- 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 Operations
- { get; private set; }
- public ICommand CountUrlBytesCommand { get; private set; }
- }
此代码等同于示例代码中的 AsyncCommandsWithQueue 项目。当用户单击“开始”按钮时,将创建一个新 AsyncCommand,并且会将其包装到子 ViewModel (CountUrlBytesViewModel) 中。然后该子 ViewModel 实例会被添加到操作列表中。与这个特殊操作相关联的所有信息(各个标签和“取消”按钮)显示在工作队列的数据模板中。我还添加了一个简单的按钮“X”,该按钮会将项目从队列中移除。
这是一个非常基本的工作队列,我做出了一些有关设计的假设。例如,在将操作从队列中移除时,不会自动取消此操作。当您开始使用多个异步操作时,我建议您最少问自己三个额外问题:
- 用户如何知道哪个通知或工作项针对哪个操作?(例如,在该工作队列示例中的忙碌状态指示器包含其正在下载的 URL)。
- 用户是否需要知道每个结果?(例如,仅通知用户错误即可,或者自动将成功操作从工作队列中移除)。
总结
目前对于异步命令还没有适合每个人需求的通用解决方案。开发者社区仍在探索异步 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