天天看點

異步程式設計 - 針對異步 MVVM 應用程式的模式:資料綁定

 Stephen Cleary

使用 async 和 await 關鍵字的異步代碼正在轉變程式的編寫方式,這一轉變有着充分的理由。 盡管 async 和 await 可能對伺服器軟體很有用,但目前人們主要關注的是具有 UI 的應用程式。 對于這些應用程式,這些關鍵字可産生更具響應能力的 UI。 然而,如何在 Model-View-ViewModel (MVVM) 等原有模式中使用 async 和 await 并不是顯而易見的。 本文是一個簡短文章系列的開篇,該系列将探讨一些将 async 和 await 與 MVVM 結合起來的模式。

更清楚地說,我關于 async 的第一篇文章“異步程式設計的最佳做法” (msdn.microsoft.com/magazine/jj991977) 涵蓋了使用 async/await 的所有應用程式,既有用戶端,也有伺服器。 這個新系列的内容以那篇文章中提到的最佳做法為基礎,介紹了專門針對用戶端 MVVM 應用程式的模式。 但這些模式也隻是模式,并不一定是特定情況的最佳解決方案。 如果您發現了更好的方式,請告訴我!

在寫這篇文章時,衆多 MVVM 平台均支援 async 和 await 關鍵字:桌上型電腦(Microsoft .NET Framework 4 和更高版本上的 Windows Presentation Foundation [WPF])、iOS/Android (Xamarin)、Windows 應用商店(Windows 8 和更高版本)、Windows Phone(7.1 和更高版本)、Silverlight(4 和更高版本),以及面向這些平台的任何組合的可移植類庫 (PCL)(例如 MvvmCross)。 現在,發展“async MVVM”模式的時機已經成熟。

我假設您對 async 和 await 已有一定了解,并且相當熟悉 MVVM。 如果不是這樣的話,可以在網上找到很多有幫助的介紹性資料。 我的部落格 (bit.ly/19IkogW) 包含了 async/await 介紹,其末尾列出了其他資源,有關 async 的 MSDN 文檔也非常不錯(搜尋“Task-based Asynchronous Programming”)。 有關 MVVM 的更多資訊,我強烈推薦大家閱讀 Josh Smith 所寫的任何文章。

一個簡單的應用程式

在這篇文章中,我将建構一個非常簡單的應用程式,如圖 1 所示。 當該應用程式加載時,它會啟動一個 HTTP 請求,并統計傳回的位元組數。 此 HTTP 請求可能成功完成,也可能出現異常,并且該應用程式将使用資料綁定進行更新。 該應用程式始終充分響應。

異步程式設計 - 針對異步 MVVM 應用程式的模式:資料綁定
異步程式設計 - 針對異步 MVVM 應用程式的模式:資料綁定
異步程式設計 - 針對異步 MVVM 應用程式的模式:資料綁定

圖 1 示例應用程式

但首先我想提一下,我在自己的項目中并未嚴格遵循 MVVM 模式,有時使用了适當的域 Model,但更常用的是一系列服務和資料傳輸對象(實際上是資料通路層),而不是實際 Model。 對于 View,我同樣秉持相當務實的态度;如果替代方案是使用支援類和 XAML 的幾十行代碼,那麼我不會回避采用幾行代碼隐藏。 是以,當我談到 MVVM 時,您需要明白,我沒有使用此術語的任何特别嚴格的定義。

在将 async 和 await 引入到 MVVM 模式時,您首先要考慮的事情之一是确定解決方案的哪些部分需要 UI 線程上下文。 有些 UI 元件僅從擁有它們的 UI 線程加以通路,Windows 平台會認真對待這些元件。 很明顯,視圖被完全綁定到了 UI 上下文。 我在我的應用程式中也證明了通過資料綁定連結到視圖的任何内容均被綁定到 UI 上下文。 WPF 的最新版本已放松了這一限制,允許在 UI 線程與背景線程(例如,BindingOperations.EnableCollection­Synchronization)之間實作一定的資料共享。 不過,并非在每個 MVVM 平台(WPF、iOS/Android/Windows Phone、Windows 應用商店)上均保證支援跨線程資料綁定,是以在我自己的項目中,我隻是将資料綁定到 UI 的任何内容均視為具有 UI 線程關聯性。

