Software • 04. Mai 2021

Geodaten auf Android performant darstellen und verarbeiten

Naturgemäß stößt die Darstellung und Verarbeitung von Geodaten auf mobilen Geräten schnell an ihre Leistungsgrenzen. Auch Android-Geräte machen hier keine Ausnahme. Mit den folgenden Tricks können die größten Hindernisse jedoch weitgehend umschifft werden.

Diese Tipps sind dabei nicht einmal auf Mobilgeräte beschränkt, sondern lassen sich unter bestimmten Gegebenheiten grundlegend auch auf eine eventuell vorhandene serverseitige Verarbeitung im Backend übertragen.

Parallelverarbeitung (Asynchronität)

Wer eine große Menge an Daten - beispielsweise 500.000 Geometrien - gleichzeitig anzeigen lassen möchte, wird schnell feststellen, dass dies im Mainthread nicht geleistet werden kann. Das Programm friert schlichtweg solange ein, bis alle Daten aus der Datenbank visualisiert wurden. Um dieses Verhalten zu vermeiden, sollten die Daten asynchron geladen und dargestellt werden.

Auf Android bietet sich hierfür die Kotlin-Bibliothek Coroutines an. Mit dieser lassen sich das Laden, Bearbeiten und Darstellen der Daten häppchenweise in einzelne Threads packen.

fun initializeLocalDataSources() = CoroutineScope(Dispatchers.Main).launch { 
        val dataSourcesListTask = async(Dispatchers.IO) {
            dataSourcesProvider.getDataSourcesList()
        }
        val dataSourcesList = dataSourcesListTask.await()
    }

Grob gesagt wird dabei zunächst die Gesamtzahl der darzustellenden Objekte ermittelt (z. B. mittels Berechnung anhand einer Bounding-Box), anschließend werden sie in x Threads aufgeteilt. Diese laufen als Hintergrund-Threads, so dass aus Sicht des Anwenders die Daten nach und nach auf der Karte angezeigt werden, während die Anwendung normal weiter bedient werden kann.

Die Anzahl von Hintergrund-Threads ist dabei leider nicht trivial zu bestimmen, da deren maximale Anzahl vom Prozessor und dem zur Verfügung stehenden Arbeitsspeicher abhängt. Greift man zu hoch, kann ein Memory-Overflow und damit der Absturz der Anwendung ausgelöst werden. Im Zweifelsfall empfiehlt es sich, auf eher niedrigere Werte zurückzugreifen und in besonders kritischen Fällen die Anzahl sogar auf zwei Threads zu beschränken.

Ein Memory-Overflow kann auch auftreten, wenn die zu verarbeitende Datenmenge zu groß ist. In diesem Fall kann man sich behelfen, indem die Anzahl der verarbeiteten Objekte je Iteration verkleinert wird.

So schön die Coroutines sind, sie sind nur für den Preis von Komplexität in Form von Concurrency Issues zu bekommen. Verwenden mehrere Threads dieselbe Instanz eines Services, können dessen interne States und Variablen in unvorhergesehener Reihenfolge geändert werden. Das kann zu Datenkorruption und weiteren Fehlern führen. Um dies zu vermeiden, wird im einfachsten Fall für jeden Thread eine eigene Instanz der Klasse erstellt. Dafür lassen sich die eingebauten Funktionalitäten von DI-Frameworks wie z. B. Koin verwenden; alternativ kann man zu diesem Zweck eine eigene Factory implementieren.

Wichtig ist bei der asynchronen Bearbeitung, dass Fehler richtig behandelt und ggf. dem Main-Thread übermittelt werden. Entweder muss die Datenverarbeitung in dem Fall erneut gestartet oder der Benutzer über den Fehler informiert werden.

val handler = CoroutineExceptionHandler { _, exception -> 
    logger.logError("CoroutineException: $exception") 
}

fun synchronizeStorage() = scope.launch(handler) {
        val synchronizationTask = async(Dispatchers.IO) {            
            synchronizationService.synchronizeStorage()
        }

        return@launch synchronizationTask.await()
    }

Bei der Speicherung von Daten sollte idealerweise auf die Parallelverarbeitung verzichtet werden, da es dabei schnell zu Konflikten kommen kann.

Logik in die Datenbank auslagern

