MVVM

Nutzen von Windows 8-Features mit MVVM

Brent Edwards

Windows 8 bietet viele neue Features, die Entwickler zum Erstellen von überzeugenden Anwendungen und einer umfassenden Benutzererfahrung nutzen können. Diese Funktionen sind leider nicht immer ideal für Komponententests. Mit Features wie beispielsweise Freigaben und sekundären Kacheln wird die App interaktiver und komfortabel, ist aber weniger gut zu testen.

In diesem Artikel betrachte ich verschiedene Methoden, mit denen eine Anwendung Features wie Freigabe, Einstellungen, sekundäre Kacheln, Anwendungseinstellungen und Anwendungsspeicher nutzen kann. Ich verwende das Model-View-ViewModel(MVVM)-Muster, Abhängigkeitsinjektion und Abstraktion, um Ihnen zu zeigen, wie Sie diese Features nutzen, während die Darstellungsschicht weiterhin für Komponententests geeignet bleibt.

Über die Beispiel-App

Zur Veranschaulichung der Konzepte, die ich in diesem Artikel erläutere, habe ich eine Windows Store-Beispiel-App mit MVVM geschrieben, mit der die Benutzer Blogbeiträge aus dem RSS-Feed ihrer bevorzugten Blogs anzeigen können. Die App verdeutlicht Folgendes:

  • Verwenden des Charms „Teilen“, um Informationen über einen Blogbeitrag für andere Apps freizugeben
  • Verwenden des Charms „Einstellungen“, um zu ändern, welche Blogs der Benutzer lesen möchte
  • Verwenden von sekundären Kacheln, um einen bevorzugten Blogbeitrag zum späteren Lesen an die Startseite zu heften
  • Verwenden von Roamingeinstellungen zum Speichern bevorzugter Blogs, um sie geräteübergreifend anzuzeigen

Zusätzlich zu der Beispiel-App habe ich die spezifische Windows 8-Funktionalität, auf die ich hier eingehe, in eine Open Source-Bibliothek namens Charmed abstrahiert. Sie können Charmed als Hilfsbibliothek und einfach als Referenz verwenden. Charmed hat das Ziel, als plattformübergreifende MVVM-Hilfsbibliothek für Windows 8 und Windows Phone 8 zu dienen. Auf die Windows Phone 8-Seite der Bibliothek gehe ich in einem zukünftigen Artikel ein. Verfolgen Sie die Fortschritte der Charmed-Bibliothek unter bit.ly/17AzFxW.

In diesem Artikel und dem Beispielcode möchte ich meinen Ansatz für testfähige Anwendungen mit MVVM zeigen und dabei einige der neuen Features verwenden, die Windows 8 bietet.

Übersicht über MVVM

Bevor ich den Code und bestimmte Windows 8-Features näher erläutere, werfe ich einen kurzen Blick auf MVVM. MVVM ist ein Entwurfsmuster, das in den letzten Jahren für XAML-basierte Technologien wie Windows Presentation Foundation (WPF), Silverlight, Windows Phone 7, Windows Phone 8 und Windows 8 (Windows-Runtime oder WinRT) enorm an Popularität gewonnen hat. MVVM unterteilt die Architektur einer Anwendung in drei logische Schichten: Modell, Ansichtsmodell und Ansicht, wie in Abbildung 1 gezeigt.

The Three Logical Layers of Model-­View-ViewModelAbbildung 1: Die drei logischen Model-View-ViewModel-Schichten

Die Modellschicht umfasst die Geschäftslogik der Anwendung – Geschäftsobjekte, Datenüberprüfung, Datenzugriff usw. In der Praxis wird die Modellschicht normalerweise in mehr Schichten und möglicherweise sogar mehrere Ebenen unterteilt. Wie in Abbildung 1 gezeigt, ist die Modellschicht die logische Grundlage der Anwendung.

Die Ansichtsmodellschicht enthält die Darstellungslogik der Anwendung, darunter die Daten, die angezeigt werden sollen; Eigenschaften, die dazu dienen, Benutzeroberflächenelemente zu aktivieren oder sichtbar zu machen; und Methoden, die sowohl mit der Modell- als auch mit der Ansichtsschicht interagieren. Die Ansichtsmodellschicht ist im Grunde eine die Ansicht ignorierende Darstellung des aktuellen Zustands der Benutzeroberfläche. „Die Ansicht ignorierend“, da sie nur Daten und Methoden für die Ansicht zur Interaktion bereitstellt, aber sie schreibt nicht vor, wie die Ansicht diese Daten darstellt oder dem Benutzer die Interaktion mit diesen Methoden ermöglicht. Wie in Abbildung 1 gezeigt, befindet sich die Ansichtsmodellschicht logisch zwischen der Modellschicht und der Ansichtsschicht und kann mit beiden interagieren. Die Ansichtsmodellschicht enthält Code, der sich vorher im Codebehind der Ansichtsschicht befand.

Die Ansichtsschicht enthält die eigentliche Darstellung der Anwendung. In XAML-basierten Anwendungen, wie denen für Windows-Runtime, besteht die Ansichtsschicht größtenteils – oder sogar vollständig – aus XAML. Die Ansichtsschicht nutzt das leistungsstarke XAML-Datenbindungsmodul zur Bindung an Eigenschaften auf dem Ansichtsmodell, wodurch die Daten ein Erscheinungsbild erhalten, die ansonsten keine visuelle Entsprechung hätten. Wie in Abbildung 1 gezeigt, ist die Ansichtsschicht die logische obere Schicht der Anwendung. Die Ansichtsschicht interagiert direkt mit der Ansichtsmodellschicht, hat aber keine Kenntnis der Modellschicht.

Der Hauptzweck des MVVM-Musters ist, die Darstellung einer Anwendung von ihrer Funktionalität zu trennen. Dies macht die Anwendung besser für Komponententests geeignet, da die Funktionalität jetzt in Plain Old CLR(POCO)-Objekten vorliegt anstatt in Ansichten, die ihre eigenen Lebenszyklen haben.

Verträge

Mit Windows 8 wurde des Konzept der Verträge eingeführt, d. h. der Vereinbarungen zwischen mindestens zwei Apps auf dem System eines Benutzers. Mit den Verträgen wird Konsistenz für alle Apps erreicht. Die Entwickler können somit die Funktionalität jeder App nutzen, die Verträge unterstützt. Eine App kann in der Datei „Package.appxmanifest“ angeben, welche Verträge sie unterstützt, wie in Abbildung 2 gezeigt.

Contracts in the Package.appxmanifest FileAbbildung 2: Verträge in der Datei „Package.appxmanifest“

Die Unterstützung von Verträgen ist zwar optional, im Allgemeinen aber empfehlenswert. Insbesondere drei Verträge sollten von einer Anwendung unterstützt werden, „Freigabe“, „Einstellungen“ und „Suche“, da sie immer über das Menü „Charms“ verfügbar sind, wie in Abbildung 3 gezeigt.

The Charms MenuAbbildung 3: Das Menü „Charms“

Ich konzentriere mich auf zwei Vertragstypen: Freigabe und Einstellungen.

Freigabe

Mit dem Vertrag für „Freigabe“ kann eine App kontextspezifische Daten für andere Apps auf dem System des Benutzers freigeben. Der Vertrag für „Freigabe“ hat zwei Seiten: die Quelle und das Ziel. Die Quelle ist die App, die die Freigabe ausführt. Sie stellt einige Daten im benötigten Format zum Teilen bereit. Das Ziel ist die App, die die freigegebenen Daten empfängt. Da der Charm „Teilen“ dem Benutzer im Menü „Charms“ immer zur Verfügung steht, soll die Beispiel-App zumindest eine Freigabequelle sein. Nicht jede App muss ein Freigabeziel sein, denn es ist nicht für alle Apps notwendig, Eingaben von anderen Quellen zu akzeptieren. Es ist aber sehr wahrscheinlich, dass jede gegebene App zumindest über eine Sache verfügt, deren Freigabe für andere Apps Sinn macht. Für eine Mehrheit von Apps ist es also nützlich, als Freigabequelle zu dienen.

Wenn der Benutzer auf den „Teilen“-Charm drückt, beginnt ein als Freigabebroker bezeichnetes Objekt mit dem Prozess, die von der App gegebenenfalls freigegebenen Daten abzurufen und sie an das vom Benutzer angegebene Freigabeziel zu senden. Mit dem DataTransferManager-Objekt kann ich Daten während dieses Prozesses freigeben. Das DataTransferManager-Objekt hat ein Ereignis namens „DataRequested“, welches ausgelöst wird, wenn der Benutzer auf den Charm „Teilen“ drückt. Der folgende Code zeigt, wie Sie einen Verweis auf „DataTransferManager“ erhalten und das DataRequested-Ereignis abonnieren:

public void Initialize()
{
  this.DataTransferManager = DataTransferManager.GetForCurrentView();
  this.DataTransferManager.DataRequested += 
    this.DataTransferManager_DataRequested;
}
private void DataTransferManager_DataRequested(
  DataTransferManager sender, DataRequestedEventArgs args)
{
  // Do stuff ...
}

Der Aufruf von „DataTransferManager.GetForCurrentView“ gibt einen Verweis auf das aktive DataTransferManager-Objekt für die aktuelle Ansicht zurück. Dieser Code kann zwar in ein Ansichtsmodell eingefügt werden, erstellt jedoch eine feste Abhängigkeit mit „DataTransferManager“, einer versiegelten Klasse, für die in Komponententests kein Pseudoobjekt verwendet werden kann. Das ist nicht ideal, da meine App so testfähig wie möglich bleiben soll. Eine bessere Lösung bietet die Abstraktion der DataTransferManager-Interaktion in eine Hilfsklasse und die Definition einer Schnittstelle, die diese Hilfsklasse implementiert.

Vor dem Abstrahieren der Interaktion muss ich entscheiden, welche Bestandteile wirklich von Bedeutung sind. Für mich sind drei Teile der Interaktion mit „DataTransferManager“ wichtig:

  1. Abonnieren des DataRequested-Ereignisses, wenn meine Ansicht aktiviert wird
  2. Aufheben des DataRequested-Ereignisabonnements, wenn meine Ansicht deaktiviert wird
  3. Die Fähigkeit, freigegebene Daten zu „DataPackage“ hinzufügen zu können

Unter Berücksichtigung dieser drei Aspekte nimmt die Schnittstelle Gestalt an:

public interface IShareManager
{
  void Initialize();
  void Cleanup();
  Action<DataPackage> OnShareRequested { get; set; }
}

„Initialize“ erhält einen Verweis auf „DataTransferManager“ und abonniert das DataRequested-Ereignis. „Cleanup“ hebt das Abonnement des DataRequested-Ereignisses auf. In „OnShareRequested“ kann ich definieren, welche Methode aufzurufen ist, wenn das DataRequested-Ereignis ausgelöst wird. Jetzt kann ich „IShareManager“ implementieren, wie in Abbildung 4 dargestellt.

Abbildung 4: Implementieren von „IShareManager“

public sealed class ShareManager : IShareManager
{
  private DataTransferManager DataTransferManager { get; set; }
  public void Initialize()
  {
    this.DataTransferManager = DataTransferManager.GetForCurrentView();
    this.DataTransferManager.DataRequested +=
      this.DataTransferManager_DataRequested;
  }
  public void Cleanup()
  {
    this.DataTransferManager.DataRequested -=
      this.DataTransferManager_DataRequested;
  }
  private void DataTransferManager_DataRequested(
    DataTransferManager sender, DataRequestedEventArgs args)
  {
    if (this.OnShareRequested != null)
    {
      this.OnShareRequested(args.Request.Data);
    }
  }
  public Action<DataPackage> OnShareRequested { get; set; }
}

