Highscores, Xamarin Forms und Azure Mobile Services

1. Aufgabe

Im Zuge der Entwicklung eines einfachen Spiels bin ich auf das Thema „Cloud-basierte Highscore-Verwaltung“ gestoßen. In einem ersten Ansatz dachte ich, dass diese Spieleergänzung eine Angelegenheit für einen verregneten Mittwoch-Nachmittag ist. Weit gefehlt! Zumindest dann, wenn die Resultate des Spiels nicht lokal auf dem Device, sondern zentral abgelegt werden sollen und damit allen Spielern auf unterschiedlichen Devices zur Verfügung stehen sollen.

Damit sollten wir unsere Vorstellungen einer Highscore-Verwaltungs-App zunächst einer Anforderungsanalyse (engl. Requirements Analysis) unterwerfen, um eine gewisse Struktur in den Entwicklungsprozess zu bringen. Pro Spielresultat soll in der Highscore-Tabelle ein Eintrag mit dem Namen des Spielers und der von ihm erzielten Punktezahl abgelegt werden. Dabei soll es zulässig sein, dass ein Spieler (mit demselben Nickname) sich des Öfteren in der Tabelle eintragen kann. Die App soll bei Bedarf die erreichten Höchstpunktezahlen absteigend sortiert darstellen. In Bezug auf die Funktionalität gelten zwei Anforderungen:

  • Lokale Ablage eines Spielresultats – Nach dem Ende eines Spiels kann das erzielte Spielresultat lokal auf dem Device in einer Highscore-Tabelle abgelegt werden. Es erfolgt dabei keine Synchronisation mit Spielresultaten, die auf anderen Devices erzielt wurden.

  • Synchronisation aller Spielresultate – Die Highscore-Tabelle kann synchronisiert werden. Dies bedeutet, dass von allen jemals erzielten Spielresultaten, die zu diesem Zweck auf einem zentralen Rechner abgelegt werden, eine Liste mit den 10 besten Spieleresultaten angefordert werden kann und diese in den lokalen Datenbestand des Devices übernommen werden.

Einige Konsequenzen und Anmerkungen zu diesen Anforderungen:

  • Nach erfolgter Synchronisation kann es sein, dass lokal erzielte Spielresultate in der Highscore-Tabelle nicht unter den ersten 10 Plätzen in Erscheinung treten. Dies ist kein Fehler, sondern einfach dem Umstand geschuldet, dass in diesem Fall die auf anderen Devices erzielten Resultate allesamt besser sind.

  • Prinzipiell sollte die Highscore-Verwaltung auch Offline funktionieren. Natürlich ist in diesem Zustand keine Synchronisation möglich, aber lokal erzielte Spielergebnisse lassen sich auf dem Device ablegen, anzeigen und sie gehen vor allem nicht verloren.

  • Eine Synchronisation setzt prinzipiell voraus, dass vor dem Herunterladen der vorhandenen Bestenliste zuerst die lokal abgelegten Spieleresultate hochgeladen werden. Auf diese Weise wird die auf dem Server vorhandene Datenmenge mit allen lokalen Teillisten bzgl. Konsistenz abgeglichen.

An die Gestaltung der Oberfläche selbst sind keine besonderen Anforderungen gestellt, wie in Abbildung 1 erkennbar ist. Über einfache Eingabe-Steuerelemente muss es möglich sein, den Namen des Spielers und seine Punktezahl eingeben zu können. Die Eingabe wird lokal abgelegt und sofort zur Anzeige gebracht – siehe Schaltfläche „Add Score“. Mit der zweiten Schaltfläche „Synchronize“ erfolgt die Synchronisation des lokalen Datenbestands mit dem globalen Datenbestand.

Xamarin Forms App (hier: Android) mit einer Highscore-Verwaltung.

Abbildung 1. Xamarin Forms App (hier: Android) mit einer Highscore-Verwaltung.


Zu einem gewissen Grad erfolgte die Anforderungsanalyse natürlich mit Blick auf vorhandene Software-Technologien, die eine Realisierungsmöglichkeit versprechen und dies auch noch mit einem möglichst geringem Entwicklungsaufwand. Meine Wahl ist auf das Tandem Xamarin Forms als Vehikel zur Entwicklung hybrider Smartphone-Apps zusammen mit den Azure Mobile Services als Backend für dezentrale App-Anwendungen gefallen.

2. Lösung

Quellcode: Siehe auch https://github.com/peterloos/Xamarin_HighscoreApp.git.

Für einen prinzipiellen Einstieg in das Thema Azure Mobile Services verweise ich auf entsprechendes Material im Netz. Wir beginnen deshalb gleich mit dem Thema der zentralen Datenablage, es kommt das Azure-Thema Easy Tables zum Zuge. Easy Tables können direkt im Azure-Portal angelegt werden, also ohne programmiersprachliche Anweisungen in der App (Abbildung 2):

