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

Flutter: HTTP-Anfragen und REST-APIs

Ahmet Balaman

9 Min. Lesezeit

FlutterHTTPREST APIJSONAsyncFutureBuilder
Flutter: HTTP-Anfragen und REST-APIs

Die Daten einer echten App kommen fast immer von einem Server: eine Produktliste, ein Nutzerprofil, Nachrichten. In Flutter ist das einfachste Werkzeug dafür das http-Paket, das vom Dart-Team veröffentlicht wird. In diesem Beitrag bauen wir Schritt für Schritt auf: GET-, POST-, PUT- und DELETE-Anfragen an eine REST-API senden, das zurückkommende JSON in Modellklassen umwandeln, Statuscodes und Timeouts sauber behandeln und am Ende alles mit einem Screen verbinden, der die Zustände „lädt, Fehler, Daten“ anzeigt. Die Beispiele nutzen JSONPlaceholder, eine Fake-API, damit Sie ohne eigenen Server experimentieren können.

Es hilft sehr, wenn async, await und Future bereits sitzen. Falls nicht, lesen Sie zuerst asynchrone Programmierung und Fehlerbehandlung in Dart.

Installation und Berechtigungen

Fügen Sie das Paket Ihrem Projekt hinzu:

flutter pub add http

Das Paket mit einem Präfix zu importieren, ist eine verbreitete Gewohnheit; so kollidieren kurze Namen wie get und post nicht mit Ihren eigenen Funktionen:

import 'package:http/http.dart' as http;

Unter Android braucht die App die Berechtigung INTERNET in android/app/src/main/AndroidManifest.xml, um ins Netz zu kommen. Die Netzwerk-Dokumentation von Flutter lässt diese Zeile ausdrücklich ergänzen:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application ...>

Wenn Sie macOS unterstützen, gehört außerdem der Schlüssel com.apple.security.network.client in DebugProfile.entitlements und Release.entitlements. iOS braucht keine zusätzliche Berechtigung, die Adresse muss aber https sein (dazu mehr im Abschnitt über Fehler).

Die erste GET-Anfrage

In der einfachsten Form sieht eine Anfrage so aus:

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

Future<void> fetchFirstPost() async {
  final uri = Uri.https('jsonplaceholder.typicode.com', '/posts/1');
  final response = await http.get(uri);

  if (response.statusCode == 200) {
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    print(json['title']);
  } else {
    print('Anfrage fehlgeschlagen: ${response.statusCode}');
  }
}

Es gibt drei Bausteine. Uri.https baut die Adresse sicher zusammen; übergeben Sie Query-Parameter als drittes Argument ({'userId': '1'}), werden Sonderzeichen für Sie kodiert. http.get liefert ein Future<Response>; response.statusCode enthält den Statuscode des Servers, response.body den Body als Text. jsonDecode wandelt diesen Text dann in Dart-Objekte um: Ein JSON-Objekt wird zu Map<String, dynamic>, ein JSON-Array zu List<dynamic>.

Von JSON zur Modellklasse: fromJson und toJson

Überall mit String-Schlüsseln wie json['title'] zu arbeiten bedeutet, dass ein Tippfehler erst zur Laufzeit auffällt statt beim Kompilieren. Stattdessen wandeln wir die Daten einmal in eine Modellklasse um und arbeiten im Rest der App mit typisierten Objekten:

class Post {
  const Post({
    required this.id,
    required this.userId,
    required this.title,
    required this.body,
  });

  final int id;
  final int userId;
  final String title;
  final String body;

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'] as int,
      userId: json['userId'] as int,
      title: json['title'] as String,
      body: json['body'] as String,
    );
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'userId': userId,
        'title': title,
        'body': body,
      };

  Post copyWith({String? title, String? body}) => Post(
        id: id,
        userId: userId,
        title: title ?? this.title,
        body: body ?? this.body,
      );
}

fromJson wandelt die Serverdaten in ein Objekt um, toJson bringt das Objekt in die Form, die der Server erwartet. copyWith erzeugt eine geänderte Kopie eines Objekts mit final-Feldern; das brauchen wir bei der Update-Anfrage.

