Skip to Content
Mobile Apps2 - AndroidViewModel und Architektur
Folienvorschau
Dauer
20 Minuten
Ziel
Zustand und Daten sauber von der Oberfläche trennen

Themen

  • MVVM-Schichten
  • UiState als sealed interface
  • ViewModel mit StateFlow
  • stateful und stateless Composables
  • Repository und Coroutines
Klicken für Tastaturnavigation · F für Vollbild
Präsentation starten · 20 Minuten ↗

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.

MVVM-Schichten der Wetter-App

  • 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):

  • Loading trägt keine Daten und wird nur einmal gebraucht, deshalb ein data object: ein Singleton wie object, dazu ein lesbares toString().
  • Success und Error tragen Daten (Stadt und Wetter bzw. Fehlermeldung). Davon entstehen beliebig viele Instanzen mit unterschiedlichem Inhalt, deshalb data 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