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

Flutter: Asynchrone Listen mit FutureBuilder

Ahmet Balaman

Zuletzt aktualisiert:

7 Min. Lesezeit

FlutterFutureBuilderAsyncFutureAPIWidget
Flutter: Asynchrone Listen mit FutureBuilder

FutureBuilder verbindet das Ergebnis eines einmaligen asynchronen Vorgangs (API-Anfrage, Datenbankabfrage, Datei lesen) mit dem Widget-Baum. Die Methode build läuft synchron, und darin können Sie kein await verwenden; FutureBuilder abonniert das übergebene Future und ruft builder für jeden der Zustände Warten, Fehler und Ergebnis erneut auf. Wann immer ein Screen mit dem Trio „lädt, Fehler, Daten“ öffnen soll, ist dies das erste Werkzeug, zu dem Sie greifen. Das eigentliche Thema dieses Beitrags ist jedoch der häufigste Fehler: das Future in build zu erzeugen.

Live-Demo

Sie können dieses Widget im interaktiven Beispiel unten ausprobieren:

💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.

Grundlegende Verwendung

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

  @override
  State<GreetingPage> createState() => _GreetingPageState();
}

class _GreetingPageState extends State<GreetingPage> {
  // Das Future wird einmal erzeugt; jeder build-Aufruf nutzt dasselbe Objekt
  late final Future<String> _greeting = fetchGreeting();

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

Future<String> fetchGreeting() async {
  await Future.delayed(const Duration(seconds: 2));
  return 'Hallo Flutter!';
}

Der snapshot, der bei builder ankommt, ist eine Momentaufnahme des Vorgangs: in welcher Phase er ist, ob Daten da sind, ob ein Fehler vorliegt. Wenn async/await und das Konzept Future noch nicht sitzen, empfehle ich zuerst den Beitrag zu asynchronen Abläufen und Fehlerbehandlung in Dart; FutureBuilder baut auf diesem Wissen auf.

Wichtige Eigenschaften

Eigenschaft Beschreibung
future Das Future, auf das gewartet wird; bei null ist der Zustand none
builder Funktion, die bei jedem Zustandswechsel aufgerufen wird und ein Widget liefert
initialData Wert, der als snapshot.data angezeigt wird, bis das Future abgeschlossen ist

Den Snapshot in der richtigen Reihenfolge lesen

AsyncSnapshot trägt drei Dinge: connectionState, data und error. Bei einem Future können Sie diese Zustände sehen (active wird nur beim StreamBuilder verwendet):

Die Lesereihenfolge im FutureBuilder-Snapshot: zuerst connectionState waiting, dann snapshot.hasError, dann snapshot.hasData

Zustand Bedeutung
none Das Feld future ist null, es wird auf nichts gewartet
waiting Das Future läuft, noch kein Ergebnis
done Das Future ist fertig; entweder data oder error ist gefüllt

Die Reihenfolge der Prüfungen ist wichtig. Erst waiting, dann hasError, zuletzt die Daten:

builder: (context, snapshot) {
  if (snapshot.connectionState == ConnectionState.waiting) {
    return const Center(child: CircularProgressIndicator());
  }
  if (snapshot.hasError) {
    return Center(child: Text('Fehler: ${snapshot.error}'));
  }
  final items = snapshot.data ?? const [];
  if (items.isEmpty) {
    return const Center(child: Text('Keine Einträge'));
  }
  return ListView.builder(/* ... */);
}

hasData sagt nur, dass die Daten nicht null sind. Warten Sie auf ein Future<void> oder auf ein Future, das null liefern kann, wird hasData nie true; erkennen Sie den Abschluss dann über connectionState == ConnectionState.done.

FutureBuilder mit ListView

Der häufigste Einsatz ist, eine Liste von einer API mit ListView.builder anzuzeigen. FutureBuilder umschließt den gesamten Rumpf; die Liste entsteht erst, wenn die Daten da sind:

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

  @override
  State<MyListPage> createState() => _MyListPageState();
}

class _MyListPageState extends State<MyListPage> {
  late final Future<List<String>> _items = fetchItems();

  Future<List<String>> fetchItems() async {
    await Future.delayed(const Duration(seconds: 2)); // simulierter API-Aufruf
    return ['Flutter', 'Dart', 'Firebase', 'Android', 'iOS'];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Liste')),
      body: FutureBuilder<List<String>>(
        future: _items,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text('Fehler: ${snapshot.error}'));
          }
          final items = snapshot.data ?? const [];
          if (items.isEmpty) {
            return const Center(child: Text('Keine Daten gefunden'));
          }
          return ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, index) {
              return ListTile(
                leading: CircleAvatar(child: Text('${index + 1}')),
                title: Text(items[index]),
              );
            },
          );
        },
      ),
    );
  }
}

Dasselbe Muster verwenden Sie mit GridView.builder; nur das zurückgegebene Widget ändert sich. Die Rastereinstellungen finden Sie im GridView-Beitrag.

Aktualisieren: das Future per setState neu zuweisen

Bekommt das Feld future ein anderes Objekt, lässt FutureBuilder das alte Abonnement fallen und wartet auf das neue. Für einen Aktualisieren-Button oder Pull-to-Refresh müssen Sie nur ein neues Future erzeugen und es per setState dem Feld zuweisen:

class _MyPageState extends State<MyPage> {
  late Future<List<String>> _futureData;

  @override
  void initState() {
    super.initState();
    _futureData = fetchData();
  }

  void _reload() {
    setState(() {
      _futureData = fetchData(); // neues Future, FutureBuilder wartet von vorn
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Daten'),
        actions: [
          IconButton(icon: const Icon(Icons.refresh), onPressed: _reload),
        ],
      ),
      body: FutureBuilder<List<String>>(
        future: _futureData,
        builder: (context, snapshot) {
          // ...
        },
      ),
    );
  }
}

Ein kleines, aber nützliches Detail: Wenn sich future ändert, wechselt der Snapshot in den Zustand waiting, das bisherige data wird aber nicht gelöscht. Wollen Sie also „während des Aktualisierens die alte Liste weiter anzeigen“, kombinieren Sie die waiting-Prüfung mit !snapshot.hasData; im Szenario unten machen wir genau das.

Wann verwenden – und wann nicht?

Wählen Sie FutureBuilder für Daten, die ein Screen oder ein Abschnitt beim Öffnen einmal lädt und danach nur anzeigt: Nutzerprofil, Produktdetails, Einstellungswerte. Dürfen die Daten beim Schließen des Screens vergessen werden und müssen sie nicht mit anderen Screens geteilt werden, ist das genau der richtige Ort.

In diesen Fällen ist ein anderes Werkzeug richtiger:

  • Ändern sich die Daten mit der Zeit (Chatnachrichten, Live-Preise, Firestore-Abfrage), reicht ein einmaliges Future nicht; verwenden Sie StreamBuilder.
  • Werden dieselben Daten auf mehreren Screens gebraucht, oder fügen Sie der Liste Einträge hinzu und entfernen welche, um sie erneut anzuzeigen, lässt sich dieser Zustand nicht in einem FutureBuilder verwalten. Verschieben Sie die Daten mit Provider in einen ChangeNotifier oder in einen FutureProvider von Riverpod; AsyncValue liefert Ihnen dasselbe Trio „lädt, Fehler, Daten“ unabhängig vom Screen.
  • Für Vorgänge, die per Button-Tap starten (Speichern, Senden), ist FutureBuilder ungeeignet; ein async-onPressed mit einem per setState verwalteten _isSaving-Flag ist schlichter.
  • Die Daten in initState per then zu holen und setState aufzurufen ist ebenfalls ein gangbarer Weg; dann liegen aber die mounted-Prüfung und die manuelle Verwaltung der drei Zustände bei Ihnen. Bei kleinen Screens nimmt FutureBuilder Ihnen diese Last ab.

Häufige Fehler

1. Das Future in build erzeugen

Der Klassiker sieht so aus:

// FALSCH
FutureBuilder<List<User>>(
  future: fetchUsers(), // bei jedem build eine neue Anfrage
  builder: (context, snapshot) { /* ... */ },
)

Symptom: Bei jedem setState, beim Öffnen der Tastatur, beim Drehen des Geräts oder beim Neuaufbau des übergeordneten Widgets verschwindet die Liste und der Spinner kommt zurück; im Netzwerk-Tab taucht dieselbe Anfrage immer wieder auf. Die Ursache ist simpel: Jedes Mal, wenn build läuft, liefert fetchUsers() ein neues Future, und FutureBuilder sagt „das Future hat sich geändert“ und wartet von vorn. Die Lösung: das Future einmal erzeugen und aufbewahren:

// RICHTIG
class _UsersPageState extends State<UsersPage> {
  late final Future<List<User>> _users = fetchUsers();
  // oder in initState: _users = fetchUsers();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<User>>(
      future: _users,
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

In einem StatelessWidget gibt es diesen Aufbewahrungsort nicht; machen Sie das Widget mit dem FutureBuilder zu einem StatefulWidget.

2. data! verwenden, ohne den Fehlerzustand zu prüfen

Symptom: Wirft das Future einen Fehler, gibt es einen roten Bildschirm mit dieser Meldung:

Null check operator used on a null value

FutureBuilder fängt den Fehler und legt ihn in snapshot.error; data bleibt null. Überspringen Sie die hasError-Prüfung und schreiben snapshot.data!, ist es Ihr Code, der abstürzt. Prüfen Sie immer zuerst hasError und geben Sie dem Nutzer einen Weg zum erneuten Versuch. Und noch etwas: Fehler, die innerhalb der builder-Funktion selbst geworfen werden, landen nicht in hasError, sondern stürzen direkt ab.

3. Bei Future auf hasData warten

Symptom: Der Vorgang ist fertig, aber der Screen bleibt beim Spinner. Ein Future<void> trägt nie Daten, hasData ist immer false. Erkennen Sie den Abschluss so:

if (snapshot.connectionState == ConnectionState.done) {
  return const Text('Abgeschlossen');
}
return const CircularProgressIndicator();

4. Ein von Widget-Parametern abhängiges Future nie erneuern

Haben Sie late final Future _detail = fetchDetail(widget.id); geschrieben, ist dieses Future für die gesamte Lebensdauer des Widgets fest. Ändert sich widget.id, während derselbe State weiterlebt (etwa wenn dasselbe Widget in einer PageView mit anderen IDs wiederverwendet wird), bleiben die alten Daten auf dem Bildschirm. Weisen Sie das Future in diesem Fall in didUpdateWidget mit der Prüfung oldWidget.id != widget.id neu zu.

Mini-Szenario: Nutzerliste von einer API

Stellen Sie sich einen Verwaltungs-Screen vor: Beim Öffnen kommen die Nutzer von der API, zieht der Nutzer nach unten, wird die Liste aktualisiert, und schlägt die Anfrage fehl, erscheint ein Button „Erneut versuchen“. Während der Aktualisierung bleibt die alte Liste sichtbar; der Spinner erscheint nur beim ersten Öffnen.

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class User {
  const User({required this.id, required this.name, required this.email});

  final int id;
  final String name;
  final String email;

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        name: json['name'] as String,
        email: json['email'] as String,
      );
}

Future<List<User>> fetchUsers() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/users'),
  );
  if (response.statusCode != 200) {
    throw Exception('Nutzer konnten nicht geladen werden (${response.statusCode})');
  }
  final data = jsonDecode(response.body) as List<dynamic>;
  return data.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
}

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

  @override
  State<UsersPage> createState() => _UsersPageState();
}

class _UsersPageState extends State<UsersPage> {
  late Future<List<User>> _usersFuture;

  @override
  void initState() {
    super.initState();
    _usersFuture = fetchUsers();
  }

  Future<void> _refresh() async {
    final next = fetchUsers();
    setState(() => _usersFuture = next);
    try {
      await next; // RefreshIndicator dreht sich, bis dieses Future fertig ist
    } catch (_) {
      // Der Fehler wird vom FutureBuilder angezeigt
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Nutzer')),
      body: FutureBuilder<List<User>>(
        future: _usersFuture,
        builder: (context, snapshot) {
          final isFirstLoad =
              snapshot.connectionState == ConnectionState.waiting &&
                  !snapshot.hasData;
          if (isFirstLoad) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('Fehler: ${snapshot.error}'),
                  const SizedBox(height: 12),
                  FilledButton(
                    onPressed: _refresh,
                    child: const Text('Erneut versuchen'),
                  ),
                ],
              ),
            );
          }
          final users = snapshot.data ?? const [];
          return RefreshIndicator(
            onRefresh: _refresh,
            child: ListView.separated(
              physics: const AlwaysScrollableScrollPhysics(),
              itemCount: users.length,
              separatorBuilder: (context, index) => const Divider(height: 1),
              itemBuilder: (context, index) {
                final user = users[index];
                return ListTile(
                  leading: CircleAvatar(child: Text(user.name[0])),
                  title: Text(user.name),
                  subtitle: Text(user.email),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

Hier stecken vier Entscheidungen. Das Future wird einmal in initState erzeugt; egal wie oft build läuft, die Anfrage wiederholt sich nicht. _refresh erzeugt ein neues Future, weist es per setState zu und wartet auf dasselbe Future, damit sich der Ring des RefreshIndicator bis zum Ende dreht; der Fehler wird per catch geschluckt, weil ihn anzuzeigen die Aufgabe des FutureBuilder ist. Dank der isFirstLoad-Prüfung bleibt die alte Liste während der Aktualisierung sichtbar. AlwaysScrollableScrollPhysics erlaubt das Herunterziehen auch bei einer kurzen Liste; ohne sie funktioniert die Geste bei einer Liste mit zwei Nutzern nicht. Vergessen Sie nicht, das Paket http in pubspec.yaml einzutragen. Die Netzwerkseite im Detail, also POST-, PUT- und DELETE-Anfragen, die Prüfung von Statuscodes und Timeouts, behandelt der Beitrag über HTTP-Anfragen und REST-APIs.

Häufig gestellte Fragen

Warum lädt FutureBuilder ständig neu?

Weil Sie dem Feld future eine in build aufgerufene Funktion (future: fetchData()) übergeben. Jeder build erzeugt ein neues Future, und FutureBuilder wartet von vorn. Erzeugen Sie das Future einmal in initState oder in einem late final-Feld und übergeben Sie in build dieses Feld.

Wie aktualisiere ich die Daten mit FutureBuilder?

Erzeugen Sie ein neues Future und weisen Sie es innerhalb von setState dem Feld zu. Sieht FutureBuilder das neue Objekt, lässt er das alte Abonnement fallen und wartet auf das neue; Pull-to-Refresh mit RefreshIndicator funktioniert auf demselben Weg.

Was ist der Unterschied zwischen FutureBuilder und StreamBuilder?

FutureBuilder wartet auf ein einziges Ergebnis und ändert sich nach done nicht mehr; StreamBuilder hört einen Datenstrom ab und zeichnet bei jedem neuen Wert neu. Für eine einmalige API-Anfrage den ersten, für sich ständig ändernde Quellen wie Chats oder Live-Daten den zweiten.

Warum bleibt snapshot.hasData false?

hasData wird nur true, wenn data nicht null ist. Bei einem Future<void> oder einem Future, das null liefert, wird es nie true; prüfen Sie den Abschluss über connectionState == ConnectionState.done.

Kommentare