Ein Detail sollten Sie kennen: json['id'] as int wirft einen TypeError, wenn der Server id unerwartet als String schickt. Das ist ein Error, keine Exception; ein on Exception catch-Block fängt ihn also nicht. Mit dem Pattern Matching von Dart 3 prüfen Sie die ganze Struktur auf einmal und erzeugen einen aussagekräftigen Fehler. Die Beispiele in der Flutter-Dokumentation gehen ebenfalls diesen Weg:

factory Post.fromJson(Map<String, dynamic> json) {
  return switch (json) {
    {
      'id': int id,
      'userId': int userId,
      'title': String title,
      'body': String body,
    } =>
      Post(id: id, userId: userId, title: title, body: body),
    _ => throw const FormatException('Post-JSON hat eine unerwartete Struktur'),
  };
}

Mit wachsender Zahl an Modellen und Feldern wird das Schreiben von Hand mühsam. Dann lohnt sich ein Codegenerator wie json_serializable; das Prinzip bleibt gleich, nur fromJson und toJson werden für Sie erzeugt.

POST, PUT und DELETE

Bei REST drückt die HTTP-Methode die Operation aus: GET liest, POST legt einen neuen Datensatz an, PUT ersetzt einen bestehenden Datensatz, DELETE löscht ihn. Bei Anfragen mit Body dürfen Sie zwei Dinge nicht vergessen: die Daten mit jsonEncode in Text umwandeln und dem Server mit dem Header Content-Type mitteilen, dass es sich um JSON handelt.

const headers = {'Content-Type': 'application/json; charset=UTF-8'};

// POST: neuen Datensatz anlegen
final created = await http.post(
  Uri.https('jsonplaceholder.typicode.com', '/posts'),
  headers: headers,
  body: jsonEncode({'userId': 1, 'title': 'Hallo', 'body': 'Mein erster Beitrag'}),
);
print(created.statusCode); // 201 Created

// PUT: Datensatz aktualisieren
final updated = await http.put(
  Uri.https('jsonplaceholder.typicode.com', '/posts/${post.id}'),
  headers: headers,
  body: jsonEncode(post.copyWith(title: 'Neuer Titel').toJson()),
);
print(updated.statusCode); // 200 OK

// DELETE: Datensatz löschen
final deleted = await http.delete(
  Uri.https('jsonplaceholder.typicode.com', '/posts/${post.id}'),
);
print(deleted.statusCode); // 200 OK

JSONPlaceholder speichert diese Änderungen nicht wirklich, sondern antwortet nur so, als ob. Nach dem POST erhalten Sie ein Objekt mit id: 101, sehen es aber nicht, wenn Sie die Liste erneut laden. Zum Ausprobieren ist das ideal, halten Sie es nur nicht für einen Fehler. http.patch, das nur einzelne Felder ändert, funktioniert genauso; welche Methoden unterstützt werden, steht in der Dokumentation der API.

Statuscodes und Fehlerbehandlung

Eine HTTP-Anfrage kann auf zwei verschiedene Arten scheitern, und es ist wichtig, beide zu unterscheiden:

  1. Die Anfrage erreicht den Server gar nicht. Kein Internet, die Domain lässt sich nicht auflösen, die Verbindung bricht ab. Dann wirft http.get eine Ausnahme; das http-Paket meldet sie als http.ClientException.
  2. Der Server antwortet, aber die Antwort ist ein Fehler. Codes wie 404, 401 oder 500 werfen keine Ausnahme. Die response kommt ganz normal an, nur liegt statusCode nicht im Bereich 2xx. Die Prüfung ist Ihre Aufgabe.

Der häufigste Anfängerfehler ist, den zweiten Fall zu überspringen: jsonDecode(response.body) aufzurufen, ohne auf statusCode zu schauen. Eine 404-Seite liefert oft HTML, und jsonDecode wirft eine FormatException; oder das Fehler-JSON des Servers landet im Modell, und Sie bekommen einen scheinbar unzusammenhängenden Typfehler. Die häufigsten Codes bedeuten grob Folgendes:

Code Bedeutung Was die App tut
200, 201, 204 Erfolg (bei 204 ist der Body leer) Daten verarbeiten
400 Fehlerhafte Anfrage Gesendete Daten prüfen
401, 403 Nicht angemeldet / keine Berechtigung Zur Anmeldung leiten
404 Ressource nicht gefunden „Nicht gefunden“ anzeigen
500, 502, 503 Problem auf dem Server „Später erneut versuchen“ anzeigen

Timeouts

http.get und die anderen Funktionen haben keinen Timeout-Parameter; ist der Server langsam oder hängt die Verbindung, starrt der Nutzer lange auf einen Spinner. Hier hilft die Methode timeout, die jedes Future in Dart besitzt:

final response = await http
    .get(uri)
    .timeout(const Duration(seconds: 10));

Läuft die Zeit ab, wird eine TimeoutException aus dart:async geworfen. Auch diese sollten Sie abfangen und dem Nutzer eine verständliche Meldung zeigen; im nächsten Abschnitt bündeln wir das alles an einer Stelle.

Anfragen in einer Service-Klasse bündeln

Schreibt jeder Screen seinen eigenen http.get-Aufruf, wiederholen sich Fehler- und Timeout-Prüfungen überall. Stattdessen sammeln wir die Anfragen in einer Klasse. Wenn Sie mehrere Anfragen an denselben Server senden, sorgt ein einzelner http.Client, den Sie am Ende mit close() schließen, wie es die Paketdokumentation empfiehlt, außerdem dafür, dass die Verbindung wiederverwendet wird:

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiException implements Exception {
  const ApiException(this.message, {this.statusCode});

  final String message;
  final int? statusCode;

  @override
  String toString() => message;
}

class PostApi {
  PostApi({http.Client? client}) : _client = client ?? http.Client();

  static const _host = 'jsonplaceholder.typicode.com';
  static const _timeout = Duration(seconds: 10);
  static const _headers = {
    'Content-Type': 'application/json; charset=UTF-8',
    'Accept': 'application/json',
  };

  final http.Client _client;

  Future<List<Post>> fetchPosts({int? userId}) async {
    final uri = Uri.https(
      _host,
      '/posts',
      userId == null ? null : {'userId': '$userId'},
    );
    final data = await _send(() => _client.get(uri, headers: _headers));
    return (data as List<dynamic>)
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  Future<Post> createPost({
    required int userId,
    required String title,
    required String body,
  }) async {
    final data = await _send(
      () => _client.post(
        Uri.https(_host, '/posts'),
        headers: _headers,
        body: jsonEncode({'userId': userId, 'title': title, 'body': body}),
      ),
    );
    return Post.fromJson(data as Map<String, dynamic>);
  }

  Future<Post> updatePost(Post post) async {
    final data = await _send(
      () => _client.put(
        Uri.https(_host, '/posts/${post.id}'),
        headers: _headers,
        body: jsonEncode(post.toJson()),
      ),
    );
    return Post.fromJson(data as Map<String, dynamic>);
  }

  Future<void> deletePost(int id) async {
    await _send(() => _client.delete(Uri.https(_host, '/posts/$id')));
  }

  /// Gemeinsamer Ablauf: Timeout, Verbindungsfehler, Statuscode, JSON.
  Future<Object?> _send(Future<http.Response> Function() request) async {
    final http.Response response;
    try {
      response = await request().timeout(_timeout);
    } on TimeoutException {
      throw const ApiException('Der Server hat nicht rechtzeitig geantwortet.');
    } on http.ClientException {
      throw const ApiException('Keine Verbindung. Bitte Internet prüfen.');
    }

    final code = response.statusCode;
    if (code == 401) {
      throw const ApiException('Ihre Sitzung ist abgelaufen.', statusCode: 401);
    }
    if (code == 404) {
      throw const ApiException('Eintrag nicht gefunden.', statusCode: 404);
    }
    if (code < 200 || code >= 300) {
      throw ApiException('Serverfehler ($code).', statusCode: code);
    }

    if (response.bodyBytes.isEmpty) return null; // z. B. 204 No Content
    try {
      return jsonDecode(utf8.decode(response.bodyBytes));
    } on FormatException {
      throw const ApiException('Unerwartete Antwort vom Server.');
    }
  }

  void close() => _client.close();
}

Diese Klasse bringt drei Vorteile. Die Screens kümmern sich nur noch um ApiException; ob Timeout, Verbindungsproblem oder 404, entscheidet eine einzige Stelle. Weil der http.Client über den Konstruktor hereinkommt, können Tests statt eines echten Servers den MockClient aus package:http/testing.dart übergeben. Und das Dekodieren mit utf8.decode(response.bodyBytes) stellt sicher, dass Umlaute und andere Sonderzeichen korrekt erscheinen, auch wenn der Server keinen Zeichensatz angibt.

Mini-Szenario: ein Beitrags-Screen

Jetzt verbinden wir den Service mit einem Screen. Beim Öffnen werden die Beiträge des Nutzers geladen, währenddessen dreht sich ein Spinner, bei einem Fehler erscheinen eine Meldung und ein „Erneut versuchen“-Button, und der Button unten rechts sendet einen neuen Beitrag:

import 'package:flutter/material.dart';

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

