Asynchrone Programmierung

Muster für asynchrone MVVM-Anwendungen: Befehle

Stephen Cleary

Codebeispiel herunterladen.

Dieser Artikel ist der zweite in einer Reihe, in der die kombinierte Verwendung von „async“ und „await“ mit dem etablierten Model-View-ViewModel(MVVM)-Muster erläutert wird. Das letzte Mal habe ich gezeigt, wie eine Datenbindung an eine asynchrone Operation durchgeführt wird. Zudem habe ich den Schlüsseltyp „NotifyTaskCompletion<TResult>“ entwickelt, der sich wie ein für die Datenbindung geeigneter Task<TResult>-Typ verhält (siehe msdn.microsoft.com/magazine/dn605875). Dieses Mal beschäftige ich mich mit „ICommand“, einer .NET-Schnittstelle, die von MVVM-Anwendungen zum Definieren einer Benutzeroperation verwendet wird (für die häufig eine Datenbindung an eine Schaltfläche durchgeführt wird). Darüber hinaus erläutere ich die Auswirkungen der Erstellung eines asynchronen ICommand-Befehls.

Die hier erwähnten Muster passen möglicherweise nicht perfekt für jedes Szenario, Sie können sie daher gerne an Ihre Anforderungen anpassen. Tatsächlich stellt der gesamte Artikel eine Reihe von Verbesserungen für einen asynchronen Befehlstyp dar. Am Ende dieser Iterationen haben Sie eine Anwendung wie in Abbildung 1. Sie ähnelt der Anwendung, die ich in meinem letzten Artikel entwickelt habe, aber dieses Mal erhält der Benutzer tatsächlich einen auszuführenden Befehl. Wenn er auf die Schaltfläche „Go“ klickt, wird die URL aus dem Textfeld gelesen, und die Anwendung zählt die Bytes nach dieser URL (nach einer künstlichen Verzögerung). Während die Operation ausgeführt wird, kann der Benutzer keine andere Operation starten, er kann die Operation jedoch abbrechen.

An Application That Can Execute One Command
An Application That Can Execute One Command
An Application That Can Execute One Command
An Application That Can Execute One Command
An Application That Can Execute One Command
Abbildung 1: Eine Anwendung, die einen einzelnen Befehl ausführen kann

Dann zeige ich, wie mit einer ganz ähnlichen Methode eine beliebige Anzahl an Operationen erstellt werden kann. Abbildung 2 zeigt die geänderte Anwendung, in der mit der Schaltfläche „Go“ eine Operation zu einer Sammlung von Operationen hinzugefügt werden kann.

An Application Executing Multiple Commands
Abbildung 2: Eine Anwendung, die mehrere Befehle ausführt

Während der Anwendungsentwicklung nehme ich einige Vereinfachungen vor, damit weiterhin die asynchronen Befehle im Mittelpunkt stehen und nicht die Implementierungsdetails. Erstens: Ich verwende keine Befehlsausführungsparameter. In der Praxis habe ich bisher nur selten Parameter in Apps verwendet. Aber wenn Sie sie benötigen, können die Muster in diesem Artikel problemlos erweitert werden, um Parameter einzuschließen. Zweitens: Ich implementiere „ICommand.CanExecuteChanged“ nicht selbst. Durch ein feldähnliches Standardereignis kommt es auf einigen MVVM-Plattformen zu Speicherverlusten (siehe bit.ly/1bROnVj). Um den Code einfach zu halten, verwende ich zur Implementierung von „CanExecuteChanged“ den in Windows Presentation Foundation (WPF) integrierten CommandManager.

Zudem verwende ich eine vereinfachte „Dienstschicht“, die bis jetzt nur eine einzelne statische Methode ist (siehe Abbildung 3). Es handelt sich im Wesentlichen um denselben Dienst wie in meinem letzten Artikel, er wurde jedoch erweitert, um Abbrüche zu unterstützen. Im nächsten Artikel wird der eigentliche Entwurf asynchroner Dienste behandelt, aber im Moment reicht dieser vereinfachte Dienst aus.

Abbildung 3: Die Dienstschicht

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;
    }
  }
}

Asynchrone Befehle

Bevor wir beginnen, schauen wir uns kurz die ICommand-Schnittstelle an:

public interface ICommand
{
  event EventHandler CanExecuteChanged;
  bool CanExecute(object parameter);
  void Execute(object parameter);
}

Lassen Sie „CanExecuteChanged“ und die Parameter außer Acht, und überlegen Sie, wie ein asynchroner Befehl mit dieser Schnittstelle funktionieren würde. Die CanExecute-Methode muss synchron sein. Der einzige Member, der asynchron sein kann, ist „Execute“. Die Execute-Methode wurde für synchrone Implementierungen entwickelt, sie gibt also „void“ zurück. Wie ich in meinem vorherigen Artikel „Bewährte Verfahren bei der asynchronen Programmierung“ (msdn.microsoft.com/magazine/jj991977) erwähnt habe, sollten async-void-Methoden vermieden werden, es sei denn, es handelt sich um Ereignishandler (oder das logische Äquivalent von Ereignishandlern). Implementierungen von „ICommand.Execute“ sind logische Ereignishandler und können daher „async void“ sein.

Es ist jedoch am besten, den Code innerhalb einer async-void-Methode zu minimieren und stattdessen eine async-Task-Methode bereitzustellen, die die eigentliche Logik enthält. Bei dieser Vorgehensweise kann der Code besser getestet werden. Vor diesem Hintergrund schlage ich Folgendes als asynchrone Befehlsschnittstelle und den Code aus Abbildung 4 als Basisklasse vor:

public interface IAsyncCommand : ICommand
{
  Task ExecuteAsync(object parameter);
}

Abbildung 4: Basistyp für asynchrone Befehle

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();
  }
}

Die Basisklasse hat zwei Funktionen: Sie gibt die CanExecuteChanged-Implementierung an die CommandManager-Klasse weiter und implementiert die async-void-Methode „ICommand.Execute“ durch Aufrufen der IAsyncCommand.ExecuteAsync-Methode. Sie wartet („await“) auf das Ergebnis, um sicherzustellen, dass Ausnahmen in der Logik des asynchronen Befehls ordnungsgemäß in der Hauptschleife des UI-Threads ausgelöst werden.

Das ist ziemlich komplex, aber diese Typen haben einen Zweck. „IAsyncCommand“ kann für jede asynchrone ICommand-Implementierung verwendet werden, soll über „ViewModels“ bereitgestellt und von „View“ und Komponententests genutzt werden. „AsyncCommandBase“ verarbeitet einige der allgemeinen Codebausteine, die allen asynchronen „ICommands“ gemeinsam sind.

Auf dieser Grundlage kann ich mit der Entwicklung eines effektiven asynchronen Befehls beginnen. Der standardmäßige Delegattyp für eine synchrone Operation ohne einen Rückgabewert ist „Action“. Das asynchrone Äquivalent ist „Func<Task>“. Abbildung 5 zeigt meine erste Iteration eines delegatbasierten AsyncCommand-Befehls.

Abbildung 5: Der erste Versuch eines asynchronen Befehls

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();
  }
}

Zu diesem Zeitpunkt besteht die Benutzeroberfläche nur aus einem Textfeld für die URL, einer Schaltfläche zum Starten der HTTP-Anforderung und einer Beschriftung für die Ergebnisse. Die XAML und die wesentlichen ViewModel-Teile sind einfach. Nachfolgend sehen Sie die Datei „Main­Window.xaml“ (die Positionierungsattribute wie „Margin“ wurden ausgelassen):

<Grid>
  <TextBox Text="{Binding Url}" />
  <Button Command="{Binding CountUrlBytesCommand}" 
      Content="Go" />
  <TextBlock Text="{Binding ByteCount}" />
</Grid>

„MainWindowViewModel.cs“ wird in Abbildung 6 dargestellt.

Abbildung 6: Der erste MainWindowViewModel-Entwurf

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
}

