Teil 2: Plattformübergreifende App-Entwicklung mit dem MVVMbasics Framework

Teil 2: Plattformübergreifende App-Entwicklung mit dem MVVMbasics Framework

Gastbeitrag von Andreas Kuntner

Dieser Artikel ist der zweite Teil einer dreiteiligen Serie. (Teil 1)

Andreas Kuntner arbeitet als .NET Entwickler mit Schwerpunkt auf Client-, UI- und Mobile Development, und betreut in seiner Freizeit das MVVMbasics Framework und den Developer-Blog https://blog.mobilemotion.eu/

Commands

Um den Login am Webservice durchzuführen wird weiters ein Command benötigt, nennen wir es LoginCommand. Für einface Delegate-Commands bietet MVVMbasics die Basisklasse BaseCommand. Ein solches Command besteht aus drei Teilen:

1. Zunächst muss das Command als Property des Viewmodels (oder Models), in dem es ausgeführt werden soll, definiert werden:

 public BaseCommand LoginCommand { get; set; }

2. Weiters brauchen wir eine private Methode, die gestartet wird sobald das Command ausgeführt wird. Der Rückgabetyp einer solchen Methode muss void sein, und sie darf maximal einen Parameter vom Typ object erwarten (in diesem Fall muss in der zugehörigen View ein CommandParameter spezifiziert sein) – im Fall unseres LoginModel ist aber kein Parameter notwendig:

 private async void Login()
{
   //TODO
}

Innerhalb dieser Methode werden wir später auf das Webservice zugreifen (aus diesem Grund ist sie auch als async gekennzeichnet), mehr dazu in Kürze.

3. Schließlich muss noch das Command mit dieser Methode verknüpft werden. Dies geschieht während der Instanziierung des Commands (üblicherweise im Constructor des Viewmodels oder Models) mit Hilfe der von MVVMbasics angebotenen CreateCommand Methode, der im einfachsten Fall nur die vorher definierte Methode übergeben wird:

 LoginCommand = CreateCommand(Login);

Optional können wir als Teil eines Commands aber auch eine Bedingung definieren, unter der das Command ausgeführt werden darf. Falls gewünscht, kann eine solche Bedingung in Form einer Expression als zweiter Parameter der CreateCommand Methode übergeben werden. In unserem Fall wollen wir den Login nur erlauben, falls ein Username angegeben wurde und der User mindestens 18 Jahre alt ist:

LoginCommand = CreateCommand(Login, () => !String.IsNullOrEmpty(Name) && Age >= 18);

MVVMbasics analysiert diese Bedingung, merkt sich alle Properties die darin vorkommen, und wertet die Bedingung erneut aus sobald sich eines der Properties ändert. In unserem Fall heißt das: Wird entweder kein Username angegeben oder ist das eingegebene Alter kleiner als 18, wird der Button der mit dem Command verknüpft ist deaktiviert und das Command kann nicht ausgeführt werden!

 

Services

Alle Teile der Businesslogik, die nicht direkt im Viewmodel enthalten sind, werden in Services ausgelagert. Ein typischer Anwendungsfall für Services ist der Zugriff auf Sensoren, Datenbanken, Dateien, Webservices, etc. MVVMbasics liefert drei fertige Services mit, die zur Navigation, zum Anzeigen von Hinweisfenstern und für Timer verwendet werden können, wir werden alle drei noch kennenlernen. Für den Zugriff aufs Webservice werden wir aber ein eigenes Service definieren. Da ich für den Zugriff lediglich die System.Net.Http.HttpClient Klasse und die JSON.NET Bibliothek verwende, kann das Service innerhalb der Portable Class Library erstellt werden – legen wir also einen neuen Namespace Services an und erstellen eine Klasse BackendService!

