async/await und Model-View-ViewModel (MVVM)

7. November 2013

Mit dem .NET-Framework 4.5 wurden neue Framework-Komponenten sowie bestehende, die in Vorgängerversionen synchron waren, asynchron mithilfe der Task Parallel Library (TPL) abgebildet. Dies hat (insbesondere im Rahmen von WinRT) zur Folge, dass für viele Aufgaben asynchrone Aufrufe verwendet werden müssen. Somit hat der Entwickler keine Alternative, als Aufgaben asynchron mit async/await umzusetzen. Wenn diese Aufgaben mit synchronen Methoden realisiert werden würden, riskiert der Entwickler Deadlocks oder das Blockieren der UI.

Das async/await-Konzept stellt allerdings nur einen Sprachaufsatz von C# auf die Common Language Specification (CLS) dar. Die Schlüsselwörter async und await werden also nicht etwa in IL-Code übersetzt, sondern zusammen mit ihrem Code-Kontext zur Übersetzungszeit vom Compiler als ein Zustandsautomat in IL-Code abgebildet. Die zugrunde liegenden Sprachbestandteile der CLS sind aber nach wie vor für die synchrone Programmierung konzipiert. So können etwa Konstruktoren und Eigenschaften nicht asynchron implementiert werden. Dies hat weitreichende Auswirkungen auf bestehende Konzepte wie MVVM, welches Gegenstand dieses Beitrags ist.

Die Eigenschaften eines ViewModels sind die Grundlage für Data Bindings, welche für einen synchronen Programmfluss konzipiert sind. Mit dem Aufkommen von async/await müssen diese jedoch auch häufiger asynchron ermittelte Informationen zurückliefern. Folglich steht der Entwickler vor der Herausforderung, asynchron bezogene Informationen synchron im ViewModel zur Verfügung zu stellen. Da das synchrone Abwarten asynchroner Aufgaben nicht in Frage kommt, ist dafür eine andere Strategie erforderlich. Um diese zu spezifizieren ist folgende Frage zu stellen:

Wann wird welche Information benötigt?

Erforderliche Informationen

Es gibt Informationen, die für die Funktion eines ViewModels unerlässlich und somit zwingend erforderlich sind. Solche erforderlichen Informationen, die synchron bezogen werden können, sollten nach wie vor im Konstruktor ermittelt werden. Zwingend erforderliche Informationen, die asynchron bezogen werden müssen, sollten hingegen über eine Factory-Methode ermittelt und erzwungen werden. Eine Factory-Methode ist die einzige Umsetzungsmöglichkeit dafür, da sie zum Erzeugen einer Instanz selbst asynchron sein kann, wodurch die nötigen Informationen asynchron bezogen werden können. An einem Beispiel könnte das wie folgt aussehen:

public class ViewModel {
    // Privater Konstruktor um Factory-Methode zur Erzeugung zu erzwingen
    private ViewModel() { }

    // Eine Eigenschaft, die asynchron bezogene Informationen enthalten soll
    public string RequiredAsyncProperty { get; set; }

    // Die Factorymethode, die eine Instanz erstellt und
// für die Befüllung der Eigenschaft sorgt
public static async Task<ViewModel> CreateAsync() { var viewModel = new ViewModel { RequiredAsyncProperty = await AsyncDataSource.GetSomeDataAsync() }; return viewModel; } } // Initialisierung vom View oder an anderer Stelle im UI-Kontext ViewModel.CreateAsync().ContinueWith( t => { this.DataContext = t.Result; }, // Da CreateAsync evtl. den Kontext wechselt, soll hier sichergestellt werden, dass
// der UI-Kontext zum Zuweisen des DataContext verwendet wird.
TaskScheduler.FromCurrentSynchronizationContext());

Nachträglich geladene/optionale Informationen

Andererseits sollen aber auch Informationen nachträglich geladen werden, die nicht zwingend erforderlich sind bzw. für den Benutzer nicht essentiell sind, die oft verändert werden oder nur in einem spezifischen Zustand verwendet werden. Solche Informationen können direkt auf Anfrage einer Eigenschaft bezogen werden:

// Das Feld für den asynchron zurückgelieferten Wert
private string onDemandPropertyValue;

// Das Feld für den asynchronen Vorgang
private Task<string> onDemandPropertyValueTask;
public string OnDemandProperty { get { // Doublechecking um zu vermeiden, dass eine unnötige Memorybarrier den Thread
// aufhält.
if (this.onDemandPropertyValueTask == null) { lock (this.lockingObject) { if (this.onDemandPropertyValueTask == null) { this.onDemandPropertyValueTask = AsyncDataSource.GetSomeDataAsync(); this.onDemandPropertyValueTask.ContinueWith(t => { lock (this.lockingObject) { // Falls der Task verändert wurde, ist diese Fortsetzung nicht
// mehr aktuell und das gelieferte Ergebnis wäre veraltet,
// insbesondere falls dieser Aufruf sogar länger gedauert hat
// als der nachfolgende.
if (this.onDemandPropertyValueTask != t) return; // Nach Abschluss des Vorgangs wir der Wert bei Erfolg gesetzt
// und die Wertänderung mitgeteilt.
if (t.IsCompleted && !t.IsFaulted) { this.onDemandPropertyValue = t.Result; this.NotifyPropertyChanged(() => this.OnDemandProperty); } } }, TaskScheduler.FromCurrentSynchronizationContext()); } } } // Das eigentliche Zurückgeben des Wertes, welcher solange er nicht bezogen wurde,
// dem default des Typen entspricht.
return this.onDemandPropertyValue; } }

Da nach der Anfrage eine Referenz auf den Task beibehalten bleibt, können Abhängigkeiten zu anderen asynchron bezogenen Informationen mithilfe dieser abgebildet werden. Allerdings kann auf diese Weise die Reihenfolge, in der Informationen bezogen werden, nur schwer oder gar nicht beeinflusst werden. Folglich nimmt die Komplexität schnell zu (und die Lesbarkeit ab), wenn Abhängigkeiten zwischen mehreren Eigenschaften abgebildet werden sollen. Auch asynchrone Vorgänge in Convertern lassen sich auf diese Art und Weise nicht abbilden. Praktisch haben sich deshalb eine Kapselung des Aufrufes und/oder ein explizites sequentielles Ermitteln mehrerer voneinander abhängiger Werte als nützlich erwiesen.

Kapselung und Reihenfolge

Ein Konzept, das sich in Projekten der SDX als nützlich erwiesen hat, ist der von uns entwickelte AsyncData-Typ. Dabei werden der Ergebniswert und der Zustand einer asynchronen Aktion passiv in eine Struktur gekapselt. Der Wert einer so gekapselten Eigenschaft wird von einem sequentiellen Aufruf zum Beziehen des jeweiligen Wertes schrittweise aktualisiert, indem die komplette Struktur je nach Zustand der Aufgabe ausgetauscht wird. Dazu folgendes Beispiel:

// Eine asynchron befüllte Eigenschaft, von der 'DependantAsyncProperty' abhängig ist
public AsyncData<string> AsyncLoadedProperty {
    get { return this.asyncLoadedProperty; }
    set {
        this.asyncLoadedProperty = value;
        this.OnPropertyChanged("AsyncLoadedProperty");
        this.OnPropertyChanged("DependantAsyncProperty");
    }
}

// Eine weitere asynchron befüllte Eigenschaft, von der 'DependantAsyncProperty' abhängig
// ist
public AsyncData<string> OtherAsyncLoadedProperty { /* ... äquivalent ... */ } // Die Initialisierungs- bzw. Aktualisierungsmethode, die die Werte in Reihenfolge neu
// bezieht und Fehler behandelt und mitteilt
public async Task InitializeAsyncDataAsync() { // AsyncLoadedProperty hat hier bereits einen Wert oder ist im IsLoading-Zustand try { this.AsyncLoadedProperty = await AsyncDataSource.GetSomeDataAsync(); // AsyncLoadedProperty hat hier einen Wert und ist im IsSucceeded-Zustand } catch (Exception) { this.AsyncLoadedProperty = new AsyncData<string>(null, true); // AsyncLoadedProperty hat hier null als Wert und ist im IsFailed-Zustand } try { this.OtherAsyncLoadedProperty = await AsyncDataSource.GetSomeDataAsync(); } catch (Exception) { this.OtherAsyncLoadedProperty = new AsyncData<string>(null, true); } } // Eine von AsyncLoadedProperty und OtherAsyncLoadedProperty abhängige Eigenschaft. public AsyncData<string> DependantAsyncProperty { get { var firstValue = this.AsyncLoadedProperty; var secondValue = this.OtherAsyncLoadedProperty; // Ist eine von beiden Eigenschaften ladend, ist auch diese Eigenschaft ladend if (firstValue.IsLoading || secondValue.IsLoading) { return new AsyncData<string>(); } // Ist eine von beiden Eigenschaften fehlgeschlagen, ist auch diese Eigenschaft
/ fehlgeschlagen
if (firstValue.IsFailed || secondValue.IsFailed) { return new AsyncData<string>(null, true); } // Ansonsten wird der von beiden Eigenschaften abhängige Wert bestimmt return string.Format("{0} combined with {1}",
firstValue.Value, secondValue.Value); } }