Wenn Sie die Anwendung ausführen („AsyncCommands1“ im Download des Beispielcodes), tritt an vier Stellen ein unerwünschtes Verhalten auf. Erstens: Die Beschriftung enthält immer ein Ergebnis, sogar bevor der Benutzer auf die Schaltfläche klickt. Zweitens: Nach dem Klicken auf die Schaltfläche wird kein Auslastungsindikator angezeigt, um anzugeben, dass die Operation ausgeführt wird. Drittens: Wenn die HTTP-Anforderung fehlschlägt, wird die Ausnahme an die Hauptschleife der Benutzeroberfläche übergeben, was zu einem Anwendungsabsturz führt. Viertens: Stellt der Benutzer mehrere Anforderungen, sind die Ergebnisse nicht zu unterscheiden. Aufgrund schwankender Serverantwortzeiten können die Ergebnisse einer früheren Anforderung die Ergebnisse einer späteren Anforderung überschreiben.

Das sind eine ganze Menge Probleme! Aber bevor ich den Entwurf wiederhole, überlegen Sie für einen Moment, welcher Art die entstandenen Probleme sind. Wenn eine Benutzeroberfläche asynchron wird, müssen Sie über weitere Zustände in der Benutzeroberfläche nachdenken. Sie sollten sich auf jeden Fall folgende Fragen stellen:

  1. Wie werden Fehler in der Benutzeroberfläche angezeigt? (Ich hoffe, dass Ihre synchrone Benutzeroberfläche diese Frage bereits beantwortet!)
  2. Wie sollte die Benutzeroberfläche aussehen, während die Operation ausgeführt wird? (Soll beispielsweise über Auslastungsindikatoren sofort ein Feedback bereitgestellt werden?)
  3. Inwiefern ist der Benutzer während der Operationsausführung eingeschränkt? (Sind beispielsweise Schaltflächen deaktiviert?)
  4. Stehen dem Benutzer während der Operationsausführung zusätzliche Befehle zur Verfügung? (Kann er beispielsweise die Operation abbrechen?)
  5. Wie werden in der Benutzeroberfläche Fortschritts- oder Fehlerdetails angezeigt, wenn der Benutzer mehrere Operationen starten kann? (Verwendet die Benutzeroberfläche beispielsweise eine Befehlswarteschlange oder Benachrichtigungspopups?)

Verarbeiten des Abschlusses asynchroner Befehle über die Datenbindung

Die meisten Probleme in der ersten Async­Command-Iteration beziehen sich darauf, wie die Ergebnisse behandelt werden. Was wirklich benötigt wird, ist ein Typ, der einen „Task<T>“ umschließt und Datenbindungsfunktionen bereitstellt, sodass die Anwendung besser reagieren kann. Der NotifyTaskCompletion<T>-Typ, der in meinem letzten Artikel entwickelt wurde, erfüllt diese Anforderungen nahezu perfekt. Ich füge diesem Typ einen Member hinzu, der einen Teil der Async­Command-Logik vereinfacht: eine TaskCompletion-Eigenschaft, die den Operationsfortschritt repräsentiert, aber keine Ausnahmen weiterleitet (bzw. kein Ergebnis zurückgibt). Nachfolgend sehen Sie die Änderungen an „NotifyTaskCompletion<T>“:

public NotifyTaskCompletion(Task<TResult> task)
{
  Task = task;
  if (!task.IsCompleted)
    TaskCompletion = WatchTaskAsync(task);
}
public Task TaskCompletion { get; private set; }

Die nächste AsyncCommand-Iteration verwendet „NotifyTaskCompletion“ zur Darstellung der eigentlichen Operation. Auf diese Weise kann XAML direkt eine Datenbindung an das Ergebnis und die Fehlermeldung dieser Operation durchführen und die Datenbindung zudem verwenden, um während der Operationsausführung eine entsprechende Meldung anzuzeigen. Der neue AsyncCommand besitzt jetzt eine Eigenschaft, die die eigentliche Operation darstellt (siehe Abbildung 7).

Abbildung 7: Der zweite Versuch eines asynchronen Befehls

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; }
}

Beachten Sie, dass „AsyncCommand.ExecuteAsync“ „TaskCompletion“ verwendet, nicht „Task“. Ich möchte keine Ausnahmen in der Hauptschleife der Benutzeroberfläche weiterleiten (was geschehen würde, wenn die Task-Eigenschaft erwartet werden würde). Stattdessen wird „TaskCompletion“ zurückgegeben, und Ausnahmen werden durch die Datenbindung verarbeitet. Zudem habe ich dem Projekt einen einfachen „NullToVisibilityConverter“ hinzugefügt, sodass der Auslastungsindikator, Ergebnisse und Fehlermeldungen ausgeblendet bleiben, bis der Benutzer auf die Schaltfläche klickt. Abbildung 8 zeigt den aktualisierten ViewModel-Code.

Abbildung 8: Das zweite „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; }
}

Und der neue XAML-Code ist in Abbildung 9 dargestellt.

Abbildung 9: Die zweite Datei „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>

Der Code entspricht jetzt dem Projekt „AsyncCommands2“ im Beispielcode. Dieser Code löst alle Probleme, die ich bei der ursprünglichen Lösung erwähnt habe: Beschriftungen werden ausgeblendet, bis die erste Operation startet. Es wird sofort ein Auslastungsindikator angezeigt, der dem Benutzer Feedback gibt. Ausnahmen werden erfasst, und über die Datenbindung wird die Benutzeroberfläche aktualisiert. Mehrere Anforderungen beeinträchtigen sich nicht mehr gegenseitig. Jede Anforderung erstellt einen neuen NotifyTaskCompletion-Wrapper, der ein eigenes unabhängiges Ergebnis und andere Eigenschaften aufweist. „NotifyTaskCompletion“ dient als an Daten bindbare Abstraktion einer asynchronen Operation. Dies ermöglicht mehrere Anforderungen, wobei die Benutzeroberfläche stets an die neueste Anforderung bindet. In vielen alltäglichen Szenarien ist es jedoch besser, mehrere Anforderungen zu deaktivieren. Das heißt, der Befehl soll von „CanExecute“ „false“ zurückgeben, während eine Operation ausgeführt wird. Dies kann ganz einfach durch eine kleine Änderung an „AsyncCommand“ bewerkstelligt werden (siehe Abbildung 10).

Abbildung 10: Deaktivieren mehrerer Anforderungen

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();
  }
}

Jetzt entspricht der Code dem Projekt „AsyncCommands3“ im Beispielcode. Die Schaltfläche ist deaktiviert, während die Operation läuft.

Hinzufügen von Abbrüchen

Viele asynchrone Operationen können unterschiedlich lange dauern. Beispielsweise reagiert eine HTTP-Anforderung sehr rasch, noch bevor der Benutzer reagieren kann. Ist das Netzwerk jedoch langsam oder der Server ausgelastet, kann dieselbe HTTP-Anforderung eine erhebliche Verzögerung verursachen. Bei der Entwicklung einer asynchronen Benutzeroberfläche muss dieses Szenario berücksichtigt werden. Die derzeitige Lösung besitzt bereits einen Auslastungsindikator. Wenn Sie eine asynchrone Benutzeroberfläche entwickeln, können Sie dem Benutzer weitere Optionen zur Verfügung stellen. Häufig werden Abbrüche verwendet.

Der Abbruch selbst ist immer eine synchrone Operation – die Abbruchanforderung ist eine sofortige Aktion. Der schwierigste Teil des Abbruchs ist der Zeitpunkt seiner Ausführung. Er sollte nur während der Ausführung eines asynchronen Befehls ausgeführt werden können. Durch die Änderungen an AsyncCommand in Abbildung 11 wird ein verschachtelter Abbruchbefehl bereitgestellt, und dieser Abbruchbefehl wird benachrichtigt, wenn der asynchrone Befehl beginnt und endet.

Abbildung 11: Hinzufügen von Abbrüchen

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();
    }
  }
}

Der Benutzeroberfläche die Schaltfläche „Cancel“ hinzuzufügen (und die Beschriftung „Canceled“), ist ganz einfach (siehe Abbildung 12).

Abbildung 12: Hinzufügen der Schaltfläche „Cancel“

<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>

Wenn Sie die Anwendung jetzt ausführen („AsyncCommands4“ im Beispielcode), ist die Schaltfläche „Cancel“ zunächst deaktiviert. Sie wird aktiviert, wenn Sie auf die Schaltfläche „Go“ klicken, und bleibt bis zum Abschluss der Operation aktiviert (unabhängig davon, ob sie erfolgreich abgeschlossen wird, fehlschlägt oder abgebrochen wird). Jetzt haben Sie eine vollständige Benutzeroberfläche für eine asynchrone Operation.

Eine einfache Arbeitswarteschlange

Bis jetzt habe ich mich mit einer Benutzeroberfläche beschäftigt, in der jeweils nur eine Operation ausgeführt wird. In vielen Fällen ist nicht mehr erforderlich, aber manchmal müssen mehrere asynchrone Operationen gestartet werden. Meiner Ansicht nach haben wir in der Community noch keine wirklich gute Benutzeroberfläche für die Verarbeitung mehrerer asynchroner Operationen entwickelt. Zwei gängige Methoden verwenden eine Arbeitswarteschlange oder ein Benachrichtigungssystem, aber keine der beiden Lösungen ist ideal.

Eine Arbeitswarteschlange zeigt alle asynchronen Operationen in einer Sammlung an. Der Benutzer erhält so einen sehr guten Einblick und maximale Kontrolle, aber in der Regel ist diese Lösung zu komplex für den typischen Endbenutzer. Bei einem Benachrichtigungssystem werden die Operationen während der Ausführung ausgeblendet. Wenn eine Operation fehlschlägt (oder auch, wenn eine Operation erfolgreich abgeschlossen wird), wird eine Benachrichtigung angezeigt. Ein Benachrichtigungssystem ist benutzerfreundlicher, bietet jedoch nicht den vollständigen Einblick und die Leistungsfähigkeit der Arbeitswarteschlange (es ist beispielsweise schwierig, einem benachrichtigungsbasierten System Abbrüche hinzuzufügen). Eine ideale Benutzeroberfläche für mehrere asynchrone Operationen habe ich noch nicht entdeckt.

Allerdings kann der Beispielcode jetzt ohne große Probleme erweitert werden, um ein Szenario mit mehreren Operationen zu unterstützen. Im vorhandenen Code sind sowohl die Schaltfläche „Go“ als auch die Schaltfläche „Cancel“ konzeptionell mit einer einzelnen asynchronen Operation verbunden. In der neuen Benutzeroberfläche wird die Schaltfläche „Go“ so geändert, dass sie eine neue asynchrone Operation startet und sie zur Liste der Operationen hinzufügt. Das bedeutet, dass die Schaltfläche „Go“ jetzt eigentlich synchron ist. Ich habe der Lösung einen einfachen (synchronen) „DelegateCommand“ hinzugefügt, und das „ViewModel“ und die XAML können jetzt aktualisiert werden (siehe Abbildung 13 und Abbildung 14).

Abbildung 13: „ViewModel“ für mehrere Befehle

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; }
}

Abbildung 14: XAML für mehrere Befehle

<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>

Dieser Code entspricht dem Projekt „AsyncCommandsWithQueue“ im Beispielcode. Wenn der Benutzer auf die Schaltfläche „Go“ klickt, wird ein neuer AsyncCommand-Befehl erstellt und von einem untergeordneten „ViewModel“ (CountUrlBytesViewModel) umschlossen. Diese untergeordnete ViewModel-Instanz wird dann der Operationsliste hinzugefügt. Alles, was mit dieser bestimmten Operation verknüpft ist (die verschiedenen Beschriftungen und die Schaltfläche „Cancel“), wird in einer Datenvorlage für die Arbeitswarteschlange angezeigt. Ich habe auch eine einfache X-Schaltfläche hinzufügt, die das Element aus der Warteschlange entfernt.

