MVVM

Schreiben einer plattformübergreifenden Darstellungsschicht mit MVVM

Brent Edwards

Mit der Veröffentlichung von Microsoft 8 und Windows Phone 8 ist Microsoft ein großer Schritt in Richtung einer wirklich plattformübergreifenden Entwicklung gelungen. Beide werden mit demselben Kernel ausgeführt, folglich kann mit ein wenig umsichtiger Planung ein Großteil des Anwendungscodes auch in beiden verwendet werden. Durch die Nutzung des MVVM (Model-View-ViewModel)-Musters, einigen anderen gemeinsamen Entwurfsmustern sowie ein paar Kniffen können Sie eine plattformübergreifende Darstellungsschicht schreiben, die sowohl unter Windows 8 als auch unter Windows Phone 8 funktioniert.

In diesem Artikel geht es um meine Erfahrung mit speziellen plattformübergreifenden Problemstellungen und um anwendbare Lösungen, mit denen die App eine klare Trennung der Bereiche beibehält und trotzdem gute Komponententests für sie geschrieben werden können.

Über die Beispiel-App

In der MSDN Magazine-Ausgabe vom Juli 2013 habe ich ein Codebeispiel aus einer Windows Store-Anwendung und den Beginn eines von mir entwickelten plattformübergreifenden Open-Source-Frameworks mit dem Namen Charmed vorgestellt („Nutzen von Windows 8-Features mit MVVM“ msdn.microsoft.com/magazine/dn296512). In diesem Artikel zeige ich Ihnen, wie ich die Beispielanwendung und das Framework noch plattformübergreifender gestaltet habe. Zudem habe ich eine begleitende Windows Phone 8-App mit derselben Grundfunktionalität entwickelt, die dasselbe Framework nutzt. Das Framework und die beispielhaften Apps sind auf GitHub unter github.com/brentedwards/Charmed verfügbar. Der Code wird bis zum letzten Artikel meiner MVVM-Reihe – der sich mit dem tatsächlichen Test der Darstellungsschicht und zusätzlichen Aspekten in Bezug auf testbaren Code beschäftigen wird – weiterentwickelt.

Bei der App handelt es sich um einen einfachen Blog-Reader mit der Bezeichnung „Charmed Reader“. Jede Plattformversion dieser App verfügt gerade über genügend Funktionalität, um einige Kernkonzepte hinsichtlich der plattformübergreifenden Entwicklung zu veranschaulichen. Beide Versionen liefern dieselbe Benutzerfreundlichkeit, sind aber in puncto Erscheinungsbild an das jeweilige Betriebssystem angepasst.

Lösungsstruktur

Jede sinnvolle Diskussion über die plattformübergreifende Entwicklung mit Visual Studio 2012 muss am Anfang beginnen: mit der Lösungsstruktur. Obwohl Windows 8 und Windows Phone 8 auf demselben Kernel ausgeführt werden, werden die jeweiligen Apps unterschiedlich kompiliert und weisen verschiedene Projekttypen auf. Es gibt mehrere Möglichkeiten für einen Lösungsansatz mit verschiedenen Projekttypen, aber mein Ziel ist eine Lösung, in die all meine plattformspezifischen Projekte eingebunden sind. In Abbildung 1 wird die Lösungsstruktur für die hier besprochenen Beispiel-Apps veranschaulicht.

Cross-Platform Charmed Reader Solution Structure
Abbildung 1: Plattformübergreifende Charmed Reader-Lösungsstruktur

Mit Visual Studio ist es möglich, dass mehrere Projektdateien auf eine einzelne physische Klassendatei verweisen. Das heißt, Sie können – wie in Abbildung 2 dargestellt – eine vorhandene Klassendatei mit „Als Link hinzufügen“ hinzufügen.

Adding an Existing Item with Add As Link
Abbildung 2: Hinzufügen eines vorhandenen Elements mit „Als Link hinzufügen“

Wenn ich die Funktion „Als Link hinzufügen“ nutze, kann ich einmal geschriebenen Code sowohl für Windows 8 als auch für Windows Phone 8 verwenden. Das möchte ich allerdings nicht für jede Klassendatei machen. Wie ich zeigen werde, gibt es Situationen, in denen für jede Plattform eine eigene Implementierung benötigt wird.

Unterschiedliche Ansichten/Views

Obwohl ich einen Großteil des in C# geschriebenen Codes mit meiner Darstellungslogik erneut verwenden kann, lässt sich der tatsächliche Darstellungscode, der in XAML erstellt ist, nicht erneut nutzen. Das liegt daran, dass Windows 8 und Windows Phone 8 leicht unterschiedliche XAML-Versionen nutzen, die nicht gut genug zusammenarbeiten und somit nicht austauschbar sind. Ein Teil des Problems ist die Syntax, besonders die Namespacedeklaration, aber die größte Schwierigkeit liegt darin, dass für die Plattformen unterschiedliche Steuerelemente verfügbar sind und verschiedene Gestaltungskonzepte implementiert werden. Beispielsweise liegt in Windows 8 eine intensive Nutzung von „GridView“ und „ListView“ vor, in Windows Phone 8 sind diese Steuerelemente jedoch nicht vorhanden. Umgekehrt bietet Windows Phone 8 das Pivot-Steuerelement sowie „LongListSelector“, und beide sind in Windows 8 nicht verfügbar.

Trotzdem der XAML-Code nicht wiederverwendbar ist, können einige Designressourcen für beide Plattformen genutzt werden, und zwar insbesondere der Benutzeroberflächenentwurf von Windows Phone. Das liegt am Konzept der angedockten Ansicht (Snap View) in Windows 8 mit einer festen Breite von 320 Pixeln. Die angedockte Ansicht arbeitet mit 320 Pixeln, da die Entwickler mobiler Anwendungen seit Jahren für Displaybreiten von 320 Pixeln programmieren. Bei der plattformübergreifenden Entwicklung ist das zu meinem Vorteil, da ich keinen brandneuen Entwurf für die angedockte Ansicht entwickeln muss; stattdessen kann ich einfach meinen Windows Phone-Entwurf anpassen. Natürlich gilt es dabei zu bedenken, dass für jede Plattform einzigartige Entwurfsprinzipien zu berücksichtigen sind. Daher werde ich ein bisschen variieren, damit jede App an die jeweilige Plattform angepasst ist.