Dialog im Azure-Portal zum Erstellen einer Easy Table.

Abbildung 2. Dialog im Azure-Portal zum Erstellen einer Easy Table.


Auch das Schema einer Easy Table ist mit einem entsprechenden Dialog im Azure-Portal erstellbar. In Abbildung 3 erkennt man in den letzten drei Zeilen drei Spalten (Name, Score und PlayedAt), die von mir im entsprechenden Dialog eingegeben wurden. Die anderen Spalten id, createdAt, updatedAt, version und deleted werden vom Azure-Portal automatisch angelegt, sie werden von den Azure Mobile Services vor allem für die Offlinedatensynchronisierung benötigt, dazu später noch mehr.

Dialog im Azure-Portal zum Erstellen des Schemas einer Easy Table.

Abbildung 3. Dialog im Azure-Portal zum Erstellen des Schemas einer Easy Table.


Für den Zugriff auf die Easy Table in einer Xamarin App benötigt diese eine C#-Klasse, deren Eigenschaften den Spalten der Easy Table entsprechen. Die automatisch generierten Eigenschaften müssen nicht alle abgebildet werden, ausgenommen hiervon sind die beiden Spalten id und version. Weitere Details hierzu finden Sie in Listing 1 vor. Noch ein letzter Hinweis: Der C#-Klassenname muss zwingend mit dem Easy Table-Namen im Azure-Portal übereinstimmen, was auch unter Verwendung des Klassenattributs DataTable erreicht werden kann:

01: namespace SimpleHighScoreApp.Services
02: {
03:     using System;
04:     using Microsoft.WindowsAzure.MobileServices;
05:     using Newtonsoft.Json;
06: 
07:     [DataTable("FirstHighScores")]
08:     public class FirstHighScores
09:     {
10:         [JsonProperty("id")]
11:         public string Id { get; set; }
12: 
13:         [Version]
14:         public string Version { get; set; }
15: 
16:         [JsonProperty(PropertyName = "Name")]
17:         public String Name { get; set; }
18: 
19:         [JsonProperty(PropertyName = "PlayedAt")]
20:         public DateTime PlayedAt { get; set; }
21: 
22:         [JsonProperty(PropertyName = "Score")]
23:         public int Score { get; set; }
24: 
25:         public override string ToString()
26:         {
27:             return String.Format(
28:                 "Highscore: Name={0}, PlayedAt={1}, Score={2}",
29:                 this.Name, this.PlayedAt.ToString(), this.Score);
30:         }
31:     }
32: }

Beispiel 1. Klasse FirstHighScores als Counterpart einer Azure Easy Table.


Mit Hilfe der Klassendefinition FirstHighScores aus Listing 1 lassen sich nun zwei generische Schnittstellentypen IMobileServiceSyncTable<FirstHighScores> und IMobileServiceTable<FirstHighScores> ableiten. Instanzen zu diesen Schnittstellentypen erzeugen die Methoden GetSyncTable bzw. GetTable, die wiederum ein MobileServiceClient-Objekt benötigen. Das MobileServiceClient-Objekt stellt das Hauptbindeglied zwischen der Xamarin-App und dem Azure-Portal dar. Zu seiner Erzeugung benötigt man die URI der Microsoft Azure Mobile App, sie wird vom Azure Portal an registrierte Clients vergeben.

Die beiden Objektarten IMobileServiceSyncTable<FirstHighScores> und IMobileServiceTable<FirstHighScores> könnten unterschiedlicher kaum sein. Mit einem Objekt des Typs IMobileServiceTable<FirstHighScores> kann man direkt auf die entfernt gelegene Easy Table im Azure-Portal zugreifen. Es stehen insbesondere die vier CRUD-Tabellenoperationen (create, read, update und delete) zur Verfügung. Hauptnachteil dieser Zugriffsart: Alle Arten von Verbindungsunterbrechung sind von der App zu meistern. Der Aufwand hierfür sollte nicht unterschätzt werden.

Das Gegenstück hierzu stellen IMobileServiceSyncTable<FirstHighScores>-Objekte dar. Sie arbeiten zunächst einmal nur auf einer Tabelle, die lokal auf dem Device residiert. In meiner Beispiel-Anwendung verwende ich hierzu das Datenbanksystem „SQLite“ zusammen mit einem MobileServiceSQLiteStore-Objekt. Sollen lokale Ergänzungen, Löschungen, usw. in der Easy Table des Azure-Portals nachgezogen werden, stehen hierfür drei zentrale Methoden zur Verfügung: PushAsync, PullAsync und PurgeAsync.

Um diese drei Methoden aufrufen zu können, bedarf es eines so genannten Synchronisationskontextes. Dieses Objekt ist eine Eigenschaft des MobileServiceClient-Objekts. Man kann es als Bindeglied zwischen dem lokalen MobileServiceSQLiteStore-Objekt und der entfernt gelegenen Easy Table im Azure-Portal ansehen. Alle Änderungen, die sich zunächst lokal auf einem Smartphone ergeben, werden durch das Synchronisationskontext-Objekt protokolliert. Mit einem Aufruf von PushAsync lassen sich derartige lokale Änderungen zentral ablegen. Umgekehrt können Ausschnitte des zentralen Datenbestands mit PullAsync in die lokale Datenbasis integriert werden. Mit der dritten und letzten Methode PurgeAsync kann der lokale Datenbestand geleert werden.

Für eine detailliertere Beschreibung dieser drei Methoden sowie der generellen Arbeitsweise muss ich wiederum auf die im Netz verfügbaren Dokumentationen verweisen. Diese Erläuterungen sollen nur einen ersten Einblick in die Arbeitsweise der Offlinedatensynchronisierung an Hand der Azure Mobile Services vermitteln. Am Beispiel einer Highscore-Verwaltung finden Sie nun in der Klasse AzureDataService (Listing 2) einen Lösungsansatz vor, wie eine Xamarin App die Highscore-Verwaltung sowohl lokal wie auch zentral organisieren kann.

001: namespace SimpleHighScoreApp.Services
002: {
003:     using System;
004:     using System.Collections.Generic;
005:     using System.Diagnostics;
006:     using System.Net;
007:     using System.Threading.Tasks;
008: 
009:     using Microsoft.WindowsAzure.MobileServices;
010:     using Microsoft.WindowsAzure.MobileServices.SQLiteStore;
011:     using Microsoft.WindowsAzure.MobileServices.Sync;
012: 
013:     public class AzureDataService
014:     {
015:         private MobileServiceClient mobileService;
016:         private IMobileServiceSyncTable<FirstHighScores> localTable;
017:         private IMobileServiceTable<FirstHighScores> remoteTable;
018: 
019:         public AzureDataService()
020:         {
021:             this.mobileService = new MobileServiceClient("http://yourazurewebsite.net");
022:         }
023: 
024:         public async Task Initialize()
025:         {
026:             try
027:             {
028:                 if (!this.mobileService.SyncContext.IsInitialized)
029:                 {
030:                     // setup local sqlite store and intialize local table
031:                     MobileServiceSQLiteStore store = new MobileServiceSQLiteStore("localscores.db");
032:                     store.DefineTable<FirstHighScores>();
033: 
034:                     // need synchronization context
035:                     await this.mobileService.SyncContext.InitializeAsync(store);
036: 
037:                     // retrieve references of tables (local and remote)
038:                     this.localTable = this.mobileService.GetSyncTable<FirstHighScores>();
039:                     this.remoteTable = this.mobileService.GetTable<FirstHighScores>();
040:                 }
041:             }
042:             catch (Exception ex)
043:             {
044:                 Debug.WriteLine("{0}", ex.Message);
045:             }
046:         }
047: 
048:         public async Task InsertIntoLocalTable(String name, int score)
049:         {
050:             await this.Initialize();
051: 
052:             // create and insert a random score object into local table
053:             var someScore = new FirstHighScores()
054:             {
055:                 Name = name,
056:                 PlayedAt = DateTime.Now,
057:                 Score = score
058:             };
059: 
060:             await this.localTable.InsertAsync(someScore);
061: 
062:             Debug.WriteLine("Inserted into local store: {0} [{1} points]",
063:                 someScore.Name, someScore.Score);
064:         }
065: 
066:         public async Task<List<FirstHighScores>> Sync()
067:         {
068:             await this.Initialize();
069: 
070:             try
071:             {
072:                 PullOptions options = new PullOptions () { MaxPageSize = 100 };
073:                 await this.localTable.PullAsync("topHighScorers", this.localTable.CreateQuery(), options);
074:             }
075:             catch (MobileServicePushFailedException ex)
076:             {
077:                 MobileServicePushStatus status = ex.PushResult.Status;
078:                 if (status == MobileServicePushStatus.Complete)
079:                 {
080:                     Debug.WriteLine("Unable to Sync -- Probably no Connectivity !!!: " + ex.Message);
081:                 }
082:                 else
083:                 {
084:                     String err = String.Format("PullAsync failed: {0} errors, message: {1}",
085:                         ex.PushResult.Errors.Count, ex.Message);
086:                     Debug.WriteLine(err + " - " + ex.PushResult.Status);
087:                 }
088:             }
089:             catch (WebException ex)
090:             {
091:                 Debug.WriteLine("PullAsync failed: " + ex.Message);
092:             } 
093:             catch (Exception ex)
094:             {
095:                 Debug.WriteLine("PullAsync failed: " + ex.Message);
096:             }
097: 
098:             // retrieve information from local table - either synched or not
099:             List<FirstHighScores> highScores =
100:                 await this.localTable.OrderByDescending(item => item.Score).Take(10).ToListAsync();
101:             return highScores;
102:         }
103: 
104:         public async Task DumpLocalTable()
105:         {
106:             await this.Initialize();
107: 
108:             Debug.WriteLine("Dump of local Table:");
109: 
110:             // retrieve full information from local table
111:             List<FirstHighScores> highScores = await this.localTable.ToListAsync();
112:             Debug.WriteLine("Found entries: {0}", highScores.Count);
113:             foreach (FirstHighScores entry in highScores)
114:                 Debug.WriteLine("  Entry: {0}", entry);
115: 
116:             Debug.WriteLine("");
117:             Debug.WriteLine("Pending operations in the sync context queue: {0}",
118:                 this.mobileService.SyncContext.PendingOperations);
119:         }
120:     }
121: }

