MVVM

Multithreading und Verteilung in MVVM-Anwendungen

Laurent Bugnion

Codebeispiel herunterladen

Vor ungefähr einem Jahr habe ich eine Artikelreihe zum MVVM-Muster (Model-View-ViewModel) für die MSDN Magazine-Webseite begonnen (die Beiträge sind unter is.gd/mvvmmsdn abrufbar). In diesen Artikeln wird erläutert, wie die Komponenten des MVVM Light Toolkits mithilfe dieses Musters zum Erstellen von lose gekoppelten Anwendungen eingesetzt werden. Ich gehe dort auf die Dependency Injection(DI)- und Inversion of Control(IOC)-Containermuster ein (einschließlich den Container „SimpleIoc“ von MVVM Light), biete eine Einführung in den Messenger und erläutere verschiedene Anzeigedienste (wie Navigation, Dialog usw.). Ich zeige auch, wie Entwurfszeitdaten erstellt werden, um die Nutzung von visuellen Designern wie Blend zu optimieren, und ich gehe auf die RelayCommand- und EventToCommand-Komponenten ein, die anstatt von Ereignishandlern verwendet werden können, um die Beziehung zwischen der Ansicht (View) und ihrem Ansichtsmodell (ViewModel) etwas zu entkoppeln.

In diesem Artikel möchte ich ein Szenario behandeln, das in modernen Clientanwendungen sehr häufig auftritt: die Verarbeitung mehrerer Threads und deren Kommunikation untereinander. Multithreading wird in modernen Anwendungsframeworks wie Windows 8, Windows Phone, Windows Presentation Foundation (WPF), Silverlight und anderen immer wichtiger. Auf jeder dieser Plattformen – auch auf den weniger leistungsfähigen – ist es erforderlich, Hintergrundthreads zu starten und zu verwalten. Man könnte sogar argumentieren, dass Multithreading auf kleinen Plattformen mit weniger Rechenleistung noch wichtiger ist, um eine verbesserte Benutzererfahrung bieten zu können.

Die Windows Phone-Plattform ist ein gutes Beispiel: In der ersten Version (Windows Phone 7) verliefen Bildläufe in langen Listen oft nicht fließend, vor allem, wenn die Elementvorlagen Bilder enthielten. In späteren Versionen wurde die Decodierung von Bildern sowie einiger Animationen an einen dedizierten Hintergrundthread übergeben. Auf diese Weise beeinträchtigt der Ladevorgang für ein Bild nicht mehr den Hauptthread, und der Bildlauf verläuft fließender.

Dieses Beispiel veranschaulicht wichtige Konzepte, die ich in diesem Artikel untersuchen werde. Zunächst werde ich erläutern, wie Multithreading in XAML-basierten Anwendungen im Allgemeinen funktioniert.

Einfach gesagt, ist ein Thread eine kleine Ausführungseinheit einer Anwendung. Jede Anwendung hat mindestens einen Thread, der als Hauptthread bezeichnet wird. Dies ist der Thread, der vom Betriebssystem gestartet wird, wenn die Hauptmethode der Anwendung beim Start aufgerufen wird. Dieses Szenario gilt für so gut wie alle unterstützten Plattformen: für WPF, das auf leistungsfähigen Computern ausgeführt wird, genauso wie für Windows Phone-basierte Geräte mit eingeschränkter Rechenleistung.

Wenn eine Methode aufgerufen wird, wird die zugehörige Operation einer Warteschlange hinzugefügt. Die Operationen werden entsprechend der Reihenfolge des Hinzufügens zur Warteschlange sequenziell ausgeführt (es ist aber möglich, die Reihenfolge der Ausführung zu ändern, indem Prioritäten zugewiesen werden). Das für die Verwaltung der Warteschlange verantwortliche Objekt wird als Threadverteiler bezeichnet. Das Objekt ist eine Instanz der Dispatcher-Klasse in WPF, Silverlight und Windows Phone. In Windows 8 heißt das Verteilerobjekt „CoreDispatcher“ und verwendet eine etwas abweichende API.