Wenn das DataRequested-Ereignis ausgelöst wird, enthalten die übergebenen Argumente ein „DataPackage“. In diesem „DataPackage“ müssen die eigentlichen freigegebenen Daten platziert werden, und das ist der Grund dafür, dass „Action“ für „OnShareRequested“ ein „DataPackage“ als Parameter übernimmt. Ich habe die IShareManager-Schnittstelle definiert, und sie wird durch „ShareManager“ implementiert. Jetzt kann ich die Freigabe dem Ansichtsmodell hinzufügen, ohne dabei Abstriche bei der gewünschten Eignung für Komponententests zu machen.

Sobald ich einen Inversion of Control(IoC)-Container meiner Wahl verwendet habe, um eine Instanz von „IShareManager“ in das Ansichtsmodell einzufügen, steht der Verwendung nichts mehr im Weg, wie in Abbildung 5 gezeigt.

Abbildung 5: Verbinden von „IShareManager“

public FeedItemViewModel(IShareManager shareManager)
{
  this.shareManager = shareManager;
}
public override void LoadState(
  FeedItem navigationParameter, Dictionary<string, 
  object> pageState)
{
  this.shareManager.Initialize();
  this.shareManager.OnShareRequested = ShareRequested;
}
public override void SaveState(Dictionary<string, 
  object> pageState)
{
  this.shareManager.Cleanup();
}

„LoadState“ wird bei einer Aktivierung der Seite und des Ansichtsmodells aufgerufen und „SaveState“ bei deren Deaktivierung. „ShareManager“ ist jetzt vollständig eingerichtet und bereit, die Freigabe zu verarbeiten. Nun muss ich die ShareRequested-Methode implementieren, die bei der Initiierung der Freigabe durch den Benutzer aufgerufen wird. Ich möchte einige Informationen über einen bestimmten Blogbeitrag („FeedItem“) freigeben, wie in Abbildung 6 gezeigt.

Abbildung 6. Auffüllen von „DataPackage“ bei „ShareRequested“

private void ShareRequested(DataPackage dataPackage)
{
  // Set as many data types as possible.
  dataPackage.Properties.Title = this.FeedItem.Title;
  // Add a Uri.
  dataPackage.SetUri(this.FeedItem.Link);
  // Add a text-only version.
  var text = string.Format(
    "Check this out! {0} ({1})", 
    this.FeedItem.Title, this.FeedItem.Link);
  dataPackage.SetText(text);
  // Add an HTML version.
  var htmlBuilder = new StringBuilder();
  htmlBuilder.AppendFormat("<p>Check this out!</p>", 
    this.FeedItem.Author);
  htmlBuilder.AppendFormat(
    "<p><a href='{0}'>{1}</a></p>", 
    this.FeedItem.Link, this.FeedItem.Title);
  var html = HtmlFormatHelper.CreateHtmlFormat(htmlBuilder.ToString());
  dataPackage.SetHtmlFormat(html);
}

Ich möchte ein paar unterschiedliche Datentypen teilen. Das ist in der Regel empfehlenswert, denn es gibt keine Kontrolle darüber, was für Apps ein Benutzer auf seinem System hat oder welche Datentypen von diesen Apps unterstützt werden. Behalten Sie im Blick, dass eine Freigabe im Grunde ein Fire-and-Forget-Szenario ist. Sie wissen nicht, für welche App der Benutzer die geteilten Daten vorsieht und was die App mit diesen Daten ausführt. Zum Teilen mit der größtmöglichen Zielgruppe stelle ich einen Titel, eine URL, eine reine Textversion und eine HTML-Version bereit.

Einstellungen

Mit dem Vertrag für „Einstellungen“ kann der Benutzer kontextspezifische Einstellungen in einer App ändern. Das können Einstellungen sein, die sich auf die App als Ganzes auswirken, oder nur bestimmte Elemente, die den aktuellen Kontext betreffen. Windows 8-Benutzer werden sich daran gewöhnen, den Charm „Einstellungen“ zu verwenden, um Änderungen an der App durchzuführen. Ich möchte, dass die App dies unterstützt, da der Charm über das Menü „Charms“ ständig für die Benutzer verfügbar ist. Wenn eine App in der Datei „Package.appxmanifest“ eine Internetfunktion deklariert, muss sie in der Praxis den Vertrag für „Einstellungen“ implementieren, indem sie an einer beliebigen Stelle im Menü „Einstellungen“ einen Link zu einer webbasierten Datenschutzrichtlinie bereitstellt. Apps, die Visual Studio 2012-Vorlagen verwenden, deklarieren die Internetfunktion standardmäßig, worauf geachtet werden muss.

Wenn ein Benutzer auf den Charm „Einstellungen“ drückt, erstellt das Betriebssystem dynamisch das Menü, das angezeigt wird. Das Menü und das zugeordnete Flyout werden vom Betriebssystem gesteuert. Das Erscheinungsbild vom Menü und vom Flyout kann ich nicht steuern, aber ich kann dem Menü Optionen hinzufügen. Ein SettingsPane-Objekt benachrichtigt mich, wenn der Benutzer den Charm „Einstellungen“ über das CommandsRequested-Ereignis auswählt. Einen Verweis auf „SettingsPane“ abzurufen und das CommandsRequested-Ereignis zu abonnieren ist ziemlich einfach:

public void Initialize()
{
  this.SettingsPane = SettingsPane.GetForCurrentView();
  this.SettingsPane.CommandsRequested += 
    SettingsPane_CommandsRequested;
}
private void SettingsPane_CommandsRequested(
  SettingsPane sender, 
  SettingsPaneCommandsRequestedEventArgs args)
{
  // Do stuff ...
}