Wie in Abbildung 3 veranschaulicht wird, habe ich die Benutzeroberfläche für die angedockte Ansicht von Windows 8 so implementiert, dass sie der Windows Phone 8-Benutzeroberfläche sehr ähnlich, aber nicht mit ihr identisch ist. Dass ich kein Designer bin, spiegelt sich in meinem vollkommen langweiligen Benutzeroberflächenentwurf wider. Aber ich hoffe, es wird dennoch klar, wie ähnlich die angedockte Ansicht von Windows 8 und Windows Phone 8 in Bezug auf ihre Benutzeroberflächenentwürfe sein können.

Sample App UI for Windows 8 Snap View (left) and Windows Phone 8 (right)
Abbildung 3: Benutzeroberfläche der Beispiel-App für die angedockte Ansicht in Windows 8 (links) sowie für Windows Phone 8 (rechts)

Unterschiedlicher Code

Als ich mit der plattformübergreifenden Entwicklung mit Windows 8 und Windows Phone 8 begann, bestand eine interessante Problemstellung darin, dass jede Plattform bestimmte Aufgaben unterschiedlich behandelt. Beispielsweise folgen beide Plattformen einem URI-basierten Navigationsschema, die dafür verarbeiteten Parameter sind jedoch unterschiedlich. Folglich müssen die sekundären Kacheln auf verschiedene Weise erstellt werden. Obwohl diese Kacheln auf beiden Plattformen unterstützt werden, ist das, was nach dem Antippen einer sekundären Kachel auf der jeweiligen Plattform geschieht, vollkommen verschieden. Auch die Anwendungseinstellungen werden auf jeder Plattform unterschiedlich gehandhabt, zur Interaktion mit diesen Einstellungen sind zudem verschiedene Klassen vorhanden.

Und schließlich sind in Windows 8 Features enthalten, über die Windows Phone 8 nicht verfügt. Meine Windows 8-Beispiel-App nutzt zwei Hauptkonzepte, die von Windows Phone 8 nicht unterstützt werden, nämlich Verträge und das Charms-Menü. Folglich werden die Charms „Teilen“ und „Einstellungen“ von Windows Phone nicht unterstützt.

Wie soll also mit diesen erheblichen Codeunterschieden umgegangen werden? Dazu gibt es mehrere Methoden.

Compilerdirektiven Beim Erstellen von Projekttypen für Windows 8 und Windows Phone 8 in Visual Studio wird automatisch eine plattformspezifische Compilerdirektive in den Projekteinstellungen definiert – ­NETFX_CORE für Windows 8 und WINDOWS_PHONE für Windows Phone 8. Durch den Einsatz dieser Compilerdirektiven können Sie in Visual Studio festlegen, was für die jeweilige Plattform kompiliert werden muss. Von den verwendbaren Methoden ist diese die grundlegendste, aber auch die am wenigsten strukturierte. Das Ergebnis besteht in Code, der ein wenig einem Schweizer Käse ähnelt: Er ist voller Löcher. Das ist zwar manchmal unumgänglich, aber in diesem Fall gibt es bessere Methoden, die in vielen Fällen eingesetzt werden können.

Abstraktion Das ist die sauberste Methode, die ich bei Plattformunterschieden verwende. Dabei wird die plattformspezifische Funktion in eine Schnittstelle oder eine abstrakte Klasse abstrahiert. Bei dieser Methode können Sie plattformspezifische Implementierungen für Schnittstellen bereitstellen und gleichzeitig eine konsistente Schnittstelle liefern, die in der gesamten Codebasis verwendbar ist. Falls es Hilfscode gibt, der von den einzelnen plattformspezifischen Implementierungen genutzt werden kann, können Sie eine abstrakte Klasse mit diesem gemeinsamen Hilfscode implementieren und nachfolgend die plattformspezifischen Implementierungen bereitstellen. Bei dieser Methode muss die Schnittstelle oder die abstrakte Klasse über die bereits zuvor erwähnte Funktion „Als Link hinzufügen“ in beiden Projekten verfügbar sein.

Abstraktion und Compilerdirektiven Als letzte Methode kann eine Kombination der beiden bereits genannten Methoden eingesetzt werden. Sie können die Plattformunterschiede in eine Schnittstelle oder eine abstrakte Klasse abstrahieren und dann die Compilerdirektiven in der tatsächlichen Implementierung verwenden. Das ist in solchen Fällen praktisch, in denen es nur geringe Plattformunterschiede gibt, sodass eine Trennung nach einzelnen Projekttypen nicht lohnenswert ist.

In der Praxis habe ich festgestellt, dass ich Compilerdirektiven kaum alleine nutze. Das gilt besonders für meine ViewModels. Ich halte meine ViewModels gerne so übersichtlich wie möglich. Wenn also Compilerdirektiven die beste Lösung sind, nutze ich normalerweise auch die Abstraktion, damit der Schweizer Käse möglichst wenig Löcher bekommt.

Als eine der ersten Problemstellungen im Rahmen meiner plattformübergreifenden Reise erwies sich die Navigation. Die Navigation in Windows 8 und Windows Phone 8 ist nicht identisch, aber sehr ähnlich. Windows 8 nutzt nun die URI-basierte Navigation, mit der Windows Phone seit Langem arbeitet. Der Unterschied besteht in der Übergabe der Parameter. In Windows 8 wird ein einzelnes Objekt als Parameter herangezogen, wohingegen in Windows Phone 8 so viele Parameter verarbeitet werden, wie Sie möchten – allerdings über eine Abfragezeichenfolge. Da Windows Phone die Abfragezeichenfolge nutzt, müssen alle Parameter in einer Zeichenfolge serialisiert werden. Wie sich herausstellt, ist Windows 8 in dieser Hinsicht gar nicht so anders.

