ViewModel und Architektur
Bisher stecken die Daten direkt im Code der Oberfläche. Für echte Daten aus dem Netz braucht die App eine klare Aufteilung: Wer lädt die Daten, wer hält den Zustand, wer zeigt ihn an? Dafür verwenden wir das Architekturmuster MVVM (Model, View, ViewModel), so wie es Google für Android empfiehlt.
MVVM-Schichten
Jede Schicht hat eine Aufgabe und kennt nur die Schicht darunter.
- UI (Composables): zeigt den Zustand an und meldet Events
- ViewModel: hält den Zustand des Screens und reagiert auf Events
- Repository: liefert Daten, egal ob aus Fake-Daten, Datenbank oder Netz
Im Code liegt das ViewModel trotzdem im Paket ui/, neben dem Screen,
zu dem es gehört. Googles Architektur-Leitfaden zählt ViewModels zur
UI-Schicht: Sie sind State Holder für die Oberfläche. Das Diagramm
trennt sie, um die Aufgaben deutlich zu machen.
Der Clou: Das Repository ist ein Interface. In Schritt 7 steckt dahinter eine Fake-Implementierung, in Schritt 9 die echte API. UI und ViewModel merken davon nichts.
UiState als sealed interface
Der Screen ist immer in genau einem von drei Zuständen.
sealed interface WeatherUiState {
data object Loading : WeatherUiState
data class Success(
val city: City,
val weather: CurrentWeather,
) : WeatherUiState
data class Error(val message: String) : WeatherUiState
}Neue Datei ui/WeatherUiState.kt.
sealed (versiegelt) bedeutet: Der Compiler kennt alle Varianten, hier
genau drei. Von außen (anderes Paket oder Modul) kann niemand weitere
hinzufügen. Deshalb kann when (uiState) prüfen, ob alle Fälle
behandelt sind, ganz ohne else-Zweig. Kommt ein Zustand hinzu, meldet
der Compiler jede Stelle, die ihn noch nicht behandelt.
Jede Variante implementiert das Interface (: WeatherUiState):
Loadingträgt keine Daten und wird nur einmal gebraucht, deshalb eindata object: ein Singleton wieobject, dazu ein lesbarestoString().SuccessundErrortragen Daten (Stadt und Wetter bzw. Fehlermeldung). Davon entstehen beliebig viele Instanzen mit unterschiedlichem Inhalt, deshalbdata class.
Im when (siehe unten) steht deshalb WeatherUiState.Loading -> ohne is (Vergleich
mit dem einen Objekt), die Klassen dagegen mit is (Typprüfung). Nach
is WeatherUiState.Success weiß Kotlin, dass uiState ein Success
ist, und erlaubt den Zugriff auf uiState.city (Smart Cast).
ViewModel mit StateFlow
Das ViewModel hält den Zustand. Es überlebt das Drehen des Bildschirms.
class WeatherViewModel : ViewModel() {
private val _uiState = MutableStateFlow<WeatherUiState>(
WeatherUiState.Success(
city = fakeCities.first(),
weather = fakeCurrentWeather.getValue("Lörrach"),
)
)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
}Neue Datei ui/WeatherViewModel.kt.
StateFlow ist ein beobachtbarer Wert, ähnlich wie mutableStateOf,
aber unabhängig von Compose. Nach außen gibt das ViewModel nur die
lesbare Variante uiState frei; ändern kann den Zustand nur das
ViewModel selbst über _uiState.
MainActivity ruft jetzt nur noch WeatherScreen() auf. Die Daten
kommen aus dem ViewModel, nicht mehr von außen.
Dafür braucht das Projekt zwei Abhängigkeiten. Sie verwenden dieselbe
Version wie das schon vorhandene lifecycle-runtime-ktx:
# gradle/libs.versions.toml, unter [libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }// app/build.gradle.kts, unter dependencies
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)Die Compose-Bibliotheken selbst (z. B. material3) stehen in
libs.versions.toml ohne Version. Ihre Versionen legt die
Compose-BOM (Bill of Materials, composeBom) fest, damit alle
zusammenpassen. Aktualisiert wird nur die BOM. Die Lifecycle-Bibliotheken
gehören nicht dazu und brauchen deshalb eine eigene Version.
Stateful und stateless
Ein Composable holt den Zustand aus dem ViewModel, ein zweites zeigt ihn nur an.
@Composable // stateful
fun WeatherScreen(viewModel: WeatherViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
WeatherContent(uiState = uiState)
}
@Composable // stateless
fun WeatherContent(uiState: WeatherUiState) {
// Scaffold, TopAppBar ...
when (uiState) { /* Loading, Success, Error */ }
}Im Repository haben beide Composables zusätzlich den üblichen Parameter
modifier: Modifier = Modifier; hier ist er der Übersicht halber weggelassen.
Beide stehen in ui/WeatherScreen.kt; aus dem bisherigen
WeatherScreen wird WeatherContent.
Warum die Aufteilung? Eine Preview kann kein ViewModel erzeugen.
WeatherContent bekommt den Zustand als Parameter und lässt sich deshalb
in der Preview mit jedem Zustand anzeigen:
@Preview(showBackground = true, name = "Loading")
@Composable
fun WeatherContentLoadingPreview() {
WeatherAndroidTIF24Theme {
WeatherContent(uiState = WeatherUiState.Loading)
}
}In WeatherContent entscheidet when (uiState) im Scaffold, was
angezeigt wird. Die TopAppBar zeigt vorerst den festen Titel „Wetter“;
den Namen der Stadt bekommt sie mit der Navigation.
when (uiState) {
WeatherUiState.Loading -> CenteredBox(Modifier.padding(innerPadding)) {
CircularProgressIndicator()
}
is WeatherUiState.Success -> CurrentWeatherCard(
city = uiState.city,
weather = uiState.weather,
useCelsius = useCelsius,
modifier = Modifier.padding(innerPadding),
)
is WeatherUiState.Error -> CenteredBox(Modifier.padding(innerPadding)) {
Text(text = uiState.message)
}
}CenteredBox ist ein kleines Hilfs-Composable, das seinen Inhalt in
einer Box mit fillMaxSize() zentriert.
Der Umschalter °C/°F bleibt als rememberSaveable in WeatherContent.
Er ist reiner Anzeige-Zustand (UI-Element-State) und gehört nicht ins
ViewModel. Was dagegen den Inhalt des Screens bestimmt (Screen-State),
gehört ins ViewModel.
Genau genommen ist WeatherContent deshalb nicht ganz stateless.
„Stateless“ bezieht sich hier auf den Screen-Zustand: Der kommt von
außen, nur der Anzeige-Zustand für °C/°F bleibt im Composable.
Code: schritt-06 ·
Änderungen
Repository
Das Repository liefert die Daten. Das ViewModel kennt nur das Interface.
interface WeatherRepository {
suspend fun getCurrentWeather(city: City): CurrentWeather
}class FakeWeatherRepository(
private val shouldFail: Boolean = false,
) : WeatherRepository {
override suspend fun getCurrentWeather(city: City): CurrentWeather {
delay(1500) // simuliert die Antwortzeit eines Servers
if (shouldFail) throw IOException("Keine Verbindung (simuliert)")
return fakeCurrentWeather[city.name]
?: throw IOException("Keine Wetterdaten für ${city.name}")
}
}Neue Dateien data/WeatherRepository.kt und
data/FakeWeatherRepository.kt.
Mit delay(1500) verhält sich das Fake-Repository wie ein langsamer
Server: Der Ladezustand wird sichtbar. Mit shouldFail = true lässt sich
der Fehlerzustand ausprobieren, ohne das WLAN abzuschalten. Ohne Emulator
zeigt eine dritte Preview den Fehlerzustand:
@Preview(showBackground = true, name = "Error")
@Composable
fun WeatherContentErrorPreview() {
WeatherAndroidTIF24Theme {
WeatherContent(uiState = WeatherUiState.Error("Keine Verbindung zum Server"))
}
}Coroutines im ViewModel
suspend-Funktionen können warten, ohne die Oberfläche zu blockieren. Das ViewModel startet sie in viewModelScope.
class WeatherViewModel(
private val repository: WeatherRepository = FakeWeatherRepository(),
) : ViewModel() {
private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Loading)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
init {
loadWeather(fakeCities.first())
}
fun loadWeather(city: City) {
_uiState.value = WeatherUiState.Loading
viewModelScope.launch {
_uiState.value = try {
WeatherUiState.Success(city, repository.getCurrentWeather(city))
} catch (e: CancellationException) {
throw e // Abbruch der Coroutine nicht als Fehler behandeln
} catch (e: Exception) {
WeatherUiState.Error(e.message ?: "Unbekannter Fehler")
}
}
}
}viewModelScope beendet laufende Coroutines automatisch, wenn das
ViewModel nicht mehr gebraucht wird. Dabei wird eine
CancellationException geworfen. Sie darf nicht als Fehler angezeigt
werden, deshalb wird sie vor dem allgemeinen catch weitergeworfen.
Das init lädt vorerst das Wetter für Lörrach. Ab Schritt 8 übernimmt
das ein Klick in der Städteliste.
Repository als Default-Parameter
Das ViewModel bekommt sein Repository als Parameter mit Default-Wert. Für die echte API ändert sich später nur diese Zeile.
class WeatherViewModel(
private val repository: WeatherRepository = FakeWeatherRepository(),
) : ViewModel()In größeren Apps übernimmt das ein Dependency-Injection-Framework wie
Hilt oder Koin. Für diese App reicht der Default-Parameter, und das
Prinzip ist ähnlich: Das ViewModel ist nicht fest an eine Datenquelle
gebunden. Ohne Argument erzeugt es per Default selbst ein
FakeWeatherRepository, von außen lässt sich aber jede andere
Implementierung übergeben. Das macht das ViewModel auch testbar, z. B.
mit WeatherViewModel(FakeWeatherRepository(shouldFail = true)).
Code: schritt-07 ·
Änderungen
Weiterführendes Material
- Leitfaden zur App-Architektur
- UI-Schicht und UiState
- ViewModel
- Coroutines in Android
- Now in Android : Referenz-App von Google mit derselben Architektur in groß