Der Haken dabei ist eine weitere feste Abhängigkeit. Diesmal ist „SettingsPane“ die Abhängigkeit, eine weitere Klasse, für die keine Pseudoklasse verwendet werden kann. Ich möchte Komponententests für das Ansichtsmodell ausführen können, das „SettingsPane“ verwendet, und ich muss daher Verweise darauf abstrahieren, wie ich es bei den Verweisen auf „DataTransferManager“ getan habe. Wie sich herausstellt, erinnern meine Interaktionen mit „SettingsPane“ stark an die mit „DataTransferManager“:

  1. Abonnieren des CommandsRequested-Ereignisses für die aktuelle Ansicht
  2. Aufheben des CommandsRequested-Ereignisabonnements für die aktuelle Ansicht
  3. Hinzufügen des eigenen SettingsCommand-Objekts, wenn das Ereignis ausgelöst wird

Die Schnittstelle, die ich abstrahieren muss, sieht also der IShareManager-Schnittstelle sehr ähnlich:

public interface ISettingsManager
{
  void Initialize();
  void Cleanup();
  Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}

„Initialize“ erhält einen Verweis auf „SettingsPane“ und abonniert das CommandsRequested-Ereignis. „Cleanup“ hebt das Abonnement des CommandsRequested-Ereignisses auf. In „OnSettingsRequested“ kann ich definieren, welche Methode aufzurufen ist, wenn das CommandsRequested-Ereignis ausgelöst wird. Jetzt kann ich „ISettingsManager“ implementieren, wie in Abbildung 7 dargestellt.

Abbildung 7: Implementieren von „ISettingsManager“

public sealed class SettingsManager : ISettingsManager
{
  private SettingsPane SettingsPane { get; set; }
  public void Initialize()
  {
    this.SettingsPane = SettingsPane.GetForCurrentView();
    this.SettingsPane.CommandsRequested += 
      SettingsPane_CommandsRequested;
  }
  public void Cleanup()
  {
    this.SettingsPane.CommandsRequested -= 
      SettingsPane_CommandsRequested;
  }
  private void SettingsPane_CommandsRequested(
    SettingsPane sender, SettingsPaneCommandsRequestedEventArgs args)
  {
    if (this.OnSettingsRequested != null)
    {
      this.OnSettingsRequested(args.Request.ApplicationCommands);
    }
  }
  public Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}

Wenn das CommandsRequested-Ereignis ausgelöst wird, geben die Ereignisargumente mir Zugriff auf die Liste von SettingsCommand-Objekten, die die Optionen des Menüs „Einstellungen“ darstellen. Um dem Menü „Einstellungen“ meine eigenen Optionen hinzuzufügen, muss ich nur eine SettingsCommand-Instanz in die Liste einfügen. Ein SettingsCommand-Objekt verlangt nicht viel, nur einen eindeutigen Bezeichner, einen Beschriftungstext und Code, der beim Auswählen der Option durch die Benutzer ausgeführt wird.

Ich füge mithilfe des IoC-Containers eine Instanz von „ISettingsManager“ in das Ansichtsmodell ein. Anschließend richte ich es für die Initialisierung und Bereinigung ein, wie in Abbildung 8 gezeigt.

Abbildung 8: Verbinden von „ISettingsManager“

public ShellViewModel(ISettingsManager settingsManager)
{
  this.settingsManager = settingsManager;
}
public void Initialize()
{
  this.settingsManager.Initialize();
  this.settingsManager.OnSettingsRequested = 
    OnSettingsRequested;
}
public void Cleanup()
{
  this.settingsManager.Cleanup();
}

Mit den Einstellungen sollen die Benutzer ändern können, welche RSS-Feeds sie mit der Beispiel-App anzeigen. Die Benutzer sollen dies an einer beliebigen Stelle in der App ändern können. Ich habe daher „ShellViewModel“ hinzugefügt, das beim Starten der App instanziiert wird. Wenn ich geplant hätte, eine Änderung der RSS-Feeds nur in einer der anderen Ansichten zu ermöglichen, hätte ich den Code für die Einstellungen dem zugeordneten Ansichtsmodell hinzugefügt.

Windows-Runtime verfügt nicht über eine integrierte Funktionalität zum Erstellen und Verwalten eines Flyouts für Einstellungen. Es ist viel mehr manuelle Programmierung erforderlich, als wünschenswert wäre, um eine Funktionalität zu erhalten, die für alle Apps konsistent sein sollte. Glücklicherweise stehe ich mit dieser Meinung nicht allein. Tim Heuer, Program Manager im XAML-Team bei Microsoft, hat ein hervorragendes Framework namens Callisto erstellt, das hier Abhilfe schafft. Callisto ist auf GitHub (bit.ly/Kijr1S) und auf NuGet (bit.ly/112ehch) verfügbar. Ich verwende es in der Beispiel-App, und ich empfehle, es auszuprobieren.

Da ich „SettingsManager“ in meinem Ansichtsmodell verbunden habe, muss ich nur den Code zum Ausführen bereitstellen, wenn die Einstellungen angefordert werden, wie in Abbildung 9 gezeigt.

Abbildung 9: Anzeigen von „SettingsView“ bei „SettingsRequested“ mit Callisto

private void OnSettingsRequested(IList<SettingsCommand> commands)
{
  SettingsCommand settingsCommand =
    new SettingsCommand("FeedsSetting", "Feeds", (x) =>
  {
    SettingsFlyout settings = new Callisto.Controls.SettingsFlyout();
    settings.FlyoutWidth =
      Callisto.Controls.SettingsFlyout.SettingsFlyoutWidth.Wide;
    settings.HeaderText = "Feeds";
    var view = new SettingsView();
    settings.Content = view;
    settings.HorizontalContentAlignment = 
      HorizontalAlignment.Stretch;
    settings.VerticalContentAlignment = 
      VerticalAlignment.Stretch;
    settings.IsOpen = true;
  });
  commands.Add(settingsCommand);
}

Ich erstelle ein neues SettingsCommand-Element mit der ID „FeedsSetting“ und dem Beschriftungstext „Feeds“. Der Lambda, den ich für den Rückruf verwende, welcher aufgerufen wird, wenn der Benutzer das Menüelement „Feeds“ auswählt, verwendet das Callisto-SettingsFlyout-Steuerelement. Das SettingsFlyout-Steuerelement übernimmt die Schwerstarbeit: wo das Flyout platziert wird, wie breit es sein soll und wann es geöffnet und geschlossen wird. Ich muss nur festlegen, ob ich die breite oder schmale Version haben möchte, etwas Kopftext und den Inhalt hinzufügen und für „IsOpen“ TRUE einstellen, um es zu öffnen. Ich rate auch dazu, „Stretch“ für „HorizontalContentAlignment“ und „VerticalContentAlignment“ anzugeben. Ohne die gestreckte Ausrichtung stimmt der Inhalt nicht mit der Größe von „SettingsFlyout“ überein.

Nachrichtenbus

Ein wichtiger Aspekt bei der Arbeit mit dem Vertrag für „Einstellungen“ ist die Erwartung, dass alle Änderungen an den Einstellungen unmittelbar übernommen und in der App reflektiert werden. Es gibt eine Reihe von Methoden zum Übertragen der Einstellungsänderungen, die der Benutzer vornimmt. Die von mir bevorzugte Methode ist ein Nachrichtenbus (auch als Ereignisaggregator bekannt). Ein Nachrichtenbus ist ein System zum Veröffentlichen von Nachrichten innerhalb der gesamten App. Das Nachrichtenbuskonzept ist nicht in Windows-Runtime integriert, weshalb ich entweder einen Nachrichtenbus erstellen oder einen aus einem anderen Framework verwenden muss. Ich habe eine Nachrichtenbusimplementierung hinzugefügt, die ich in mehreren Projekten mit dem Charmed-Framework verwendet habe. Die Quelle steht Ihnen unter bit.ly/12EBHrb zur Verfügung. Es gibt mehrere andere gute Implementierungen; Caliburn.Micro bietet den EventAggregator und MVVM Light den Messenger. Alle Implementierungen folgen in der Regel demselben Muster: Sie stellen eine Methode bereit, um Nachrichten zu abonnieren, das Abonnement aufzuheben und Nachrichten zu veröffentlichen.

Im Einstellungen-Szenario verwende ich den Charmed-Nachrichtenbus und konfiguriere „MainViewModel“ (welches die Feeds anzeigt), damit es „FeedsChangedMessage“ abonniert:

this.messageBus.Subscribe<FeedsChangedMessage>((message) =>
  {
    LoadFeedData();
  });

Sobald „MainViewModel“ auf Änderungen an den Feeds wartet, konfiguriere ich „SettingsViewModel“, damit es „FeedsChangedMessage“ veröffentlicht, wenn der Benutzer einen RSS-Feed hinzufügt oder entfernt:

this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());

Bei der Verwendung eines Nachrichtenbusses ist es immer wichtig, dass jeder Teil der App dieselbe Nachrichtenbusinstanz verwendet. Ich habe daher den IoC-Container konfiguriert, sodass er für jede Anforderung eine Singletoninstanz übergibt, um einen „IMessageBus“ aufzulösen.

Die Beispiel-App ist jetzt so eingerichtet, dass die Benutzer Änderungen an den RSS-Feeds vornehmen können, die über den Charm „Einstellungen“ angezeigt werden, und die Hauptansicht aktualisiert wird, um diese Änderungen zu reflektieren.

Roamingeinstellungen

Mit Windows 8 wurde ein weiteres spannendes Konzept eingeführt: die Roamingeinstellungen. Mit den Roamingeinstellungen können App-Entwickler geringe Mengen von Daten zwischen allen Geräten eines Benutzers übertragen. Diese Daten dürfen 100 KB nicht übersteigen und sollten auf Informationen beschränkt sein, die erforderlich sind, um eine persistente, angepasste Benutzererfahrung auf allen Geräten zu erstellen. Im Falle der Beispiel-App möchte ich die RSS-Feeds beibehalten können, die der Benutzer geräteübergreifend lesen möchte.

Der bereits angesprochene Vertrag für „Einstellungen“ geht in der Regel mit den Roamingeinstellungen einher. Es ist einfach sinnvoll, dass die Anpassungen, die ich den Benutzer mithilfe des Vertrags für „Einstellungen“ vornehmen lasse, durch die Roamingeinstellungen auf allen Geräten beibehalten werden.

Zugriff auf die Roamingeinstellungen zu erhalten ist wie bei den anderen bisher betrachteten Punkten ziemlich unkompliziert. Die ApplicationData-Klasse gewährt sowohl auf „LocalSettings“ als auch auf „RoamingSettings“ Zugriff. Das Einfügen in „RoamingSettings“ erfolgt einfach durch Bereitstellen eines Schlüssels und eines Objekts:

ApplicationData.Current.RoamingSettings.Values[key] = value;

Die Arbeit mit „ApplicationData“ ist zwar einfach, aber es kann eine weitere versiegelte Klasse nicht in Komponententests simuliert werden. Um die Anzeigemodelle so testfähig wie möglich zu halten, muss ich die Interaktion mit „ApplicationData“ also abstrahieren. Bevor ich eine Schnittstelle definiere, um die Funktionalität für Roamingeinstellungen dahinter zu abstrahieren, muss ich entscheiden, welchem Zweck die Schnittstelle dienen soll:

  1. Prüfen, ob ein Schlüssel vorhanden ist
  2. Hinzufügen oder Aktualisieren einer Einstellung
  3. Entfernen einer Einstellung
  4. Abrufen einer Einstellung

Ich habe jetzt alles, was ich benötige, um eine Schnittstelle zu erstellen, die ich „ISettings“ nenne:

public interface ISettings
{
  void AddOrUpdate(string key, object value);
  bool TryGetValue<T>(string key, out T value);
  bool Remove(string key);
  bool ContainsKey(string key);
}

Nachdem ich die Schnittstelle definiert habe, muss ich sie implementieren, wie in Abbildung 10 gezeigt.

Abbildung 10: Implementieren von „ISettings“

