arrow arrow--cut calendar callback check chevron chevron--large cross cross--large download filter kununu linkedin magnifier mail marker menu minus Flieger phone play plus quote share
jetpack compose im CI Praxistest

Booster für Android Developer: Jetpack Compose

Als Softwareentwickler arbeiten wir immer darauf hin, unseren Code schlanker und effizienter zu schreiben. In der Frontend-Entwicklung wird bereits seit einigen Jahren zunehmend auf deklarative UI-Modelle gesetzt. Das Ziel: die Erstellung und Aktualisierung von UIs signifikant zu vereinfachen. Der Trend fing an mit React Native von Facebook im Jahr 2013, 2017 folgte Google mit dem Cross-Platform Framework Flutter. Als erster Plattformanbieter führte Apple im Jahr 2019 das deklarative UI-Framework SwiftUI ein. Mit Jetpack Compose läutete Google im letzten Jahr die Zukunft von modernen Android-Apps mit deklarativen UIs ein. Das moderne Toolkit vereinfacht und beschleunigt die Entwicklung von nativen Benutzeroberflächen für Android. Die vormals mühsame Art der UI-Entwicklung für Android soll langfristig durch Jetpack Compose ersetzt werden. Wir haben Compose in einem Projekt ausprobiert und möchten verschiedene Aspekte davon beleuchten.

Imperative vs. Declarative UI in Android

Im bisherigen imperativen Ansatz des Android View Systems wird die View-Hierarchie durch einen Tree von UI-Widgets repräsentiert. Immer, wenn sich der Zustand der App verändert, muss die UI aktualisiert werden. Um den internen Zustand eines UI Widgets im Tree zu aktualisieren, muss man sich das konkrete Element aus dem Tree suchen und die Methoden des Widgets verwenden. Schnell entsteht eine Menge an Boilerplate Code, welcher nur existiert, um die Änderungen der Zustände der App in den verschiedenen UI-Widgets zu repräsentieren. Das manuelle Manipulieren der Views erhöht das Fehlerrisiko. Ebenso erhöht sich die Komplexität der Softwarewartung entsprechend der steigenden Anzahl von Widgets.

Bei Jetpack Compose UI wird der ganze Screen erneuert. Dabei werden notwendige Änderungen automatisch übernommen, was sämtliche manuelle Arbeit zum Aktualisieren der Views ersetzt. Auf den ersten Blick scheint eine Erneuerung des gesamten Screens “teuer” zu sein, auf die Zeit, Rechenleistung und den Batterieverbrauch. Diesen Nachteil gleicht Compose jedoch aus, indem es auf intelligente Weise auswählt, welcher Teil der UI zu einem bestimmten Zeitpunkt neu gezeichnet werden muss.

Foto von David Cleef und Maximilian Keppeler
David Cleef und Maximilian Keppeler

Ressourcen in Compose

Da eine parallele Entwicklung mit Views in XML-Dateien und Jetpack Compose möglich ist, können wir nach wie vor auch auf die herkömmliche Weise mit unseren Ressourcen arbeiten. Allerdings ist das langfristige Ziel, sich mit dem Einsatz von Compose so weit wie möglich von XML-Dateien zu distanzieren. Da das derzeit noch nicht überall möglich ist, müssen wir für die Ressourcen bspw. bei Homescreen Widgets und andere RemoteViews nach wie vor mit XML-Dateien erstellen. Die Jetpack Compose Roadmap kündigt aber bereits an, auch Homescreen Widgets mit dem nächsten Release zu unterstützen. Viele Ressourcen können und sollten dann als Code hinterlegt werden. 

Styles werden in Compose dynamisch in Composable Methoden gesetzt und übergeben eine Farbpalette, Typografie, Formen und den Inhalt an das Material Theme. Da Jetpack Compose eine Implementierung von Material Design anbietet, bedarf es keiner zusätzlichen Abhängigkeit mehr zu der Material Components Library. Mit dem nächsten Release beinhaltet Jetpack Compose auch Material You Components.

@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
   val colors = if (darkTheme) DarkColorPalette else LightColorPalette
   MaterialTheme(
       colors = colors,
       typography = Typography,
       shapes = Shapes,
       content = content
   )
}

Farben werden nach den Material Color Definitionen gesetzt und können nach Bedarf überschrieben werden. Benutzt man darkColors() als Basis, werden als Standard dunkle Theme-Farben verwendet, bei lightColors() hingegen helle Theme-Farben:

private val DarkColorPalette = darkColors(
   primary = ColorDarkPrimary,
   primaryVariant = ColorDarkPrimaryVariant,
 background = ColorDarkBackground,
 onPrimary = ColorDarkOnPrimary,
 surface = ColorDarkSurface,
   secondary = ColorSecondary,
)

