Android-App-Entwicklung in Jetpack Compose – Tech’n’Drinks @myposter

In unserem letzten Tech‘n’Drinks am 2. September hat sich Jannis, Android Entwickler in unserem App Team, mit Jetpack Compose auseinandergesetzt, die Basics der reaktiven UI-Entwicklung erläutert und im Live Coding Insights zur Anwendung des neuen Frameworks von Google gegeben. In unserem Blog Beitrag fassen wir Jannis’ Talk für Euch zusammen.

Welches Problem löst reaktive UI-Entwicklung?

Ein grundlegendes Problem der UI-Entwicklung ist es, das UI einer Anwendung korrekt zu aktualisieren, wenn der Zustand der Anwendung verändert wird. Das ist zum Beispiel bei einer Serie von Eingaben des Nutzers der Fall. Hier wird bei jeder Eingabe des Nutzers potentiell eine Anpassung des UIs notwendig.

Klassischerweise wird ein UI initial in einem Grundzustand erzeugt und dann teilweise verändert, wenn es notwendig ist. Problem hieran ist, dass mehrere nacheinander ausgeführte Änderungen miteinander wechselwirken können und dadurch nicht zum gewünschten Ergebnis führen. So muss also bei jeder Änderung der aktuelle Zustand des UI berücksichtigt werden, um den gewünschten  Zielzustand zu erreichen.

Da bei komplexen User Interfaces nicht alle möglichen Zustandsveränderungen bedacht werden können, führt diese Vorgehensweise häufig zu Problemen.

Was ist reaktive UI-Entwicklung?

Reaktive UI-Entwicklung ist ein Überbegriff für eine Kombination verschiedener Paradigmen, die von verschiedenen reaktiven UI-Frameworks alle oder teilweise verwendet werden.

Mehr zu den zentralen Elementen der reaktiven UI-Entwicklung findet ihr hier:

Reaktivität bezieht sich auf die Verwendung reaktiver Datenströme. Ein reaktiver Datenstrom ist ein Fluss von Datensätzen, dessen Veränderungen automatisch im UI verarbeitet wird.

Ein simples Beispiel hierfür sind Tabellenkalkulationsprogramme: Wenn man den Wert einer Zelle eines Spreadsheets verändert, werden automatisch auch alle anderen Zellen aktualisiert, welche die veränderte Zelle referenzieren.

In der reaktiven UI-Entwicklung kann der Zustand der Anwendung als reaktiver Datenstrom modelliert werden. Dadurch werden Veränderungen des Zustands automatisch im UI propagiert. Es entsteht ein Kreislauf, in dem Eingaben des Nutzers eine Veränderung des Anwendungs-Zustands bewirken: Ein neuer Anwendungs-Zustand löst darin eine Aktualisierung des UIs aus. Im aktualisierten UI können dann wiederum weitere Nutzer-Eingaben erfolgen – und so weiter.

In der reaktiven UI-Entwicklung werden User Interfaces meist mit deklarativer Programmierung angelegt. Deklarativ bedeutet, dass der Code beschreibt, wie  die Hierarchie der verschiedenen UI-Elemente aussehen soll.

Diese Beschreibung des UI verwendet Daten, die den Zustand der Anwendung abbilden, und transformiert diese Daten dann zu UI-Elementen. Die Transformation von Daten zu UI berücksichtigt aber nicht den vorherigen Zustand des UI. Bei jeder Veränderung wird das UI also komplett neu aufgebaut.

Mit der deklarativen Programmierung geht einher, dass die UI-Hierarchie nicht teilweise verändert werden kann, nachdem sie erstellt wurde. Sie repräsentiert einen fixen Zustand, und nur durch Veränderung des Zustands kann ein neues, aktualisiertes UI erzeugt werden. Das ist grundsätzlich umständlich. Kompensiert wird das allerdings dadurch, dass unveränderte Teile der UI-Hierarchie in reaktiven UI-Frameworks wiederverwendet werden.

Dem Prinzip „Composition over Inheritance“ folgend, werden in der reaktiven UI-Entwicklung häufig UI-Komponenten aus anderen, simpleren Komponenten zusammengesetzt. Die Komponenten funktionieren unabhängig voneinander, sodass Teile des UI wiederverwendet werden können. Das ist insbesondere bei Komponenten der Fall, die ihren Zustand nicht selbst verwalten, sondern über Parameter von außen gesteuert werden können. Solche Komponenten werden als stateless bezeichnet.

So viel zum Hintergrund reaktiver UI-Frameworks, die wir bei myposter zur Entwicklung unserer nativen Mobile Apps nutzen. 

Für Android hat Google Ende Juli diesen Jahres das neue UI-Toolkit Jetpack Compose veröffentlicht, das die Entwicklung nativer Android Apps laut eigener Aussage schneller und viel einfacher machen soll. Was hinter dem neuen Toolkit von Google steckt und was Jannis davon hält, erfahrt ihr im zweiten Teil unseres Beitrags:

JETPACK COMPOSE

Reaktive UI-Frameworks sind zuerst in der Web-Entwicklung mit React populär geworden. Frameworks für die Entwicklung von nativer Mobile Apps sind nachgezogen: SwiftUI für iOS und Jetpack Compose für Android.

Was ist Jetpack Compose?

Jetpack Compose ist ein neues UI-Framework für Android-Apps, das von Google entwickelt wird. Compose verfolgt mehrere Ziele. Zum einen sollen die Erfahrungen aus der Entwicklung des alten View-Systems in Compose eingebracht werden, um problematische Bereiche des View-Systems in Compose besser zu lösen. Zum anderen sollen Kompatibilitätsprobleme mit verschiedenen Versionen von Android, wie man sie kennt, vermieden werden. Die Lösung: Jetpack Compose wird als Library mit der App ausgespielt und nutzt somit auf allen Versionen von Android das exakt gleiche UI-Framework.

Compose basiertet auf Kotlin

Jetpack Compose wurde für und mit der Programmiersprache Kotlin entwickelt. Das UI wird also komplett mit Kotlin-Code aufgebaut. Die Basis sind fünf Konzepte, die sich klar vom XML-basierten View-System unterscheiden. Wir stellen sie Euch vor:

Die Grundbausteine von Compose-UIs sind Funktionen, die mit @Composable annotiert werden. Einen solche Composable-Funktion repräsentiert ein Element der UI-Hierarchie. Indem eine Composable-Funktion andere Composable-Funktionen aufruft und diese wiederum weitere Funktionen aufrufen, werden Ebenen zur UI-Hierarchie hinzugefügt.

Composables in der UI-Hierarchie können verschiedene Funktionen haben, z. B. Inhalte darstellen (Texte, Bilder, Buttons, usw.) oder in der Hierarchie darunter liegende Composables anordnen (Layouts).

Um das UI zuverlässig und performant aufzubauen, müssen Composable-Funktionen einige Bedingungen erfüllen:

  1. Sie sollten schnell ausführbar sein,  weil sie unter Umständen sehr häufig aufgerufen werden, z. B. bei Animationen bis zu einmal pro Frame.
  2. Sie dürfen keine externen Effekte haben, also den Zustand der App nicht verändern.
  3. Jeder Aufruf der Funktion mit den gleichen Parametern muss zum gleichen Ergebnis führen.

In Compose wird zwischen stateful und stateless Composables unterschieden. Stateless Composables verwalten ihren Zustand nicht selbst, sondern bekommen alle Informationen über ihren Zustand per Parameter übergeben. Dadurch sind sie anpassbar und können leicht an verschiedenen Stellen einer App verwendet werden. Oft ist es sinnvoll, für einige Parameter eines stateless Composables Standardwerte festzulegen, da diese Parameter so beim Aufrufen des Composables weggelassen werden können.

Stateful composables verwalten ihren Zustand selbst. Das macht sie zwar im Prinzip weniger flexibel, aber dafür auch leichter zu verwenden.

Damit Compose bei State-Änderungen automatisch das UI aktualisieren kann, muss Composes eigenes Interface für reaktive State-Container verwendet werden: State<T>. Dieser State-Container wird nur im UI-Layer verwendet. In anderen Schichten der App können andere reaktive Streams verwendet werden, wie z. B. LiveData, Flow oder RxJava-Streams. Diese Streams können im UI-Layer zu einem Compose-State konvertiert werden.

Ein weiteres wichtiges Konzept für das State-Handling in Compose ist State-Hoisting. Damit ist gemeint, dass State möglichst weit oben in der UI-Hierarchie an einer zentralen Stelle gehalten wird. Dies hat den Vorteil, dass es eine „Single source of truth“ gibt, die entkoppelt vom darunterliegenden UI ist. In der Praxis wird oft der State eines Screens z. B. als StateFlow in einem ViewModel gehalten. Das oberste Composable der UI-Hierarchie holt diesen StateFlow aus dem ViewModel, konvertiert ihn mit der Funktion collectAsState() zu einem Compose-State und reicht diesen dann an die darunterliegenden UI-Elemente weiter.

CompositionLocals sind eine Möglichkeit, Werte implizit durch die UI-Hierarchie weiterzureichen. Ein CompositionLocal ist selbst Teil der UI-Hierarchie und stellt seinen Wert der unter ihm liegenden Teilhierarchie zur Verfügung. Durch diesen Mechanismus lassen sich Teile des UIs gezielt anpassen, ohne die Elemente  selbst verändern zu müssen. Das ist z. B. für das Theming einer App hilfreich. Ein Anwendungsbeispiel: Das CompositionLocal LocalContentColor wird genutzt,  um die Farbe des Inhalts von UI-Komponenten zu bestimmen.

Layouts sind UI-Elemente, die kontrollieren, wie die in ihnen enthaltenen Elemente positioniert werden. In Compose sind natürlich auch Layouts Composable-Funktionen. Die drei einfachsten von Compose bereitgestellten Layouts sind Row, Column und Box.