是以,我始終将 ViewModel 視為仿佛綁定到 UI 上下文。 在我的應用程式中,ViewModel 與 View 的聯系更緊密,而不是 Model — ViewModel 層實質上是用于整個應用程式的 API。 實際上,View 僅提供實際應用程式所處的 UI 元素的外殼。 從概念上來說,ViewModel 層是包含 UI 線程關聯性的可測試 UI。 如果您的 Model 是實際域模型(而不是資料通路層),且該 Model 與 ViewModel 之間存在資料綁定,則 Model 本身也具有 UI 線程關聯性。 确定了哪些層具有 UI 關聯性後,您可以在“與 UI 關聯的代碼”(View 和 ViewModel,也可能是 Model)和“與 UI 無關的代碼”(可能是 Model,并且一定是其他所有層,例如服務和資料通路)之間加以區分。

此外,View 層之外的所有代碼(即,ViewModel 和 Model 層、服務等)都不應依賴綁定到特定 UI 平台的任何類型。 不要直接使用 Dispatcher (WPF/Xamarin/Windows Phone/Silverlight)、CoreDispatcher(Windows 應用商店)或 ISynchronizeInvoke(Windows 窗體)。 (SynchronizationContext 略微好些,但也僅僅是勉強好些。)例如,Internet 上有許多代碼進行一些異步工作,然後使用 Dispatcher 更新 UI;更便捷且不太繁瑣的解決方案是使用 await 進行異步工作,然後在不使用 Dispatcher 的情況下更新 UI。

ViewModel 是最有趣的層,因為它們具有 UI 關聯性,但不依賴特定 UI 上下文。 在該系列中,我将 async 和 MVVM 結合起來,在避免特定 UI 類型的同時還遵循 async 最佳做法;第一篇文章着重說明異步資料綁定。

異步資料綁定屬性

術語“異步屬性”實際上是一種沖突。 屬性 getter 應立即執行并檢索目前值,而不是啟動背景操作。 這可能是在屬性 getter 上不能使用 async 關鍵字的原因之一。 如果您發現您的設計需要異步屬性,則首先考慮一些替代選擇。 尤其是,該屬性實際上是否應是一個方法(或指令)? 如果每次通路屬性 getter 時其都需要啟動一個新異步操作,則這根本不是屬性。 異步方法直截了當,我将在另一個文章中涉及異步指令。

在該文章中,我将開發一個異步資料綁定屬性;即,我利用異步操作的結果更新的資料綁定屬性。 一個常見情況是 ViewModel 需要從某個外部源檢索資料。

如前所述,我的示例應用程式中将定義一個統計網頁中的位元組數的服務。 為展示 async/await 的響應能力,此服務還将延遲幾秒鐘。 我将在後續文章中介紹更現實的異步服務;目前,此“服務”隻是圖 2 中所示的單一方法。

圖 2 MyStaticService.cs

  1.           using System;
  2. using System.Net.Http;
  3. using System.Threading.Tasks;
  4. public static class MyStaticService
  5. {
  6.   public static async Task<int> CountBytesInUrlAsync(string url)
  7.   {
  8.     // Artificial delay to show responsiveness.
  9.           await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
  10.     // Download the actual data and count it.
  11.           using (var client = new HttpClient())
  12.     {
  13.       var data = await client.GetByteArrayAsync(url).ConfigureAwait(false);
  14.       return data.Length;
  15.     }
  16.   }
  17. }

注意,這被視為服務,是以它與 UI 無關。 由于該服務與 UI 無關,是以其每次執行等待時都使用 ConfigureAwait(false)(如在我的其他文章“異步程式設計中的最佳做法”中所讨論的)。

我們添加一個簡單 View 和 ViewModel,在啟動時發起 HTTP 請求。 該示例代碼使用 WPF 視窗,View 在建構時建立它們的 ViewModel。 這隻是為了簡單;該系列文章中探讨的異步原則和模式在所有 MVVM 平台、架構和庫間均适用。 目前,View 包含一個帶有單标簽的單個主視窗。 用于主 View 的 XAML 則綁定到 UrlByteCount 成員:

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <Label Content="{Binding UrlByteCount}"/>
  </Grid>
</Window>
        
           

主視窗的代碼隐藏建立 ViewModel:

  1.           public partial class MainWindow
  2. {
  3.   public MainWindow()
  4.   {
  5.     DataContext = new BadMainViewModelA();
  6.     InitializeComponent();
  7.   }
  8. }

