Suche
Multi-Versioning Application

Multi-Versioning: Versionsübergreifend erfolgreiche Apps entwickeln

Lars Röwekamp, Arne Limburg
Multi-Versioning Application

© Shutterstock / Bloomua

Plant man als Android-Entwickler eine neue App, stellt sich schnell die Frage nach der angestrebten Zielgruppe bzw. den entsprechenden Zieldevices. Wegen der nach wie vor stark fragmentierten Landschaft von Android-Versionen kann das Ignorieren der einen oder anderen Version durchaus über den Erfolg der eigenen App entscheiden. Wie geht man also am besten vor, wenn möglichst viele Nutzer erreicht und gleichzeitig keine Kompromisse in User Interface und Usability eingegangen werden sollen?

Die Entwicklung von Android-Apps wird seit jeher von der Herausforderung begleitet, dass sich eine Vielzahl verschiedener Android-Varianten mit zum Teil extrem unterschiedlicher Funktionalität auf dem Markt befindet. Nicht ohne Grund veröffentlicht Google regelmäßig die aktuelle Verteilung der Plattformversionen als eine Art Gradmesser für Entwickler, bis zu welcher Version die eigene App abwärtskompatibel gehalten werden sollte.

Neben der nicht zu empfehlenden „Hauruck“-Methode, bei der für jede Android-Version ein eigenes APK erstellt wird, existieren noch etliche weitere Strategien, (s)eine App mit überschaubarem Wartungsaufwand abwärtskompatibel zu halten. Eine wichtige Rolle spielen dabei u. a. die von Google angebotenen Support Libraries, mit deren Hilfe die – aus Googles Sicht – wichtigsten Features eines neuen Releases auch älteren Android-Versionen zur Verfügung gestellt werden. Dazu aber später mehr.

Bad Practice

Eine erste, sehr einfache Strategie zur Gewährleistung der gewünschten Abwärtskompatibilität ist das Aussitzen von Neuerungen. Diese auch als „Avoid Evolution“ bekannte Strategie hat den riesigen Vorteil, dass durch den Verzicht auf neue Features und UI-Elemente die eigene App per Definition abwärtskompatibel ist und zusätzlich noch auf allen Devices gleich aussieht. Leider bringt diese Strategie aber auch den Nachteil mit sich, dass die App sich auf ein Minimal-Set an Features beschränkt und insbesondere auf neueren Devices extrem altbacken daherkommt. Das Look and Feel des UI ist zwar auf allen Devices gleich – aber eben leider gleich schlecht.

Eine zweite, ebenfalls sehr einfache Strategie geht genau den umgekehrten Weg und nutzt für jede Android-Version das Maximum an zur Verfügung stehenden Features. Erreicht wird dies, indem letztendlich für jede Android-Version ein eigener Sourcecode-Zweig existiert und ein eigenes APK erzeugt wird. Man muss kein Prophet sein, um vorhersagen zu können, dass diese Strategie mit an Sicherheit grenzender Wahrscheinlichkeit ins Chaos und somit zu einer nicht mehr wartbaren App führt. Gleiches gilt übrigens, wenn man das Problem lediglich verlagert und für nahezu jede Android-Version eigene Ressourcen (z. B. Layouts) zur Verfügung stellt. Auch hier ist das Chaos vorprogrammiert.

Nachdem die beiden eben gezeigten Strategien scheinbar nicht zum Ziel führen, stellt sich die Frage nach dem richtigen Weg. Um es gleich vorweg zu nehmen: Es gibt nicht die eine Lösung. Je nach gewünschtem Feature und zu unterstützenden Android-Versionen sind unterschiedlichste Vorgehen sinnvoll, die mal mit mehr und mal mit weniger Aufwand verbunden sind.

Parallel Activity Pattern

Ist eine App an den meisten Stellen für die zu unterstützenden Android-Versionen gleich und unterscheidet sich nur in einigen wenigen Activities, dann bietet sich die Verwendung des Parallel Activity Patterns an. Bei diesem Pattern wird explizit im Code die aktuelle Laufzeitumgebung abgefragt und je nach Android-Version die passende Activity gestartet (Listing 1). Sinnvoll ist dies allerdings nur dann, wenn man klare Versionsgrenzen – in unserem Fall Pre- und Post-Ice-Cream-Sandwich – für die Activities definieren kann. Andernfalls führt auch dieses Pattern sehr schnell zu nicht wartbarem Code.