Row und Column sind sich sehr ähnlich. Beide positionieren ihre Kind-Elemente linear: Row richtet Elemente horizontal -Column vertikal aus. Box erlaubt hingegen eine freie Positionierung von Elementen.

Im Unterschied zum Android-View-System verursachen stark verschachtelte Layouts in Compose keine Performance-Probleme. Somit ist es möglich, die meisten UIs nur durch eine Kombination von Row, Column und Box aufzubauen. Um starke Verschachtelung zu vermeiden, kann aber auch in Compose das aus dem View-System bekannte ConstraintLayoutverwendet werden.

Layouts (und auch alle anderen UI-Komponenten) können in Compose durch die Verwendung von Modifiern angepasst werden. Modifier werden per Parameter an eine Composable-Funktion übergeben und ermöglichen die Veränderung der Größe und des Hintergrunds, die Verarbeitung von Klick-Events und Touch-Gesten, das Hinzufügen von Accessibility-Informationen und vieles mehr.

Material Design ist als Theming-System in Jetpack Compose enthalten. Umgesetzt wird das Theme, wie fast alles in Compose, als Composable-Funktion. Theme-Funktionen können an beliebigen Stellen in der UI-Hierarchie aufgerufen werden und wirken sich auf das gesamte darunter liegende UI aus. Mit CompositionLocals werden Informationen zu Farben, Formen und Typographie bereitgestellt und können so von den UI-Elementen verwendet werden. Material Design ermöglicht es, diese Farben, Formen und Schrift-Stile frei anzupassen.

Material Design ist nur ein Beispiel der Design Systeme in Compose. Auch andere Design Systeme werden von Compose unterstützt. Und weil die mitgelieferte Implementierung von Material Design nur öffentlich zugängliche Schnittstellen verwendet, kann auch ein eigenes Design-System ohne Einschränkungen umgesetzt werden.

Die Einschränkungen – aus unserer Sicht

Jetpack Compose ist ein noch sehr junges UI-Framework. Klar also, dass es noch nicht alle Funktionen hat, die man von einem ausgereiften Framework erwartet und die im Android-View-System vorhanden sind. Darunter fallen  z. B. Drag-and-Drop-Funktionen, Screen-Transition-Animation und Animationen für Listen. Wird aber sicher noch kommen.

Die Performance von Compose ist zur Zeit noch nicht so gut, wie die des View-Systems, aber in den allermeisten Situationen gut genug.

Die Preview-Funktion für Compose in Android Studio ist zwar deutlich mächtiger, als Previews für das View-System, aber auch noch wesentlich langsamer. Nach Änderungen am UI-Code muss erst der Code kompiliert werden, bevor die aktualisierte Vorschau angezeigt werden kann.

Eine weitere, eindeutige Einschränkung ist, dass Compose nur mit Kotlin verwendet werden kann. Und dabei auch nur mit bestimmten  Versionen von Kotlin zusammenarbeitet (z. B. funktioniert die aktuelle Version 1.1.1 von Compose nur mit Kotlin 1.6.10). Das kann dann zum Problem werden, wenn eine ältere Kotlin-Version in der App verwendet wird, oder aber eine neue Version von Kotlin, die von Compose noch nicht unterstützt wird.

Google hat eine Roadmap für die zukünftige Entwicklung von Compose veröffentlicht. Demnach soll es bei den genannten Einschränkungen in Zukunft Verbesserungen geben.

Was uns noch wichtig ist: Migration zu Compose

Apps können Compose integrieren, ohne den bestehenden Code komplett hierauf umzustellen. Um die Interoperabilität von Compose und dem View-System zu ermöglichen, enthält das Framework ComposeView und AndroidView. Mit ComposeView kann ein Compose-UI in das View-System eingebunden werden. AndroidView ist eine Composable-Funktion, mit der ein View-basiertes UI in Compose verwendet werden kann.

Was das bringt? Mit ComposeView ist es z. B. möglich, einzelne Screens einer App in Compose umzusetzen, indem ein Compose-UI mit ComposeView in ein Fragment oder eine Activity eingebettet wird. Eine Migrationsstrategie für Compose kann damit also sein, neue Screens mit Compose umzusetzen, aber eine Fragment- oder Activity-basierte Navigation beizubehalten. Bestehende Screens oder Custom Views können dann bei Bedarf mit Compose neu implementiert werden.

Andere von Google entwickelte Jetpack-Libraries, wie ViewModel, Navigation, Paging und ConstraintLayout können mit Compose zusammen verwendet werden und wurden, wo nötig, bereits an Compose angepasst.

FAZIT

Jetpack Compose unterscheidet sich grundlegend vom althergebrachten UI-System in Android. Der Umstieg auf Compose bringt deshalb eine Lernphase mit sich, in der man den deklarativen und reaktiven Ansatz verinnerlicht und die verschiedenen Komponenten von Compose kennenlernt.

Diese Lernphase hat sich für uns bei myposter ausgezahlt. Wir haben mittlerweile mehrere Projekte mit Compose umgesetzt und dabei weniger UI-bezogene Bugs, höhere Produktivität und, last but not least, mehr Developer-Happiness festgestellt :).