常見錯誤

您可能注意到 ViewModel 類型稱為 BadMainViewModelA。 這是因為我将首先檢視與 ViewModel 相關的一些常見錯誤。 一個常見錯誤是在該操作上同步阻塞,如下所示:

  1.           public class BadMainViewModelA
  2. {
  3.   public BadMainViewModelA()
  4.   {
  5.     // BAD CODE!!!
  6.           UrlByteCount =
  7.       MyStaticService.CountBytesInUrlAsync("http://www.example.com").Result;
  8.   }
  9.   public int UrlByteCount { get; private set; }
  10. }

這違反了“始終使用異步”的異步指導原則,但有時開發人員出于無奈會嘗試這樣做。 如果您執行此代碼,您會看到這在某種程度上是有效的。 使用 Task.Wait 或 Task<T>.Result 而不是 await 的代碼會在該操作上同步阻塞。

同步阻塞有幾個問題。 最明顯的是此代碼現在正在采取異步操作,但又在該操作上阻塞;結果會失去異步的所有好處。 如果您執行目前代碼,您将看到應用程式僵住幾秒鐘,然後 UI 視窗中會瞬間迸發已填充了結果的視圖。 此問題是應用程式不響應,這對許多新式應用程式來說是不可接受的。 此示例代碼具有故意延遲,以突出這種不響應問題;在真實應用程式中,此問題在開發過程中可能不會引起注意,僅在“異常”用戶端情況(例如失去網絡連接配接)下才會出現。

另一個同步阻塞問題更加微妙:此代碼更加脆弱。 我的示例服務正确使用了 ConfigureAwait(false),就像服務應該做的那樣。 但這容易忘記,尤其是如果您(或您的同僚)不經常使用異步。 請考慮随時間的推移,在維護此服務代碼時會發生什麼。 維護開發人員可能會忘記 ConfigureAwait,此時 UI 線程的阻塞會變成 UI 線程的死鎖。 (在我有關異步最佳做法的上一篇文章中更詳細地描述了這種情況。)

好了,是以您應“始終使用異步”。但許多開發人員采用圖 3 所示的第二個錯誤方法。

圖 3 BadMainViewModelB.cs

  1.           using System.ComponentModel;
  2. using System.Runtime.CompilerServices;
  3. public sealed class BadMainViewModelB : INotifyPropertyChanged
  4. {
  5.   public BadMainViewModelB()
  6.   {
  7.     Initialize();
  8.   }
  9.   // BAD CODE!!!
  10.           private async void Initialize()
  11.   {
  12.     UrlByteCount = await MyStaticService.CountBytesInUrlAsync(
  13.       "http://www.example.com");
  14.   }
  15.   private int _urlByteCount;
  16.   public int UrlByteCount
  17.   {
  18.     get { return _urlByteCount; }
  19.     private set { _urlByteCount = value; OnPropertyChanged(); }
  20.   }
  21.   public event PropertyChangedEventHandler PropertyChanged;
  22.   private void OnPropertyChanged([CallerMemberName] string propertyName = null)
  23.   {
  24.     PropertyChangedEventHandler handler = PropertyChanged;
  25.     if (handler != null)
  26.         handler(this, new PropertyChangedEventArgs(propertyName));
  27.   }
  28. }

再次說明,如果您執行此代碼,您會看到這是有效的。 現在 UI 立即顯示,并且在标簽中顯示了幾秒鐘“0”,然後被更新為正确值。 此 UI 具有響應性,一切似乎正常。 但在這種情況下,問題是處理錯誤。 使用 async void 方法時,異步操作引發的任何錯誤在預設情況下均會導緻應用程式崩潰。 這是在開發過程中容易疏忽的另一種情況,其僅在用戶端裝置上的“怪異”情況下出現。 甚至将圖 3 中的代碼從 async void 更改為 async Task 也幾乎不會改進此應用程式;所有錯誤均将被默默地忽視,進而使使用者想知道發生了什麼。 任何一種錯誤處理方法都不适用。 盡管可通過捕獲異步操作中的異常并更新其他資料綁定屬性來處理這種情況,但這将導緻大量冗長代碼。

更有效的方法