Je nach Anwendung können neue Threads explizit im Code oder implizit durch Bibliotheken oder das Betriebssystem gestartet werden. In der Regel besteht der Zweck eines neuen Threads darin, eine Operation auszuführen (oder auf das Ergebnis einer Operation zu warten), ohne dabei den Rest der Anwendung zu blockieren. Dies kann auf rechenintensive Operationen, E/A-Operationen und Ähnliches zutreffen. Daher greifen moderne Anwendungen zunehmend auf Multithreading zurück, denn auch die Anforderungen an die Benutzererfahrung steigen immer mehr. Je komplexer Anwendungen sind, desto mehr Threads müssen sie starten. Ein gutes Beispiel für diesen Trend ist das Windows-Runtime-Framework, das für die Windows Store-Apps verwendet wird. In diesen modernen Clientanwendungen sind asynchrone Operationen (Operationen, die in Hintergrundthreads ausgeführt werden) sehr verbreitet. Jeder Dateizugriff in Windows 8 ist zum Beispiel jetzt eine asynchrone Operation. So wird eine Datei in WPF (synchron) gelesen:

public string ReadFile(FileInfo file)
{
  using (var reader = new StreamReader(file.FullName))
  {
    return reader.ReadToEnd();
  }
}

Und so wird die äquivalente (asynchrone) Operation in Windows 8 ausgeführt:

public async Task<string> ReadFile(IStorageFile file)
{
  var content = await FileIO.ReadTextAsync(file);
  return content;
}

Beachten Sie die await- und async-Schlüsselwörter in der Windows 8-Version. Durch sie kann auf Rückrufe in asynchronen Operationen verzichtet werden, und sie tragen dazu bei, dass der Code leichter zu lesen ist. Sie werden hier benötigt, da die Dateioperation asynchron ist. Die WPF-Version ist dagegen synchron, was die Gefahr birgt, dass der Hauptthread blockiert wird, wenn die zu lesende Datei lang ist. Dies kann dazu führen, dass Animationen nicht einwandfrei dargestellt werden oder die Benutzeroberfläche nicht richtig aktualisiert wird, was die Benutzererfahrung verschlechtert.

Lange Operationen in Ihren Anwendungen sollten daher in einem Hintergrundthread verarbeitet werden, wenn sie die Leistung der Benutzeroberfläche beeinträchtigen könnten. In WPF, Silverlight und Windows Phone initiiert der Code aus Abbildung 1 zum Beispiel eine Hintergrundoperation, die eine lange Schleife ausführt. In jeder Schleife wird der Thread kurzzeitig angehalten, damit die anderen Threads ihre eigenen Operationen verarbeiten können.

Abbildung 1: Asynchrone Operation im Microsoft .NET-Framework

public void DoSomethingAsynchronous()
{
  var loopIndex = 0;
  ThreadPool.QueueUserWorkItem(
    o =>
    {
      // This is a background operation!
      while (_condition)
      {
        // Do something
        // ...
        // Sleep for a while
        Thread.Sleep(500);
      }
  });
}

Kommunikation der Threads untereinander

Wenn ein Thread mit einem anderen Thread kommunizieren muss, müssen einige Vorsichtsmaßnahmen getroffen werden. Ich ändere zum Beispiel den Code in Abbildung 1, damit dem Benutzer bei jeder Schleife eine Statusmeldung angezeigt wird. Hierzu füge ich der while-Schleife ganz einfach eine Codezeile hinzu, mit der die Text-Eigenschaft eines StatusTextBlock-Steuerelements in XAML festgelegt wird:

while (_condition)
{
  // Do something
  // Notify user
  StatusTextBlock.Text = 
    string.Format("Loop # {0}", loopIndex++);
  // Sleep for a while
  Thread.Sleep(500);
}

Die Anwendung „SimpleMultiThreading“, die für diesen Artikel verwendet wird, veranschaulicht dieses Beispiel. Wenn Sie die Anwendung über die Schaltfläche „Start (crashes the app)“ ausführen, stürzt die Anwendung tatsächlich ab, wie es die Bezeichnung schon andeutet. Was ist also passiert? Wenn ein Objekt erstellt wird, gehört es zu dem Thread, in dem die Konstruktormethode aufgerufen wurde. Bei Benutzeroberflächenelementen werden die Objekte vom XAML-Parser erstellt, wenn das XAML-Dokument geladen wird. Dies alles wird im Hauptthread verarbeitet. Daher gehören alle Benutzeroberflächenelemente zum Hauptthread, der oft auch als Benutzeroberflächenthread bezeichnet wird. Wenn der Hintergrundthread im vorigen Code versucht, die Text-Eigenschaft von „StatusTextBlock“ zu ändern, erzeugt dies einen unzulässigen threadübergreifenden Zugriff. Als Konsequenz wird eine Ausnahme ausgelöst. Dies kann veranschaulicht werden, wenn der Code in einem Debugger ausgeführt wird. In Abbildung 2 ist das Ausnahmedialogfeld zu sehen. Beachten Sie die Meldung unter „Additional information“, in der die Ursache des Problems angegeben wird.

Cross-Thread Exception Dialog
Abbildung 2: Ausnahmedialogfeld für threadübergreifenden Zugriff

Damit dieser Code funktioniert, muss der Hintergrundthread die Operation in die Warteschlange des Hauptthreads stellen, indem er seinen Verteiler kontaktiert. Praktischerweise ist jedes „FrameworkElement“ auch ein „DispatcherObject“, wie in der .NET-Klassenhierarchie in Abbildung 3 zu sehen. Jedes „DispatcherObject“ macht eine Dispatcher-Eigenschaft verfügbar, die Zugriff auf seinen Besitzerverteiler ermöglicht. Daher kann der Code wie in Abbildung 4 gezeigt geändert werden.

Window Class Hierarchy
Abbildung 3: Windows-Klassenhierarchie

Abbildung 4: Verteilen des Aufrufs an den Benutzeroberflächenthread

while (_condition)
{
  // Do something
  Dispatcher.BeginInvoke(
    (Action)(() =>
    {
      // Notify user
      StatusTextBlock.Text = 
        string.Format("Loop # {0}", loopIndex++);
    }));
  // Sleep for a while
  Thread.Sleep(500);
}

Verteilung in MVVM-Anwendungen

Wenn eine Hintergrundoperation durch ein ViewModel ausgeführt wird, verhalten sich die Dinge etwas anders. In der Regel erben ViewModels nicht vom DispatcherObject. Sie sind Plain Old CLR Objects (POCOs), die die INotifyPropertyChanged-Schnittstelle implementieren. In Abbildung 5 ist beispielsweise ein ViewModel zu sehen, das von der MVVM Light-Klasse "ViewModelBase" abgeleitet wird. Wie bei MVVM üblich, füge ich eine überwachbare Eigenschaft namens "Status" hinzu, die das PropertyChanged-Ereignis auslöst. Dann versuche ich diese Eigenschaft über den Hintergrundthreadcode auf eine Informationsmeldung festzulegen.

Abbildung 5: Aktualisieren einer gebundenen Eigenschaft im ViewModel

public class MainViewModel : ViewModelBase
{
  public const string StatusPropertyName = "Status";
  private bool _condition = true;
  private RelayCommand _startSuccessCommand;
  private string _status;
  public RelayCommand StartSuccessCommand
  {
    get
    {
      return _startSuccessCommand
        ?? (_startSuccessCommand = new RelayCommand(
          () =>
          {
            var loopIndex = 0;
            ThreadPool.QueueUserWorkItem(
              o =>
              {
                // This is a background operation!
                while (_condition)
                {
                  // Do something
                  DispatcherHelper.CheckBeginInvokeOnUI(
                    () =>
                    {
                      // Dispatch back to the main thread
                      Status = string.Format("Loop # {0}", 
                         loopIndex++);
                    });
                  // Sleep for a while
                  Thread.Sleep(500);
                }
              });
          }));
    }
  }
  public string Status
  {
    get
    {
      return _status;
    }
    set
    {
      Set(StatusPropertyName, ref _status, value);
    }
  }
}

Das Ausführen dieses Codes in Windows Phone oder Silverlight funktioniert so lange gut, bis ich versuche, die Status-Eigenschaft im XAML-Front-End an einen TextBlock zu binden. Das erneute Ausführen der Operation führt dann zum Absturz der Anwendung. Wie zuvor wird eine Ausnahme ausgelöst, sobald der Hintergrundthread versucht, auf ein Element eines anderen Threads zuzugreifen. Diese Ausnahme tritt auch dann auf, wenn der Zugriff über Datenbindung erfolgt.

In WPF verhalten sich die Dinge jedoch anders, und der in Abbildung 5 gezeigte Code funktioniert auch dann, wenn die Status-Eigenschaft eine Datenbindung zu einem TextBlock aufweist. Dies liegt daran, dass WPF das PropertyChanged-Ereignis im Gegensatz zu allen anderen XAML-Frameworks automatisch an den Hauptthread verteilt. In allen anderen Frameworks ist eine Verteilungslösung erforderlich. Genauer gesagt wird ein System benötigt, das den Aufruf nur dann verteilt, wenn dies notwendig ist. Um den ViewModel-Code auch in WPF und anderen Frameworks verwenden zu können, wäre es sehr praktisch, wenn Sie sich nicht um die Verteilung zu kümmern bräuchten, sondern ein Objekt hätten, das dies automatisch vornimmt

Da das ViewModel ein POCO ist, hat es keinen Zugriff auf die Dispatcher-Eigenschaft, sodass wir eine andere Möglichkeit benötigen, um den Zugriff auf den Hauptthread zu ermöglichen und die Operation in die Warteschlange einzureihen. Dies ist die Aufgabe der MVVM Light-Komponente "DispatcherHelper". Diese Klasse speichert den Hauptthread des Verteilers in einer statischen Eigenschaft und macht einige Hilfsmethoden verfügbar, damit auf ihn einfach und konsistent zugegriffen werden kann. Die Klasse muss im Hauptthread initialisiert werden, damit sie funktioniert. Idealerweise sollte dies frühzeitig im Lebenszyklus der Anwendung erfolgen, damit die Features von Beginn an zugänglich sind. In einer MVVM Light-Anwendung wird die DispatcherHelper-Klasse in der Regel in "App.xaml.cs" initialisiert. Dies ist die Datei, die die Startklasse der Anwendung definiert. In Windows Phone rufen Sie "Dispatcher­Helper.Initialize" in der InitializePhoneApplication-Methode auf, kurz nachdem der Hauptframe der Anwendung erstellt wurde. In WPF wird die Klasse im App-Konstruktor initialisiert. In Windows 8 rufen Sie die Initialize-Methode in "OnLaunched" auf, kurz nachdem das Fenster aktiviert wurde.

Wenn der Aufruf der DispatcherHelper.Initialize-Methode abgeschlossen ist, enthält die UIDispatcher-Eigenschaft der DispatcherHelper-Klasse einen Verweis auf den Verteiler des Hauptthreads. Die Eigenschaft wird selten direkt verwendet, dies ist aber grundsätzlich möglich. Stattdessen wird jedoch empfohlen, die CheckBeginInvokeOnUi-Methode zu verwenden. Diese Methode verwendet einen Delegaten als Parameter. In der Regel verwenden Sie einen Lambda-Ausdruck wie in Abbildung 6 gezeigt, es kann jedoch auch eine andere benannte Methode verwendet werden.

Abbildung 6: Verwenden von "DispatcherHelper", um den Absturz der Anwendung zu verhindern

while (_condition)
{
  // Do something
  DispatcherHelper.CheckBeginInvokeOnUI(
    () =>
    {
      // Dispatch back to the main thread
      Status = string.Format("Loop # {0}", loopIndex++);
    });
  // Sleep for a while
  Thread.Sleep(500);
}

Wie es der Name schon andeutet, führt diese Methode zunächst eine Überprüfung aus. Wenn der Aufrufer der Methode bereits im Hauptthread ausgeführt wird, ist keine Verteilung notwendig. In diesem Fall wird der Delegat unmittelbar und direkt im Hauptthread ausgeführt. Wenn der Aufrufer jedoch in einem Hintergrundthread ausgeführt wird, findet eine Verteilung statt.

Da die Methode vor der Verteilung eine Überprüfung durchführt, kann sich der Aufrufer darauf verlassen, dass der Code immer den optimalen Aufruf verwendet. Dies ist vor allen Dingen hilfreich, wenn Sie plattformübergreifenden Code schreiben: Das Multithreading könnte dann mit kleinen Unterschieden auf verschiedenen Plattformen funktionieren. In unserem Beispiel kann der in Abbildung 6 gezeigte ViewModel-Code überall wiederverwendet werden, ohne dass die Zeile mit der Festlegung der Status-Eigenschaft verändert werden muss.

Außerdem abstrahiert die DispatcherHelper-Klasse die Unterschiede in der Verteiler-API zwischen den XAML-Plattformen. In Windows 8 sind die RunAsync-Methode und die HasThreadAccess-Eigenschaft die Hauptmember von CoreDispatcher. In anderen XAML-Frameworks wird jedoch die BeginInvoke-Methode bzw. die CheckAccess-Methode verwendet. Wenn Sie die DispatcherHelper-Klasse verwenden, müssen Sie sich um diese Unterschiede keine Gedanken machen, und können den Code ganz einfach wieder verwenden.

Verteilung in der Praxis: Sensoren

Ich veranschauliche die Verwendung der DispatcherHelper-Klasse, indem ich eine Windows Phone-Anwendung mit einem Kompasssensor erstelle.

Der Beispielcode, der begleitend zu diesem Artikel zur Verfügung steht, enthält eine Entwurfsanwendung mit dem Namen "CompassSample - Start". Wenn Sie diese Anwendung in Visual Studio öffnen, wird der Zugriff vom MainViewModel auf den Kompasssensor in einem Dienst namens "SensorService" gekapselt, der eine Implementierung der ISensorService-Schnittstelle ist. Diese beiden Elemente befinden sich im Model-Ordner.

Das MainViewModel ruft einen Verweis auf den ISensorService-Dienst in seinem Konstruktor ab und registriert jede Kompassänderung mit der SensorService-Methode "RegisterForHeading". Diese Methode erfordert einen Rückruf, der jedes Mal ausgeführt wird, wenn der Sensor eine Änderung in der Ausrichtung des Windows Phone-basierten Geräts meldet. Ersetzen Sie den Standardkonstruktor im MainViewModel durch folgenden Code:

sensorService.RegisterForHeading(
  heading =>
  {
    Heading = string.Format("{0:N1}°", heading);
    Debug.WriteLine(Heading);
  });

Leider gibt es keine Möglichkeit, den Gerätekompass im Windows Phone-Emulator zu simulieren. Um den Code zu testen, müssen Sie die App auf einem physischen Gerät ausführen. Schließen Sie ein Entwicklergerät an, und führen Sie den Code im Debugmodus aus, indem Sie F5 drücken. Beachten Sie die Ausgabekonsole in Visual Studio. Die Ausgabe des Kompasses ist zu sehen. Suchen Sie mit dem Gerät die nördliche Himmelsrichtung, und beobachten Sie, wie sich der Wert ändert.

Als Nächstes binden wir einen TextBlock in XAML an die Heading-Eigenschaft im MainViewModel. Öffnen Sie "MainPage.xaml", und suchen Sie den TextBlock im Bereich "ContentPanel". Ersetzen Sie den Text "Nothing yet" in der Text-Eigenschaft durch "{Binding Heading}". Wenn Sie die Anwendung erneut im Debugmodus ausführen, tritt ein Absturz mit einer ähnlichen Fehlermeldung auf wie zuvor. Dies ist wieder eine threadübergreifende Ausnahme.

Der Fehler wird ausgelöst, weil der Kompasssensor in einem Hintergrundthread ausgeführt wird. Wenn der Rückrufcode aufgerufen wird, wird er ebenfalls im Hintergrundthread ausgeführt, wie auch der Setter der Heading-Eigenschaft. Da der TextBlock zum Hauptthread gehört, wird die Ausnahme ausgelöst. Auch hier müssen Sie eine "sichere Zone" erstellen, in der die Verteilung der Operationen zum Hauptthread vorgenommen werden kann. Öffnen Sie hierzu die SensorService-Klasse. Das CurrentValueChanged-Ereignis wird von einer Methode namens "CompassCurrentValueChanged" verarbeitet; dort wird auch die Rückrufmethode ausgeführt. Ersetzen Sie diesen Code durch folgenden, der die DispatcherHelper-Klasse verwendet:

void CompassCurrentValueChanged(
  object sender,
  SensorReadingEventArgs<CompassReading> e)
{
  if (_orientationCallback != null)
  {
    DispatcherHelper.CheckBeginInvokeOnUI(
      () => _orientationCallback(e.SensorReading.TrueHeading));
  }
}

Jetzt muss die DispatcherHelper-Komponente initialisiert werden. Öffnen Sie hierzu "App.xaml.cs", und suchen Sie die Methode namens "InitializePhoneApplication". Fügen Sie ganz am Ende der Methode "DispatcherHelper.Initialize();" hinzu. Das Ausführen des Codes erzeugt nun das erwartete Ergebnis: Die Ausrichtung des Windows Phone-basierten Geräts wird korrekt angezeigt.

Beachten Sie, dass nicht alle Sensoren in Windows Phone ihre Ereignisse in einem Hintergrundthread auslösen. Der GeoCoordinateWatcher-Sensor beispielsweise, der die Geolocation des Telefons überwacht, ist bereits so konfiguriert, dass er seine Rückgabe im Hauptthread vornimmt. Wenn Sie die DispatcherHelper-Klasse verwenden, müssen Sie sich darum keine Gedanken machen und können den Rückruf des Hauptthreads immer auf die gleiche Weise aufrufen.

Zusammenfassung

Ich habe erläutert, wie im Microsoft .NET Framework Threads verarbeitet werden, und welche Vorsichtsmaßnahmen getroffen werden müssen, wenn ein Hintergrundthread ein von einem Hauptthread (auch als Benutzeroberflächenthread bezeichnet) erstelltes Objekt ändern möchte. Sie haben erfahren, dass dies ein Absturz verursachen kann, und dass Sie zum Vermeiden eines solchen Absturzes den Verteiler des Hauptthreads für die ordnungsgemäße Verarbeitung der Operation verwenden sollten.

Dann habe ich diese Erkenntnis in eine MVVM-Anwendung übertragen und die DispatcherHelper-Komponente des MVVM Light Toolkit eingeführt. Ich habe veranschaulicht, wie Sie diese Komponente verwenden können, um Probleme bei der Kommunikation mit einem Hintergrundthread zu vermeiden, und wie die Komponente diesen Zugriff optimiert und die Unterschiede zwischen WPF und anderen XAML-basierten Frameworks abstrahiert. Dadurch ermöglicht sie die einfache Wiederverwendung des ViewModel-Codes und erleichtert Ihre Arbeit.

Schließlich habe ich in einem realen Beispiel gezeigt, wie die Dispatcher­Helper-Komponente in einer Windows Phone-Anwendung verwendet werden kann, um Probleme mit bestimmten Sensoren zu vermeiden, die ihre Ereignisse in einem Hintergrundthread auslösen.

Im nächsten Artikel werde ich mich näher mit der Messenger-Komponente von MVVM Light beschäftigen und zeigen, wie sie für eine einfache, wirklich entkoppelte Kommunikation zwischen Objekten verwendet werden kann, ohne dass diese voneinander wissen müssen.

Laurent Bugnion ist Senior Director beim Microsoft-Partner IdentityMine Inc., der Technologien wie Windows Presentation Foundation, Silverlight, Pixelsense, Kinect, Windows 8, Windows Phone und UX einsetzt. Er lebt in Zürich in der Schweiz. Er ist außerdem Microsoft MVP und Microsoft Regional Director.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Thomas Petchel (Microsoft)