Flutter: HTTP-Anfragen und REST-APIs
9 Min. Lesezeit

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 httpDas 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 OKJSONPlaceholder 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:
- Die Anfrage erreicht den Server gar nicht. Kein Internet, die Domain lässt sich nicht auflösen, die Verbindung bricht ab. Dann wirft
http.geteine Ausnahme; dashttp-Paket meldet sie alshttp.ClientException. - Der Server antwortet, aber die Antwort ist ein Fehler. Codes wie 404, 401 oder 500 werfen keine Ausnahme. Die
responsekommt ganz normal an, nur liegtstatusCodenicht 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.
Verwandte Artikel
Flutter: Asynchrone Listen mit FutureBuilder
FutureBuilder in Flutter: Snapshot-Zustände richtig lesen, der Fehler „Future in build erzeugen“, die Lösung mit initState und Aktualisieren per setState.
Flutter: Lokale Datenspeicherung mit SharedPreferences
Theme und Einstellungen mit shared_preferences speichern: SharedPreferencesAsync, WithCache und alte API im Vergleich, Migration und was nicht hineingehört.
Flutter: Echtzeit-Datenströme mit StreamBuilder
StreamBuilder in Flutter: connectionState bei Streams, initialData, Broadcast vs. Single-Subscription, StreamController schließen, listen() im Vergleich.