Beispiel 2. Klasse AzureDataService.


Die AzureDataService-Klasse aus Listing 2 besitzt den Charakter einer Service-Klasse. Um sie in der Xamarin App zum Einsatz zu bringen, kommen wir nun auf die App selbst und ihre Realisierung auf Basis des MVVM-Entwurfsmusters zu sprechen. Wir traversieren die einzelnen Softwareschichten in der Reihenfolge Top-Down, sprich wir kommen zunächst auf die View der Anwendung zu sprechen (Listing 3):

001: <?xml version="1.0" encoding="utf-8" ?>
002: <ContentPage
003:     xmlns="http://xamarin.com/schemas/2014/forms"
004:     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
005:     x:Class="SimpleHighScoreApp.Views.MainViewPage"
006:     Title=" Simple High Score App - Using Azure Mobile Services"
007:     xmlns:local="clr-namespace:SimpleHighScoreApp.ViewModels;assembly=SimpleHighScoreApp"
008:     xmlns:conv="clr-namespace:SimpleHighScoreApp.Converters;assembly=SimpleHighScoreApp"
009:     BindingContext="{local:HighScoresViewModel}">
010: 
011:     <ContentPage.Resources>
012:         <ResourceDictionary>
013:             <conv:StringToColorConverter x:Key="stringConverter" />
014:         </ResourceDictionary>
015:     </ContentPage.Resources>
016:     
017:     <ContentPage.Content>
018:         <StackLayout
019:              Padding="5"
020:                 Orientation="Vertical"
021:                 VerticalOptions="Start"
022:                 HorizontalOptions="FillAndExpand">
023:                 
024:                 <Grid 
025:                     VerticalOptions="Fill"
026:                     HorizontalOptions="FillAndExpand">
027:                     <Grid.RowDefinitions>
028:                         <RowDefinition Height="Auto" />
029:                         <RowDefinition Height="Auto" />
030:                     </Grid.RowDefinitions>
031: 
032:                     <Grid.ColumnDefinitions>
033:                         <ColumnDefinition Width="Auto" />
034:                         <ColumnDefinition Width="*" />
035:                     </Grid.ColumnDefinitions>
036:                 
037:                     <Label
038:                         Grid.Row="0" Grid.Column="0" FontSize="18"
039:                         HorizontalTextAlignment="Start" VerticalOptions="Center" 
040:                         Text="User Name:"></Label>                
041:                     
042:                     <Entry
043:                         Grid.Row="0" Grid.Column="1" x:Name="EntryUsername"
044:                         FontSize="18" Placeholder="Enter User Name" Text="{Binding PlayerName}"/>
045: 
046:                     <Label
047:                         Grid.Row="1" Grid.Column="0" FontSize="18"
048:                         HorizontalTextAlignment="Start" VerticalOptions="Center" 
049:                         Text="Score:"></Label> 
050:                     
051:                     <Entry
052:                         Grid.Row="1" Grid.Column="1" x:Name="EntryScore"
053:                         FontSize="18" Placeholder="Enter Score" Text="{Binding PlayerScore}" />
054:                 </Grid>
055:                 
056:             <StackLayout
057:                 Orientation="Horizontal"
058:                 VerticalOptions="Fill"
059:                 HorizontalOptions="FillAndExpand">
060: 
061:                 <Button
062:                     Command="{Binding InsertCommand}"
063:                     Text="Add Score"
064:                     VerticalOptions="Fill"
065:                     HorizontalOptions="FillAndExpand" />
066: 
067:                 <Button
068:                     Command="{Binding SynchCommand}"
069:                     Text="Synchronize"
070:                     VerticalOptions="Fill"
071:                     HorizontalOptions="FillAndExpand" />
072: 
073:                 <Button
074:                     Command="{Binding DumpCommand}"
075:                     Text="{Binding StatusConnectivity}"
076:                     BackgroundColor="{Binding StatusConnectivity, Converter={StaticResource stringConverter }}"
077:                     VerticalOptions="Fill"
078:                     HorizontalOptions="FillAndExpand" />
079:             </StackLayout>
080: 
081:             <Label
082:                 FontSize="14" HorizontalTextAlignment="Start" VerticalOptions="Fill"
083:                 HorizontalOptions="FillAndExpand" Text="{Binding StatusMessage}"/>
084: 
085:             <Label
086:                 FontSize="24" HorizontalTextAlignment="Center" VerticalOptions="Fill"
087:                 HorizontalOptions="FillAndExpand" Text="Highscore List"/>
088: 
089:             <ListView
090:                 x:Name="HighScoresListView" ItemsSource="{Binding HighScorers}"
091:                 VerticalOptions="CenterAndExpand" HorizontalOptions="FillAndExpand">
092:                 <ListView.ItemTemplate>
093:                     <DataTemplate>
094:                         <ViewCell>
095:                             <Grid>
096:                                 <Grid.RowDefinitions>
097:                                     <RowDefinition/>
098:                                 </Grid.RowDefinitions>
099:                                 <Grid.ColumnDefinitions>
100:                                     <ColumnDefinition Width="60" />
101:                                     <ColumnDefinition Width="*" />
102:                                     <ColumnDefinition Width="*" />
103:                                 </Grid.ColumnDefinitions>
104:                                 <Label
105:                                     Grid.Row = "0" Grid.Column = "0" Text="{Binding Position}"
106:                                     FontSize="20" VerticalTextAlignment="Center"
107:                                     HorizontalTextAlignment="Center" />
108:                                 <Label
109:                                     Grid.Row = "0" Grid.Column = "1" Text="{Binding Name}"    
110:                                     FontSize="20" VerticalTextAlignment="Center"
111:                                     HorizontalTextAlignment="Start" />
112:                                 <Label
113:                                     Grid.Row = "0" Grid.Column = "2" Text="{Binding Score}"   
114:                                     FontSize="20" VerticalTextAlignment="Center"
115:                                     HorizontalTextAlignment="Start" />
116:                             </Grid>
117:                         </ViewCell>
118:                     </DataTemplate>
119:                 </ListView.ItemTemplate>
120:             </ListView>     
121:             </StackLayout>
122:     </ContentPage.Content>
123: </ContentPage>

Beispiel 3. Ansicht der Xamarin App: Klasse MainViewPage (XAML).


Von der Gestaltung der Oberfläche einmal abgesehen, kann man in Listing 3 gut erkennen, dass jeglicher Zugriff auf Daten und Methoden in der unterlagerten Logikschicht der App mittels Datenbindung eingearbeitet ist. In den Zeilen 44, 62, 68, 74, 75, 83, 90, 105, 109 und 113 werden entweder Eigenschaften oder Kommandos des ViewModels angesprochen. Hervorheben wollen wir Zeile 90: Dort wird ein ObservableCollection-Objekt an ein ListView-Objekt gebunden. Alle Änderungen, die sich im ObservableCollection-Objekt ergeben, werden auf diese Weise automatisch im ListView-Objekt zur Anzeige gebracht.

Das ViewModel-Objekt selbst, eine Instanz der Klasse HighScoresViewModel, wird der View deklarativ in Zeile 9 zugeordnet. In XAML lautet die Begrifflichkeit hierfür BindingContext, die deklarative Wertzuweisung in Zeile 9 an das ContentPage-Objekt bewirkt unter anderem, dass alle unterlagerten Objekte des ContentPage-Objekts dasselbe ViewModel-Objekt besitzen.

Bemerkung: In einer reinrassigen MVVM-Anwendung sollte der CodeBehind-Anteil der View leer sein. Im Großen und Ganzen ist uns dies auch gelungen, wie ein Blick auf Listing 4 zeigt. Die beiden Methoden OnAppearing und OnDisappearing eines Xamarin Forms ContentPage-Objekts lassen sich deklarativ nur schwer überschreiben – und im Prinzip besteht dazu auch gar keine Veranlassung. Alles, was in diesen beiden Methoden programmiert ist, wurde in den Aufruf zweier ICommand-Schnittstellenobjekte ausgelagert und lässt sich damit auch ohne UI aufrufen bzw. testen:

01: namespace SimpleHighScoreApp.Views
02: {
03:     using System;
04:     using System.Diagnostics;
05: 
06:     using SimpleHighScoreApp.ViewModels;
07:     using Xamarin.Forms;
08: 
09:     public partial class MainViewPage : ContentPage
10:     {
11:         private HighScoresViewModel viewModel;
12:         public MainViewPage()
13:         {
14:             InitializeComponent();
15:             this.viewModel = (HighScoresViewModel) this.BindingContext;
16:         }
17: 
18:         protected override void OnAppearing()
19:         {
20:             base.OnAppearing();
21: 
22:             // issuing command 'Appearing'
23:             this.viewModel.AppearingCommand.Execute(null);
24:         }
25: 
26:         protected override void OnDisappearing()
27:         {
28:             base.OnDisappearing();
29: 
30:             // issuing command 'Disappearing'
31:             this.viewModel.DisappearingCommand.Execute(null);
32:         }
33:     }
34: }

Beispiel 4. Ansicht der Xamarin App: Klasse MainViewPage (CodeBehind).


Es fehlt noch das ViewModel-Objekt der App. Wir sind bei der Klasse HighScoresViewModel angekommen, siehe Listing 5:

001: namespace SimpleHighScoreApp.ViewModels
002: {
003:     using System;
004:     using System.Collections.Generic;
005:     using System.Collections.ObjectModel;
006:     using System.ComponentModel;
007:     using System.Diagnostics;
008:     using System.Threading.Tasks;
009:     using System.Windows.Input;
010: 
011:     using Plugin.Connectivity;
012:     using Plugin.Connectivity.Abstractions;
013:     using Xamarin.Forms;
014:     using SimpleHighScoreApp.Services;
015: 
016:     public class HighScoresViewModel : INotifyPropertyChanged
017:     {
018:         public event PropertyChangedEventHandler PropertyChanged;
019: 
020:         private AzureDataService service;
021:         private ObservableCollection<HighScoreEntry> highScorers;
022:         private String playerName;
023:         private String playerScore;
024:         private String statusMessage;
025:         private String statusConnectivity;
026: 
027:         // c'tor
028:         public HighScoresViewModel()
029:         {
030:             this.service = new AzureDataService();
031:             this.highScorers = new ObservableCollection<HighScoreEntry>();
032: 
033:             this.StatusConnectivity =
034:                 (CrossConnectivity.Current.IsConnected) ? "Online" : "Offline";  
035:         }
036: 
037:         // properties
038:         public ObservableCollection<HighScoreEntry> HighScorers
039:         {
040:             get
041:             {
042:                 return this.highScorers;
043:             }
044: 
045:             set
046:             {
047:                 this.highScorers = value;
048:             }
049:         }
050: 
051:         public String PlayerName
052:         {
053:             get
054:             {
055:                 return this.playerName;
056:             }
057: 
058:             set
059:             {
060:                 if (this.playerName != value)
061:                 {
062:                     this.playerName = value;
063: 
064:                     if (this.PropertyChanged != null)
065:                     {
066:                         this.PropertyChanged(
067:                             this, new PropertyChangedEventArgs("PlayerName"));
068:                     }
069:                 }
070:             }
071:         }
072: 
073:         public String PlayerScore
074:         {
075:             get
076:             {
077:                 return this.playerScore;
078:             }
079: 
080:             set
081:             {
082:                 if (this.playerScore != value)
083:                 {
084:                     this.playerScore = value;
085: 
086:                     if (this.PropertyChanged != null)
087:                     {
088:                         this.PropertyChanged(
089:                             this, new PropertyChangedEventArgs("PlayerScore"));
090:                     }
091:                 }
092:             }
093:         }
094: 
095:         public String StatusMessage
096:         {
097:             get
098:             {
099:                 return this.statusMessage;
100:             }
101: 
102:             set
103:             {
104:                 if (this.statusMessage != value)
105:                 {
106:                     this.statusMessage = value;
107: 
108:                     if (this.PropertyChanged != null)
109:                     {
110:                         this.PropertyChanged(
111:                             this, new PropertyChangedEventArgs("StatusMessage"));
112:                     }
113:                 }
114:             }
115:         }
116: 
117:         public String StatusConnectivity
118:         {
119:             get
120:             {
121:                 return this.statusConnectivity;
122:             }
123: 
124:             set
125:             {
126:                 if (this.statusConnectivity != value)
127:                 {
128:                     this.statusConnectivity = value;
129: 
130:                     if (this.PropertyChanged != null)
131:                     {
132:                         this.PropertyChanged(
133:                             this, new PropertyChangedEventArgs("StatusConnectivity"));
134:                     }
135:                 }
136:             }
137:         }
138: 
139:         // commands
140:         public ICommand InsertCommand
141:         {
142:             get
143:             {
144:                 return new Command(async () =>
145:                 {
146:                     this.StatusMessage = "";
147: 
148:                     if (String.IsNullOrEmpty (this.PlayerName) || String.IsNullOrEmpty (this.PlayerScore))
149:                     {
150:                         this.StatusMessage = "ERROR: Please enter Username or Score!";
151:                         return;
152:                     }
153: 
154:                     int scoreValue;
155:                     if (!Int32.TryParse(this.PlayerScore, out scoreValue))
156:                     {
157:                         this.StatusMessage = "ERROR: Score Value: Illegal format!";
158:                         return;
159:                     }
160: 
161:                     // reset input fields
162:                     String userName = this.PlayerName;
163:                     this.PlayerName = "";
164:                     this.PlayerScore = "";
165: 
166:                     // enter players name and score into local database
167:                     await this.Insert(userName, scoreValue);
168:                 });
169:             }
170:         }
171: 
172:         public ICommand SynchCommand
173:         {
174:             get
175:             {
176:                 return new Command(async () =>
177:                 {
178:                     this.StatusMessage = "";
179: 
180:                     await this.Sync();
181:                 });
182:             }
183:         }
184: 
185:         public ICommand DumpCommand
186:         {
187:             get
188:             {
189:                 return new Command(async () =>
190:                 {
191:                     await this.DumpLocalTable();
192:                 });
193:             }
194:         }
195: 
196:         public ICommand AppearingCommand
197:         {
198:             get
199:             {
200:                 return new Command(() =>
201:                 {
202:                     CrossConnectivity.Current.ConnectivityChanged += this.ConnecitvityChanged;
203:                 });
204:             }
205:         }
206: 
207:         public ICommand DisappearingCommand
208:         {
209:             get
210:             {
211:                 return new Command(() =>
212:                 {
213:                     CrossConnectivity.Current.ConnectivityChanged -= this.ConnecitvityChanged;
214:                 });
215:             }
216:         }
217: 
218:         private void ConnecitvityChanged(Object sender, ConnectivityChangedEventArgs e)
219:         {
220:             Device.BeginInvokeOnMainThread(() =>
221:             {
222:                 this.StatusConnectivity = (e.IsConnected) ? "Online" : "Offline";  
223:             });
224:         }
225: 
226:         // private (asynchronous) helper methods 
227:         private async Task Insert (String name, int score)
228:         {
229:             // insert name and score into local database
230:             await this.service.InsertIntoLocalTable(name, score);
231: 
232:             // update observable collection bind to UI
233:             HighScoreEntry next = new HighScoreEntry()
234:             {
235:                 Name = name,
236:                 Score = score,
237:                 Position = 1
238:             };
239: 
240:             if (this.highScorers.Count == 0)
241:             {
242:                 // empty collection
243:                 this.highScorers.Add(next);
244:             }
245:             else
246:             {
247:                 // seach position in observable collection, where to insert
248:                 int i = this.highScorers.Count;
249:                 while (i > 0)
250:                 {
251:                     HighScoreEntry entry = this.highScorers[i-1];
252:                     if (score > entry.Score)
253:                         i--;
254:                     else
255:                         break;
256:                 }
257: 
258:                 // insert new entry
259:                 this.highScorers.Insert(i, next);
260:             }
261: 
262:             // adjust property 'Position' of all entries
263:             for (int i = 0; i < this.highScorers.Count; i++)
264:             {
265:                 HighScoreEntry entry = this.highScorers[i];
266:                 entry.Position = i + 1;
267:             }
268:         }
269: 
270:         private async Task Sync()
271:         {
272:             // retrieve actual list of high scores from backend
273:             List<FirstHighScores> currentHighScores = await this.service.Sync();
274: 
275:             this.highScorers.Clear();
276: 
277:             // map list into observable collection (without AutoMapper :-( )
278:             for (int i = 0; i < currentHighScores.Count; i++)
279:             {
280:                 HighScoreEntry entry = new HighScoreEntry()
281:                 {
282:                     Name = currentHighScores[i].Name,
283:                     Score = currentHighScores[i].Score,
284:                     Position = i+1
285:                 };
286: 
287:                 this.highScorers.Add(entry);
288:             }
289:         }
290: 
291:         private async Task DumpLocalTable()
292:         {
293:             await this.service.DumpLocalTable();
294:         }
295:     }
296: }

Beispiel 5. Ansicht der Xamarin App: Klasse HighScoresViewModel (CodeBehind).


Um in der App den Online-Status der App anzeigen zu können, kommt ein „Connectivity Plugin for Xamarin and Windows“ zum Einsatz. Dieses kann als NuGet-Paket geladen und in der Anwendung installiert werden.

Noch ein Hinweis: Die gesamte Xamarin App ist als Xamarin Forms-Projekt geschrieben. Im Falle von Azure Mobile Services bedeutet dies allerdings, dass zur Initialisierung des SQLite-Datenbanksystems auch im Android-Quellcodeanteil der App eine Initialisierungs-Anweisung einzufügen ist. Wir finden diese in der OnCreate-Methode der MainActivity-Klasse vor:

01: namespace SimpleHighScoreApp.Droid
02: {
03:     public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsApplicationActivity
04:     {
05:         protected override void OnCreate(Bundle bundle)
06:         {
07:             base.OnCreate(bundle);
08: 
09:             global::Xamarin.Forms.Forms.Init(this, bundle);
10: 
11:             Microsoft.WindowsAzure.MobileServices.CurrentPlatform.Init();
12: 
13:             LoadApplication(new App());
14:         }
15:     }
16: }

Der Vollständigkeit ergänzen wir noch den Einsprungpunkt für die Xamarin Forms-App. Softwaretechnisch handelt es sich dabei um den Default-Konstruktor einer Klasse mit dem Namen App. Dieser befindet sich in der Datei App.cs und fällt sehr kurz aus:

01: namespace SimpleHighScoreApp
02: {
03:     using System;
04:     using System.Diagnostics;
05: 
06:     using SimpleHighScoreApp.Views;
07:     using Xamarin.Forms;
08: 
09:     public class App : Application
10:     {
11:         public App()
12:         {
13:             this.MainPage = new NavigationPage(new MainViewPage());
14:         }
15:     }
16: }

Beispiel 6. Einsprungpunkt einer Xamarin Forms-App.


Zum Testen der App habe ich zwei Android-Smartphones verwendet, ein Nexus 7 mit Android Version 5.1.1 (a.k.a. „Lollipop“) und ein Samsung GT-S5280 mit Android 4.1.2 (a.k.a. „Jelly Bean“). Vor allem das Samsung Gerät kann man zum Zeitpunkt des App-Entwicklung getrost nicht mehr als „hochaktuelles Modell“ bezeichnen, um es etwas vornehm auszudrücken. Trotzdem sollte nicht unerwähnt bleiben, dass die zu testende App ohne irgendwelche Probleme lief und vor allem der komplette Mono-Stack für die Xamarin-App anstandslos geladen wurde.

Am Nexus Device beginnen wir im Benutzerdialog mit einem Spieler „Franz“ samt Score-Wert 50. Die Werte werden mit der Schaltfläche Add Score an das Device übertragen. Das Resultat erkennen wir in Abbildung 4:

Test von Add Score.

Abbildung 4. Test von „Add Score“.


Weiter geht es mit dem Samsung Device. Die Spielerin „Susan“ wird mit einem Score-Wert von 55 und wiederum mit der Schaltfläche Add Score eingegeben. Das entsprechende Resultat gibt Abbildung 5 wieder:

Test von Add Score.

Abbildung 5. Test von „Add Score“.


In Abbildung 5 lässt sich gut erkennen, dass der Score-Wert von Spielerin „Susan“ korrekt wiedergegeben wird, das Resultat des Nexus Devices hingegen fehlt. Deshalb drücken wir auf dem Samsung Device nun die Schaltfläche Synchronize, die entsprechende Aktualisierung gibt Abbildung 6 wieder:

Test von Synchronize.

Abbildung 6. Test von „Synchronize“.


Und damit bedienen wir zur Abwechslung wieder das Nexus Device. Der Spieler „Werner“ mit einem Score-Wert von 35 wird mittels Add Score eingegeben, siehe Abbildung 7:

Test von Add Score.

Abbildung 7. Test von „Add Score“.


Das Resultat in Abbildung 7 ist software-technisch korrekt, entspricht trotzdem nur bedingt unseren Erwartungen, denn es fehlt das letzte Resultat des Samsung Devices. Mit dem Betätigen der Synchronize-Schaltfläche können wir Abhilfe schaffen:

Test von Synchronize.

Abbildung 8. Test von „Synchronize“.


Natürlich wollen wir auch auf dem Samsung Device alle Resultate zu sehen bekommen. Wir drücken auf dem Samsung Device die Synchronize-Schaltfläche und erhalten nun auch auf diesem Device dieselbe Ausgabe wie auf dem Nexus Device (Abbildung 9):

Test von Synchronize.

Abbildung 9. Test von „Synchronize“.


Ein Blick in das Azure-Portal lässt erkennen, dass alles Devices, die einen Synchronisations-Aufruf angestoßen haben, ihre Einträge in der FirstHighScores-Tabelle vorfinden (Abbildung 10):

Die Easy Table FirstHighScores des Azure-Portals.

Abbildung 10. Die Easy TableFirstHighScores“ des Azure-Portals.