Schauen wir uns die Farb-Ressourcen an, spricht nichts gegen die Deklaration als Code. Die Farben werden als ARGB Integers hinterlegt:

val ColorLightPrimary = Color(0xFFFFE800)
val ColorLightPrimaryVariant = Color(0xFFB39E00)
val ColorLightBackground = Color(0xFFEEEEEE)
val ColorLightOnPrimary = Color(0xFF5F575A)
val ColorLightSurface = Color(0xFFFFFFFF)

Wollen wir den Wechsel zwischen Hell- und Dunkel-Modus von Anfang an unterstützen, greifen wir in der UI nur auf die Farben des Themes zu.

MaterialTheme.colors

Grundsätzlich kann man wie gewohnt die Vektor-XML-Dateien verwenden, schaut man sich aber die Icons von Compose an, erkennt man, dass auch diese als Code hinterlegt werden können. Die Klasse Icons bietet standardmäßig fünf verschiedene Stile an. Bei den ImageVector-Icons werden die Vector-Daten in einem Lambda-Builder-Block definiert. Man kann eine Vector-Drawable oder eine SVG-Datei zu einem Image Vector konvertieren.

Theming in Compose

Ein großer Vorteil von Compose ist die deutliche Vereinfachung beim Implementieren und Managen von Themes. 

Das Theme ist eine Composable-Methode. Abhängig vom Hell- oder Dunkel-Modus wird die richtige Farbpalette dem MaterialTheme übergeben.

@Composable
fun Theme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
   val colors = if (darkTheme) {
       DarkColorPalette
   } else {
       LightColorPalette
   }
   MaterialTheme(
       colors = colors,
       typography = Typography,
       shapes = Shapes,
       content = content
   )
}

Nun kann man ein ViewModel erstellen, welches für die Theme-Logik zuständig ist. Dies kann beispielsweise auch eine lokale Implementierung von Hell- und Dunkel-Modus nach eigenen Zeitangaben sein, oder die Möglichkeit, beliebige Themes abhängig vom Nutzer oder ganz anderen Bedingungen übernehmen zu können. 

Ähnlich wie bei der Composable-Methode isSystemInDarkTheme() erstellen wir eine weitere Composable-Methode. Dort injecten wir uns das ViewModel und geben das Theme in die Methode zurück. Ist das Auto-Modus-Theme ausgewählt, können wir das richtige Theme, wie bisher, abhängig von DarkTheme auswählen.

@Composable
fun getTheme(darkTheme: Boolean = isSystemInDarkTheme()): Theme {
   val themeViewModel: ThemeViewModel = hiltViewModel()
 val theme = themeViewModel.theme.observeAsState(Theme.AUTO).value
   return when (theme) {
       Theme.AUTO -> if (darkTheme) Theme.DARK else Theme.LIGHT
       else -> theme
   }
}

In unserem Theme übergeben wir dann das aktuelle Theme und wählen abhängig davon die richtige Farbpalette aus.

@Composable
fun AppTheme(theme: Theme = getTheme(), content: @Composable () -> Unit) {


   val colors = when (theme) {
       Theme.LIGHT -> LightColorPalette
       Theme.DARK -> DarkColorPalette
      	     Theme.AMOLED -> AmoledColorPalette
       Theme.BLUE -> BlueColorPalette
   }


   MaterialTheme(
       colors = colors,
       typography = Typography,
       shapes = Shapes,
       content = content
   )
}

Auf die gleiche Weise können wir neben dem Theme auch Typografie und Formen dynamisch beeinflussen.

Navigation in Compose

Die Jetpack Navigation Component bietet Support für Jetpack Compose Apps. Unter Verwendung der Compose Navigation Library soll man laut Google zwischen Composables navigieren und sich dabei die Feature und Infrastruktur der Navigation Component zunutze machen können. Die zentrale API ist hierbei weiterhin der NavController, welcher den Backstack der Composables, aus welchen sich die einzelnen Screens zusammensetzen, sowie deren State nachhält. Jedem NavController muss zudem ein einzelnes NavHost Composable angegliedert werden, aus welchem der Navigationsgraph erzeugt wird. Jedes Composable, zu welchem navigiert werden kann, wird im NavHost mit einer Route assoziiert. Eine Route ist ein String, der den Pfad zu einem Composable in der App definiert, ähnlich wie man es von Frameworks wie Flutter oder React kennt oder auch von der Pfaddefinition in Web-Apps. Jede Route sollte hierbei einzigartig sein und als Konstante definiert werden, um die Entstehung von Bugs durch Tippfehler zu vermeiden. Argumente können bei der Navigation ausschließlich über den Pfad einer bestehenden Route übergeben werden, analog zu Query-Parametern bei einer URL. Für jedes Argument muss ein Platzhalter zu der Route des entsprechenden Composable im NavHost hinzugefügt werden. Für die Typsicherheit ist man dabei komplett selbst verantwortlich, was bedeutet, dass der Typ explizit bei der Deklaration der Composables mit angegeben werden muss:

NavHost(...) {
    composable(
            "peerdetail/{id}",
            arguments = listOf(
                    navArgument(Screen.ARG_ID) { type = NavType.StringType }
            )
           ) {...}
}

Das Gleiche gilt auch für die  Extraktion aus den gebündelten Argumenten des Eintrags im Backstack bei der Navigation:

NavHost(...) {
    composable(
                       "peerdetail/{id}",
                  arguments = listOf(
                          navArgument(Screen.ARG_ID) { type = NavType.StringType }
                       )
    ) {
           PeerDetailScreen(
          id = it.arguments?.getString("id"),
               ...
           )
              } 
}

Bei der Verwendung vieler solcher Argumente kann es schnell unübersichtlich werden und etwaige Fehler werden nicht zur Compile Time angezeigt. Hinzu kommt, dass das Übergeben von Serializables und Parcelables als Navigationsargumente nicht von Compose-Navigation unterstützt wird. Stattdessen müssen wir solche Parcelables zu Json Objekten parsen, um sie dem Navigationspfad anhängen zu können. Zusätzlich wird ein maßgefertigter Navtype benötigt, der dem Composable als Typ für das entsprechend Argument im Pfad übergeben wird. Einen neuen NavType definieren wir dabei wie folgt:

class CustomNavType : NavType<Peer>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Peer? {
        return bundle.getParcelable(key)
    }
 
    override fun parseValue(value: String): Peer {
        return Gson().fromJson(value,Peer::class.java)
    }
 
    override fun put(bundle: Bundle, key: String, value: Peer) {
        bundle.putParcelable(key,value)    }
}

Genutzt werden kann dieser dann zum Beispiel so:

NavHost(...) {
   composable("home") {
       Home (
          onClick = {
                     val peer = Peer("1", "Example")
                    val json = Uri.encode(Gson().toJson(peer))
                    navController.navigate("peerDetail/$json")
          }
            )
    }
    composable(
        "peerdetail/{peer}",
        arguments = listOf(
            navArgument("peer") { type = CustomNavType}
            )
     ) {
       PeerDetailScreen(
      id = it.arguments?.getParcelable<Peer>("id"),
         ...
       )
       }
}

Für mehrere solcher Parcelables als Navigationsargumente bedeutet das jede Menge Boilerplate Code. 

Im Vergleich mit der herkömmlichen Art, die Argumente und ihre Typen (sowohl primitiv als auch komplex) einfach in XML zu definieren, spart man sich hier also weder Code noch Arbeit. Da der Speicher für das Vorhalten von State in Android limitiert ist, will Google damit vermutlich Entwickler ermutigen, nur IDs von lokal vorgehaltenen Objekten, z.B. aus einer Room Datenbank oder dem Datastore, zu verwenden und keine komplexen Objekte bei der Navigation zu übergeben. Das wirkt sich positiv auf die Performance aus.

Fazit

Jetpack Compose begeistert die Community der Android Entwickler nicht ohne Grund. Android Entwickler erstellen mit Compose bereits beeindruckende UIs mit Leichtigkeit. Die Auswahl an Bibliotheken von Dritten für Compose erweitert sich ebenso zügig. Die Entwicklung von neuen Android Apps mit Jetpack Compose erscheint entsprechend logisch. Setzt man nicht auf Compose und die wachsende Compose-Umgebung, entstehen im Laufe der Zeit technische Schulden, erhöhte Kosten für Wartung, erhöhtes Risiko für Bugs und ein größerer Aufwand generell im UI-relevantem Code. Unsere Erfahrungen mit Compose waren durchaus positiv. Die Implementierung des Pendant einer Recyclerview in Compose, der LazyColumn, ist beispielsweise in wenigen Zeilen Code umgesetzt und entbehrt komplett der aufwendigen Implementierung eines Adapters und der Viewholder für verschiedene Views.  Es gibt jedoch auch noch Verbesserungspotential, wie beispielsweise bei der Navigation. Es gibt außerdem noch viele Aspekte, welche wir bisher noch nicht in Augenschein genommen haben, unter anderem Animationen oder das Schreiben von Tests mit Compose.