Obwohl Windows 8 ein einzelnes Objekt als Parameter verwendet, muss dieses Objekt irgendwie serialisiert werden, wenn eine andere App in den Vordergrund tritt und meine App deaktiviert wird. Das Betriebssystem wählt hier den einfachen Weg und ruft „ToString“ für diesen Parameter auf. Das ist für mich allerdings kaum hilfreich, wenn meine App wieder aktiviert wird. Da ich diese beiden Plattformen hinsichtlich meines Entwicklungsaufwands so nahe wie möglich zusammenbringen möchte, ist es sinnvoll, meinen Parameter vor der Navigation als Zeichenfolge zu serialisieren und diese Serialisierung nach abgeschlossener Navigation wieder aufzuheben. Diesen Prozess kann ich durch die Implementierung meines Navigators sogar noch vereinfachen.

Ich möchte eine direkte Referenzierung der Ansichten von meinen ViewModels verhindern, daher muss die Navigation vom ViewModel gesteuert sein. Meine Lösung besteht darin, mittels einer Konvention festzulegen, wie die Ansichten im Namespace bzw. Ordner für Ansichten und wie die ViewModels im Namespace bzw. Ordner für ViewModels platziert werden. Zudem werde ich sicherstellen, dass meine Ansichten „{Irgendetwas}Seite“ und meine ViewModels „{Irgendetwas}ViewModel“ benannt werden. Ist diese Konvention etabliert, kann ich basierend auf dem ViewModel-Typ eine einfache Logik zur Auflösung der Instanz einer Ansicht verwenden.

Anschließend entscheide ich, welche anderen Funktionen für die Navigation benötigt werden:

  • ViewModel-gesteuerte Navigation
  • Möglichkeit, zurückzugehen
  • Für Windows Phone 8: Möglichkeit, Backstack-Eintrag zu entfernen

Die ersten beiden Punkte sind kein Problem. Ich werde später erklären, warum es wichtig ist, einen Backstack-Eintrag entfernen zu können; diese Möglichkeit ist in Windows Phone 8 vorhanden, jedoch nicht in Windows 8.

Sowohl in Windows 8 als auch in Windows Phone 8 werden Klassen zur Navigation eingesetzt, für die nicht einfach Pseudoklassen erstellbar sind. Da aber eines meiner Ziele bei diesen Apps lautet, dass sie testbar sein sollen, werde ich diesen Code hinter einer Pseudoschnittstelle abstrahieren. Daher nutzt mein Navigator eine Kombination aus Abstraktion und Compilerdirektiven. So ist die folgende Schnittstelle entstanden:

public interface INavigator
{
  bool CanGoBack { get; }
  void GoBack();
  void NavigateToViewModel<TViewModel>(object parameter = null);
#if WINDOWS_PHONE
  void RemoveBackEntry();
#endif // WINDOWS_PHONE
}

Beachten Sie die Verwendung von #if WINDOWS_PHONE. Damit wird der Compiler angewiesen, „RemoveBackEntry“ nur dann in die Schnittstellendefinition zu kompilieren, wenn die Compilerdirektive WINDOWS_PHONE definiert ist (also wie bei Windows Phone 8-Projekten). In Abbildung 4 folgt nun meine Implementierung.

Abbildung 4: Implementieren von „INavigator“

public sealed class Navigator : INavigator
{
  private readonly ISerializer serializer;
  private readonly IContainer container;
#if WINDOWS_PHONE
  private readonly Microsoft.Phone.Controls.PhoneApplicationFrame frame;
#endif // WINDOWS_PHONE
  public Navigator(
    ISerializer serializer,
    IContainer container
#if WINDOWS_PHONE
    , Microsoft.Phone.Controls.PhoneApplicationFrame frame
#endif // WINDOWS_PHONE
    )
  {
    this.serializer = serializer;
    this.container = container;
#if WINDOWS_PHONE
    this.frame = frame;
#endif // WINDOWS_PHONE
  }
  public void NavigateToViewModel<TViewModel>(object parameter = null)
  {
    var viewType = ResolveViewType<TViewModel>();
#if NETFX_CORE
    var frame = (Frame)Window.Current.Content;
#endif // NETFX_CORE
      if (parameter != null)
                             {
#if WINDOWS_PHONE
      this.frame.Navigate(ResolveViewUri(viewType, parameter));
#else
      frame.Navigate(viewType, this.serializer.Serialize(parameter));
#endif // WINDOWS_PHONE
    }
    else
    {
#if WINDOWS_PHONE
      this.frame.Navigate(ResolveViewUri(viewType));
#else
      frame.Navigate(viewType);
#endif // WINDOWS_PHONE
    }
  }
  public void GoBack()
  {
#if WINDOWS_PHONE
    this.frame.GoBack();
#else
    ((Frame)Window.Current.Content).GoBack();
#endif // WINDOWS_PHONE
  }
  public bool CanGoBack
  {
    get
    {
#if WINDOWS_PHONE
      return this.frame.CanGoBack;
#else
      return ((Frame)Window.Current.Content).CanGoBack;
#endif // WINDOWS_PHONE
    }
  }
  private static Type ResolveViewType<TViewModel>()
  {
    var viewModelType = typeof(TViewModel);
    var viewName = viewModelType.AssemblyQualifiedName.Replace(
      viewModelType.Name,
      viewModelType.Name.Replace("ViewModel", "Page"));
    return Type.GetType(viewName.Replace("Model", string.Empty));
  }
  private Uri ResolveViewUri(Type viewType, object parameter = null)
  {
    var queryString = string.Empty;
    if (parameter != null)
    {
      var serializedParameter = this.serializer.Serialize(parameter);
      queryString = string.Format("?parameter={0}", serializedParameter);
    }
    var match = System.Text.RegularExpressions.Regex.Match(
      viewType.FullName, @"\.Views.*");
    if (match == null || match.Captures.Count == 0)
    {
      throw new ArgumentException("Views must exist in Views namespace.");
    }
    var path = match.Captures[0].Value.Replace('.', '/');
    return new Uri(string.Format("{0}.xaml{1}", path, queryString),
      UriKind.Relative);
  }
#if WINDOWS_PHONE
  public void RemoveBackEntry()
  {
    this.frame.RemoveBackEntry();
  }
#endif // WINDOWS_PHONE
}

Auf einige Segmente der Navigator-Implementierung in Abbildung 4 möchte ich eingehen, darunter insbesondere die Verwendung der beiden Compilerdirektiven WINDOWS_PHONE und NETFX_CORE. Dadurch kann ich den plattformspezifischen Code separat, aber innerhalb derselben Codedatei lassen. Bei der ResolveViewUri-Methode möchte ich noch darauf hinweisen, wie der Parameter für die Abfragezeichenfolge definiert ist. Damit alles auf beiden Plattformen so konsistent wie möglich ist, soll nur ein Parameter übergeben werden. Dieser eine Parameter wird dann serialisiert und im Rahmen der plattformspezifischen Navigation weitergeleitet. Im Falle von Windows Phone 8 wird der Parameter folglich über eine Variable „Parameter“ in der Abfragezeichenfolge übergeben.

Natürlich ist meine Navigator-Implementierung besonders aufgrund der extrem einfachen Konventionserwartung recht eingeschränkt. Wenn Sie mit einer MVVM-Bibliothek wie beispielsweise „Caliburn.Micro“ arbeiten, kann die tatsächliche Navigation deutlich stabiler verarbeitet werden. Möglicherweise möchten Sie in Ihrer eigenen Navigation dennoch die kombinierte Methode aus Abstraktion und Compilerdirektiven einsetzen, um die in den Bibliotheken selbst vorhandenen Plattformunterschiede auszugleichen.

Anwendungseinstellungen

Auch bei den Anwendungseinstellungen gibt es Unterschiede zwischen Windows 8 und Windows Phone 8. Auf jeder Plattform lassen sich die Anwendungseinstellungen recht einfach speichern, auch die Implementierungen sind sehr ähnlich. Der Unterschied liegt in den verwendeten Klassen. Beide nutzen Klassen, für die nicht einfach Pseudoklassen erstellbar sind, was wiederum der gewünschten Testbarkeit meines ViewModels zuwiderläuft. Also nutze ich einmal mehr die Kombination aus Abstraktion und Compilerdirektiven. Zunächst muss ich entscheiden, wie meine Schnittstelle aussehen soll. Die Schnittstelle soll folgende Anforderungen erfüllen:

  • Hinzufügen oder Aktualisieren einer Einstellung
  • Abrufen einer Einstellung, ohne bei einem Fehler eine Ausnahme auszulösen
  • Entfernen einer Einstellung
  • Ermitteln, ob eine Einstellung für einen bestimmten Schlüssel vorhanden ist

Ganz einfach eigentlich, daher ist auch meine Schnittstelle sehr einfach gehalten:

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

Da beide Plattformen dieselbe Funktionalität aufweisen sollen, brauche ich mich nicht um Compilerdirektiven in der Schnittstelle zu kümmern und kann alles schön einfach und direkt für meine ViewModels halten. In Abbildung 5 wird meine Implementierung der ISettings-Schnittstelle veranschaulicht.

Abbildung 5: Implementieren von „ISettings“

public sealed class Settings : ISettings
{
  public void AddOrUpdate(string key, object value)
  {
#if WINDOWS_PHONE
    IsolatedStorageSettings.ApplicationSettings[key] = value;
    IsolatedStorageSettings.ApplicationSettings.Save();
#else
    ApplicationData.Current.RoamingSettings.Values[key] = value;
#endif // WINDOWS_PHONE
  }
  public bool TryGetValue<T>(string key, out T value)
  {
#if WINDOWS_PHONE
    return IsolatedStorageSettings.ApplicationSettings.TryGetValue<T>(
      key, out value);
#else
    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;
#endif // WINDOWS_PHONE
  }
  public bool Remove(string key)
  {
#if WINDOWS_PHONE
    var result = IsolatedStorageSettings.ApplicationSettings.Remove(key);
    IsolatedStorageSettings.ApplicationSettings.Save();
    return result;
#else
    return ApplicationData.Current.RoamingSettings.Values.Remove(key);
#endif // WINDOWS_PHONE
  }
  public bool ContainsKey(string key)
  {
#if WINDOWS_PHONE
    return IsolatedStorageSettings.ApplicationSettings.Contains(key);
#else
    return ApplicationData.Current.RoamingSettings.Values.ContainsKey(key);
#endif // WINDOWS_PHONE
  }
}

Die in Abbildung 5 dargestellte Implementierung ist ebenso wie die ISettings-Schnittstelle selbst recht einfach gehalten. Sie veranschaulicht, dass die einzelnen Plattformen nur geringe Unterschiede aufweisen und somit nur geringfügig angepasster Code zum Hinzufügen, Abrufen und Entfernen der Anwendungseinstellungen erforderlich ist. Ich möchte noch darauf hinweisen, dass die Windows 8-Codeversion Roamingeinstellungen nutzt, eine spezielle Windows 8-Funktionalität, mit der die Einstellungen einer App in der Cloud gespeichert werden. Wenn ein Benutzer diese App dann auf einem anderen Windows 8-Gerät öffnet, werden dieselben Einstellungen angewendet.

