本文章是由機器翻譯。
非同步程式設計
非同步 MVVM 應用程式的模式:命令
這是第二個系列的文章中結合非同步,等待與既定的模型-視圖-ViewModel (MVVM) 模式。最後一次,我給如何對資料繫結到非同步作業,並開發了金鑰類型稱為 NotifyTaskCompletion<TResult>行事喜歡資料繫結-友好任務<TResult> (見 msdn.microsoft.com/magazine/dn605875)。現在我會轉到 ICommand,使用 MVVM 應用程式用來定義使用者的操作 (這通常是資料繫結到一個按鈕),.NET 介面,我會考慮的非同步 ICommand 含義的。
這裡的模式不可能完全適合每種情況下,所以覺得免費為您的需求來調整這些。其實,這整篇文章被介紹作為一系列的非同步命令類型上的改進。在這些反覆運算結束了,你就會死于什麼所示類似的應用程式圖 1。這是類似于在我的上一篇文章中開發的應用程式,但這一次我向使用者提供一個實際的命令來執行。當使用者按一下 Go 按鈕時,該 URL 從文字方塊中讀取和應用程式將 (後人工延遲) 計算的該 URL 處的位元組數。操作時,使用者可能無法啟動另一個,但他可以取消該操作。
圖 1 的應用程式可以執行一個命令
然後會如何非常類似的方法可以用於創建任何數量的操作。圖 2 說明了應用程式修改因此 Go 按鈕表示將操作添加到操作的集合。
圖 2 應用程式中執行多個命令
有幾個我要使在此應用程式,把重點放在非同步命令而不是實現細節上的發展過程中的簡單化。第一,我不會用命令執行中的參數。我幾乎不需要使用參數在實際的應用程式 ; 但如果你需要他們,這篇文章中的模式可以輕鬆地擴展包括它們。第二,我不要自己執行 ICommand.CanExecuteChanged。標準的類似欄位的事件將洩漏記憶體在某些平臺上使用 MVVM (見 bit.ly/1bROnVj)。為了保持代碼的簡單,我使用了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 方法被設計用於同步實施,因此它返回 void。 如前所述在先前的文章,"最佳做法在非同步程式設計"(msdn.microsoft.com/magazine/jj991977),應避免非同步 void 方法,除非他們是事件處理常式 (或邏輯可以事件處理常式的人才)。 ICommand.Execute 的實現在邏輯上的事件處理常式,因此,可能是非同步無效。
然而,這是最好儘量減少非同步 void 方法內的代碼,並改為公開非同步任務方法,其中包含的實際邏輯。 這種做法會使代碼更易測試。 為此,我建議如下: 非同步命令介面,並在代碼圖 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 方法實現非同步 void ICommand.Execute 方法。 它正等待要確保非同步命令邏輯中的任何異常將正確引發到 UI 執行緒的主迴圈的結果。
這是相當大的複雜性,但每個這些類型都有一個目的。 IAsyncCommand 可以用於任何非同步 ICommand 實施中,和是打算從 ViewModels 公開與消耗由視圖和單元測試。 AsyncCommandBase 處理一些通用的所有非同步 ICommands 常見的樣板代碼。
在地方這個基礎,我準備好要開始發展有效的非同步命令。 同步操作沒有傳回值的標準委託類型是行動。 非同步等值是 Func<任務>。 圖 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();
}
}
此時,使用者介面有只有一個文字方塊為 URL,一個按鈕開始的 HTTP 要求和結果的標籤。 XAML 和 ViewModel 的基本部分很簡單。 這裡是主Window.xaml (跳過如保證金的定位屬性):
<Grid>
<TextBox Text="{Binding Url}" />
<Button Command="{Binding CountUrlBytesCommand}"
Content="Go" />
<TextBlock Text="{Binding ByteCount}" />
</Grid>
MainWindowViewModel.cs 所示圖 6。
圖 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 提供完成或錯誤的詳細資訊為每個? (例如,將使用者介面使用"命令佇列"樣式或通知快顯視窗嗎?)
通過資料繫結處理非同步命令完成
大多數的問題在第一次非同步命令反覆運算涉及如何處理結果。 真正需要的是某種類型的一項任務就會包裝<T>和提供一些資料繫結功能,以便應用程式可以更優雅地作出回應。 當它發生,NotifyTaskCompletion<T>類型在我的上一篇文章開發適合這些需要幾乎完美。 我要對此簡化某些非同步類型添加一個成員命令的邏輯: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 和不的任務。 我不想傳播異常到 UI 主迴圈 (如果它等待的任務屬性就有會發生) ; 相反,我返回 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; }
}
新的 XAML 代碼所示圖 9。
圖 9 第二個主視窗 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 專案中的代碼示例。 此代碼在照顧我提到與原始解決方案的所有關注:直到第一次操作隱藏標籤啟動 ; 有提供回饋給使用者 ; 立即忙指示燈 異常被捕獲和更新的使用者介面通過資料繫結 ; 多個請求不再互相干擾。 每個請求創建一個新的 NotifyTaskCompletion 包裝,有其自己獨立的結果和其他屬性。 NotifyTaskCompletion 作為一個可綁定資料抽象的非同步作業。 這允許多個請求,與使用者介面總是將綁定到最新的請求。 然而,在許多實際方案中,適當的解決方案是禁用多個請求。 就是你想要返回 false 從 CanExecute 時正在操作的命令。 這是容易做小修改為 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 時,您還可以選擇為使用者提供更多的選項,和取消是一個共同的選擇。
取消本身始終是同步的操作 — — 是立即請求登出的行為。最棘手的取消部分時,它可以運行 ; 它應該能夠執行時才有非同步命令在進展中。對在 AsyncCommand 的修改圖 11 提供一個嵌套的取消命令,當非同步命令的開始和結束時通知該取消命令。
圖 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();
}
}
}
將取消按鈕 (和一個已取消的標籤) 添加到使用者介面非常簡單,作為圖 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 的示例代碼中),你會發現最初禁用取消按鈕。 它啟用,當您按一下 Go 按鈕時,並且仍處於啟用狀態,直到操作完成 (無論成功,出現故障或已取消)。 現在,您有一個非同步作業可以說是完整的 UI。
一個簡單的工作佇列
到現在為止,我一直在關注一次僅在一個操作的 UI。 這是所有在許多情況下,是必要的但有時您需要啟動多個非同步作業的能力。 在我看來,作為一個社會我們還沒想出很好的使用者體驗用於處理多個非同步作業。 兩種常見方法使用一個工作佇列或通知制度,兩者均不理想。
工作佇列顯示所有非同步作業中集合 ; 這給使用者最大的可見度和控制,但通常是過於複雜,典型的最終使用者,以應付。 通知系統隱藏操作,雖然它們運行,並會彈出如果其中的任何故障 (和可能如果他們成功完成)。 通知系統更加方便使用者,但它不能提供充分的可見度和工作佇列的電源 (例如,很難工作到一個基於通知系統取消)。 我尚未發現理想 UX 多個非同步作業。
儘管如此,示例代碼在此時可以進行擴展以支援多操作方案中沒有太多的麻煩。 在現有代碼中,轉到按鈕和取消按鈕是兩個概念上與有關的一個非同步作業。 新的使用者介面將更改為"啟動一個新的非同步作業並將它添加到的操作的清單"的意思是 Go 按鈕這是什麼意思是 Go 按鈕現在是真正同步。 簡單的 (同步) 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 專案。 當使用者按一下 Go 按鈕時,創建新的 AsyncCommand 並將其纏繞成一個孩子 ViewModel (CountUrlBytesViewModel)。 這孩子 ViewModel 實例然後被添加到的操作的清單。 一切相關的該特定操作 (各種標籤和取消按鈕) 將顯示在工作佇列的資料範本。 我還添加了一個簡單的按鈕將從佇列中刪除該專案的"X"。
這是一個非常基本的工作佇列,而設計的一些假設。 例如,當從佇列中刪除操作,它不會自動取消。 當您開始使用多個非同步作業時,我建議你問你自己,至少這些附加的問題:
- 使用者如何知道哪些通知或工作專案是為哪些操作? (例如,在此示例中的工作佇列忙指示燈包含它下載的 URL)。
- 使用者是否需要知道每一次結果嗎? (例如,它可能會通知使用者唯一的錯誤,或自動從工作佇列中移除的成功操作可以接受)。
總結
那裡不是適合每個人的需要非同步命令的通用解決方案 — — 尚未。 開發者社區仍然在探索非同步 UI 模式。 我在這篇文章的目標是展示如何想想使用 MVVM 應用程式,尤其考慮到使用者體驗問題,必須加以解決,當 UI 變為非同步上下文中的非同步命令。 但請記住這篇文章中的模式和示例代碼只是模式,並且應適應應用程式的需要。
尤其是,不是一個完美的故事,關於多個非同步作業。 有缺點的工作佇列和通知,,似乎對我的普遍的 UX 尚未開發。 隨著更多的使用者介面變得非同步,更多的心靈會思考這個問題,和革命性的突破可能就在拐角處。 一些思想,親愛的讀者提供問題。 也許你會發現新的使用者體驗
在此期間,您仍然必須船。 在這篇文章我開始與最基本的非同步 ICommand 實現,逐漸添加功能,直到最終得到相當適合應用,最現代的東西。 結果也是完全單位可測試 ; 因為非同步 void ICommand.Execute 方法只調用任務返回的 IAsyncCommand.ExecuteAsync 方法,您可以直接在單元測試中使用 ExecuteAsync。
在我最後一篇文章,我開發了 NotifyTaskCompletion<T>,任務的資料繫結包裝<T>。 在這一個,顯示了如何開發一種 AsyncCommand<T>,ICommand 非同步執行。 在我的下一篇文章,我會處理非同步服務。 銘記非同步使用 MVVM 模式仍是相當新的 ; 不要害怕他們偏離和創新您自己的解決方案。
Stephen Cleary 是一個丈夫、 父親和程式師生活在北密歇根。他曾與多執行緒和非同步程式設計 16 年並已在 Microsoft.NET 框架以來,第一次的 CTP 使用非同步支援。他的主頁,包括他的博客,是在 stephencleary.com。
感謝以下 Microsoft 技術專家對本文的審閱:James麥卡弗裡和StephenToub