Skip to Content
Mobile Apps2 - AndroidNavigation und API
Folienvorschau
Dauer
40 Minuten
Ziel
Eine Liste anzeigen, Screens verbinden und echte Daten mit einem Coding Agent anbinden

Themen

  • Listen mit LazyColumn
  • Navigation 3 mit Back Stack als Liste
  • Routen als Typen
  • API-Anbindung mit einem Coding Agent
  • Ktor und kotlinx.serialization
Klicken für Tastaturnavigation · F für Vollbild
Präsentation starten · 40 Minuten ↗

Navigation und API

Die App bekommt eine Städteliste, die wir mit Navigation 3 und der Wetteranzeige verbinden. Danach ersetzen wir die Fake-Daten durch echte Wetterdaten. Die API-Anbindung übernimmt ein Coding Agent; unsere Aufgabe ist, seinen Plan und seinen Code zu prüfen.

Listen mit LazyColumn

LazyColumn zeichnet nur die sichtbaren Einträge. Damit bleiben auch lange Listen performant.

LazyColumn(contentPadding = innerPadding) { items(cities, key = { it.name }) { city -> ListItem( headlineContent = { Text(text = city.name) }, modifier = Modifier.clickable { onCityClick(city) }, ) HorizontalDivider() } }

Eine normale Column würde alle Einträge auf einmal erzeugen. Für wenige, feste Elemente reicht das, für lange Listen nicht.

key sagt Compose, welcher Eintrag welcher ist, z. B. wenn sich die Liste ändert. Der Key muss eindeutig sein. Später (Stadtsuche) heißen mehrere Städte gleich; dann sind die Koordinaten der bessere Key.

Neue Datei ui/CityListScreen.kt. Der Code ist hier verkürzt. Im Repository steckt die LazyColumn in einem Scaffold mit TopAppBar(title = { Text(text = "Städte") }), und jeder Eintrag zeigt zusätzlich die Koordinaten:

supportingContent = { Text(text = "%.2f° N, %.2f° O".format(city.latitude, city.longitude)) },

Eine Preview zeigt die Liste mit fakeCities und einem leeren onCityClick = {}.

CityListScreen bekommt die Städte und eine Funktion onCityClick als Parameter. Was beim Antippen passiert, entscheidet der Aufrufer: Mit Navigation 3 öffnet das Antippen den Wetter-Screen der Stadt.

Der Back Stack ist eine Liste, die die App selbst als State hält.

  • Navigieren: Element hinzufügen, backStack.add(…)
  • Zurück: letztes Element entfernen, backStack.removeLastOrNull()
  • Angezeigt wird immer das letzte Element der Liste

Das passt zu dem, was wir über State wissen: Der Back Stack ist eine beobachtbare Liste. Ändert sie sich, zeigt NavDisplay den passenden Screen an.

Navigation 3 braucht drei Abhängigkeiten und ein Gradle-Plugin:

# gradle/libs.versions.toml [versions] nav3Core = "1.2.0" kotlinxSerializationCore = "1.9.0" [libraries] androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Das Plugin muss außerdem in der Root-build.gradle.kts (mit apply false) und in app/build.gradle.kts eingetragen werden, die drei Bibliotheken unter dependencies:

// build.gradle.kts (Root) plugins { // ... alias(libs.plugins.kotlin.serialization) apply false } // app/build.gradle.kts plugins { // ... alias(libs.plugins.kotlin.serialization) } dependencies { // ... implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.kotlinx.serialization.core) }

Die Versionen am besten aus der Dokumentation  übernehmen. Navigation 3 verlangt außerdem compileSdk 36 oder höher (in app/build.gradle.kts); neue Projekte erfüllen das bereits.

Routen als Typen

Jeder Screen ist ein Typ, Argumente sind Properties.

@Serializable data object CityList : NavKey @Serializable data class CityWeather(val city: City) : NavKey

@Serializable und NavKey sind nötig, damit rememberNavBackStack den Back Stack speichern kann, z. B. beim Drehen des Bildschirms. Weil City Teil der Route ist, bekommt auch City in model/City.kt die Annotation @Serializable.

Die Routen stehen in der neuen Datei ui/WeatherApp.kt (siehe unten).

NavDisplay zeigt das letzte Element des Back Stacks an.

Die neue Datei ui/WeatherApp.kt; MainActivity zeigt statt WeatherScreen() jetzt WeatherApp() an:

@Composable fun WeatherApp() { // Der Back Stack ist eine Liste: Navigieren = Element hinzufügen oder entfernen val backStack = rememberNavBackStack(CityList) // Ohne Decorator ist das ViewModel an die Activity gebunden val weatherViewModel: WeatherViewModel = viewModel() NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = entryProvider { entry<CityList> { CityListScreen( cities = fakeCities, onCityClick = { city -> weatherViewModel.loadWeather(city) backStack.add(CityWeather(city)) }, ) } entry<CityWeather> { key -> WeatherScreen( city = key.city, viewModel = weatherViewModel, ) } }, ) }

WeatherScreen und WeatherContent bekommen dafür einen neuen Parameter city: City. Statt „Wetter“ zeigt die TopAppBar jetzt city.name aus der Route; so steht der Name der Stadt schon während des Ladens da. Die Previews übergeben city = fakeCities.first().

onBack wird bei der Zurück-Geste des Systems aufgerufen. Einen Zurück-Pfeil in der TopAppBar haben wir bewusst weggelassen (siehe Übungen).

Das Wetter wird jetzt beim Antippen geladen. Der init-Block im WeatherViewModel, der bisher Lörrach geladen hat, entfällt deshalb.

Das ViewModel wird oberhalb von NavDisplay geholt und an WeatherScreen übergeben. Ohne weitere Konfiguration ist es an die Activity gebunden. Für diese App reicht das.

Bewusste Vereinfachung: Das Wetter wird beim Antippen in der Liste geladen, nicht beim Öffnen des Screens. Wird die App vom System beendet und wiederhergestellt, stellt der Back Stack den Wetter-Screen wieder her, das ViewModel hat aber keine Daten. Sauberer wäre LaunchedEffect(city) { viewModel.loadWeather(city) } im Screen. Nachstellen lässt sich das mit der Entwickleroption Aktivitäten nicht speichern (Don’t keep activities): Stadt öffnen, App in den Hintergrund, wieder öffnen → der Ladekreis dreht sich endlos.

Zweite Vereinfachung: Jedes Antippen startet eine neue Coroutine, ohne die vorige abzubrechen. Mit der echten API kann bei schnellem Wechsel die langsamere Antwort zuletzt ankommen; dann steht das Wetter von Stadt A unter dem Titel von Stadt B. Abhilfe: den Job von launch speichern und vor jedem neuen Laden cancel() aufrufen.

Code: schritt-08 · Änderungen 

API-Anbindung mit einem Coding Agent

Aufgabe für den Agent: ein ApiWeatherRepository für Open-Meteo bauen und im ViewModel einsetzen.

  • Plan anfordern und prüfen, bevor Code entsteht
  • umsetzen lassen, bauen, App testen
  • Diff lesen und erklären können
  • erst dann committen

Die Datei AGENTS.md im Repository gibt dem Agent Regeln vor, z. B. Ktor statt Retrofit, keine neuen Libraries ohne Rückfrage, die UI nicht anfassen, nicht selbst committen. Gängige Agents lesen sie automatisch, auch Gemini in Android Studio.

Hinein gehört nur, was der Agent nicht aus dem Code ablesen kann: Abweichungen vom Üblichen und Regeln für die Zusammenarbeit. Eine Beschreibung des Projekts oder der Architektur hilft dagegen kaum, denn der Agent liest den Code ohnehin. Jede Zeile kostet bei jeder Anfrage Tokens.

Ein möglicher Prompt:

Baue ein ApiWeatherRepository, das das aktuelle Wetter einer Stadt von Open-Meteo lädt (Temperatur, Wettercode, Windgeschwindigkeit; Koordinaten aus City). Setze es als Default im WeatherViewModel ein. Zeig mir zuerst deinen Plan.

Die API braucht keinen Schlüssel. Eine Antwort lässt sich vorab im Browser ansehen: api.open-meteo.com/v1/forecast?latitude=47.62&longitude=7.66&current=temperature_2m,weather_code,wind_speed_10m 

Ktor-Client

Ktor ist ein HTTP-Client von JetBrains für Kotlin. Der Request steht explizit im Code.

override suspend fun getCurrentWeather(city: City): CurrentWeather { val url = "https://api.open-meteo.com/v1/forecast" val response: ForecastResponse = client.get(url) { parameter("latitude", city.latitude) parameter("longitude", city.longitude) parameter("current", "temperature_2m,weather_code,wind_speed_10m") parameter("timezone", "auto") }.body() return response.current.toCurrentWeather() }

Neue Datei data/ApiWeatherRepository.kt. Die Funktion gehört zur Klasse ApiWeatherRepository(private val client: HttpClient = httpClient) : WeatherRepository. Was der Agent im Referenz-Repository außerdem angelegt hat:

  • INTERNET-Permission in AndroidManifest.xml, ohne die Android keine Netzwerkzugriffe erlaubt:
<uses-permission android:name="android.permission.INTERNET" />
  • Abhängigkeiten in gradle/libs.versions.toml (Version ktor = "3.6.0") und app/build.gradle.kts:
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json)
  • einen gemeinsamen HttpClient mit JSON-Unterstützung in data/api/HttpClient.kt:
val httpClient = HttpClient(OkHttp) { expectSuccess = true // HTTP-Fehler (4xx, 5xx) als Exception werfen install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } }

ignoreUnknownKeys ist wichtig: Die API liefert mehr Felder, als wir brauchen.

Alternative zu Ktor ist Retrofit, das in vielen bestehenden Android-Projekten und in Googles Codelabs verwendet wird. Gute Übung: den Agent fragen, wie dasselbe mit Retrofit aussähe, und die beiden Varianten vergleichen.

DTO und Datenmodell

Die JSON-Antwort bekommt eigene Klassen (DTOs). Sie werden auf unser Datenmodell abgebildet.

@Serializable data class CurrentDto( @SerialName("temperature_2m") val temperature: Double, @SerialName("weather_code") val weatherCode: Int, @SerialName("wind_speed_10m") val windSpeed: Double, )

Die DTOs stehen in der neuen Datei data/api/OpenMeteoDto.kt. CurrentDto steckt in einer Hülle für die gesamte Antwort, weil die API die Werte im Feld current liefert:

@Serializable data class ForecastResponse( val current: CurrentDto, )

Eine Erweiterungsfunktion in ApiWeatherRepository.kt bildet das DTO auf das Datenmodell ab:

private fun CurrentDto.toCurrentWeather() = CurrentWeather( temperature = temperature, windSpeed = windSpeed, condition = weatherConditionFromWmoCode(weatherCode), )

Warum nicht gleich CurrentWeather mit @Serializable versehen? Weil das Datenmodell dann vom Format der API abhängt. Mit DTOs bleibt eine Änderung der API auf die Datenschicht beschränkt; die Oberfläche merkt nichts davon.

Die Wetterlage liefert die API als Zahl (WMO-Code). Eine Funktion bildet sie auf WeatherCondition ab:

private fun weatherConditionFromWmoCode(code: Int): WeatherCondition = when (code) { 0 -> WeatherCondition.CLEAR 1, 2 -> WeatherCondition.PARTLY_CLOUDY 3 -> WeatherCondition.CLOUDY 45, 48 -> WeatherCondition.FOG in 51..67, in 80..82 -> WeatherCondition.RAIN in 71..77, 85, 86 -> WeatherCondition.SNOW in 95..99 -> WeatherCondition.THUNDERSTORM else -> WeatherCondition.CLOUDY }

Der Nutzen der Architektur

Für echte Daten wurde kein einziges Composable geändert.

$ git diff --stat schritt-08 schritt-09 app/build.gradle.kts | 4 ++ app/src/main/AndroidManifest.xml | 2 + .../data/ApiWeatherRepository.kt | 45 +++++++++ .../data/api/HttpClient.kt | 15 ++++ .../data/api/OpenMeteoDto.kt | 18 ++++ .../ui/WeatherViewModel.kt | 4 +- gradle/libs.versions.toml | 5 ++

Im ViewModel hat sich nur der Default-Parameter geändert:

class WeatherViewModel( private val repository: WeatherRepository = ApiWeatherRepository(), ) : ViewModel()

Fehler wie fehlendes Internet landen automatisch im Zustand Error, weil das ViewModel die Exceptions des Repositorys bereits abfängt.

Ein Blick beim Review lohnt sich trotzdem: Angezeigt wird e.message, und die kommt jetzt von Ktor bzw. OkHttp, z. B. „Unable to resolve host …“. Das ist Englisch und für Nutzer unverständlich, obwohl AGENTS.md deutsche Texte verlangt. Am einfachsten im Emulator mit Flugmodus ausprobieren (siehe Übungen).

Code: schritt-09 · Änderungen 

Übungen

Selbst oder mit einem Coding Agent umsetzen, jeweils Plan und Diff prüfen.

  • Stadtsuche über die Geocoding-API von Open-Meteo
  • 7-Tage-Vorhersage im Wetter-Screen
  • Zurück-Pfeil in der TopAppBar
  • Button „Erneut versuchen“ bei Fehlern
  • Favoriten dauerhaft speichern (DataStore oder Room)
  • Unit-Tests für das ViewModel mit FakeWeatherRepository

Hinweise zu den Übungen:

  • Stadtsuche: Endpunkt https://geocoding-api.open-meteo.com/v1/search?name=…&count=5&language=de. Die Städteliste wird dafür zu State in einem eigenen ViewModel. Achtung: Mehrere Städte heißen gleich (es gibt mehrere „Hamburg“), key = { it.name } in der LazyColumn führt dann zum Absturz. In der Trefferliste lassen sich gleichnamige Städte über die Felder admin1 (Bundesland) und country der Antwort unterscheiden; dafür braucht City ein weiteres Feld. Negative Koordinaten (Süd, West) sollten als „33,87° S“ statt „-33,87° N“ erscheinen. Auch bei der Suche können sich Anfragen überholen (siehe Laden abbrechen). Eine Referenzlösung liegt im Tag schritt-11 (Änderungen ).
  • 7-Tage-Vorhersage: Parameter daily=weather_code,temperature_2m_max,temperature_2m_min, neues Datenmodell DailyForecast, Liste im Wetter-Screen.
  • Zurück-Pfeil: Material 3 bringt keine Icons mehr mit. Auf fonts.google.com/icons  z. B. „arrow_back“ wählen, im Reiter Android als XML herunterladen und per Drag-and-Drop in den Resource Manager von Android Studio ziehen. In der TopAppBar unter navigationIcon ein IconButton(onClick = onBack) mit Icon(painterResource(R.drawable.…), contentDescription = "Zurück") einsetzen. onBack bekommt WeatherScreen als neuen Parameter und reicht es an WeatherContent weiter; in WeatherApp wird onBack = { backStack.removeLastOrNull() } übergeben. Meldet der Build einen Fehler zu ?attr/colorControlNormal, das Attribut android:tint aus der XML-Datei löschen.
  • Erneut versuchen: Die Stadt kennt der Screen schon aus der Route. WeatherContent bekommt einen Parameter onRetry, WeatherScreen übergibt { viewModel.loadWeather(city) }.
  • Laden abbrechen: Vor jedem neuen Laden die vorige Coroutine abbrechen (siehe Hinweis bei NavDisplay).
  • Laden beim Öffnen: LaunchedEffect(city) statt Laden beim Antippen (siehe Hinweis bei NavDisplay).
  • Fehlermeldungen auf Deutsch: Im ViewModel statt e.message eigene Texte setzen, z. B. bei IOException (kein Netz) „Keine Internetverbindung“, sonst „Wetterdaten konnten nicht geladen werden“.
  • Favoriten: Für wenige einfache Werte (z. B. die Namen der Lieblingsstädte) reicht DataStore . Für strukturierte Daten mit Abfragen gibt es die Datenbank Room  (SQLite), Einstieg mit dem Codelab . Der Zugriff gehört in ein eigenes Repository in data/, wie beim Wetter; ViewModel und UI bleiben beim selben Muster. Die Datenbank im Emulator lässt sich unter View → Tool Windows → App Inspection ansehen.
  • Unit-Tests: formatTemperature lässt sich ohne weitere Abhängigkeit testen (src/test, JUnit ist schon eingebunden). Für Tests des ViewModels mit FakeWeatherRepository braucht es kotlinx-coroutines-test und Dispatchers.setMain(…), weil viewModelScope den Main-Dispatcher verwendet.

Weiterführendes Material