Sekundäre Kacheln

Wie bereits gesagt unterstützen sowohl Windows 8 als auch Windows Phone 8 die Erstellung von sekundären Kacheln, die programmgesteuert generiert und an den Startbildschirm des Benutzers angeheftet werden. Sekundäre Kacheln bieten die Deep-Linking-Funktion, das heißt, der Benutzer tippt eine sekundäre Kachel an und springt direkt zu einem bestimmten Anwendungsteil. Diese Funktion wird von den Benutzern sehr geschätzt, da sie App-Elemente praktisch mit einem Lesezeichen versehen und diese direkt ohne Umwege aufrufen können. Im Falle meiner Beispiel-Apps möchte ich, dass die Benutzer einen einzelnen Blogbeitrag („FeedItem“) mit einem Lesezeichen versehen und dann direkt vom Startbildschirm aus dorthin springen können.

Interessant an den sekundären Kacheln ist, dass die Art und Weise der Implementierung für die einzelnen Plattformen sehr unterschiedlich ist, obwohl diese Funktion von beiden unterstützt wird. Das ist die perfekte Situation, um ein komplexeres Abstraktionsbeispiel vorzustellen.

Wenn Sie meinen Artikel in der Ausgabe vom Juli 2013 gelesen haben, erinnern Sie sich vielleicht daran, dass ich die Abstraktion sekundärer Kacheln für die MVVM-Entwicklung mit Windows 8 angesprochen habe. Die von mir vorgestellte Lösung funktioniert perfekt für Windows 8, bei Windows Phone 8 hingegen erfolgt nicht einmal die Kompilierung. Im Folgenden möchte ich die natürliche Weiterentwicklung der Windows 8-Lösung präsentieren, die sowohl mit Windows 8 als auch mit Windows Phone 8 funktioniert.

In Windows 8 sieht die Schnittstelle folgendermaßen aus:

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

Wie gesagt: Diese Schnittstelle wird in Windows Phone 8 nicht kompiliert. Besonders problematisch ist der Einsatz von „FrameworkElement“ und der Placement-Aufzählung. Keines dieser Elemente gleicht sich in Windows Phone 8 und in Windows 8. Mein Ziel ist nun, diese Schnittstelle ein wenig zu ändern, damit sie von beiden Plattformen problemlos verwendet werden kann. Wie Sie sehen, verwendet die ISecondaryPinner.Pin-Methode ein TileInfo-Objekt als Parameter. Bei „TileInfo“ handelt es sich um ein von mir angelegtes, einfaches Datentransferobjekt (DTO) mit Informationen, die zum Erstellen einer sekundären Kachel erforderlich sind. Es ist ganz einfach, die von der Windows 8-Version benötigten Parameter in die TileInfo-Klasse zu verschieben und diese dann mithilfe der Compilerdirektiven in die Windows 8-Version der TileInfo-Klasse zu kompilieren. Dann sieht meine ISecondaryPinner-Schnittstelle folgendermaßen geändert aus:

public interface ISecondaryPinner
{
  Task<bool> Pin(TileInfo tileInfo);
  Task<bool> Unpin(TileInfo tileInfo);
  bool IsPinned(string tileId);
}

Wie Sie sehen, sind die Methoden dieselben, jedoch haben sich die Parameter für „Pin“ (Anheften) und „Unpin“ (Lösen) leicht verändert. Diese Änderungen haben sich auch auf die TileInfo-Klasse ausgewirkt, die nun wie folgt aussieht:

public sealed class TileInfo
{
  public string TileId { get; set; }
  public string ShortName { get; set; }
  public string DisplayName { get; set; }
  public string Arguments { get; set; }
  public Uri LogoUri { get; set; }
  public Uri WideLogoUri { get; set; }
  public string AppName { get; set; }
  public int? Count { get; set; }
#if NETFX_CORE
  public Windows.UI.StartScreen.TileOptions TileOptions { get; set; }
  public Windows.UI.Xaml.FrameworkElement AnchorElement { get; set; }
  public Placement RequestPlacement { get; set; }
#endif // NETFX_CORE
}

Eigentlich stelle ich lieber Konstruktoren für jedes Szenario bereit, in dem Hilfs-DTOs wie diese verwendet werden. So wird absolut deutlich, welche Parameter zu welchem Zeitpunkt benötigt werden. Aus Gründen der Übersichtlichkeit habe ich die verschiedenen Konstruktoren aus dem TileInfo-Codeausschnitt entfernt, aber Sie finden sie in aller Ausführlichkeit im Codebeispiel.

„TileInfo“ verfügt nun über alle Eigenschaften, die sowohl für Windows 8 als auch für Windows Phone 8 erforderlich sind. Im nächsten Schritt wird die ISecondaryPinner-Schnittstelle implementiert. Da diese Implementierung für beide Plattformen sehr unterschiedlich ist, verwende ich zwar dieselbe Schnittstelle in beiden Projekttypen, stelle dann aber plattformspezifische Implementierungen für die einzelnen Projekte bereit. Dadurch vermeide ich den Schweizer-Käse-Effekt, den Compilerdirektiven in diesem Fall verursachen würden. In Abbildung 6 wird die ISecondaryPinner-Implementierung für Windows 8 mit den aktualisierten Methodensignaturen dargestellt.

Abbildung 6: Implementieren von „ISecondaryPinner“ für Windows 8

public sealed class Win8SecondaryPinner : ISecondaryPinner
{
  public async Task<bool> Pin(TileInfo tileInfo)
  {
    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(tileInfo.AnchorElement), tileInfo.RequestPlacement);
    }
    return isPinned;
  }
  public async Task<bool> Unpin(TileInfo tileInfo)
  {
    var wasUnpinned = false;
    if (SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(tileInfo.TileId);
      wasUnpinned = await secondaryTile.RequestDeleteForSelectionAsync(
        GetElementRect(tileInfo.AnchorElement), tileInfo.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));
  }
}

Wichtig zu wissen ist, dass in Windows 8 nicht einfach stillschweigend und programmgesteuert eine sekundäre Kachel erstellt werden kann. Dazu ist die Zustimmung des Benutzers erforderlich. In Windows Phone 8 hingegen ist das möglich. Ich muss folglich eine SecondaryTile-Instanz erstellen und einen RequestCreateForSelectionAsync-Aufruf starten, sodass an der von mir angegebenen Position ein Dialogfeld angezeigt wird. In diesem wird der Benutzer aufgefordert, der Erstellung (oder Löschung) der sekundären Kachel zuzustimmen. Die GetElementRect-Hilfsmethode empfängt ein „FrameworkElement“ – die vom Benutzer gewählte Schaltfläche zum Anheften der sekundären Kachel – und berechnet dann das Rechteck für die Positionierung des erforderlichen Dialogfelds.

In Abbildung 7 wird die ISecondaryPinner-Implementierung für Windows Phone 8 veranschaulicht.

Abbildung 7: Implementieren von „ISecondaryPinner“ für Windows Phone 8

public sealed class WP8SecondaryPinner : ISecondaryPinner
{
  public Task<bool> Pin(TileInfo tileInfo)
  {
    var result = false;
    if (!this.IsPinned(tileInfo.TileId))
    {
      var tileData = new StandardTileData
      {
        Title = tileInfo.DisplayName,
        BackgroundImage = tileInfo.LogoUri,
        Count = tileInfo.Count,
        BackTitle = tileInfo.AppName,
        BackBackgroundImage = new Uri("", UriKind.Relative),
        BackContent = tileInfo.DisplayName
      };
      ShellTile.Create(new Uri(tileInfo.TileId, UriKind.Relative), 
        tileData);
      result = true;
    }
  return Task.FromResult<bool>(result);
  }
  public Task<bool> Unpin(TileInfo tileInfo)
  {
    ShellTile tile = this.FindTile(tileInfo.TileId);
    if (tile != null)
    {
      tile.Delete();
    }
    return Task.FromResult<bool>(true);
  }
  public bool IsPinned(string tileId)
  {
    return FindTile(tileId) != null;
  }
  private ShellTile FindTile(string uri)
  {
    return ShellTile.ActiveTiles.FirstOrDefault(
      tile => tile.NavigationUri.ToString() == uri);
  }
}

In Bezug auf die Windows Phone 8-Implementierung möchte ich noch auf einige Punkte hinweisen. Erstens, dass die sekundäre Kachel mit der StandardTileData-Klasse und der statischen ShellTile.Create-Methode erstellt wird. Zweitens, dass die Windows Phone 8-Implementierung zum Erstellen sekundärer Kacheln nicht asynchron ist. Da die Windows 8-Implementierung jedoch asynchron ist, muss ich die Schnittstelle so programmieren, dass sie das async/await-Muster unterstützt. Glücklicherweise lässt es sich einfach bewerkstelligen, dass eine ansonsten nicht asynchrone Methode das async/await-Muster unterstützt, und zwar mithilfe der statischen und generischen Task.FromResult-Methode. Daher ist es für die ViewModels, die mit der ISecondaryPinner-Schnittstelle arbeiten, nicht wichtig, dass Windows 8 an sich asynchron ist und Windows Phone 8 eben nicht.

Sie können sehen, wie Windows 8 (Abbildung 6) und Windows Phone 8 (Abbildung 7) in ihrer Implementierung der sekundären Kacheln variieren. In Bezug auf die sekundären Kacheln ist das jedoch noch nicht das Ende vom Lied. Ich habe nur die Implementierung der ISecondaryPinner-Schnittstelle vorgestellt. Da jede Plattform anders ist und Werte für unterschiedliche Eigenschaften der TileInfo-Klasse bereitstellen muss, bin ich gezwungen, auch plattformspezifische Implementierungen der ViewModels, die sie verwenden, zu entwickeln. In meiner Beispielanwendung biete ich die Möglichkeit, einen einzelnen Blogbeitrag (oder ein „FeedItem“) anzuheften, folglich handelt es sich bei dem fraglichen ViewModel um „FeedItemViewModel“.

Aus ViewModel-Sicht gibt es auch gemeinsame Funktionen in Windows 8 und Windows Phone 8. Hat der Benutzer ein „FeedItem“ angeheftet, möchte ich, dass dieses auf beiden Plattformen lokal gespeichert wird, damit es erneut geladen wird, sobald der Benutzer die entsprechende sekundäre Kachel antippt. Umgekehrt soll das „FeedItem“, nachdem es vom Benutzer wieder gelöst wurde, auf beiden Plattformen aus dem lokalen Speicher gelöscht werden. Diese gemeinsame Funktion soll auf beiden Plattformen implementiert werden, jedoch ergänzt durch plattformspezifische Implementierungen für die Funktionalität der sekundären Kachel. Folglich ist es sinnvoll, eine Basisklasse mit einer Implementierung der gemeinsamen Funktion bereitzustellen und diese Klasse auf beiden Plattformen verfügbar zu machen. Dann kann diese Basisklasse auf jeder Plattform mit plattformspezifischen Klassen vererbt werden, in denen die plattformspezifischen Implementierungen für das Anheften und Lösen sekundärer Kacheln enthalten sind.

In Abbildung 8 wird die FeedItemViewModel-Basisklasse dargestellt, die von beiden Plattformen geerbt wird. „FeedItemViewModel“ enthält sämtliche Elemente, die beiden Plattformen gemein sind.

Abbildung 8: FeedItemViewModel-Basisklasse