Es ist eine sehr einfache Arbeitswarteschlange, und ich habe einige Annahmen hinsichtlich des Entwurfs zu Grunde gelegt. Wird beispielsweise eine Operation aus der Warteschlange entfernt, wird sie nicht automatisch abgebrochen. Wenn Sie beginnen, mit mehreren asynchronen Operationen zu arbeiten, empfehle ich Ihnen, sich auf jeden Fall diese zusätzlichen Fragen zu stellen:

  1. Woher weiß der Benutzer, welche Benachrichtigung oder welches Arbeitselement für welche Operation gilt? (Beispielsweise enthält der Auslastungsindikator in diesem Arbeitswarteschlangenbeispiel die URL, die heruntergeladen wird.)
  2. Muss der Benutzer jedes Ergebnis kennen? (Es kann beispielsweise ausreichen, wenn der Benutzer nur bei Fehlern benachrichtigt wird oder wenn erfolgreiche Operationen automatisch aus der Arbeitswarteschlange entfernt werden.)

Zusammenfassung

Eine universelle Lösung für einen asynchronen Befehl, die alle Anforderungen erfüllt, gibt es nicht – noch nicht. In der Entwicklercommunity werden asynchrone Benutzeroberflächenmuster noch erforscht. In diesem Artikel möchte ich zeigen, wie asynchrone Befehle im Kontext einer MVVM-Anwendung zu sehen sind, insbesondere, wenn die Probleme mit der Benutzeroberfläche berücksichtigt werden, die es zu lösen gilt, wenn die Benutzeroberfläche asynchron wird. Behalten Sie jedoch im Hinterkopf, dass die Muster aus diesem Artikel und der Beispielcode nur Muster sind und an die Anforderungen der Anwendung angepasst werden müssen.

Besonders bei mehreren asynchronen Operationen gibt es keine perfekte Lösung. Arbeitswarteschlangen und Benachrichtigungen haben Nachteile. Ich glaube, eine universelle Benutzeroberfläche muss noch entwickelt werden. Da mehr Benutzeroberflächen synchron werden, werden viel mehr Entwickler über dieses Problem nachdenken. Vielleicht stehen wir kurz vor einem revolutionärem Durchbruch. Denken Sie über dieses Problem nach. Vielleicht werden Sie der Entdecker einer neuen Benutzeroberfläche sein.

In der Zwischenzeit müssen Sie sich behelfen. In diesem Artikel habe ich mit den einfachsten Implementierungen eines asynchronen ICommand-Befehls begonnen und nach und nach Features hinzugefügt, bis das Ergebnis sich schließlich für die meisten modernen Anwendungen eignet. Die Komponenten des Ergebnisses können zudem vollständig getestet werden. Da die async-void-Methode „ICommand.Execute“ nur die Methode „IAsyncCommand.ExecuteAsync“ aufruft, die das Task-Objekt zurückgibt, können Sie „ExecuteAsync“ direkt in den Komponententests verwenden.

In meinem letzten Artikel habe ich „NotifyTaskCompletion<T>“ entwickelt, einen an Daten bindenden Wrapper für „Task<T>“. In diesem Artikel habe ich gezeigt, wie eine Art von „AsyncCommand<T>“ entwickelt werden kann, eine asynchrone ICommand-Implementierung. In meinem nächsten Artikel geht es um asynchrone Dienste. Denken Sie daran, dass asynchrone MVVM-Muster noch recht neu sind. Sie können ruhig von ihnen abweichen und Ihre eigenen Lösungen ändern.

Stephen Cleary lebt als Ehemann, Vater und Entwickler in den USA im Norden von Michigan. Seit 16 Jahren beschäftigt er sich mit Multithreading und asynchroner Programmierung und nutzt die async-Unterstützung in Microsoft .NET Framework seit der ersten CTP-Version. Seine Homepage und seinen Blog finden Sie unter stephencleary.com.

Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: James McCaffrey und Stephen Toub