public sealed class Settings : ISettings
{
  public void AddOrUpdate(string key, object value)
  {
    ApplicationData.Current.RoamingSettings.Values[key] = value;
  }
  public bool TryGetValue<T>(string key, out T value)
  {
    var result = false;
    if (ApplicationData.Current.RoamingSettings.Values.ContainsKey(key))
    {
      value = (T)ApplicationData.Current.RoamingSettings.Values[key];
      result = true;
    }
    else
    {
      value = default(T);
    }
    return result;
  }
  public bool Remove(string key)
  {
    return ApplicationData.Current.RoamingSettings.Values.Remove(key);
  }
  public bool ContainsKey(string key)
  {
    return ApplicationData.Current.RoamingSettings.Values.ContainsKey(key);
  }
}

„TryGetValue“ überprüft zuerst, ob ein bestimmter Schlüssel vorhanden ist, und weist, wenn dies zutrifft, den Wert dem out-Parameter zu. Anstatt eine Ausnahme auszulösen, wenn der Schlüssel nicht gefunden wird, gibt „TryGetValue“ einen booleschen Wert zurück, um anzugeben, ob der Schlüssel gefunden wurde. Die übrigen Methoden erklären sich praktisch selbst.

Ich kann jetzt „ISettings“ vom IoC-Container auflösen lassen und an „SettingsViewModel“ übergeben. Sobald ich dies tue, verwendet das Ansichtsmodell die Einstellungen, um die Benutzerfeeds zu laden, die bearbeitet werden sollen, wie in Abbildung 11 gezeigt.

Abbildung 11: Laden und Speichern der Benutzerfeeds

public SettingsViewModel(
  ISettings settings,
  IMessageBus messageBus)
{
  this.settings = settings;
  this.messageBus = messageBus;
  this.Feeds = new ObservableCollection<string>();
  string[] feedData;
  if (this.settings.TryGetValue<string[]>(Constants.FeedsKey, out feedData))
  {
    foreach (var feed in feedData)
    {
      this.Feeds.Add(feed);
    }
  }
}
public void AddFeed()
{
  this.Feeds.Add(this.NewFeed);
  this.NewFeed = string.Empty;
  SaveFeeds();
}
public void RemoveFeed(string feed)
{
  this.Feeds.Remove(feed);
  SaveFeeds();
}
private void SaveFeeds()
{
  this.settings.AddOrUpdate(Constants.FeedsKey, this.Feeds.ToArray());
  this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());
}

Beim Code in Abbildung 11 ist beachtenswert, dass die Daten, die ich tatsächlich in den Einstellungen speichere, ein Zeichenfolgenarray sind. Da die Roamingeinstellungen auf 100 KB begrenzt sind, muss ich die Daten einfach halten und bei primitiven Typen bleiben.

Sekundäre Kacheln

Das Entwickeln von Apps, die das Interesse von Benutzern finden, kann bereits herausfordernd genug sein. Aber wie stellen Sie es an, dass die Benutzer nach dem Installieren der App wiederkommen? Bei dieser Aufgabe können sekundäre Kacheln helfen. Eine sekundäre Kachel bietet Deep Linking für eine Anwendung. Die Benutzer können den Rest der App übergehen und direkt zu dem Inhalt wechseln, der sie am meisten interessiert. Eine sekundäre Kachel wird mit einem Symbol Ihrer Wahl an die Startseite des Benutzers angeheftet. Wenn die Benutzer auf die sekundäre Kachel tippen, wird die App mit Argumenten gestartet, die der App genau mitteilen, wohin sie wechseln und was sie laden soll. Das Bereitstellen der Funktionalität sekundärer Kacheln für die Benutzer ist eine gute Methode, um die Benutzer ihre Erfahrung anpassen zu lassen, damit sie gerne zurückkommen möchten.

Sekundäre Kacheln sind komplizierter als die anderen in diesem Artikel behandelten Themen, da Verschiedenes implementiert werden muss, bevor die vollständige Verwendung von sekundären Kacheln richtig funktioniert.

Zum Anheften einer sekundären Kachel gehört die Instanziierung der SecondaryTile-Klasse. Der SecondaryTile-Konstruktor übernimmt mehrere Parameter, mit denen er bestimmt, wie die Kachel aussehen soll. Dazu gehören ein Anzeigename, ein URI für die Logobilddatei zur Verwendung für die Kachel und Zeichenfolgenargumente, die beim Drücken auf die Kachel an die App übergeben werden. Wenn die SecondaryTile-Klasse instanziiert wurde, muss ich eine Methode aufrufen, die schließlich ein kleines Popupfenster anzeigt, in dem der Benutzer aufgefordert wird, das Anheften der Kachel zu bestätigen, wie in Abbildung 12 gezeigt.

SecondaryTile Requesting Permission to Pin a Tile to the Start Screen
Abbildung 12: „SecondaryTile“ fordert die Erlaubnis an, eine Kachel an die Startseite zu heften

Wenn der Benutzer auf die Bestätigung zum Anheften gedrückt hat, wird die erste Hälfte der Arbeit ausgeführt. Die zweite Hälfte ist das Konfigurieren der App, damit sie tatsächlich Deep Linking unterstützt, indem die beim Drücken der Kachel von dieser bereitgestellten Argumente verwendet werden. Bevor ich mich der zweiten Hälfte zuwende, möchte ich erläutern, wie ich die erste Hälfte so implementiere, dass sie getestet werden kann.

Da „SecondaryTile“ Methoden verwendet, die direkt mit dem Betriebssystem interagieren (welches wiederum Benutzeroberflächenkomponenten anzeigt), kann ich „SecondaryTile“ nicht direkt aus den Ansichtsmodellen verwenden, ohne die Testbarkeit zu gefährden. Ich abstrahiere also eine weitere Schnittstelle, die ich „ISecondaryPinner“ nenne. Sie soll mir ermöglichen, eine Kachel anzuheften, zu lösen und zu prüfen, ob eine Kachel bereits angeheftet wurde:

public interface ISecondaryPinner
{
  Task<bool> Pin(FrameworkElement anchorElement,
    Placement requestPlacement, TileInfo tileInfo);
  Task<bool> Unpin(FrameworkElement anchorElement,
    Placement requestPlacement, string tileId);
  bool IsPinned(string tileId);
}

Sowohl „Pin“ als auch „Unpin“ geben „Task<bool>“ zurück. Der Grund dafür ist, dass „SecondaryTile“ asynchrone Aufgaben verwendet, um die Benutzer zum Anheften oder Loslösen einer Kachel aufzufordern. Es bedeutet auch, dass die ISecondaryPinner-Pin- und Unpin-Methoden erwartet werden können.

Außerdem übernehmen sowohl „Pin“ als auch „Unpin“ einen FrameworkElement- und einen Placement-Enumerationswert als Parameter. Der Grund dafür ist, dass „SecondaryTile“ ein Rechteck und „Placement“ benötigt, die bestimmen, wo das Popupfenster mit der Anforderung zum Anheften platziert wird. Ich möchte, dass die Implementierung von „SecondaryPinner“ dieses Rechteck anhand vom übergebenen „FrameworkElement“ berechnet.

Schließlich erstelle ich die TileInfo-Hilfsklasse, um die von „SecondaryTile“ verwendeten erforderlichen und optionalen Parameter weiterzugeben, wie in Abbildung 13 gezeigt.

Abbildung 13: Die TileInfo-Hilfsklasse

public sealed class TileInfo
{
  public TileInfo(
    string tileId,
    string shortName,
    string displayName,
    TileOptions tileOptions,
    Uri logoUri,
    string arguments = null)
  {
    this.TileId = tileId;
    this.ShortName = shortName;
    this.DisplayName = displayName;
    this.Arguments = arguments;
    this.TileOptions = tileOptions;
    this.LogoUri = logoUri;
    this.Arguments = arguments;
  }
  public TileInfo(
    string tileId,
    string shortName,
    string displayName,
    TileOptions tileOptions,
    Uri logoUri,
    Uri wideLogoUri,
    string arguments = null)
  {
    this.TileId = tileId;
    this.ShortName = shortName;
    this.DisplayName = displayName;
    this.Arguments = arguments;
    this.TileOptions = tileOptions;
    this.LogoUri = logoUri;
    this.WideLogoUri = wideLogoUri;
    this.Arguments = arguments;
  }
  public string TileId { get; set; }
  public string ShortName { get; set; }
  public string DisplayName { get; set; }
  public string Arguments { get; set; }
  public TileOptions TileOptions { get; set; }
  public Uri LogoUri { get; set; }
  public Uri WideLogoUri { get; set; }
}

Die TileInfo-Hilfsklasse hat zwei Konstruktoren, die verwendet werden können, je nach den Daten. Ich implementiere jetzt „ISecondaryPinner“ wie in Abbildung 14 dargestellt.

Abbildung 14: Implementieren von „ISecondaryPinner“

public sealed class SecondaryPinner : ISecondaryPinner
{
  public async Task<bool> Pin(
    FrameworkElement anchorElement,
    Placement requestPlacement,
    TileInfo tileInfo)
  {
    if (anchorElement == null)
    {
      throw new ArgumentNullException("anchorElement");
    }
    if (tileInfo == null)
    {
      throw new ArgumentNullException("tileInfo");
    }
    var isPinned = false;
    if (!SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(
        tileInfo.TileId,
        tileInfo.ShortName,
        tileInfo.DisplayName,
        tileInfo.Arguments,
        tileInfo.TileOptions,
        tileInfo.LogoUri);
      if (tileInfo.WideLogoUri != null)
      {
        secondaryTile.WideLogo = tileInfo.WideLogoUri;
      }
      isPinned = await secondaryTile.RequestCreateForSelectionAsync(
        GetElementRect(anchorElement), requestPlacement);
    }
    return isPinned;
  }
  public async Task<bool> Unpin(
    FrameworkElement anchorElement,
    Placement requestPlacement,
    string tileId)
  {
    var wasUnpinned = false;
    if (SecondaryTile.Exists(tileId))
    {
      var secondaryTile = new SecondaryTile(tileId);
      wasUnpinned = await secondaryTile.RequestDeleteForSelectionAsync(
        GetElementRect(anchorElement), requestPlacement);
    }
    return wasUnpinned;
  }
  public bool IsPinned(string tileId)
  {
    return SecondaryTile.Exists(tileId);
  }
  private static Rect GetElementRect(FrameworkElement element)
  {
    GeneralTransform buttonTransform =
      element.TransformToVisual(null);
    Point point = buttonTransform.TransformPoint(new Point());
    return new Rect(point, new Size(
      element.ActualWidth, element.ActualHeight));
  }
}

„Pin“ stellt zuerst sicher, dass die angeforderte Kachel nicht bereits vorhanden ist, und fordert anschließend den Benutzer auf, sie anzuheften. „Unpin“ stellt zuerst sicher, dass die angeforderte Kachel vorhanden ist, und fordert anschließend den Benutzer auf, sie loszulösen. Beide geben einen booleschen Wert zurück, der angibt, ob das Anheften oder Loslösen erfolgreich war.

Ich kann jetzt eine Instanz von „ISecondaryPinner“ in das Ansichtsmodell einfügen und verwenden, wie in Abbildung 15 gezeigt.

Abbildung 15: Anheften und Loslösen mit „ISecondaryPinner“

public FeedItemViewModel(
  IShareManager shareManager,
  ISecondaryPinner secondaryPinner)
{
  this.shareManager = shareManager;
  this.secondaryPinner = secondaryPinner;
}
public async Task Pin(FrameworkElement anchorElement)
{
  var tileInfo = new TileInfo(
    FormatSecondaryTileId(),
    this.FeedItem.Title,
    this.FeedItem.Title,
    TileOptions.ShowNameOnLogo | TileOptions.ShowNameOnWideLogo,
    new Uri("ms-appx:///Assets/Logo.png"),
    new Uri("ms-appx:///Assets/WideLogo.png"),
    this.FeedItem.Id.ToString());
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    tileInfo);
}
public async Task Unpin(FrameworkElement anchorElement)
{
  this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    this.FormatSecondaryTileId());
}

