+49 40 608 12 460 (Development)
+49 175 5611694 (Kommunikation)

Titanium und APIOMAT – eine Fallstudie

Heute soll im Blog erläutert werden, wie man das Gespann Titanium und APIOMAT nutzen kann, um in kurzer Zeit eine anspruchsvolle mobile App zu erstellen.

Inhaltlich soll die App dem Nutzer ermöglichen, geobasiert Photos und Audioaufnahmen in eine Karte einzutragen. Gemäß dem Spruch „mobile zuerst“ soll die App mit ihren Funktionalitäten auf dem iPhone und dem Android-Phone laufen. Auf einer Webseite soll das Ergebnis lediglich angezeigt werden. Jeder Telefonnutzer darf also hochladen, er darf seine Obkekte wieder löschen und alle Benutzer der App und der Webseite dürfen alle Objekte sehen. Eine lokale Speicherung auf dem Gerät ergibt demnach kein Sinn, die Objekte müssen zentral gelagert wrden. Üblicherweise  würde man jetzt in herkömmlicher Art ein Backend auf einem Server einrichten, der diese Aufgabe übernimmt. Wahrscheinlich kommt ein Java-, PHP- oder Rubyserver ins Spiel, der dann die Kommunikation zwischen Netz und lokalem Datendepot (beispielsweise mySQL) übernimmt.

Mit dem „Backend-as-a-Service“ APIOMAT reduziert sich der Aufwand beträchtlich. Wie, das sehen wir gleich.

Konfiguration des Backends

Nachdem ich mir im APIOMAT einen Account eingerichtet habe, lege ich eine Applikation an, in unsere Fall heßt sie „FreeJosef“.  Das Kernstück ist der sogenannte Klasseneditor . Mit ihm legen ich die Klassen mit Eigenschaften an. Im Sinn einer relationalen Datenbank sind das die Tabellen mit ihren Feldern.

Jede APIOMAT-App benutzt als Basisklasse user . Grund dafür ist, dass jedes Objekt an einen Benutzer gebunden ist. Als erstes legen wir deshalb einen Nutzer an.

Jede Klasse, so auch Nutzer hat drei voreingestellte Properties, die auf Zeitstempel und auf die ForeignId beziehen. Die sind bei Anlage einer Klasse schon einmal parat. Unsere Klasse erbt alles von der Standardklasse 'user'. Das wähle ich ich im Bereich 'Parent Class' aus. Zusätzlichzu einem Benutzer gehörenden Feldern wie Vorname, Familienname, Login, Passwort usw. benötigen wir eine Referenz auf die Objekte, die ihm gehören. Da die Objekte noch nicht angelegt sind, müssen wir erst einmal diese anlegen, um dann zu dieser Einstellung zurückzukommen.

Legen wir also die Klasse 'Photo' an. Jedes Photo hat eine geografische Position. Dafür nutzen wir den Typ 'Location'. Das eigentliche Photo hat den Typ 'Image'. Dann brauchen wir noch ein Textfeld und ein Seitenverhältnis ('ratio') des Bildes. Das hilft später beim Rendern.Die Klasse 'Audio' ist analog angelegt. Das eigentliche Asset ist jetzt vom Typ  'File'. Der Typ 'Image' ist eben auch ein File, der aber zusätzlich eine eingebaute Funktion der Skalierung hat. Später dazu mehr.

Wenn ich nun die Klasse 'Nutzer' wieder öffne, dann gibt es in der Dateitypenauswahlliste zwei neue Datentypen, nämlich 'FreeJosefMain::Photo' und 'FreeJosefMain::Audio'. Nun kann ich die Typen 'myPhotos' und 'myAudios' hinzufügen. Da ein Nutzer vermutlich/hoffentlich  mehrere Assets hochlädt, kreuze ich den Haken 'Collection' an.

Nachdem der Button 'Deploy' geklickt ist, war es jetzt schon mit dem Backend.  Nun brauchts noch etwas, damit das potentielle Frontend mit dem Backend „reden“ kann.

Das Zauberwort heisst 'SDK'. Unter dieser Lasche findet sich jetzt fertige SDKs für Java, PHP, ObjectivC, Python, C#, Javascript und Titanium. Diese Sammlung von API-Befehlen sind nicht etwas generisch (wie etwa bei parse.com), sondern passen genau zur Applikation. Wir laden also im Bereich 'Titanium' das SDK runter und kopieren dann die Datei 'apiomat.js' in das Verzeichnis 'vendor' unterhalb des Resourcenverzeichnisses des Titaniumprojektes. Im Verzeichnis 'frontend' findet sich der nichtkomprimierte Quelltext.

Titanium-Frontend

Wie schon erwähnt, steht im Mittelpunkt der Mensch. Bevor die App mit der APIOMAT-Cloud spricht, muss ein Nutzer ins Spiel kommen. Nun könnte man in gewissen Fällen einfach in der App einen generischen Nutzer definieren – quasi eine Max Mustermann. Das würde funktionieren, dann verlören wir aber die Möglichkeit, dass jeder Nutzer seine eigene Assets löschen kann.

Auf der andere Seite könnte ich auch in einem Dialogfenster einen Registrierungsprozess starten – aber in der beschriebenen App ist der 'username' automatisch an die GeräteId gebunden und das Passwort ist schnuppe. Das Login sieht also so aus:

ApiomatAdapter.prototype.Login = function() {
  var options = arguments[0] || {}, callbacks = arguments[1] || {};
  this.user = new Apiomat.Nutzer();
  this.user.setUserName(Ti.Utils.md5HexDigest(Ti.Platform.getMacaddress()));
  this.user.setPassword('mylittlesecret');

Nun gibt es also ein lokales User-Objekt und wir versuchen uns im nächsten Schritt anzumelden:

  this.user.loadMe({
     onOk : _callbacks.ononline,
     onError : function(error) {
        if (error.statusCode === Apiomat.Status.UNAUTHORIZED) {
           that.user.save(saveCB);
        } else _callbacks.onoffline();
    }
});

Mit dem Befehl 'loadMe()' wird versucht, sich mit dem Login anzumelden. Existiert das Login und das Passwort ist stimmig, wird der onOk-Zweig bedient. Im anderen Fall wird der onErrror-Zweig aufgerufen und wenn es ein Authorisierungsproblem war, dann wird der User angelegt.

Nachdem wir nun dem System bekannt sind, wollen wir die Karte mit den Daten füttern. Dazu rufen wir im Kartenwindow 'getAllPhotos()' im Apiomat-Adapter auf:

ApiomatAdapter.prototype.getAllPhotos = function() {
  var args = arguments[0] || {}, callbacks = arguments[1] || {};
  var that = this;
  Apiomat.Photo.getPhotos("order by createdAt limit 500", {
     onOk : function(_res) {
       that.photos = _res;  // Referenzen brachne wir beim späteren Löschen
       var photolist = [];
       for (var i = 0; i < that.photos.length; i++) {
         var photo = that.photos[i];
         var ratio = photo.getRatio() || 1.3;
         photolist.push({
           id : (photo.data.ownerUserName == that.user.getUserName())//
                        ? photo.data.id : undefined,
           latitude : photo.getLocationLatitude(),
           longitude : photo.getLocationLongitude(),
           title : photo.getTitle(),
           thumb : photo.getPhotoURL(100, null, null, null, 'png'),
           ratio : ratio,
           bigimage : photo.getPhotoURL(1000, null, null, null, 'png') ,
        });
      }
      callbacks.onload(photolist);
    },
   onError : function(error) {
    //handle error
   }
  });
};

Die eigentliche Logik beginnt in er vierten Zeile. Auf die Klasse 'Apiomat.Photo' wird die generierte Methode getPhotos() angewendet. Grund: die Klasse 'Photo' hat auch die Eigenschaft 'photo'. APiomat bildet daraus 'get Photo s()'. Im Erfolgsfalle  liefert die REST-API ein Array, das ab der achten Zeile iteriert wird. Aus den Eigenschaften der Klasse bildet das System API-Befehle wie 'getTitle()' und 'getLocationLatitude()'. Eine Besonderheit stellen Bilder dar. APIOMAT hat einen eingebauten Imageskalierer. So kann man verschiedene Auflösungen der Bilder abfragen.

Der Nutzer soll seine eigene Bilder auch wieder löschen können. Dafür braucht er eine Referenz. In der Eigenschaft 'ownerUsername' des Photos steckt wie zu erwarten der Username. Ist der gleich dem eigenen Namen, wird sich in der Eigenschaft 'id' die id des Photos gemerkt.

In der MapView wird das Ergebnis der Abfrage dargestellt. Mit 'Ti.App.ApiomatAdapter.getAllPhotos()' wird das eben besprochene Module angesprochen, das asynchron über den Callback das Ergebnis liefert. Nun wird über das array mittels pop() iteriert. Loops sind immer „Leistungsfresser“ und so wird die wiederholte Generierung der Map-Annotations in ein commonJS-Module ausgelagert. In diesem Fall wird die Logik in der Loop nur einmal geparst und steht im cache zur Verfügung. Die Annotations werden aus Performanzgründen auch nicht einzeln der Map hinzugefügt – sondern als ganze Liste. So wird die Bridge zwischen JS und nativer welt nur einmal in Anspruch genommen.

Im Anschluss wird das jüngste Photo ausgewählt.

Ti.App.ApiomatAdapter.getAllPhotos(null, {
  onload : function(_data) {
    var pindata, annotations = [];
    while ( pindata = _data.pop()) {
      if (pindata.thumb) annotations.push(require('ui/annotation.widget').create(pindata));
    }
    self.mapview.addAnnotations(annotations);
    self.mapview.selectAnnotation(annotations[0]);
    Ti.Android && Ti.UI.createNotification({
       message : annotations.length + ' Photos'
    }).show();
 }
});

Photohochlade

In der mobilen Version (und das ist der Hauptzweck der App), sollen Photos gemacht und publiziert werden können. In der View gibt es in der Androidversion in der Actionbar ein Menüitem 'Kamera'. Nach Klick öffnet sich der Photoapparat, in dessen Erfolgszweig sich eine Dialogbox, die eine Vorschau bietet und den Nutzer auffordert einen Begleittext einzugeben. Nebenbei wird vom Vorschaubild das Seitenverhältnis bestimmt. Diese Ratio wird dem Datenfeld zugefügt und mit persistiert.

Nach 'OK' wird im ApiomatAdapter die Methode 'postPhoto()' aufgerufen.

/* this function will called from camera: */
ApiomatAdapter.prototype.postPhoto = function(_args, _callbacks) {
  var that = this;
  var myNewPhoto = new Apiomat.Photo();
  myNewPhoto.setLocationLatitude(_args.latitude);
  myNewPhoto.setLocationLongitude(_args.longitude);
  myNewPhoto.setTitle(_args.title);
  myNewAudio.setRatio(_args.ratio);
  myNewPhoto.save({
    onOK : function() {
      myNewPhoto.postPhoto(_args.photo);
      that.user.postmyPhotos(myNewPhoto, {
        onOk : function() {
          Ti.Android && Ti.UI.createNotification({message : 'Photo erfolgreich gespeichert.'}).show();
          Ti.Media.vibrate();
        },
         onError : function() {}
      });
    },
     onError : function() {
   }
 });
};

Im Adapter wird zuerst ein neues, lokales Objekt 'Photo' geschaffen. Diesem Objekt werden mit Settern verschiedene Properties zugewiesen.

Mit 'save()' wird nun der Transfer zur APIOMAT-Cloud angestoßen. Im Erfolgsfall wird jetzt noch mittels 'postPhoto()' das Binary hochgeladen und in einem Zweitrequest dem Nutzer eine Referenz an dieses Objekt mitgegeben. Diese Referenz wird zur Zeit im Projekt nicht genutzt, könnte aber beispielsweise eine Sicht auf „Meine Photos“ ermöglichen.

Löschen von Bildern

In der Photoübersicht sind eigene Bilder mit einem Löschsymbol gekennzeichnet. Klick  darauf (auch eine seitliche Wischgeste auf das gesamte Bild wäre möglich) startet eine Dialog, der die Ernshaftigkeit des Anliegens abfragt. Im Falle der Betätigung wird im Adapter 'deletePhoto()' gestartet.

ApiomatAdapter.prototype.deletePhoto = function(_id, _callbacks) {
   for (var i = 0; i < this.photos.length; i++) {
   // only own phots has an id:
     if (this.photos[i].data.id && this.photos[i].data.id == _id) {
     this.photos[i].deleteModel({
       onOk : function() {
         Ti.Android && Ti.UI.createNotification({message : 'Photo in Liste gelöscht'}).show();
         Ti.Media.vibrate();
         _callbacks.ondeleted();
       },
       onError : function(error) { console.log(error);}
     });
    break;
  }
 }
};

Web-Frontend

Im Web kann und soll nur der Inhalt angezeigt werden. im konkreten Fall ist das eine Wordpress-Seite und ein entsprechendes Plugin verrichtet seinen Dienst. Dieses Plugin könnte auch per PHP vom server aus mit APIOMAT kommunizieren. Wir haben eine reine JS-Lösung bevorzugt. Das Wordpress-Plugin bindet also nur JS-Code ein und rendert dann ein mit einem entsprechenden ID gekennzeichnetes DIV mit der Karte. Es wird in dem Fall der identische SDK-Code wie in der Titaniumlösung referenziert. Zusätzlich wird noch die Leaflet-Lib für die Kartendarstellung inkludiert. Wenn die HTML-Seite fertig ist, wird folgender Code abgearbeitet:

var osm_mapnik = new L.tileLayer('http://services.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}', {
   attribution : 'Tiles &copy; Esri &mdash; National Geographic, Esri, DeLorme, NAVTEQ, UNEP-WCMC, USGS, NASA, ESA, METI, NRCAN, GEBCO, NOAA, iPC',
   maxZoom : 16
});
var map = L.map('josefmap').setView([52.6, 10], 7);
map.addLayer(osm_mapnik);

Ab diesem Zeitpunkt ist die Karte sichtbar. Nun müssen wir auch im Web einen Nutzer definieren und uns einloggen.

var myNutzer = new Apiomat.Nutzer();
myNutzer.setUserName("anonymouswebuser");
myNutzer.setPassword("mylittlesecret");
Apiomat.Datastore.configure(myNutzer);
myNutzer.save(saveCB);
myNutzer.loadMe({
	onOk : loadPhotos,
	onError : loadPhotos
});

Mit den Settern  wird das lokale Objekt gefüttert und mit dem Save-Befehl geht die API ins Netz. Das nachfolgende 'loadMe()' läuft in den OK-Zweig, wenn es eine Neuanalge war – das passiert nur genau einmal  – und in den Error-Zweig, wenn es den Nutzer schon gibt. In beiden Fällen wird jetzt loadPhotos() gerufen.

function loadPhotos() {
		var timer= setTimeout(function() {if (!done) alert('Der verwendete Browser ist für die Darstellung der Karte nicht geeignet.');},15000);
		Apiomat.Photo.getPhotos("order by createdAt limit 500", {
			onOk : function(_res) {
				done =true;
				clearTimeout(timer);
				var josefIcon = L.icon({
					iconUrl : '../wp-content/plugins/freejosef/images/high-pin.png',
					iconSize : [32, 32] // size of the icon
   			        });
				var len = _res.length,markers  = [],WIDTH=360;
				for (var i = 0; i < len; i++) {
					var url = _res[i].getPhotoURL(WIDTH,parseInt(WIDTH/_res[i].getRatio()), null, null, 'png');
					var html = '<div style="width:"'+parseInt(WIDTH+20)+'px"><img width="'+WIDTH+'" src="' + url + '" /><br/>'+ _res[i].getTitle()+'</div>';
					markers[i] = L.marker([_res[i].getLocationLatitude(), _res[i].getLocationLongitude()], {
						icon : josefIcon
					}).addTo(map).bindPopup(html,{ minWidth: parseInt(WIDTH + 20) +'px'});
				}
				map.setView([_res[len-1].getLocationLatitude(), _res[len-1].getLocationLongitude()], 6);
				markers[len-1].openPopup();
			},
			onError : function(error) {
				alert('Keine Verbindung zum Photodepot.\n' + error);
			}
		});
	}

Kernstück der Abfrage ist dieses 'Apiomat.Photo.getPhotos()'. Das ist eine Methode, die extra und nur für uns gebaut wurde ;-))

Wir haben eine Klasse 'Photo'. Deswegen gibt es 'Apiomat.Photos' (für Blitzmerker: unser Applikationname „FreeJosef“ steckt im Endpoint der REST-API). Da wir eine Property 'Photo' angelegt haben, ergibt sich daraus 'getPhotos'.

Die interne RESTful-API nutzt CORS. Das ist eine Möglichkeit trotz der Same-Origin-Policy des Webbrowsers zu fremden Domains zu konnekten. Wie zu erwarten war, wird das in älteren IEs (<v10) nicht realisiert. Leider weiss zur Zeit die API davon nichts und bedient deswegen den Error-Zweig nicht. Deswegen ist im Projekt das Timeout zu Beginn der Methode gesetzt. Das ist zwar hässlich, funktioniert und generiert eine entsprechende Meldung für Microsoftnutzer.

for (var i = 0; i < len; i++) {
   var url = _res[i].getPhotoURL(WIDTH,parseInt(WIDTH/_res[i].getRatio()), null, null, 'png');
   var html = '<div style="width:"'+parseInt(WIDTH+20)+'px"><img width="'+WIDTH+'" src="' + url + '" /><br/>'+ _res[i].getTitle()+'</div>';
   markers[i] = L.marker([_res[i].getLocationLatitude(), _res[i].getLocationLongitude()], {
                        icon : josefIcon
        }).addTo(map).bindPopup(html,{ minWidth: parseInt(WIDTH + 20) +'px'});
}

Nach erfolgreicher Abfrage der APIOMAT-Cloud befindet sich in '_res' eine Liste aller Photos. Nun brauchen wir nur noch diese Liste zu durchlaufen. Um  einzelne Properties abzufragen, gibt es diverse Methode. Mit 'get Location Latitude()' wird die Latitude abgefragt. Wie erinnern uns: Unsere Eigenschaft haben wir 'location' getauft. Daraus baut APIOMAT diese Methode.

Bilder bedürfen einer Spezialbehandlung. Zum einen brauchen wir nicht das Objekt an sich, sondern die URL und wir hätten es gerne auf die nötige Größe geschnitten. Der passende Befehl lautet: 'getPhotoURL()'. Ab Zeile 4 wird die Leaflet-typische API angesprochen, die den Marker baut und der Karte zuordnet.

Fazit

APIOMAT  ist eine schlaue Lösung, um sich Aufwände im Bereich Backendentwicklung und -pflege einzusparen. Da der Frontendler das Backend bauen kann, entfallen Kommunikationsprobleme. Nachdem die Version für die mobile Welt fertig war, dauert die Migration für die Webwelt nur noch eine Stunde.