理想的情況下,我真正想要的是就像 Task<T> 這樣的類型,這種類型具有用于獲得結果或錯誤詳細資訊的屬性。 不幸的是,由于兩個原因,Task<T> 沒有友好地進行資料綁定:它沒有實作 INotify­PropertyChanged,并且其 Result 屬性會導緻阻塞。 但您可以定義各種“任務觀察器”,例如圖 4 中的類型。

圖 4 NotifyTaskCompletion.cs

  1.           using System;
  2. using System.ComponentModel;
  3. using System.Threading.Tasks;
  4. public sealed class NotifyTaskCompletion<TResult> : INotifyPropertyChanged
  5. {
  6.   public NotifyTaskCompletion(Task<TResult> task)
  7.   {
  8.     Task = task;
  9.     if (!task.IsCompleted)
  10.     {
  11.       var _ = WatchTaskAsync(task);
  12.     }
  13.   }
  14.   private async Task WatchTaskAsync(Task task)
  15.   {
  16.     try
  17.     {
  18.       await task;
  19.     }
  20.     catch
  21.     {
  22.     }
  23.     var propertyChanged = PropertyChanged;
  24.     if (propertyChanged == null)
  25.         return;
  26.     propertyChanged(this, new PropertyChangedEventArgs("Status"));
  27.     propertyChanged(this, new PropertyChangedEventArgs("IsCompleted"));
  28.     propertyChanged(this, new PropertyChangedEventArgs("IsNotCompleted"));
  29.     if (task.IsCanceled)
  30.     {
  31.       propertyChanged(this, new PropertyChangedEventArgs("IsCanceled"));
  32.     }
  33.     else if (task.IsFaulted)
  34.     {
  35.       propertyChanged(this, new PropertyChangedEventArgs("IsFaulted"));
  36.       propertyChanged(this, new PropertyChangedEventArgs("Exception"));
  37.       propertyChanged(this,
  38.         new PropertyChangedEventArgs("InnerException"));
  39.       propertyChanged(this, new PropertyChangedEventArgs("ErrorMessage"));
  40.     }
  41.     else
  42.     {
  43.       propertyChanged(this,
  44.         new PropertyChangedEventArgs("IsSuccessfullyCompleted"));
  45.       propertyChanged(this, new PropertyChangedEventArgs("Result"));
  46.     }
  47.   }
  48.   public Task<TResult> Task { get; private set; }
  49.   public TResult Result { get { return (Task.Status == TaskStatus.RanToCompletion) ?
  50.           Task.Result : default(TResult); } }
  51.   public TaskStatus Status { get { return Task.Status; } }
  52.   public bool IsCompleted { get { return Task.IsCompleted; } }
  53.   public bool IsNotCompleted { get { return !Task.IsCompleted; } }
  54.   public bool IsSuccessfullyCompleted { get { return Task.Status ==
  55.     TaskStatus.RanToCompletion; } }
  56.   public bool IsCanceled { get { return Task.IsCanceled; } }
  57.   public bool IsFaulted { get { return Task.IsFaulted; } }
  58.   public AggregateException Exception { get { return Task.Exception; } }
  59.   public Exception InnerException { get { return (Exception == null) ?
  60.           null : Exception.InnerException; } }
  61.   public string ErrorMessage { get { return (InnerException == null) ?
  62.           null : InnerException.Message; } }
  63.   public event PropertyChangedEventHandler PropertyChanged;
  64. }

我們來看一下核心方法 NotifyTaskCompletion<T>.WatchTaskAsync。 此方法接受呈現異步操作的任務,并且(異步)等待其完成。 注意,await 不使用 ConfigureAwait(false);我想在引發 PropertyChanged 通知前傳回到 UI 上下文。 此方法在這裡違反了常見編碼指導原則:它有一個空的正常 Catch 子句。 但在這種情況下,這恰恰是我想要的。 我不想将異常直接傳播回到主 UI 循環;我想捕獲任何異常并設定屬性,以便通過資料綁定執行錯誤處理。 當該任務完成時,此類型會引發針對所有适用屬性的 PropertyChanged 通知。

使用 NotifyTaskCompletion<T> 的已更新 ViewModel 将如下所示:

  1.           public class MainViewModel
  2. {
  3.   public MainViewModel()
  4.   {
  5.     UrlByteCount = new NotifyTaskCompletion<int>(
  6.       MyStaticService.CountBytesInUrlAsync("http://www.example.com"));
  7.   }
  8.   public NotifyTaskCompletion<int> UrlByteCount { get; private set; }
  9. }