public abstract class FeedItemViewModel : ViewModelBase<FeedItem>
{
  private readonly IStorage storage;
  protected readonly ISecondaryPinner secondaryPinner;
  public FeedItemViewModel(
    ISerializer serializer,   
    IStorage storage,
    ISecondaryPinner secondaryPinner)
    : base(serializer)
  {
    this.storage = storage;
    this.secondaryPinner = secondaryPinner;
  }
  public override void LoadState(FeedItem navigationParameter,
    Dictionary<string, object> pageState)
  {
    this.FeedItem = navigationParameter;
  }
  protected async Task SavePinnedFeedItem()
  {
    var pinnedFeedItems =
      await this.storage.LoadAsync<List<FeedItem>>(
      Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems == null)
    {
      pinnedFeedItems = new List<FeedItem>();
    }
    pinnedFeedItems.Add(feedItem);
    await this.storage.SaveAsync(Constants.PinnedFeedItemsKey, 
      pinnedFeedItems);
  }
  protected async Task RemovePinnedFeedItem()
  {
    var pinnedFeedItems =
      await this.storage.LoadAsync<List<FeedItem>>(
      Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
       var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id ==
         this.FeedItem.Id);
       if (pinnedFeedItem != null)
       {
         pinnedFeedItems.Remove(pinnedFeedItem);
       }
      await this.storage.SaveAsync(Constants.PinnedFeedItemsKey, 
        pinnedFeedItems);
    }
  }
  private FeedItem feedItem;
  public FeedItem FeedItem
  {
    get { return this.feedItem; }
    set { this.SetProperty(ref this.feedItem, value); }
  }
  private bool isFeedItemPinned;
  public bool IsFeedItemPinned
  {
    get { return this.isFeedItemPinned; }
    set { this.SetProperty(ref this.isFeedItemPinned, value); }
  }
}

Mit einer Basisklasse, die das Speichern und Löschen von angehefteten „FeedItems“ ermöglicht, kann ich nun zu den plattformspezifischen Implementierungen übergehen. Die konkrete FeedItemViewModel-Implementierung für Windows 8 und die Verwendung der TileInfo-Klasse mit den für Windows 8 relevanten Eigenschaften werden in Abbildung 9 dargestellt.

Abbildung 9: Konkrete FeedItemViewModel-Implementierung für Windows 8

public sealed class Win8FeedItemViewModel : FeedItemViewModel
{
  private readonly IShareManager shareManager;
  public Win8FeedItemViewModel(
    ISerializer serializer,
    IStorage storage,
    ISecondaryPinner secondaryPinner,
    IShareManager shareManager)
    : base(serializer, storage, secondaryPinner)
  {
  this.shareManager = shareManager;
  }
  public override void LoadState(Models.FeedItem navigationParameter,
    Dictionary<string, object> pageState)
  {
    base.LoadState(navigationParameter, pageState);
    this.IsFeedItemPinned =
      this.secondaryPinner.IsPinned(FormatSecondaryTileId());
  }
  public override void SaveState(Dictionary<string, object> pageState)
  {
    base.SaveState(pageState);
    this.shareManager.Cleanup();
  }
  public async Task Pin(Windows.UI.Xaml.FrameworkElement anchorElement)
  {
    // Pin the feed item, then save it locally to make sure it is still
    // available when they return.
    var tileInfo = new TileInfo(
      this.FormatSecondaryTileId(),
      this.FeedItem.Title,
      this.FeedItem.Title,
      Windows.UI.StartScreen.TileOptions.ShowNameOnLogo |
        Windows.UI.StartScreen.TileOptions.ShowNameOnWideLogo,
      new Uri("ms-appx:///Assets/Logo.png"),
      new Uri("ms-appx:///Assets/WideLogo.png"),
      anchorElement,
      Windows.UI.Popups.Placement.Above,
      this.FeedItem.Id.ToString());
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
    if (this.IsFeedItemPinned)
    {
       await SavePinnedFeedItem();
    }
  }
  public async Task Unpin(Windows.UI.Xaml.FrameworkElement anchorElement)
  {
    // Unpin, then delete the feed item locally.
  var tileInfo = new TileInfo(this.FormatSecondaryTileId(), anchorElement,
    Windows.UI.Popups.Placement.Above);
    this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(tileInfo);
    if (!this.IsFeedItemPinned)
    {
      await RemovePinnedFeedItem();
    }
  }
  private string FormatSecondaryTileId()
  {
    return string.Format(Constants.SecondaryIdFormat, this.FeedItem.Id);
  }
}

In Abbildung 10 wird die konkrete FeedItemViewModel-Implementierung für Windows Phone 8 veranschaulicht. Zur Verwendung der TileInfo-Klasse für Windows Phone 8 werden weniger Eigenschaften benötigt als für Windows 8.

Abbildung 10: Konkrete FeedItemViewModel-Implementierung für Windows Phone 8

public sealed class WP8FeedItemViewModel : FeedItemViewModel
{
  public WP8FeedItemViewModel(
    ISerializer serializer,
    IStorage storage,
    ISecondaryPinner secondaryPinner)
    : base(serializer, storage, secondaryPinner)
  {
  }
  public async Task Pin()
  {
    // Pin the feed item, then save it locally to make sure it is still
    // available when they return.
    var tileInfo = new TileInfo(
      this.FormatTileIdUrl(),
      this.FeedItem.Title,
      Constants.AppName,
      new Uri("/Assets/ApplicationIcon.png", UriKind.Relative));
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
    if (this.IsFeedItemPinned)
    {
      await this.SavePinnedFeedItem();
    }
  }
  public async Task Unpin()
  {
    // Unpin, then delete the feed item locally.
    var tileInfo = new TileInfo(this.FormatTileIdUrl());
    this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(tileInfo);
    if (!this.IsFeedItemPinned)
    {
      await this.RemovePinnedFeedItem();
    }
  }
  private string FormatTileIdUrl()
  {
    var queryString = string.Format("parameter={0}", FeedItem.Id);
    return string.Format(Constants.SecondaryUriFormat, queryString);
  }
}

