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

使用 async 和 await 关键字的异步代码正在改变程序的编写方式,这是有充分理由的。尽管 async 和 await 对于服务器软件很有用,但当前的大部分关注点都集中在具有 UI 的应用程序上。对于此类应用程序,这些关键字可以产生更具响应性的 UI。但是,如何使用 async 和 await 以及 Model-View-ViewModel (MVVM) 等已建立的模式并不是很明显。本文是一个简短系列中的第一篇,将考虑将 async 和 await 与 MVVM 结合使用的模式。

需要明确的是,我关于异步的第一篇文章“异步编程的最佳实践”( msdn.microsoft.com/magazine/jj991977 ) 与所有使用 async/await 的应用程序相关,包括客户端和服务器。这个新系列建立在该文章中的最佳实践之上,并介绍了专门用于客户端 MVVM 应用程序的模式。然而,这些模式只是模式,不一定是特定场景的最佳解决方案。如果您找到更好的方法,请告诉我!

在撰写本文时,许多 MVVM 平台都支持 async 和 await 关键字:桌面(Microsoft .NET Framework 4 及更高版本上的 Windows Presentation Foundation [WPF])、iOS/Android (Xamarin)、Windows Store (Windows 8 和更高版本)、Windows Phone(7.1 版和更高版本)、Silverlight(4 版和更高版本),以及针对这些平台(例如 MvvmCross)的任何组合的可移植类库 (PCL)。现在开发“异步 MVVM”模式的时机已经成熟。

我假设您对 async 和 await 有点熟悉,并且对 MVVM 非常熟悉。如果不是这种情况,可以在线获得许多有用的介绍性材料。我的博客 ( bit.ly/19IkogW ) 包含一个 async/await 介绍,最后列出了其他资源,关于 async 的 MSDN 文档非常好(搜索“基于任务的异步编程”)。有关 MVVM 的更多信息,我推荐几乎所有由 Josh Smith 编写的内容。

一个简单的应用程序

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




图 1 示例应用程序

首先,我想提一下,我在自己的项目中相当松散地遵循 MVVM 模式,有时使用适当的域模型,但更多时候使用一组服务和数据传输对象(本质上是数据访问层)而不是实际型号。在视图方面,我也相当务实;如果替代方案是支持类和 XAML 的数十行代码,我不会回避几行代码隐藏。所以,当我谈论 MVVM 时,请理解我没有使用任何特别严格的术语定义。

在将 async 和 await 引入 MVVM 模式时,您必须考虑的第一件事是确定解决方案的哪些部分需要 UI 线程上下文。Windows 平台非常重视仅从拥有它们的 UI 线程访问 UI 组件。显然,视图完全与 UI 上下文相关联。我还在我的应用程序中表明,通过数据绑定链接到视图的任何内容都与 UI 上下文相关联。WPF 的最新版本放宽了此限制,允许在 UI 线程和后台线程之间共享某些数据(例如,BindingOperations.EnableCollectionSynchronization)。但是,不能保证每个 MVVM 平台(WPF、iOS/Android/Windows Phone、Windows Store)都支持跨线程数据绑定,

因此,我总是将我的 ViewModel 视为绑定到 UI 上下文。在我的应用程序中,ViewModel 与 View 的关系比与 Model 的关系更密切——ViewModel 层本质上是整个应用程序的 API。视图实际上只提供了实际应用程序所在的 UI 元素的外壳。ViewModel 层在概念上是一个可测试的 UI,具有 UI 线程关联性。如果您的模型是一个实际的域模型(不是数据访问层)并且模型和视图模型之间存在数据绑定,那么模型本身也具有 UI 线程亲和性。一旦您确定了哪些层具有 UI 亲和性,您应该能够在“UI 仿射代码”(视图和 ViewModel,可能还有模型)和“与 UI 无关的代码”(可能是模型绝对是所有其他层,

此外,View 层之外的所有代码(即 ViewModel 和 Model 层、服务等)不应依赖于与特定 UI 平台绑定的任何类型。任何直接使用 Dispatcher (WPF/Xamarin/Windows Phone/Silverlight)、CoreDispatcher (Windows Store) 或 ISynchronizeInvoke (Windows Forms) 都是一个坏主意。(SynchronizationContext 稍微好一点,但勉强。)例如,互联网上有很多代码做一些异步工作,然后使用 Dispatcher 更新 UI;一个更便携且不那么繁琐的解决方案是使用 await 进行异步工作并在不使用 Dispatcher 的情况下更新 UI。

ViewModel 是最有趣的层,因为它们具有 UI 亲和性,但不依赖于特定的 UI 上下文。在本系列中,我将结合 async 和 MVVM,避免使用特定的 UI 类型,同时遵循 async 最佳实践;第一篇文章重点介绍异步数据绑定。

异步数据绑定属性

术语“异步属性”实际上是矛盾的。属性 getter 应立即执行并检索当前值,而不是启动后台操作。这可能是不能在属性 getter 上使用 async 关键字的原因之一。如果您发现您的设计需要异步属性,请首先考虑一些替代方案。特别是,属性实际上应该是方法(或命令)吗?如果属性 getter 每次访问时都需要启动一个新的异步操作,那根本就不是属性。异步方法很简单,我将在另一篇文章中介绍异步命令。

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

正如我之前解释的,对于我的示例应用程序,我将定义一个对网页中的字节数进行计数的服务。为了说明 async/await 的响应性方面,该服务还将延迟几秒钟。我将在后面的文章中介绍更现实的异步服务;目前,“服务”只是图 2中所示的单一方法。

图 2 MyStaticService.cs

XML复制
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)(如我的另一篇文章“异步编程中的最佳实践”中所述)。

让我们添加一个在启动时启动 HTTP 请求的简单 View 和 ViewModel。示例代码使用 WPF 窗口和视图在构造时创建它们的视图模型。这只是为了简单;本系列文章中讨论的异步原则和模式适用于所有 MVVM 平台、框架和库。现在的视图将由一个带有单个标签的主窗口组成。主视图的 XAML 只绑定到 UrlByteCount 成员:

XML复制
<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:

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

常见错误

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

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

这违反了异步准则“一直异步”,但有时开发人员会在他们觉得别无选择时尝试这样做。如果您执行该代码,您会看到它在一定程度上有效。使用 Task.Wait 或 Task.Result 而不是 await 的代码会同步阻塞该操作。

同步阻塞存在一些问题。最明显的是代码现在正在执行异步操作并对其进行阻塞;这样一来,它就失去了异步性的所有好处。如果您执行当前代码,您将看到应用程序在几秒钟内什么都不做,然后 UI 窗口完全形成并显示其结果。问题是应用程序没有响应,这对于许多现代应用程序来说是不可接受的。示例代码故意延迟以强调无响应;在实际应用程序中,这个问题在开发过程中可能不会被注意到,并且只会出现在“不寻常的”客户端场景中(例如网络连接丢失)。

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

好的,所以你应该使用“一直异步”。然而,许多开发人员继续采用第二种错误方法,如图 3 所示

图 3 BadMainViewModelB.cs

XML复制
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(
      "https://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 的类型,它具有用于获取结果或错误详细信息的属性。不幸的是,Task 不是数据绑定友好的,原因有两个:它没有实现 INotifyPropertyChanged 并且它的 Result 属性是阻塞的。但是,您可以定义各种“任务观察器”,例如图 4中的类型。

图 4 NotifyTaskCompletion.cs

XML复制
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.WatchTaskAsync。这个方法接受一个代表异步操作的任务,并且(异步地)等待它完成。注意 await 不使用 ConfigureAwait(false); 我想在引发 PropertyChanged 通知之前返回 UI 上下文。这种方法在这里违反了一个通用的编码准则:它有一个空的通用 catch 子句。不过,在这种情况下,这正是我想要的。我不想将异常直接传播回主 UI 循环;我想捕获任何异常并设置属性,以便通过数据绑定完成错误处理。任务完成后,该类型会针对所有适当的属性引发 PropertyChanged 通知。

使用 NotifyTaskCompletion 更新的 ViewModel 如下所示:

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

这个 ViewModel 将立即开始操作,然后为结果任务创建一个数据绑定的“观察者”。需要更新视图数据绑定代码以显式绑定到操作结果,如下所示:

XML复制
<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>

请注意,标签内容是数据绑定到 NotifyTaskCompletion.Result,而不是 Task.Result。NotifyTaskCompletion.Result 是数据绑定友好的:它不是阻塞的,它会在任务完成时通知绑定。如果您现在运行代码,您会发现它的行为与前面的示例一样:UI 具有响应性并立即加载(显示默认值“0”),然后在几秒钟内更新为实际结果。

NotifyTaskCompletion 的好处是它还具有许多其他属性,因此您可以使用数据绑定来显示繁忙指示器或错误详细信息。使用其中一些便利属性在视图中完全创建繁忙指示器和错误详细信息并不难,例如图 5中更新的数据绑定代码。

图 5 MainWindow.xaml

XML复制
<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>
    
    <Label Content="Loading..." Visibility="{Binding UrlByteCount.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    
    <Label Content="{Binding UrlByteCount.Result}" Visibility="{Binding
      UrlByteCount.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    
    <Label Content="{Binding UrlByteCount.ErrorMessage}" Background="Red"
      Visibility="{Binding UrlByteCount.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
  Grid>
Window>

使用这个仅更改视图的最新更新,应用程序会显示“正在加载...”几秒钟(同时保持响应),然后更新为操作结果或显示在红色背景上的错误消息。

NotifyTaskCompletion 处理一个用例:当您有一个异步操作并想要对结果进行数据绑定时。这是在启动期间进行数据查找或加载时的常见情况。但是,当您有一个异步的实际命令时,它并没有多大帮助,例如,“保存当前记录”。(我将在下一篇文章中考虑异步命令。)

乍一看,构建一个异步 UI 似乎需要做更多的工作,这在某种程度上是正确的。正确使用 async 和 await 关键字强烈鼓励您设计更好的用户体验。当您移动到异步 UI 时,您会发现在异步操作正在进行时您无法再阻止 UI。您必须考虑在加载过程中 UI 应该是什么样子,并有目的地针对该状态进行设计。这是更多的工作,但对于大多数现代应用程序来说,这是应该完成的工作。这也是 Windows 应用商店等较新平台仅支持异步 API 的原因之一:鼓励开发人员设计响应速度更快的用户体验。

包起来

当代码库从同步转换为异步时,通常服务或数据访问组件首先发生变化,然后异步从那里向 UI 增长。完成几次后,将方法从同步转换为异步就变得相当简单了。我期望(并希望)这种翻译将被未来的工具自动化。但是,当异步访问 UI 时,就需要进行真正的更改。

当 UI 变为异步时,您必须通过增强其 UI 设计来解决应用程序无响应的情况。最终结果是响应速度更快、更现代的应用程序。“快速而流畅”,如果你愿意的话。

本文介绍了一种简单的类型,可以概括为Task进行数据绑定。下一次,我将研究异步命令,并探讨一个本质上是“用于异步的 ICommand”的概念。然后,在本系列的最后一篇文章中,我将讨论异步服务。请记住,社区仍在开发这些模式;随意调整它们以满足您的特定需求。

你可能感兴趣的:(C#,多线程,前端)