Da die jeweiligen Versionsnamen durch den Compiler in die passenden Integer-Werte der Version übersetzt werden, können neuere Versionsnamen auch auf Devices abgefragt werden, die diese Versionen theoretisch noch nicht kennen können.

Listing 1

Intent intent = null; 
if (android.os.Build.VERSION.SDK_INT >=
android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
  intent = new Intent(this, SupeICSActivity.class);
} else {
  intent = new Intent(this, OldSchoolActivity.class);
} 
...

Lazy Loading Pattern

Einen ähnlichen Effekt wie beim Parallel Activity Pattern kann man durch das Lazy Loading Pattern auch für allgemeine Codefragmente erreichen. Dieses Pattern hat im Gegensatz zum vorab gezeigten Parallel Activity Pattern den Vorteil, dass nicht ganze Activities dupliziert werden müssen, nur weil sich deren Code in einigen wenigen Zeilen unterscheidet. Beim Lazy Loading Pattern wird zunächst ein Interface bzw. eine abstrakte Klasse implementiert, im Anschluss die konkreten Klassen für die relevanten OS-Versionen. Zur Laufzeit wird dann bei Bedarf genau die Klasse geladen, die den Code für die vorliegende Laufzeitumgebung enthält (Listing 2).

Listing 2

MyAbstractClass myClass;
int currentSDK = Build.VERSION.SDK_INT;
if (currentSDK <= Build.VERSION_CODES.ECLAIR) {
  myClass = new EclairMyClass();
} else if  (currentSDK <= Build.VERSION_CODES.FROYO) {
  myClass = new FrodoMyClass();
} else {
  myClass = new GingerbreadMyClass();
}
myClass.doSomething();

Genau diesen Ansatz verfolgen übrigens auch die Android Support Libraries. Wenn möglich, d. h. wenn auf den Devices vorhanden, delegieren sie mittels Lazy-Loading-Mechanismus die Aufrufe an die Implementierung des Originalfeatures. Nur wenn das Feature nicht im Original vorhanden ist, wird die Implementierung der Support Library selbst herangezogen.

Ressourcen

Eine ebenfalls sehr effektive Strategie zur Laufzeitmanipulation der App ist die Verwendung unterschiedlicher Ressourcen für verschiede Android-Versionen. Stellen wir uns einmal ein UI vor, das etliche Ein- und Ausgabeelemente enthält und an einer Stelle einige Switch-UI-Komponenten darstellen soll. Da es diese Komponente erst seit API-Level 14 gibt, muss für Android-Versionen vor 4.0 auf die klassische CheckBox zurückgegriffen werden.

In der einfachsten Variante würde man nun für jede relevante Android-Version ein eigenes res/layout-vN-Verzeichnis anlegen und dort die spezifischen Layoutressourcen der Activities mit Switch bzw. mit CheckBox ablegen. Auch hier gilt allerdings wieder, dass schnell die Übersicht verloren geht und die Gefahr der unnötigen Codeduplizierung besteht. Eine deutlich effizientere Strategie wäre die Verwendung von Layout-Includes.

Layout-Includes erlauben die Verwendung von „Teillayouts“ und ermöglichen so modulweise das versionsabhängige Zusammenbauen eines UI. Bei der Verwendung von Layout-Includes gäbe es in unserem Beispiel also nur eine Layoutdatei für alle Versionen. An der kritischen Stelle, also dort, wo sich die Switches bzw. Checkboxen befinden, würde via <include … >-Tag ein versionsspezifisches Layoutmodul eingebunden, das sich im entsprechenden res/layoutVerzeichnis der Zielversion befindet. Der Trick bei diesem Ansatz ist, dass nur die wirklich unterschiedlichen Layoutelemente mehrfach vorliegen und somit die Codeduplizierung minimiert wird. Die für alle Versionen gemeinsamen Teile eines UI dagegen finden sich nur einmal im Projekt wieder und führen so bei Änderungen nicht zu Problemen. Eine zusätzliche Herausforderung ergibt sich in diesem Szenario immer dann, wenn innerhalb des Quellcodes auf die versionsspezifischen Elemente zugegriffen werden soll. Liegen die verwendeten UI-Elemente, wie in unserem Fall, in einer Ableitungshierarchie, ist es meist relativ einfach möglich, den Quellcode so aufzubauen, dass er für alle Varianten gültig ist (Listing 3).