Services müssen bei der Verwendung von MVVMbasics vom Interface IService abgeleitet werden. Dieses enthält aber keine Member die implementiert werden müssen, der Inhalt eines Service kann also frei definiert werden. Es müssen lediglich eine oder mehrere öffentliche Methoden vorhanden sein, denn diese können später aus einem der Viewmodels aufgerufen werden. Unser BackendService enthält drei Methoden:

 public class BackendService : IService
{
 public async Task<int> LoginAsync(string username)
 {
       //TODO
 }

   public async void SendMessageAsync(string message, int userId)
  {
       //TODO
 }
 
   public async Task<List<MessageModel>> GetNewMessagesAsync()
    {
       //TODO
 }
}

Da sie auf einen Webserver zugreifen, habe ich alle Methoden als async gekennzeichnet. Die Inhalte der drei Methoden könnt ihr in im vollständigen Projekt nachlesen, fürs MVVM-Pattern sind sie nicht weiter interessant.

Um auf solche Services zuzugreifen gibt es natürlich viele Möglichkeiten, wenn gewünscht kann etwa ein IoC Container eingebunden werden, die einfachste ist jedenfalls den in MVVMbasics enthaltenen ServiceLocator zu verwenden: Jede auf MVVMbasics basierende Anwendung beinhaltet eine globale ServiceLocator-Instanz, auf die aus jedem Viewmodel zugegriffen werden kann. Um etwa innerhalb unserer Login Methode die LoginAsync Methode des BackendService zu verwenden, holen wir eine Instanz des BackendService:

 ServiceLocator.Retrieve<BackendService>().LoginAsync(Username);

Die Voraussetzung dafür ist, dass das BackendService zuvor im ServiceLocator registriert wurde – doch dazu später. Da beim Login auf dem Webserver durchaus etwas schiefgehen kann (was etwa, wenn der Server nicht erreichbar ist?), stecken wir das ganze sicherheitshalber in einen try/catch Block, und verwenden im Fehlerfall das mit MVVMbasics ausgelieferte MessageboxService, um dem Benutzer eine sprechende Fehlermeldung zu präsentieren. Und eins noch: Falls der Login erfolgreich war, benutzen wir das (ebenfalls in MVVMbasics integrierte) NavigatorService, um zur Chat-Page zu navigieren. Die gesamte Login Methode sieht nun folgendermaßen aus:

 

Zwei Hinweise noch für die Verwendung von ServiceLocator:

· Services werden im ServiceLocator in den meisten Fällen nur mit ihrem Typ registriert – eine Instanz dieses Service-Typs wird erst erstellt, wenn das Service zum ersten Mal verwendet wird (also beim ersten Aufruf von ServiceLocator.Retrieve<T>). Danach wird diese Instanz aber im Speicher behalten, weitere Aufrufe der Retrieve Methode mit dem selben Service-Typ verwenden dann diese vorhandene Instanz.
Services, die nur sehr selten genutzt werden (in unserem Fall das MessageboxService – hoffentlich muss der Benutzer nicht allzu oft über einen Fehler benachrichtigt werden!), können stattdessen mit der RetrieveOnce Methode aufgerufen werden. Der einzige Unterschied zur Retrieve Methode ist, dass die erzeugte Service-Instanz nicht im Speicher behalten wird.

· Die drei mit MVVMbasics ausgelieferten Services haben für jede Zielplattform eine unterschiedliche Implementierung, aus diesem Grund werden statt der konkreten Service-Klassen die Interfaces benutzt, die den Funktionsumfang der Services definieren (wir befinden uns ja nach wie vor innerhalb eine Portable Class Library, innerhalb derer wir keine plattformspezifischen Klassen aufrufen können!).

Nach dem erfolgreichen Login wechseln wir auf die Chat-Page, indem wir der NavigateTo Methode des NavigatorService den Typ desjenigen Viewmodels übergeben, das der Chat-Page zugeordnet ist (wie bereits erwähnt ist ja immer genau ein Viewmodel einer Page zugeordnet, mehr zu diesen Zuordnungen später).