  @override
  State<PostsPage> createState() => _PostsPageState();
}

class _PostsPageState extends State<PostsPage> {
  final _api = PostApi();
  late Future<List<Post>> _postsFuture;

  @override
  void initState() {
    super.initState();
    _postsFuture = _api.fetchPosts(userId: 1);
  }

  @override
  void dispose() {
    _api.close();
    super.dispose();
  }

  void _reload() {
    setState(() => _postsFuture = _api.fetchPosts(userId: 1));
  }

  Future<void> _addPost() async {
    final messenger = ScaffoldMessenger.of(context);
    try {
      final post = await _api.createPost(
        userId: 1,
        title: 'Neuer Beitrag',
        body: 'Aus Flutter gesendet',
      );
      messenger.showSnackBar(
        SnackBar(content: Text('Angelegt, id: ${post.id}')),
      );
    } on ApiException catch (e) {
      messenger.showSnackBar(SnackBar(content: Text(e.message)));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Beiträge'),
        actions: [
          IconButton(onPressed: _reload, icon: const Icon(Icons.refresh)),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addPost,
        child: const Icon(Icons.add),
      ),
      body: FutureBuilder<List<Post>>(
        future: _postsFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('${snapshot.error}'),
                  const SizedBox(height: 12),
                  FilledButton(
                    onPressed: _reload,
                    child: const Text('Erneut versuchen'),
                  ),
                ],
              ),
            );
          }
          final posts = snapshot.data ?? const <Post>[];
          if (posts.isEmpty) {
            return const Center(child: Text('Noch keine Beiträge'));
          }
          return ListView.separated(
            itemCount: posts.length,
            separatorBuilder: (context, index) => const Divider(height: 1),
            itemBuilder: (context, index) {
              final post = posts[index];
              return ListTile(
                title: Text(post.title),
                subtitle: Text(
                  post.body,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Diese Entscheidungen sind wichtig. Das Future wird einmal in initState erzeugt; würden wir _api.fetchPosts() in build aufrufen, löste jeder Rebuild eine neue Anfrage aus. Diesen Fehler beschreibe ich ausführlich im FutureBuilder-Beitrag. Die Fehlermeldung kommt direkt aus ApiException.toString, der Nutzer sieht also einen lesbaren Satz statt eines Stacktraces. In _addPost wird der ScaffoldMessenger vor dem await geholt; verlässt der Nutzer die Seite, während die Anfrage läuft, wird so kein veralteter context benutzt. Zur Liste selbst lesen Sie den ListView-Beitrag, zur Meldung den SnackBar-Beitrag.

Wenn dieselben Daten auf mehreren Screens gebraucht werden oder die Liste nach dem Hinzufügen und Löschen aktuell bleiben soll, reicht dieser FutureBuilder-Aufbau nicht. Dann ist es besser, den Service mit Provider in einen ChangeNotifier oder mit Riverpod in einen Provider zu verlagern. Die Klasse PostApi funktioniert in beiden Fällen unverändert.

Wann lohnt sich dio?

Das http-Paket ist bewusst klein gehalten. dio, in größeren Projekten eine beliebte Wahl, bringt einiges fertig mit: gemeinsame Basis-URL und Verbindungs-Timeout über BaseOptions, Interceptors, die jeder Anfrage automatisch ein Token anhängen, CancelToken zum Abbrechen von Anfragen und Fortschrittsmeldungen bei Up- und Downloads. Außerdem liefert es das dekodierte JSON direkt in response.data und wirft standardmäßig eine DioException bei Codes außerhalb von 2xx.

Für eine App, die eine Handvoll Anfragen an eine API schickt, reichen http und eine kleine Service-Klasse wie oben; Sie sehen dabei genau, was passiert. Sobald Anforderungen wie Token-Erneuerung, zentrales Logging oder große Dateiübertragungen dazukommen, ist der Umstieg auf dio weniger Arbeit, als all das selbst zu schreiben. Die Konzepte (Methode, Header, Body, Statuscode) sind in beiden Paketen dieselben.

Häufige Fehler

1. Eine Map ohne jsonEncode senden

Schreiben Sie body: {'title': 'Hallo'}, sendet http das nicht als JSON, sondern als Formulardaten (application/x-www-form-urlencoded). Der Server antwortet mit 400 oder 415 oder sieht leere Felder. Senden Sie an eine API, die JSON erwartet, immer body: jsonEncode(...) zusammen mit dem Header 'Content-Type': 'application/json'.

2. jsonDecode aufrufen, ohne den Statuscode zu prüfen

Symptom: FormatException: Unexpected character (at character 1) <!DOCTYPE html>. Der Server hat eine HTML-Fehlerseite geliefert, und Sie haben sie für JSON gehalten. Prüfen Sie zuerst statusCode, dann dekodieren Sie.

3. Im Debug-Build läuft alles, im Release-Build gibt es kein Internet

Symptom: Während der Entwicklung funktioniert alles, in der Version im Store geht keine Anfrage durch. In der Vorlage von flutter create steht die Berechtigung INTERNET nur in den Manifesten unter android/app/src/debug/ und profile/; dort ist sie, damit Hot Reload und Debugger funktionieren. Der Release-Build verwendet das Manifest im Ordner main. Vergessen Sie nicht, die Berechtigung dort einzutragen.

4. Aus dem Emulator localhost aufrufen und unverschlüsseltes http nutzen

Läuft eine API auf Ihrem eigenen Rechner (etwa ein Dienst, den Sie mit ASP.NET Core Minimal API geschrieben haben), zeigt localhost im Android-Emulator auf den Emulator selbst. Ihren Rechner erreichen Sie über 10.0.2.2. Außerdem blockieren Android ab Version 9 und iOS standardmäßig unverschlüsselte http://-Verbindungen. Während der Entwicklung können Sie das lockern, eine veröffentlichte App sollte aber immer https verwenden.

5. Das Future in build erzeugen

FutureBuilder(future: api.fetchPosts(), ...) schickt dieselbe Anfrage jedes Mal erneut, wenn sich die Tastatur öffnet oder setState aufgerufen wird. Erzeugen Sie das Future einmal in initState und speichern Sie es in einem Feld.

Häufig gestellte Fragen

Wie hänge ich ein Token an Anfragen, und wo speichere ich es?

Das Token senden Sie bei jeder Anfrage in headers als 'Authorization': 'Bearer $token'; mit einer Service-Klasse genügt es, es zu den gemeinsamen Headern hinzuzufügen. SharedPreferences ist dafür nicht der richtige Ort, weil die Daten nicht verschlüsselt werden. Was wohin gehört, erkläre ich in lokale Datenspeicherung mit SharedPreferences; kurz gesagt: flutter_secure_storage.

Warum scheitert dieselbe Anfrage im Flutter-Web mit einem CORS-Fehler?

Flutter Web sendet die Anfrage aus dem Browser heraus, und bei Anfragen an eine andere Domain erwartet der Browser, dass der Server sie mit CORS-Headern erlaubt. Auf dem Handy gibt es diese Prüfung nicht, deshalb kann derselbe Code mobil funktionieren und im Web hängen bleiben. Die Lösung liegt nicht in Flutter, sondern auf dem Server: Die API braucht eine CORS-Konfiguration, die die Adresse Ihrer App zulässt.

Was tun, wenn die Oberfläche beim Dekodieren großer JSON-Antworten ruckelt?

jsonDecode und die Umwandlung in Modelle laufen im Haupt-Isolate. Bei einigen hundert Einträgen merken Sie davon nichts, bei sehr großen Antworten können Animationen aber kurz einfrieren. Für diesen Fall empfiehlt die Flutter-Dokumentation, das Parsen mit der Funktion compute (oder mit Isolate.run) in ein eigenes Isolate zu verlagern; die Funktion muss auf oberster Ebene oder statisch sein und sollte nur den Body-Text entgegennehmen und die Modellliste zurückgeben.

Kommentare