此 ViewModel 将立即啟動操作,然後為最終任務建立資料綁定的“觀察器”。 View 資料綁定代碼需要進行更新才能顯式綁定到此操作的結果,如下所示:

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <Label Content="{Binding UrlByteCount.Result}"/>
  </Grid>
</Window>
        
           

注意,标簽内容被資料綁定到了 NotifyTask­Completion<T>.Result,而不是 Task<T>.Result。 NotifyTaskCompletion<T>.Result 正在進行友好地資料綁定:它沒有阻塞,并且将在任務完成時通知綁定。 如果您現在運作此代碼,您将發現其行為就像先前示例:UI 具有響應性,并且立即加載(顯示預設值“0”),然後在幾秒鐘内更新為實際結果。

NotifyTaskCompletion<T> 的好處是它還具有許多其他屬性,是以您能夠使用資料綁定來顯示忙碌狀态訓示器或錯誤詳細資訊。 使用這些便捷屬性來建立完全位于 View 中的忙碌狀态訓示器或錯誤詳細資訊并不難,例如圖 5 中的新版資料綁定代碼。

圖 5 MainWindow.xaml

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Window.Resources>
    <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
  </Window.Resources>
  <Grid>
    <!-- Busy indicator -->
    <Label Content="Loading..." Visibility="{Binding UrlByteCount.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <!-- Results -->
    <Label Content="{Binding UrlByteCount.Result}" Visibility="{Binding
      UrlByteCount.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <!-- Error details -->
    <Label Content="{Binding UrlByteCount.ErrorMessage}" Background="Red"
      Visibility="{Binding UrlByteCount.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
  </Grid>
</Window>
        
           

有了最新版本的 View,應用程式會顯示幾秒鐘“正在加載…”(同時保持響應性),然後更新為此操作的結果,或以紅色背景顯示的錯誤消息。

NotifyTaskCompletion<T> 處理一種用例:當您有一個異步操作并且想對結果進行資料綁定時。 當執行資料鎖定或在啟動過程中進行加載時,這種情況很常見。 但當您具有異步的實際指令,例如“儲存目前記錄”時,這沒有太多幫助。(我将在我的下一篇文章中考慮異步指令。)

乍一看,這似乎像是建構異步 UI 需要大量工作,并且在某種程度上确實如此。 正确使用 async 和 await 關鍵字會極大促使您設計更出色的 UX。 當您移動到異步 UI 時,您發現當異步操作正在進行時,您不再阻塞 UI。 您必須考慮在加載過程中 UI 應具有的外觀,并且有目的地針對這種外觀進行設計。 這需要更多工作,但對于大多數新式應用程式來說,這是應完成的工作。 這是像 Windows 應用商店等更新的平台僅支援異步 API 的一個原因:鼓勵開發人員設計更具響應性的 UX。

總結

将代碼庫從同步轉換為異步時,通常服務或資料通路元件會首先更改,并且異步會從那裡延伸到 UI。 當您實作了幾次之後,将方法從同步轉變到異步會變得相當簡單。 我期望(并且希望)未來能通過工具自動完成這種轉換。 但當異步觸及 UI 時,此時需要進行真正的更改。

當 UI 變成異步時,您必須通過增強應用程式的 UI 設計來解決應用程式沒有響應的情況。 這最終将實作更具響應性、更加現代化的應用程式。 “快而流暢”,如果您願意那樣說的話。

本文介紹了一個簡單類型,可将其概括為用于資料綁定的 Task<T>。 下次,我将探讨異步指令,以及探究實質上為“用于異步的 ICommand”的概念。然後在該系列的最後一篇文章中,我将通過考慮異步服務進行總結。 記住,這些模式仍在發展中;請随時針對您的特定需求調整它們。

Stephen Cleary 生活在密歇根州北部,他是一位丈夫、父親和程式員。他已從事了 16 年的多線程和異步程式設計工作,自第一個 CTP 以來便在使用 Microsoft .NET Framework 中的異步支援。他的首頁(包括部落格)位于 stephencleary.com。

衷心感謝以下 Microsoft 技術專家對本文的審閱:James McCaffrey 和 Stephen Toub