In der Methode InitializeAsyncDataAsync() werden optionale Informationen in einer spezifischen Reihenfolge ermittelt, Fehler behandelt und ebenso der spezifischen Eigenschaft zugeordnet. Zudem wird hierbei aufgezeigt, wie der Wert einer davon abhängigen optionalen Eigenschaft asynchron ermittelt und signalisiert werden kann. Selbigen Sachverhalt mit dem zuvor aufgezeigten Konzept würde einige Felder und Codezeilen mehr erfordern. Ein Problem stellt hierbei allerdings dar, dass entweder alle Werte aktualisiert werden müssen, sobald es für einen Wert erforderlich ist, oder eine separate Methode benötigt wird. Dafür kann hier allerdings der Wert während der Aktualisierung erhalten bleiben. Für den Fall, dass Logik abgebildet werden soll, sind asynchrone Eigenschaften allerdings in beiden vorangestellten Fällen mit dem Umstand verbunden, dass die Asynchronität berücksichtigt werden muss. Folglich entsteht dadurch nach wie vor Komplexität.

Asynchrone Aufrufe in Convertern

Auch lassen sich mit dem AsyncData-Konzept keine asynchronen Converter abbilden. Hierfür ist ein aktiver Container erforderlich, der über den Zustand eines Tasks Auskunft gibt. Im Nito Projekt findet sich dazu ein interessanter Ansatz: der TaskCompletionNotifier (dort NotifyTaskCompletion genannt). Der TaskCompletionNotifier implementiert INotifyPropertyChanged und teilt der UI mit, wenn ein Task abgeschlossen wurde, woraufhin jene das Ergebnis des Tasks weiternutzen kann. Dies wird im nachfolgenden Beispiel anhand eines Converters, der asynchrone Aufrufe verwendet, und anhand eines Bindings unter Nutzung einer Instanz dieses Converters veranschaulicht:

// Beispiel für einen Converter der einen asynchronen Aufruf durchführt.
public class TaskCompletionNotifierConverter : IValueConverter {
    public object Convert(object value, Type targetType, object parameter, 
CultureInfo culture) { // Kapseln des asynchronen Aufrufs in einen Notifier return new TaskCompletionNotifier<string>(
AsyncDataSource.GetSomeDataAsync(value as string)); } /* ... */ }
<!-- Ein Binding dessen Converter den Wert asynchron verändert. -->
<TextBlock Text="{Binding Result}" DataContext="{Binding AValueToConvertAsync, 
Converter={StaticResource TaskCompletionNotifierConverter}}"
/>

Dieser Container kann auch zum Beziehen von Informationen außerhalb von Convertern eingesetzt werden. Aufgrund der Kapselung sind allerdings Abhängigkeiten schwer abbildbar. Ferner ist auf diese Weise auch die Fehlerbehandlung eher umständlich und explizit vorzunehmen. Ich empfehle daher, diesen Container vor allem in Convertern einzusetzen. Dabei ist allerdings zu bedenken, dass es sich schwierig gestaltet, in diesen Converter Abhängigkeiten oder Instanzen von Services zu injizieren.

Fazit

Es gibt verschiedene Strategien, um auch im Rahmen synchroner MVVM-Bindings asynchrone Aufrufe zu verwenden, ohne auf synchrones (blockierendes) Warten zurückgreifen zu müssen. Mit diesen kann letztlich eine technisch saubere Umsetzung gewährleistet werden.

Jede dieser Strategien stellt einen Lösungsansatz für eine spezifische Anforderung (OnDemand, Converter, Abhängigkeiten) dar. Somit sind diese Strategien zwar Konkurrent zueinander, aber nicht vollständig untereinander austauschbar. Solche oder ähnlich geartete Ansätze bilden letztlich das Fundament einer MVVM-Anwendung mit async/await.

Ein Beispielprojekt, in welchem die zuvor beschriebenen Ansätze konsolidiert sind, findet sich hier.