个人随笔
技术改变世界

异步 MVVM 命令绑定

异步MVVM系列 目录

(一)异步 MVVM 数据绑定

(二)异步 MVVM 命令绑定

(三)异步 MVVM 服务

 

简介

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

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

mvvm async1 mvvm async2 mvvm async3 mvvm async4 mvvm async5

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

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

mvvm async6

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

在开发此应用程序过程中,我将进行一些简化,以便将重点始终放在异步命令上,而不是实现细节上。首先,我不会使用命令执行参数。在真实应用程序中我几乎从不需要使用参数;但如果需要,本文中的模式可轻松进行扩展,将其包含在内。其次,我不亲自实现 ICommand.CanExecuteChanged。类似字段的标准事件将在某些 MVVM 平台上泄漏内存。为使此代码保持简单,我使用 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。C#最佳做法应避免 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

另外,关于UX的文章可以参考:http://www.wxzzz.com/310.html

转载请注明出处王旭博客 » 异步 MVVM 命令绑定

分享到:更多 ()

评论 2

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
  1. #1

    大师,我又折腾了一个主题!!帮我找BUG啊

    大谋3年前 (2014-06-06)回复
    • 唉,来玩wordpress啊 ,我一个人多无聊。

      紫寒3年前 (2014-06-06)回复