Nach den FeedItemViewModel-Implementierungen für Windows 8 (Abbildung 9) and Windows Phone 8 (Abbildung 10) können in all meinen Apps sekundäre Kacheln auf dem jeweiligen Startbildschirm angeheftet werden. Um die Schleifenfunktionalität zu schließen, bleibt noch festzulegen, was geschieht, wenn der Benutzer tatsächlich die angehefteten sekundären Kacheln antippt. Mein Ziel für beide Apps ist, die App direkt im durch die sekundäre Kachel dargestellten Blogbeitrag zu starten, es dem Benutzer aber zu ermöglichen, mit der Schaltfläche „Zurück“ zur Hauptseite mit den aufgeführten Blogs zu navigieren, anstatt die App selbst zu verlassen.

Aus der Windows 8-Perspektive gibt es keine Änderungen zu meinem Artikel in der Ausgabe vom Juli 2013. In Abbildung 11 wird der unveränderte Windows 8-Code für den Start der App dargestellt, bei der es sich um einen Abschnitt der Klassendatei „App.xaml.cs“ handelt.

Abbildung 11: Starten der App aus Windows 8

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

In Windows Phone 8 gestaltet sich dies schwieriger. In diesem Fall benötigt die sekundäre Kachel anstelle von Parametern einen URI (Uniform Resource Identifier). Das heißt, Windows Phone 8 kann meine App automatisch auf jeder von mir gewünschten Seite starten, ohne zuerst – wie bei Windows 8 der Fall – einen zentralen Startpunkt zu durchlaufen. Da ich auf beiden Plattformen eine konsistente Benutzerfreundlichkeit bieten möchte, habe ich einen eigenen zentralen Startpunkt mit der Bezeichnung „SplashViewModel“ für Windows Phone 8 erstellt, der in Abbildung 12 dargestellt wird. Ich habe das Projekt so eingerichtet, dass der Start immer dann erfolgt, wenn auch die App gestartet wird, über eine sekundäre Kachel oder anders.

Abbildung 12: „SplashViewModel“ für Windows Phone 8

public sealed class SplashViewModel : ViewModelBase<int?>
{
  private readonly IStorage storage;
  private readonly INavigator navigator;
  public SplashViewModel(
    IStorage storage,
    INavigator navigator,
    ISerializer serializer)
    : base(serializer)
  {
    this.storage = storage;
    this.navigator = navigator;
  }
  public override async void LoadState(
    int? navigationParameter, Dictionary<string, object> pageState)
  {
    this.navigator.NavigateToViewModel<MainViewModel>();
    this.navigator.RemoveBackEntry();
    if (navigationParameter.HasValue)
    {
      List<FeedItem> pinnedFeedItems =
        await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
      if (pinnedFeedItems != null)
      {
        var pinnedFeedItem =
          pinnedFeedItems.FirstOrDefault(fi => 
            fi.Id == navigationParameter.Value);
        if (pinnedFeedItem != null)
        {
  this.navigator.NavigateToViewModel<FeedItemViewModel>(pinnedFeedItem);
        }
      }
    }
  }
}

Der SplashViewModel-Code ist ziemlich einfach. Ich möchte noch betonen, wie wichtig es ist, diese Seite aus dem Backstack zu entfernen, denn ansonsten können die Benutzer die App nicht mehr verlassen. Sie würden jedes Mal, wenn sie versuchen, auf diese Seite zurückzukehren, wieder zurück zur App geleitet. Hier kommt eine wertvolle Erweiterung für „INavigator“ für Windows Phone 8 ins Spiel: „RemoveBackEntry“. Nach der Navigation zu „MainViewModel“ rufe ich „RemoveBackEntry“ auf, um die Splash-Seite aus dem Backstack zu entfernen. Diese Seite wird jetzt nur einmal beim Start der App verwendet.

Zusammenfassung

In diesem Artikel habe ich die plattformübergreifende Entwicklung mit Windows 8 und Windows Phone 8 dargelegt. Ich habe ausgeführt, welche Elemente zwischen beiden Plattformen wiederverwendbar sind (Entwürfe und Codeabschnitte) und welche nicht (XAML). Zudem bin ich auf einige Probleme eingegangen, auf die Entwickler beim Arbeiten an plattformübergreifenden Apps treffen, und habe einige Lösungen für die Problemstellungen vorgestellt. Ich hoffe, dass diese Lösungen auch in anderen als den hier dargelegten Fällen von Navigation, Anwendungseinstellungen und sekundären Kacheln angewendet werden können. Diese Lösungen können Sie dabei unterstützen, Ihre ViewModels so zu gestalten, dass sie getestet werden können. Dafür bieten sie die Möglichkeit, einige Interaktionen des Betriebssystems zu abstrahieren und mithilfe von Schnittstellen daraus Pseudointeraktionen zu erzeugen.

In meinem nächsten Artikel beschäftige ich mich eingehend mit den tatsächlichen Komponententests dieser plattformübergreifenden Anwendungen, denn nun können sie getestet werden. Außerdem werde ich einige der von mir getroffenen Entscheidungen im Zusammenhang mit den Tests erläutern und darlegen, wie genau die Komponententests für die Apps ablaufen.

Ich bin mit dem Ziel an die plattformübergreifende Entwicklung herangegangen, auf beiden Plattformen eine identische Benutzerfreundlichkeit zu bieten. Durch ein wenig Planung im Vorfeld kann ich Apps schreiben, die eine maximale Codewiederverwendbarkeit bieten und Komponententests ermöglichen. Ich kann die plattformspezifischen Features – wie das Charms-Menü in Windows 8 – nutzen, ohne dass die jeweils von der Plattform gebotene Benutzerfreundlichkeit beeinträchtigt wird.

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: Jason Bock (Magenic)