Listing 3: Pre- und Post-ICS-kompatibler Code

// check box in version prio ICS, switch else
CompoundButton cb; 
cb = (CompoundButton)findViewById(R.id.toggleBtn);
if (cb.isChecked()) {
...
} else {
  ...
} 

Eine weitere Möglichkeit, den Android-Ressourcenmechanismus als Versionsweiche zu nutzen, ergibt sich durch die Verwendung von Boolean Resources. In unserem Szenario könnte man zwei „globale“ Ressourcen, preICS und postICS, definieren. Im Verzeichnis res/values würde man die Ressourcen mit preICS = true und postICS = false belegen. Im Verzeichnis res/values-v14 dagegen genau mit den entgegengesetzten Werten. Da die Boolean Resources sowohl im Quellcode als auch in anderen Ressourcen und dem Android-Manifest herangezogen werden können, erhält man so eine optimale Ausgangsbasis für eine übergreifende Versionsweiche.

Support Libraries

Bei allen bisher gezeigten Strategien wurde davon ausgegangen, dass lediglich einzelne Activities bzw. Layouts einer App versionsspezifische Features enthalten. Was aber ist, wenn man „neuere“ UI-Patterns, wie zum Beispiel die ActionBar, die erweiterten Notifications oder aber (Nested) Fragments auch auf älteren Devices nutzen möchte? In diesem Fall kann man auf direkte Unterstützung aus dem Hause Google zurückgreifen und die Android Support Libraries nutzen.

Die Support Libraries stellen Features einer neuen Android-Version, bei denen Google davon ausgeht, dass sie auch für ältere Devices von großem Interesse sein dürften, bis zu einer vorgegebenen Version abwärtskompatibel zur Verfügung. In der Regel handelt es sich dabei um Features, die mit neu eingeführten UI-Paradigmen im Zusammenhang stehen. Daher ist es durchaus eine „Best Practice“, innerhalb seiner App diese Libs einzubinden und zu verwenden. Dies gilt insbesondere für die Support Library v4 und Teile der Support Library v7. Google selbst verwendet diese beiden Libs u. a. auch in nahezu allen Codetemplates des Android Studios.

Während die Support Library v4 u. a. Fragments, Rich Notifications, Loader und Local Broadcast Manager abwärtskompatibel bis Android Donut (entspricht Android 1.6 bzw. API-Level 4, daher auch der Name v4 ) unterstützt, kann mithilfe der Support Library v7 u. a. auf Features wie ActionBar, CardView, GridLayout oder RecyclerView abwärtskompatibel bis Android Eclair (Version 2.1/API-Level 7) zurückgegriffen werden.

In der Regel programmiert man gegen die APIs der Support Libraries genauso wie gegen die Originalimplementierungen. Lediglich die Importpackages unterscheiden sich. Mittels interner Versionsweiche stellt die Support Library zur Laufzeit fest, welche Version aktuell zugrunde liegt. Unterstützt die Version das gewünschte Feature im Original, wird der Call lediglich delegiert. Ist dies nicht der Fall, wird die Implementierung der Support Library herangezogen. Je nach Feature kann es auch leichte Unterschiede in der Anwendung geben. Dies ist zum Beispiel bei der ActionBar oder den Fragments der Fall. Ein Blick in die zugehörige Doku hilft.

Pitfalls

Leider gibt es auch bei Verwendung der bisher genannten Strategien noch den einen oder anderen Versionsstolperstein, den es zu beachten gilt. So erwarten zum Beispiel die Notifications je nach Laufzeitumgebung unterschiedliche Icons. Zum Glück gibt es mit dem Android Asset Studio ein nettes, kleines Tool, das einem die Generierung der verschiedenen Icontypen auf Basis eines Base-Icons abnimmt.

Einen weiteren Pitfall stellen die Android-Dialoge dar. Während in den Versionen vor Ice Cream Sandwich der Positivbutton („ok“) links und der Negativbutton („cancel“) rechts platziert wurde, ist dies seit ICS genau umgekehrt. Baut man also für seine App eigene Dialoge, gilt es, dieses Verhalten versionsspezifisch nachzubilden, um den Nutzer nicht zu irritieren.