Zusätzlich kann optional eine beliebige Anzahl an Parametern an die Chat-Page weitergeleitet werden – in unserem Fall brauchen wir nur einen Parameter übergeben, nämlich die ID des angemeldeten Users. Dazu übergeben wir einen eindeutigen Namen („user“ ), gefolgt vom Inhalt des Parameters (das kann ein beliebiges Objekt sein, in unserem Fall ein Integer). Wo dieser Parameter ankommt, sehen wir uns im folgenden Abschnitt an!

 private async void Login()
{
  int userId = 0;
   try
    {
       userId = await ServiceLocator.Retrieve<BackendService>().LoginAsync(Username);
    }
   catch
  {
       ServiceLocator.RetrieveOnce<IMessageboxService>().Show("Fehler bei der Anmeldung!");
     return;
    }
 
   ServiceLocator.Retrieve<INavigatorService>().NavigateTo<ChatViewmodel>("user", userId);
}

Navigations-Events

Mit diesem Vorwissen sollte es jetzt ganz einfach sein, das zweite Viewmodel ChatViewmodel zu befüllen! Die Chat-Page soll eine Liste der bisher empfangenen Nachrichten und ein Textfeld zum Schreiben eigener Nachrichten beinhalten - Nach bewährtem Muster erstellen wir also zwei Properties, eine vom Typ ObservableCollection<MessageModel> (die Liste aller bisherigen Nachrichten) und eine vom Typ string (der Inhalt einer neuen Nachricht).

Weiters benötigen wir zwei Commands, eines zum Absenden einer neuen Nachricht, und eines zum Aktualisieren der Liste der bisherigen Nachrichten. Da beide Commands nur mit einer Methode verbunden werden, aber keine Bedingung haben wann sie ausgeführt werden dürfen, können wir uns ein kleines Feature vom MVVMbasics zunutze machen: Wir markieren die ChatViewmodel-Klasse mit dem Attribut [MvvmCommandAutobinding], und benennen die beiden Methoden genauso wie die entsprechenden Commands nur ohne den Zusatz „...Command“. Dadurch wird automatisch das SendCommand mit der Send Methode und das RefreshCommand mit der Refresh Methode verknüpft, und wir ersparen uns die beiden Aufrufe von CreateCommand im Constructor – keine große Ersparnis in diesem Fall, aber ganz praktisch wenns mal größere Mengen von Commands geben sollte!

Da wir in diesem Viewmodel einige Services öfters benötigen werden, speichern wir deren Instanzen in privaten Variablen beim Start des Viewmodels, dadurch muss nicht bei jeder Verwendung der lange Weg über ServiceLocator.Retrieve<T>() gegangen werden. Da der globale ServiceLocator im Constructor des Viewmodels noch nicht zur Verfügung steht, passiert das am besten in der überschriebenen Methode OnServiceLocatorAvailable des Viewmodels, die üblicherweise direkt nach dem Constructor aufgerufen wird:

 public override void OnServiceLocatorAvailable()
{
    base.OnServiceLocatorAvailable();
  _backend = ServiceLocator.Retrieve<BackendService>();
 _timer = ServiceLocator.Retrieve<ITimerService>();
}

Richtig spannend wird’s aber jetzt: Das ChatViewmodel repräsentiert ja eine ganze Page, daher ist es auch ganz einfach möglich auf Navigations-Events dieser Page zu reagieren: Beim Aufruf einer Page (oder, im Fall eine WPF-Anwendung, eines Fensters) wird die Methode OnNavigatedTo aufgerufen, beim Schließen oder Weiter-Navigieren von einer Page oder einem Fenster die CancelNavigatingFrom und OnNavigatedFrom Methoden, und alle drei können ganz einfach überschrieben werden:

In der OnNavigatedTo Methode rufen wir die Refresh-Methode auf, um die Liste aller bisherigen Nachrichten zu befüllen. Außerdem soll sich diese Liste ja regelmäßig aktualisieren – die eleganteste Lösung wäre natürlich, vom Webservice direkt benachrichtigt zu werden, da es hier ja aber nicht um Webservices sondern um die Möglichkeiten des Clients gehen soll begnügen wir uns damit, mit Hilfe eines Timers die Liste alle 10 Sekunden zu aktualisieren. Dazu benutzen wir das TimerService, starten einen Timer der sich alle 10 Sekunden neu startet, übergeben die Refresh Methode als Aktion, und speichern die ID dieses neu erstellen Timers, wir werden sie später noch brauchen:

 _refreshTimerId = _timer.StartLooping(TimeSpan.FromSeconds(10), Refresh);

Innerhalb der OnNavigatedTo Methode ist es auch möglich festzustellen, in welchem Modus sich die Page bzw. das Fenster befinden. Wird die Page zum ersten Mal gestartet (ViewState.Activated), sorgen wir dafür, dass

1. die als Parameter übergebene User-ID ausgelesen und in einer privaten Variable gespeichert wird (beim Absenden einer neuen Nachricht muss diese immer mitgesendet werden), und

2. die Login-Page aus der Liste der bisher geöffneten Pages entfernt wird – dabei helfen die Methoden RemoveBackEntry und ClearBackStack des NavigatorService (ansonsten würde ja, wenn ein User am Phone die Zurück-Taste drückt, wieder die Login-Page angezeigt werden, stattdessen wollen wir aber die App beenden)

Die gesamte OnNavigatedTo Methode sieht jetzt so aus:

 public override void OnNavigatedTo(ParameterList uriParameters, ParameterList parameters, ViewState viewState)
{
 base.OnNavigatedTo(uriParameters, parameters, viewState);
  if (viewState == ViewState.Activated)
  {
       parameters.TryGetInt("user", out _userId);
        ServiceLocator.Retrieve<INavigatorService>().RemoveBackEntry();
   }
 
   Refresh();
  _refreshTimerId = _timer.StartLooping(TimeSpan.FromSeconds(10), Refresh);
}

Einen kleinen Schönheitsfehler gibt es noch: Wenn die App in den Hintergrund tritt, etwa weil der Benutzer auf den Windows-Startbildschirm wechselt oder weil am Phone eine ankommende SMS gelesen wird, werden im Hintergrund unnötigerweise weiterhin alle 10 Sekunden neue Nachrichten heruntergeladen. Um das zu unterbinden, überschreiben wir auch noch die OnNavigatedFrom Methode, um benachrichtigt zu werden wenn die App deaktiviert wird, und stoppen in diesem Fall den Refresh-Timer:

 public override void OnNavigatedFrom(ViewState viewState)
{
  base.OnNavigatedFrom(viewState);
 
   if (viewState == ViewState.Hidden)
     _timer.Stop(_refreshTimerId);
}

Ableiten der App-Klasse

So weit, so gut – was nun noch fehlt, ist das App-Projekt zu erstellen und die beiden Pages zu designen. Fügen wir also ein Universal App Projekt zur Solution hinzu, das sowohl Windows 8 als auch die Windows Phone 8.1 Plattform unterstützt. Sowohl zum Windows- als auch zum WindowsPhone-Projekt muss einerseits eine Referenz auf das bestehende Portable Class Library Projekt hinzugefügt werden, dazu wählen wir Add > Reference aus dem Kontextmenü der beiden Projekte im Solution Explorer, wählen Solution und klicken das PCL-Projekt an. Andererseits muss auch das MVVMbasics NuGet-Paket in beiden Projekten installiert werden, dazu gehen wir vor wie ganz am Anfang dieses Artikels beschrieben. Am Ende sollte der References-Bereich der beiden Projekte zwei MVVMbasics Bibliotheken (MVVMbasics enthält die plattformübergreifenden Klassen, und MVVMbasicsRT jene Codeteile des Frameworks die spezifisch für die WinRT Plattform geschrieben wurden) sowie das PCL-Projekt (in meinem Fall „SimpleChat“) beinhalten:

20141006_Pic1

Die im Shared-Projekt vorhandenen App.xaml bzw. App.xaml.cs Dateien müssen in zwei Punkten verändert werden:

Erstens muss die App-Klasse jedes MVVMbasics Projekts von BaseApplication abgeleitet sein. Da es sich um eine partielle Klasse handelt, muss diese Deklaration sowohl im C#-Code (App.xaml.cs) als auch im XAML-Code (App.xaml) eingefügt werden:

 public sealed partial class App : BaseApplication
 <mvvmBasics:BaseApplication 
x:Class="UniversalApp.App" 
xmlns:mvvmBasics="using:MVVMbasics"

Weiters müssen im C#-Code der App-Klasse alle verwendeten Services registriert werden (da es sich bei einigen der Services ja um plattformabhängige Services handeln kann, und da nicht jedes Service für jede Plattform verfügbar sein muss, muss dies auf jeder Plattform individuell geschehen und kann nicht innerhalb der Portable Class Library erledigt werden).

Auf den globalen ServiceLocator kann innerhalb der App-Klasse jederzeit zugegriffen werden. Die Registrierung geschieht im Idealfall im Constructor der App. Im Unterschied zum Zugriff auf Services aus dem Viewmodel müssen hier aber die tatsächlichen Service-Klassen, und nicht die Interfaces, registriert werden! Sobald versucht wird, ein Service mit Hilfe der Retrieve Methode anzusprechen das zuvor nicht registriert wurde, tritt eine ServiceNotFoundException auf.

Die üblichste Form ein Service zu registrieren ist, der Register Methode nur den Typ des gewünschten Services zu übergeben, sodass das Service wie weiter oben beschrieben erst bei der ersten Verwendung instanziiert wird. Dies hat auch den Vorteil, dass problemlos alle im Projekt vorhandenen Services registriert werden können auch wenn sie dann nie verwendet werden, da diese solang sie nicht instanziiert wurden ja kaum Speicher verbrauchen.

Nur wenn bestimmte Eigenschaften eines Services von vornherein festgelegt werden sollen, muss dieses vor dem Regstrieren instanziiert werden. Dann wird der Register Methode einfach die erstellte Instanz übergeben. In unserem Fall ist das beim NavigatorService notwendig, da diesem beim Start der App alle Verknüpfungen von Views zu Viewmodels mitgeteilt werden müssen – mehr zu diesem Thema im nächsten Absatz.

 var navigator = new NavigatorService();
navigator.RegisterAll("UniversalApp.Views");
ServiceLocator.Register(navigator);
ServiceLocator.Register<TimerService>();
ServiceLocator.Register<MessageboxService>();
ServiceLocator.Register<BackendService>();

Das bereits in MVVMbasics enthaltene NavigatorService ist (unter anderem) dafür zuständig, eine Liste von Zuordnungen von Viewmodels zu Views (Fenster bzw. Pages) zu verwalten. Sobald die NavigateTo Methode aufgerufen wird, muss das zum übergebenen Viewmodel zugehörige Fenster bzw. die entsprechende Page in dieser Liste gefunden und geöffnet werden.Um dem Service beim Start der App alle diese Zuordnungen mitzuteilen, die Register und RegisterAll Methoden verwendet werden. Mit der Register Methode können sämtliche Kombinationen von Viewmodel zu View einzeln registriert werden, das ist aber aufwändig und fehleranfällig (was, wenn später Views dazukommen?) und wird daher nicht empfohlen.

Besser ist es, die RegisterAll Methode zu verwenden. Dieser wird der Namespace übergeben, indem sich die Views befinden, diese werden dann automatisch registriert (dazu muss aber jede View das [MvvmNavigationTarget] Attribut beinhalten, wie im folgenden Abschnitt erklärt wird). Da meistens alle Views im selben Namespace definiert sind, reicht üblicherweise ein einziger Aufruf der RegisterAll Methode. In großen Projekten, in denen verschiedenen Views in Sub-Namespaces organisiert sind, kann die Schreibweise „.*“ verwendet werden, um alle Sub-Namespaces in die Suche nach Views mit einzubeziehen.