Außerdem können zur Steigerung der Performance einzelne oder mehrere Verarbeitungsschritte in die Datenbank ausgelagert werden. So fallen die Daten bereits "fertig" aus der Datenbank und müssen nicht nachträglich in der Applikation weiterarbeitet werden. Es können prinzipiell auch Daten in der Datenbank verarbeitet werden, die ihren Ursprung an anderer Stelle haben. Als Beispiel für die Verarbeitung von Daten in der Datenbank sei hier die Umwandlung von Objekten in eine andere Projektion oder die Zurückgabe als JSON Feature Collection genannt.

Je nach Anzahl und Größe der Daten (sowie natürlich der Anzahl und Komplexität der Verarbeitungsschritte) schwankt der Einfluss auf die Performance erheblich: Wenn es nur um einige tausend Geometrien geht, dürfte der Einfluss kaum bemerkbar sein. Jenseits der 100.000 Objekte machen sich in der Regel jedoch schnell große Performancegewinne bemerkbar.

Da Android SQLite unterstützt, kann für die konkrete Implementierung die SpatiaLite-Erweiterung verwendet werden. Mit dieser sollten die meisten GIS-spezifischen Fälle problemlos abgedeckt werden können.

Zu beachten ist, dass nicht zu viele Daten auf einmal aus der Datenbank geholt werden sollten, da dadurch ein MemoryOverflow ausgelöst werden kann. Günstiger ist es, die Anfragen in mehrere kleinere Chunks zu verpacken (s.o. unter "Asynchronität").

Problematisch gestaltet sich bei der Auslagerung der Verabeitung in die Datenbank jedoch das Debuggen. Da wir die Kontrolle über die einzelnen Prozesse weitgehend verlieren, wird es schwierig sein, dem eigentlichen Grund für das Fehlverhalten auf die Spur zu kommen.

Inwiefern das Auslagern von Logik in die Datenbank mit den Clean-Code-Paradigmen in Übereinstimmung zu bringen ist, muss jeder für sich selbst entscheiden. Zurecht wird der eine oder andere einwenden, dass man sich mit dieser Vorgehensweise eine feste Kopplung an die Datenbank einhandelt. Wer etwas pragmatischer an die Sache geht, abstrahiert vielleicht den Zugriff auf die Datenbank und achtet darauf, dass die Daten von dort in einem standardisierten Format, wie z. B. einer JSON Feature Collection, übergeben werden.

Native C++-Module

Sollte es nötig sein, komplexe Datenverarbeitungen auf dem Mobilgerät vorzunehmen, kann diese Logik zur Ressourcenschonung in ein natives C++-Modul ausgelagert werden. Vorher sollte geprüft werden, ob die entsprechenden Verarbeitungsschritte nicht doch direkt auf der Datenbankebene umsetzbar sind (s.o.).

Die Einbindung der nativen C++-Module erfolgt über Android Studio. Beachtet werden muss jedoch die korrekte Behandlung von geworfenen Fehlern im Main-Thread. Auch das Debugging wird durch die Einbindung nativer Module – ähnlich wie bei der Auslagerung in die Datenbank – deutlich erschwert.

VectorTiles (oder sonstige Generalisierung)

Durch die Umwandlung der Objekte in VectorTiles kann die Darstellung von Geodaten auf Mobilgeräten erheblich beschleunigt werden. Hierbei werden je nach Zoomlevel unterschiedlich starke Generalisierungen der Daten vorgenommen. Wer nicht gleich zum VectorTiles-Format wechseln möchte, kann deren Verhalten z. B. mit dem Douglas-Peucker-Algorithmus trotzdem teilweise nachahmen.

Als weiterer Optimierungsschritt wäre es möglich, die VectorTiles nicht nur "on demand" zu berechnen, sondern diese in einen Cache zu schreiben und diesen erst bei Änderung der zugrundeliegenden Daten zu aktualisieren.

 

Weitere Blogartikel, die Dich interessieren könnten:

Svitalana Aliferova

Svitlana Aliferova

Svitlana Aliferova ist seit acht Jahren in der Software-Entwicklung tätig. Sie hat in der Nationalen Technischen Universität "Charkiw Polytechnisches Institut" in Charkiw (Ukraine) den Titel "Ingenieurin für Steuerungs- und Automatisierungssysteme" erworben. Seit mehreren Jahren ist sie Teil des Freiburger WhereGroup-Teams.

Artikel teilen: