ViewModel und Architektur

20 MinutenZustand und Daten sauber von der Oberfläche trennen

MVVM-Schichten

Jede Schicht hat eine Aufgabe und kennt nur die Schicht darunter.

MVVM-Schichten der Wetter-App
MVVM-Schichten der Wetter-App

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
}

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()
}

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 */ }
}

Repository

Das Repository liefert die Daten. Das ViewModel kennt nur das Interface.

interface WeatherRepository {
    suspend fun getCurrentWeather(city: City): CurrentWeather
}

Repository

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}")
    }
}

Coroutines im ViewModel

suspend-Funktionen können warten, ohne die Oberfläche zu blockieren. Das ViewModel startet sie in viewModelScope.

fun loadWeather(city: City) {
    _uiState.value = WeatherUiState.Loading
    viewModelScope.launch {
        _uiState.value = try {
            val weather = repository.getCurrentWeather(city)
            WeatherUiState.Success(city, weather)
        } catch (e: CancellationException) { throw e }
        catch (e: Exception) {
            WeatherUiState.Error(e.message ?: "Unbekannter Fehler")
        }
    }
}

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()