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.
Navigation 3
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
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 ausCity). Setze es als Default imWeatherViewModelein. 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¤t=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 inAndroidManifest.xml, ohne die Android keine Netzwerkzugriffe erlaubt:
<uses-permission android:name="android.permission.INTERNET" />- Abhängigkeiten in
gradle/libs.versions.toml(Versionktor = "3.6.0") undapp/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
HttpClientmit JSON-Unterstützung indata/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 derLazyColumnführt dann zum Absturz. In der Trefferliste lassen sich gleichnamige Städte über die Felderadmin1(Bundesland) undcountryder Antwort unterscheiden; dafür brauchtCityein 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 Tagschritt-11(Änderungen ). - 7-Tage-Vorhersage: Parameter
daily=weather_code,temperature_2m_max,temperature_2m_min, neues DatenmodellDailyForecast, 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
TopAppBarunternavigationIconeinIconButton(onClick = onBack)mitIcon(painterResource(R.drawable.…), contentDescription = "Zurück")einsetzen.onBackbekommtWeatherScreenals neuen Parameter und reicht es anWeatherContentweiter; inWeatherAppwirdonBack = { backStack.removeLastOrNull() }übergeben. Meldet der Build einen Fehler zu?attr/colorControlNormal, das Attributandroid:tintaus der XML-Datei löschen. - Erneut versuchen: Die Stadt kennt der Screen schon aus der Route.
WeatherContentbekommt einen ParameteronRetry,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.messageeigene Texte setzen, z. B. beiIOException(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:
formatTemperaturelässt sich ohne weitere Abhängigkeit testen (src/test, JUnit ist schon eingebunden). Für Tests des ViewModels mitFakeWeatherRepositorybraucht eskotlinx-coroutines-testundDispatchers.setMain(…), weilviewModelScopeden Main-Dispatcher verwendet.
Weiterführendes Material
- Listen und Raster
- Navigation 3
- nav3-recipes : Beispiele, z. B. Bottom Navigation
- Ktor Client
- kotlinx.serialization
- Open-Meteo API
- Datenschicht (Data Layer)