Min- und Target-SDK-Version

Abschließend noch eine kleine Anmerkung zu den beiden Android-Manifest-Einträgen minSdkVersion und targetSdkVersion. Beide Werte sollten grundsätzlich angegeben werden. Mittels minSdkVersion selektiert Android, ob die App überhaupt auf einem Device installiert werden kann oder nicht. Das Weglassen dieses Werts wird mit dem Wert 1 gleichgesetzt, was bedeutet, dass eine Abwärtskompatibilität bis zu Android-API-Level 1 (Version Alpha/1.0) vorausgesetzt wird. Abgesehen davon, dass wahrscheinlich weltweit kaum noch eine Handvoll kompatibler Geräte existieren, dürfte es bei einer Installation auf einem entsprechenden Device mit sehr, sehr hoher Wahrscheinlichkeit zu einem Crash kommen.

Genauso wichtig, wie die Angabe der minSdkVersion ist auch die Angabe der targetSdkVersion. In diesem Attribut sollte man die höchste getestete Version hinterlegen. Android nutzt diese Information u. a., um die Komptabilität zu neueren Versionen zu berechnen. Gleichzeitig werden anhand des targetSdkVersion-Werts verschiedene Optimierungen, wie zum Beispiel Hardware Acceleration, aktiviert – oder eben nicht. Es gilt also prinzipiell „je höher desto besser“.

Fazit

Auch wenn mittlerweile die Verbreitung von Android 4.x die 90-Prozent-Hürde überschritten hat, stellt die starke Fragmentierung am Markt nach wie vor eine nicht zu vernachlässigende Herausforderung dar. Wird das Problem falsch angegangen, ist der eigene Quellcode schnell unübersichtlich und unwartbar. Zum Glück lässt sich dem potenziellen Chaos bereits mit überschaubaren Maßnahmen wie der Verwendung des Parallel Activity oder des Lazy Loading Patterns entgegensteuern.

Wichtig für eine versionsübergreifend erfolgreiche App ist, sich von Anfang an eine Strategie zurechtzulegen, die berücksichtigt, welche Versionen mit welchen Mitteln unterstützt werden sollen. Dabei gilt es zu beachten, dass die eigene App nicht die einzige sein wird, die der Nutzer – hoffentlich mehrfach am Tag – aufruft. Primäres Ziel sollte es daher nicht sein, dass die App auf allen Devices identisch aussieht bzw. sich identisch verhält, sondern vielmehr, dass die App auf allen Android-Versionen so aussieht und sich so verhält, wie es der Nutzer von der jeweiligen Version erwartet.

Eine Ausnahme bilden grundlegende UI-Patterns wie ActionBar oder Fragments, die dem Entwickler dank Android Support Library auch abwärtskompatibel für nahezu alle älteren Android-Versionen zur Verfügung stehen. Zwar fehlen in den Libraries nach wir vor einige wichtige Elemente, allerdings bessert Google ständig nach. In diesem Sinne: Stay tuned.

Aufmacherbild: Designer develop a mobile application via Shutterstock / Urheberrecht: Bloomua

Geschrieben von
Lars Röwekamp
Lars Röwekamp
Lars Röwekamp ist Gründer des IT-Beratungs- und Entwicklungsunternehmens open knowledge GmbH, beschäftigt sich im Rahmen seiner Tätigkeit als „CIO New Technologies“ mit der eingehenden Analyse und Bewertung neuer Software- und Technologietrends. Ein besonderer Schwerpunkt seiner Arbeit liegt derzeit in den Bereichen Enterprise und Mobile Computing, wobei neben Design- und Architekturfragen insbesondere die Real-Life-Aspekte im Fokus seiner Betrachtung stehen. Lars Röwekamp, Autor mehrerer Fachartikel und -bücher, beschäftigt sich seit der Geburtsstunde von Java mit dieser Programmiersprache, wobei er einen Großteil seiner praktischen Erfahrungen im Rahmen großer internationaler Projekte sammeln konnte.
Arne Limburg
Arne Limburg
Arne Limburg ist Softwarearchitekt bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: