Skip to Content
Mobile Apps4 - Flutter (Wetter-App)ViewModel und Architektur
Folienvorschau
Dauer
15 Minuten
Ziel
Zustand und Daten sauber von der Oberfläche trennen

Themen

  • MVVM-Schichten
  • UiState als sealed class
  • ViewModel mit ChangeNotifier
  • ListenableBuilder
  • Repository und async/await
Klicken für Tastaturnavigation · F für Vollbild
Präsentation starten · 15 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 wie in der Android-App das Architekturmuster MVVM (Model, View, ViewModel). Auch der Architektur-Leitfaden von Flutter  empfiehlt MVVM, umgesetzt mit Bordmitteln von Flutter, ohne zusätzliche Pakete.

MVVM-Schichten

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

MVVM-Schichten der Wetter-App

  • UI (Widgets): 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

Das ViewModel liegt im Ordner ui/, neben dem Screen, zu dem es gehört.

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 class

Der Screen ist immer in genau einem von drei Zuständen.

sealed class WeatherUiState { const WeatherUiState(); } class WeatherLoading extends WeatherUiState { /* ... */ } class WeatherSuccess extends WeatherUiState { const WeatherSuccess(this.city, this.weather); final City city; final CurrentWeather weather; } class WeatherError extends WeatherUiState { /* final String message */ }

Neue Datei ui/weather_ui_state.dart.

sealed bedeutet wie in Kotlin: Der Compiler kennt alle Unterklassen, weil sie in derselben Datei stehen müssen. Ein switch über WeatherUiState muss deshalb alle drei Fälle behandeln, ohne default. Kommt ein Zustand hinzu, meldet der Analyzer jede Stelle, die ihn noch nicht behandelt.

Ein data object gibt es in Dart nicht. WeatherLoading ist eine Klasse ohne Felder mit const-Konstruktor; const WeatherLoading() ergibt dadurch immer dasselbe Objekt.

WeatherSuccess(this.city, this.weather) nutzt positionale Parameter (ohne {}): Aufruf mit WeatherSuccess(city, weather). this.city weist den Parameter direkt dem Feld zu.

Der switch-Ausdruck mit Pattern Matching (siehe unten) prüft den Typ und holt die Felder heraus:

switch (uiState) { WeatherLoading() => /* ... */, WeatherSuccess(:final city, :final weather) => /* ... */, WeatherError(:final message) => /* ... */, }

WeatherSuccess(:final weather) bedeutet: Wenn uiState ein WeatherSuccess ist, lege sein Feld weather in eine gleichnamige Variable. Das entspricht is WeatherUiState.Success mit Smart Cast in Kotlin.

ViewModel mit ChangeNotifier

Das ViewModel hält den Zustand und meldet Änderungen mit notifyListeners().

class WeatherViewModel extends ChangeNotifier { final WeatherUiState _uiState = WeatherSuccess( fakeCities.first, fakeCurrentWeather[fakeCities.first.name]!, ); WeatherUiState get uiState => _uiState; }

Neue Datei ui/weather_view_model.dart.

ChangeNotifier gehört zu Flutter (package:flutter/foundation.dart), es braucht keine zusätzliche Abhängigkeit. Wer sich für Änderungen interessiert, meldet sich als Listener an; notifyListeners() benachrichtigt alle. Das entspricht dem StateFlow in Kotlin.

Nach außen gibt das ViewModel nur den Getter uiState frei; ändern kann den Zustand nur das ViewModel selbst über das private Feld _uiState. Vorerst ist es final, weil sich noch nichts ändert. Das kommt mit dem Repository.

Ein Unterschied zum ViewModel von Android: Ein ChangeNotifier kennt keinen Lebenszyklus. Wer ihn erzeugt, muss ihn mit dispose() auch wieder freigeben (siehe unten).

Stateful und stateless

Ein Widget besitzt das ViewModel, ein zweites zeigt den Zustand nur an.

class _WeatherScreenState extends State<WeatherScreen> { // stateful final _viewModel = WeatherViewModel(); @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _viewModel, builder: (context, _) => WeatherContent(uiState: _viewModel.uiState), ); } } class WeatherContent extends StatelessWidget { /* ... */ } // stateless

Beide stehen in ui/weather_screen.dart. Aus dem bisherigen Inhalt von WeatherScreen wird das neue WeatherContent; WeatherScreen bekommt keine Daten mehr über den Konstruktor, main.dart setzt nur noch const WeatherScreen() ein.

ListenableBuilder hört auf das ViewModel und ruft builder bei jedem notifyListeners() erneut auf. Das entspricht collectAsStateWithLifecycle() in Compose.

Das ViewModel entsteht einmal mit dem State und wird mit ihm freigegeben:

@override void dispose() { _viewModel.dispose(); super.dispose(); }

Warum die Aufteilung? WeatherContent bekommt den Zustand von außen und lässt sich deshalb mit jedem Zustand anzeigen, z. B. in einem Widget-Test oder einer Widget Preview, ohne ViewModel.

In WeatherContent entscheidet ein switch-Ausdruck im body des Scaffold, was angezeigt wird. Die AppBar zeigt vorerst den festen Titel „Wetter“; den Namen der Stadt bekommt sie mit der Navigation.

body: switch (uiState) { WeatherLoading() => const Center(child: CircularProgressIndicator()), WeatherSuccess(:final city, :final weather) => SingleChildScrollView( child: CurrentWeatherCard( city: city, weather: weather, useCelsius: useCelsius, ), ), WeatherError(:final message) => Center(child: Text(message)), },

Weil switch in Dart auch ein Ausdruck sein kann, steht es direkt als Wert von body. Ein Hilfs-Widget wie CenteredBox in Compose braucht es nicht, Center gibt es schon.

Der Umschalter °C/°F bleibt als Feld _useCelsius im State von WeatherScreen. Er ist reiner Anzeige-Zustand (UI-Element-State) und gehört nicht ins ViewModel. WeatherContent bekommt ihn mit dem Callback als Parameter:

WeatherContent( uiState: _viewModel.uiState, useCelsius: _useCelsius, onToggleUnit: () => setState(() => _useCelsius = !_useCelsius), )

Damit ist WeatherContent ganz stateless, anders als in der Android-App, wo rememberSaveable in WeatherContent steckt.

Code: schritt-06 · Änderungen 

Repository

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

abstract interface class WeatherRepository { Future<CurrentWeather> getCurrentWeather(City city); }
class FakeWeatherRepository implements WeatherRepository { FakeWeatherRepository({this.shouldFail = false}); final bool shouldFail; @override Future<CurrentWeather> getCurrentWeather(City city) async { await Future.delayed(const Duration(milliseconds: 1500)); if (shouldFail) throw Exception('Keine Verbindung (simuliert)'); final weather = fakeCurrentWeather[city.name]; if (weather == null) throw Exception('Keine Wetterdaten für ${city.name}'); return weather; } }

Neue Dateien data/weather_repository.dart und data/fake_weather_repository.dart.

Ein eigenes Schlüsselwort interface wie in Kotlin hat Dart nicht. abstract interface class ist eine Klasse ohne Implementierung, die andere Dateien nur mit implements umsetzen, nicht mit extends erben können.

Ein Future<T> ist ein Wert, der später ankommt, wie ein Promise in JavaScript. Eine async-Funktion liefert immer ein Future; mit await wartet sie darauf, ohne die Oberfläche zu blockieren. Das entspricht suspend und delay in Kotlin.

Mit Future.delayed 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.

async/await im ViewModel

Das ViewModel setzt den Zustand vor und nach dem Warten und meldet jede Änderung.

Future<void> loadWeather(City city) async { _uiState = const WeatherLoading(); notifyListeners(); try { final weather = await _repository.getCurrentWeather(city); _uiState = WeatherSuccess(city, weather); } catch (e) { _uiState = WeatherError('$e'); } notifyListeners(); }

Vollständig:

class WeatherViewModel extends ChangeNotifier { WeatherViewModel({WeatherRepository? repository}) : _repository = repository ?? FakeWeatherRepository() { loadWeather(fakeCities.first); } final WeatherRepository _repository; WeatherUiState _uiState = const WeatherLoading(); WeatherUiState get uiState => _uiState; Future<void> loadWeather(City city) async { // ... wie oben } }

Ein viewModelScope wie in Android braucht es nicht: async-Funktionen laufen in Dart ohne Scope. Dafür lassen sie sich auch nicht abbrechen. Was das bedeutet, zeigt sich mit der Navigation.

'$e' macht aus der Exception einen Text, z. B. „Exception: Keine Verbindung zum Server (simuliert)“.

Der Konstruktor lädt vorerst das Wetter für Lörrach. Ab Schritt 8 übernimmt das der Wetter-Screen der angetippten Stadt.

Repository als Default-Parameter

Das ViewModel bekommt sein Repository als optionalen Parameter. Für die echte API ändert sich später nur diese Zeile.

WeatherViewModel({WeatherRepository? repository}) : _repository = repository ?? FakeWeatherRepository();

Ein Default-Wert wie in Kotlin geht hier nicht, weil Default-Werte in Dart const sein müssen. Deshalb ist der Parameter nullable, und die Initialisierungsliste nach dem : setzt mit ?? das FakeWeatherRepository ein, wenn nichts übergeben wurde.

In größeren Apps übernimmt das ein Paket wie provider , das der Architektur-Leitfaden von Flutter dafür verwendet. Für diese App reicht der optionale Parameter, und das Prinzip ist ähnlich: Das ViewModel ist nicht fest an eine Datenquelle gebunden. Das macht es auch testbar, z. B. mit WeatherViewModel(repository: FakeWeatherRepository(shouldFail: true)).

Code: schritt-07 · Änderungen 

Weiterführendes Material