In „Pin“ erstelle ich eine TileInfo-Hilfsinstanz, der ich eine eindeutig formatierte ID, die Titel des Feeds, URIs für das Logo und das breite Logo und die Feed-ID als Startargument hinzufüge. „Pin“ übernimmt die Schaltfläche, auf die geklickt wurde, als Ankerelement, auf dessen Basis der Ort für das Popupfenster mit der Anforderung zum Anheften festgelegt wird. Ich bestimme anhand des Ergebnisses der SecondaryPinner.Pin-Methode, ob das Feedelement angeheftet wurde.

In „Unpin“ übergebe ich die eindeutig formatierte ID der Kachel, indem ich mit der Umkehrung des Ergebnisses bestimme, ob das Feedelement immer noch angeheftet ist. Wieder wird die Schaltfläche, auf die geklickt wurde, als Ankerelement für das Popupfenster mit der Anforderung zum Anheften an „Unpin“ übergeben.

Nachdem ich dies erstellt habe und es verwende, um einen Blogbeitrag („FeedItem“) an die Startseite anzuheften, kann ich auf die neu erstellte Kachel tippen, um die App zu starten. Hiermit wird die App allerdings wie zuvor mit der Hauptseite gestartet, auf der alle Blogbeiträge angezeigt werden. Ich möchte auf den bestimmten Blogbeitrag gelangen, den ich angeheftet habe. Hier kommt die zweite Hälfte der Funktionalität ins Spiel.

Die zweite Hälfte der Funktionalität wird in „app.xaml.cs“ hinzugefügt, aus der die App startet, wie in Abbildung 16 gezeigt.

Abbildung 16: Starten der App

protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
  Frame rootFrame = Window.Current.Content as Frame;
  if (rootFrame.Content == null)
  {
    Ioc.Container.Resolve<INavigator>().
      NavigateToViewModel<MainViewModel>();
  }
  if (!string.IsNullOrWhiteSpace(args.Arguments))
  {
    var storage = Ioc.Container.Resolve<IStorage>();
    List<FeedItem> pinnedFeedItems =
      await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
      int id;
      if (int.TryParse(args.Arguments, out id))
      {
        var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id == id);
        if (pinnedFeedItem != null)
        {
          Ioc.Container.Resolve<INavigator>().
            NavigateToViewModel<FeedItemViewModel>(
            pinnedFeedItem);
        }
      }
    }
  }
  Window.Current.Activate();
}

Ich füge dem Ende der überschriebenen OnLaunched-Methode etwas Code hinzu, um zu prüfen, ob während des Startvorgangs Argumente übergeben wurden. Wenn dies zutrifft, formatiere ich die Argumente zur Verwendung als Feed-ID in „int“. Mit dieser ID rufe ich den Feed aus den gespeicherten Feeds ab und übergebe ihn zum Anzeigen an „FeedItemViewModel“. Ich stelle vorher aber sicher, dass die App bereits die Hauptseite anzeigt, und wenn sie nicht angezeigt wurde, navigiere ich erst zu ihr. Auf diese Weise können die Benutzer mit der Schaltfläche „Zurück“ auf die Hauptseite gelangen, unabhängig davon, ob sie die App bereits ausgeführt haben oder nicht.

Zusammenfassung

In diesem Artikel habe ich meinen Ansatz beschrieben, mithilfe des MVVM-Musters eine zum Testen geeignete Windows Store-App zu implementieren und dabei weiterhin einige der interessanten neuen Features zu nutzen, die Windows 8 bietet. Ich bin insbesondere darauf eingegangen, wie Freigabe, Einstellungen, Roamingeinstellungen und sekundäre Kacheln in Hilfsklassen abstrahiert werden, die simulierbare Schnittstellen implementieren. Mithilfe dieser Technik kann ich Komponententests für die Anzeigemodellfunktionalität ausführen, die so umfangreich wie möglich sind.

In zukünftigen Artikeln werde ich auf mehr Einzelheiten dazu eingehen, wie Komponententests für die Anzeigemodelle, die jetzt besser zum Testen geeignet sind, in der Praxis geschrieben werden. Ich werde außerdem beleuchten, wie dieselben Techniken dazu dienen können, die Anzeigemodelle plattformübergreifend für Windows Phone 8 zu erstellen, wobei sie testfähig bleiben.

Mit ein bisschen Planung können Sie eine überzeugende Anwendung mit einer innovativen Benutzererfahrung erstellen, die neue, wichtige Features von Windows 8 nutzt. Und das ist möglich, ohne dass Sie Abstriche bei Best Practices oder Komponententests machen müssen.

Brent Edwards ist Associate Principal Consultant bei Magenic, einem Unternehmen für benutzerdefinierte Anwendungsentwicklung, das seinen Schwerpunkt im Microsoft-Bereich und der Entwicklung mobiler Anwendungen hat. Er ist außerdem Mitbegründer der Twin Cities Windows 8 User Group in Minneapolis im US-amerikanischen Minnesota. Sie erreichen ihn unter brente@magenic.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Rocky Lhotka (Magenic).
Rockford Lhotka ist CTO bei Magenic und Entwickler des weitverbreiteten CSLA .NET-Entwicklungsframeworks. Er hat zahlreiche Bücher verfasst und hält regelmäßig Vorträge bei wichtigen Entwicklerkonferenzen auf der ganzen Welt. Lhotka ist Microsoft Regional Director und MVP. Das Unternehmen Magenic (www.magenic.com) ist darauf spezialisiert, die wichtigsten erfolgsentscheidenden Systeme von Unternehmen zu planen, zu entwerfen, zu erstellen und zu verwalten. Weitere Informationen finden Sie unter www.lhotka.net.