İçeriğe geç / Skip to content / Zum Inhalt

Fehlerbehandlung in Dart: try-catch und asynchrone Abläufe

Ahmet Balaman

Zuletzt aktualisiert:

8 Min. Lesezeit

FlutterDartTry CatchAsyncAwaitFutureError Handling
Fehlerbehandlung in Dart: try-catch und asynchrone Abläufe

Fehlerbehandlung und asynchroner Code sind die Themen, bei denen Lernende zum ersten Mal den Unterschied zwischen „es läuft“ und „es läuft zuverlässig“ spüren. Die zwei Gewohnheiten, die ich im Unterricht am häufigsten sehe: alles mit catch (e) abfangen und den Fehler still verschlucken, und bei einer async-Funktion das await vergessen und dann nicht verstehen, warum der Fehler nie abgefangen wurde. Dieser Beitrag behandelt beides von Grund auf.

Nach den Collections ist das der letzte große Baustein der Dart-Grundlagen. Die Beispiele sind für Dart 3 geschrieben.

Live-Demo: try-catch und async/await

Lernen Sie Fehlerbehandlung und asynchrone Programmierung interaktiv:

try-catch: Fehler abfangen

Programme verhalten sich nicht immer wie erwartet: Der Nutzer tippt etwas Falsches ein, eine Datei fehlt, die Verbindung bricht ab. Sehen wir uns dieses Programm an, das eine Zahl von der Konsole liest:

import 'dart:io';

void main() {
  print('Geben Sie eine Zahl ein:');
  final eingabe = stdin.readLineSync() ?? '';
  final zahl = int.parse(eingabe); // FormatException bei Eingabe von "abc"
  print('Ihre Zahl: $zahl');
}

Tippt der Nutzer „abc“, wirft int.parse eine FormatException, und das Programm bricht ab. Dieses Verhalten kennen wir aus dem Beitrag über Typumwandlungen. (stdin funktioniert nur in Konsolen-Apps; um das in DartPad zu testen, schreiben Sie die Eingabe in eine Variable.)

Gezielt abfangen mit on

void main() {
  const eingabe = 'abc';

  try {
    final zahl = int.parse(eingabe);
    print('Ihre Zahl: $zahl');
  } on FormatException catch (e) {
    print('Bitte geben Sie eine gültige Zahl ein. (${e.message})');
  }
}

on FormatException fängt nur Fehler dieses Typs ab. Das nackte catch (e) aus älteren Beispielen fängt dagegen alles, was geworfen wird, auch Ihre Programmierfehler. Auch der Leitfaden Effective Dart rät von catch-Klauseln ohne on ab. Fangen Sie den erwarteten Fehler beim Namen; unerwartete Fehler sollen sichtbar bleiben, damit Sie sie beheben können.

Mehrere Fehlertypen lassen sich getrennt behandeln. Die Reihenfolge zählt: der spezielle zuerst, der allgemeine zuletzt:

void verarbeiten(String eingabe) {
  try {
    final zahl = int.parse(eingabe);
    if (zahl < 0) throw ArgumentError.value(zahl, 'eingabe', 'Darf nicht negativ sein');
    print('Ergebnis: ${100 ~/ zahl}');
  } on FormatException {
    print('Keine Zahl.');
  } on ArgumentError catch (e) {
    print('Ungültiger Wert: ${e.message}');
  } catch (e, stackTrace) {
    print('Unerwarteter Fehler: $e');
    print(stackTrace); // Zeigt, wo der Fehler entstanden ist
    rethrow;           // Reicht den Fehler weiter, statt ihn zu verschlucken
  }
}

Achten Sie hier auf drei Details:

  • catch (e, stackTrace) liefert als zweiten Parameter den Stacktrace. Beim Debuggen ist er so wertvoll wie der Fehler selbst.
  • rethrow wirft den abgefangenen Fehler samt ursprünglichem Stacktrace erneut. Das nutzen Sie, wenn Sie den Fehler protokollieren und einer höheren Schicht überlassen wollen. throw e verliert dagegen den Stacktrace.
  • ArgumentError ist ein Error. In Dart steht eine Exception für eine erwartbare Situation zur Laufzeit (ungültige Eingabe, Netzwerkfehler), ein Error für einen Programmierfehler (falsches Argument, Zugriff auf null). Als Grundregel korrigieren Sie bei Errors den Code, statt sie abzufangen und weiterzumachen; das on ArgumentError hier dient nur dazu, beide Arten nebeneinander zu zeigen.

Nachfragen, bis die Eingabe gültig ist

import 'dart:io';

void main() {
  int? zahl;

  while (zahl == null) {
    print('Geben Sie eine Zahl ein:');
    zahl = int.tryParse(stdin.readLineSync() ?? '');
    if (zahl == null) print('Bitte geben Sie eine gültige Zahl ein!');
  }

  print('Sehr gut! Ihre Zahl: $zahl');
}

Die frühere Fassung dieses Beispiels nutzte try-catch in der Schleife. Da ungültige Eingaben hier kein Ausnahmefall, sondern erwartbar sind, passt tryParse besser: Eine null-Prüfung genügt, statt einen Fehler zu werfen und abzufangen.

Der finally-Block

Ein finally-Block läuft, ob ein Fehler auftritt oder nicht:

void main() {
  try {
    print('Verbindung geöffnet');
    int.parse('abc'); // FormatException
  } on FormatException catch (e) {
    print('Fehler: ${e.message}');
  } finally {
    print('Verbindung geschlossen'); // Läuft in jedem Fall
  }
}

Er eignet sich für alles, was in jedem Fall geschlossen werden muss, etwa eine Datei, eine Datenbankverbindung oder eine Ladeanzeige.

Eine eigene Exception-Klasse

Für app-spezifische Fehler können Sie eine Klasse schreiben, die Exception implementiert. Der aufrufende Code kann sie dann mit on beim Namen abfangen:

class ZuWenigGuthaben implements Exception {
  final double fehlbetrag;
  const ZuWenigGuthaben(this.fehlbetrag);

  @override
  String toString() => 'ZuWenigGuthaben: $fehlbetrag € fehlen';
}

double bezahlen(double guthaben, double betrag) {
  if (betrag > guthaben) throw ZuWenigGuthaben(betrag - guthaben);
  return guthaben - betrag;
}

void main() {
  try {
    bezahlen(100, 150);
  } on ZuWenigGuthaben catch (e) {
    print('Zahlung fehlgeschlagen, es fehlen ${e.fehlbetrag} €.'); // ... es fehlen 50.0 €.
  }
}

Einen String zu werfen, etwa throw 'Etwas ist schiefgelaufen', kompiliert zwar, aber die abfangende Seite hat dann keine Typinformation. Werfen Sie eine aussagekräftige Klasse.

Asynchrone Abläufe: async und await

Manche Vorgänge brauchen Zeit: Daten aus dem Netz holen, eine Datei lesen, in eine Datenbank schreiben. Währenddessen soll die App nicht einfrieren.

Auf einer Zeitachse laufen zwei Aufgaben synchron nacheinander, asynchron überlappen sie sich und sind früher fertig

Räumen wir gleich mit einem verbreiteten Missverständnis auf: Asynchroner Code läuft in Dart nicht in einem eigenen Thread. Dart arbeitet mit einer einzigen Ereignisschleife (Event Loop). await pausiert nur die jeweilige Funktion und lässt die Ereignisschleife andere Arbeit erledigen, bis die erwartete Aufgabe (etwa eine Netzwerkantwort) eintrifft. Findet die erwartete Arbeit außerhalb Ihres Codes statt, wie bei Netzwerk- oder Dateizugriffen, bleibt die Oberfläche dabei flüssig. Eine aufwendige Berechnung in eine async-Funktion zu packen, macht sie aber nicht schneller und friert die Oberfläche trotzdem ein; für solche Arbeit verwenden Sie mit Isolate.run ein eigenes Isolate.

Future, async, await

  • Future: Steht für einen Vorgang, dessen Wert später bereitsteht. Er endet entweder mit einem Wert oder mit einem Fehler.
  • async: Kennzeichnet eine Funktion, in der await verwendet werden darf; die Funktion gibt automatisch ein Future zurück.
  • await: Pausiert die Funktion, bis das Future abgeschlossen ist, und liefert das Ergebnis.
Future<void> main() async {
  print('Warte auf Daten...');

  final daten = await ausDatenbankLaden();

  print('Daten empfangen: $daten');
}

Future<String> ausDatenbankLaden() async {
  // Wir simulieren die Verzögerung einer Datenbank
  await Future.delayed(const Duration(seconds: 3));
  return 'Benutzerdaten';
}

Was passiert ohne await?

Future<void> main() async {
  // String daten = ausDatenbankLaden(); // Kompilierfehler: Future<String> ist kein String
  final daten = ausDatenbankLaden();     // Kompiliert, aber daten ist ein Future
  print(daten); // Instance of 'Future<String>'
}

Die erste Zeile kompiliert nicht, denn die Funktion liefert nicht den Wert, sondern das Versprechen, dass der Wert später kommt (ein Future). Die zweite Zeile ist tückischer: Mit final ergibt die Typinferenz Future<String>, der Code kompiliert, und statt der Daten erscheint Instance of 'Future<String>'. Sehen Sie diese Ausgabe, suchen Sie zuerst nach einem fehlenden await.

Ein Beispiel mit Fortschrittsanzeige

Future<void> main() async {
  print('1. Warte auf Daten...');
  final daten = await ausDatenbankLaden();
  print('2. Daten empfangen: $daten');
}

Future<String> ausDatenbankLaden() async {
  for (var i = 1; i <= 5; i++) {
    await Future.delayed(const Duration(seconds: 1));
    print('Lädt... ${i * 20} %');
  }
  return 'Datenbankbestand';
}

Ausgabe:

1. Warte auf Daten...
Lädt... 20 %
Lädt... 40 %
Lädt... 60 %
Lädt... 80 %
Lädt... 100 %
2. Daten empfangen: Datenbankbestand

Asynchrone Fehler abfangen

Endet ein mit await erwartetes Future mit einem Fehler, wird der Fehler in der await-Zeile geworfen und lässt sich mit einem normalen try-catch abfangen:

import 'dart:async';

class ServerFehler implements Exception {
  final String meldung;
  const ServerFehler(this.meldung);
}

Future<String> vonApiLaden() async {
  await Future.delayed(const Duration(seconds: 2));
  throw const ServerFehler('Keine Verbindung zum Server');
}

Future<void> main() async {
  try {
    print('Lade Daten von der API...');
    final daten = await vonApiLaden().timeout(const Duration(seconds: 5));
    print('Daten: $daten');
  } on TimeoutException {
    print('Der Server hat nicht rechtzeitig geantwortet.');
  } on ServerFehler catch (e) {
    print('Fehler: ${e.meldung}');
  }
}

timeout beendet ein Future, das nicht in der angegebenen Zeit fertig wird, mit einer TimeoutException. Ein Timeout bei Netzwerkanfragen verhindert, dass Nutzer ewig auf eine Ladeanzeige starren.

Nicht erwartete Futures und unawaited

Der Fehler eines Futures ohne await wird vom umgebenden try-catch nicht abgefangen. Der try-Block startet das Future und ist sofort fertig; der Fehler entsteht erst später:

Future<void> main() async {
  try {
    vonApiLaden(); // kein await: der Fehler wird hier NICHT abgefangen
  } on ServerFehler {
    print('Wird nie erreicht');
  }
}

Ein solcher Fehler landet beim Handler der App für nicht abgefangene Fehler. Die Lösung ist meist einfach: await ergänzen. Wollen Sie bewusst nicht auf einen Vorgang warten (etwa ein Analyseereignis im Hintergrund senden), machen Sie das mit unawaited aus dart:async ausdrücklich und behandeln den Fehler am Future selbst:

import 'dart:async';

Future<void> ereignisSenden(String ereignis) async {
  // Stellen wir uns vor, hier wird ein Analyseereignis im Hintergrund gesendet
}

void buttonGedrueckt() {
  unawaited(
    ereignisSenden('button').catchError((Object e) => print('Senden fehlgeschlagen: $e')),
  );
}

unawaited teilt Lesern und dem Analyzer mit: „Hier nicht zu warten, ist Absicht.“ Der Callback von catchError muss einen Wert liefern, der zum Typ des Futures passt; hier ist der Typ void, also genügt print.

Parallel warten

Unabhängige Vorgänge nacheinander mit await abzuwarten, addiert ihre Dauer:

Future<String> benutzerLaden() async {
  await Future.delayed(const Duration(seconds: 1));
  return 'Ayse';
}

Future<int> mitteilungenZaehlen() async {
  await Future.delayed(const Duration(seconds: 1));
  return 3;
}

Future<void> main() async {
  // Nacheinander: etwa 2 Sekunden
  final name = await benutzerLaden();
  final anzahl = await mitteilungenZaehlen();

  // Parallel: etwa 1 Sekunde
  final (name2, anzahl2) = await (benutzerLaden(), mitteilungenZaehlen()).wait;

  print('$name $anzahl / $name2 $anzahl2'); // Ayse 3 / Ayse 3
}

Dank der Records aus Dart 3 können Sie Futures unterschiedlicher Typen mit (a, b).wait gemeinsam abwarten und das Ergebnis in einer Zeile zerlegen. Schlägt eines der Futures fehl, wirft .wait einen ParallelWaitError. Für viele Futures desselben Typs funktioniert auch das klassische Future.wait([...]), das die Ergebnisse als List liefert.

Asynchroner Code in Flutter

In Flutter ist asynchroner Code allgegenwärtig: API-Aufrufe, lokale Datenbanken, Dateizugriffe, Berechtigungsanfragen. Das häufigste Muster: Daten in initState laden und das Ergebnis per setState anzeigen:

import 'package:flutter/material.dart';

class DatenSeite extends StatefulWidget {
  const DatenSeite({super.key});

  @override
  State<DatenSeite> createState() => _DatenSeiteState();
}

class _DatenSeiteState extends State<DatenSeite> {
  String _daten = 'Lädt...';

  @override
  void initState() {
    super.initState();
    _datenLaden();
  }

  Future<void> _datenLaden() async {
    try {
      // In einer echten App steht hier ein API-Aufruf
      await Future.delayed(const Duration(seconds: 2));
      if (!mounted) return; // Abbrechen, falls die Seite inzwischen geschlossen wurde
      setState(() => _daten = 'Daten erfolgreich geladen!');
    } on Exception catch (e) {
      if (!mounted) return;
      setState(() => _daten = 'Fehler: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text(_daten)),
    );
  }
}

Die Zeile if (!mounted) return; ist eine wichtige Absicherung, die älteren Beispielen fehlte. Verlässt der Nutzer die Seite, bevor die Daten da sind, wird das Widget aus dem Baum entfernt; setState nach einem await ohne Prüfung aufzurufen, führt zu einem Fehler. Fragen Sie nach jedem await, ob das Widget noch auf dem Bildschirm ist.

Das Future.delayed steht hier für eine echte Netzwerkanfrage. Wie Sie mit dem Paket http echte API-Anfragen senden, Statuscodes prüfen und ein Timeout setzen, zeigt Schritt für Schritt der Beitrag über HTTP-Anfragen und REST-APIs.

FutureBuilder

Flutter bietet ein fertiges Widget, das die Oberfläche anhand des Zustands eines Futures zeichnet:

class ProfilSeite extends StatefulWidget {
  const ProfilSeite({super.key});

  @override
  State<ProfilSeite> createState() => _ProfilSeiteState();
}

class _ProfilSeiteState extends State<ProfilSeite> {
  late final Future<String> _daten = ausDatenbankLaden(); // Einmal erzeugt

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: _daten,
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const CircularProgressIndicator();
        }
        if (snapshot.hasError) {
          return Text('Fehler: ${snapshot.error}');
        }
        return Text('Daten: ${snapshot.data}');
      },
    );
  }
}

Beachten Sie, dass das Future in einem Feld des State entsteht, nicht in build. Mit future: ausDatenbankLaden() würde die Anfrage bei jedem Neuaufbau erneut gesendet. Ausführlich behandle ich das im Beitrag über FutureBuilder; für Daten, die fortlaufend statt einmalig eintreffen, gibt es den Bruder StreamBuilder.

Regeln und häufige Fehler

  1. await ist nur in async-Funktionen erlaubt.
// FALSCH
void etwasTun() {
  await etwas(); // Kompilierfehler
}

// RICHTIG
Future<void> etwasTun() async {
  await etwas();
}
  1. Der Rückgabetyp einer async-Funktion sollte ein Future sein. void f() async kompiliert, aber der Aufrufer kann weder auf die Funktion warten noch ihre Fehler abfangen. Für asynchrone Funktionen ohne Rückgabewert schreiben Sie Future<void>.

  2. Unabhängige Vorgänge nicht nacheinander abwarten. Starten Sie sie mit dem Record-.wait von oben oder mit Future.wait parallel.

  3. Fehler beim Namen abfangen. on FormatException, on TimeoutException, on ServerFehler; ein nacktes catch (e) gehört nur ganz ans Ende, zum Protokollieren und für rethrow.

  4. Fehler nicht still verschlucken. Ein leerer catch-Block löst kein Problem, er versteckt es nur.

  5. Nach await auf mounted prüfen. In Flutter kann das Widget den Baum inzwischen verlassen haben.

Nächster Schritt

Die Code-Seite von Dart ist geschafft; als Nächstes kommt die Oberfläche: Widget-Baum, Row, Column und Stack in Flutter.

Ressourcen


Noch Fragen?

Häufig gestellte Fragen

Was ist der Unterschied zwischen catch (e) und on FormatException catch (e)?

catch (e) fängt alles ab, was geworfen wird; on FormatException catch (e) nur Fehler dieses Typs. Behandeln Sie bestimmte Fehler zuerst einzeln mit on und setzen Sie das allgemeine catch ans Ende, damit Sie unerwartete Fehler nicht versehentlich verschlucken.

Warum wird der Fehler einer async-Funktion nicht abgefangen, wenn ich sie ohne await aufrufe?

Ohne await gibt die Funktion sofort ein Future zurück, und der Fehler entsteht in diesem Future, wenn der try-Block längst beendet ist. Setzen Sie await vor den Aufruf, oder markieren Sie den Aufruf mit unawaited, wenn Sie bewusst nicht warten, und fangen Sie den Fehler mit .catchError am Future ab.

Was ist der Unterschied zwischen throw e und rethrow?

rethrow wirft den abgefangenen Fehler samt ursprünglichem Stacktrace erneut. throw e tut so, als würde der Fehler an einer neuen Stelle geworfen, und die Information, wo er zuerst entstand, geht verloren. Wollen Sie einen Fehler aus einem catch-Block weiterreichen, nehmen Sie rethrow.

Warum sollte ich dem future-Parameter von FutureBuilder keinen direkten Funktionsaufruf übergeben?

build läuft bei jedem Neuaufbau, daher löst future: datenLaden() die Anfrage jedes Mal neu aus. Erzeugen Sie das Future einmal in einem Feld des State (etwa in initState oder mit late final) und übergeben Sie FutureBuilder dieses Feld.

Kommentare