Toute application mobile sérieuse parle tôt ou tard à un backend. La couche HTTP est donc l’une des trois ou quatre briques structurantes que vous écrirez en début de projet, et celle qui rapportera ou coûtera le plus selon la qualité du design initial. Ce tutoriel construit une couche réseau Flutter complète sur Dio 5.9 : un client configuré, un intercepteur d’authentification, une gestion d’erreurs typée, du retry exponentiel, le téléchargement avec progression et l’annulation. À la fin, vous aurez un module copiable directement dans vos projets.
📖 Guide principal : Flutter 3 et Dart 3 pour le développement mobile cross-platform — pour le contexte général, commencez par cette page.
Prérequis
- Flutter SDK 3.41+ installé (tutoriel d’installation).
- Une application Flutter de base, idéalement la première app Flutter avec Riverpod pour bénéficier de l’injection des providers.
- Une API REST de test : on utilisera JSONPlaceholder (publique, gratuite, idéale pour les tutoriels).
- Niveau : intermédiaire. Temps estimé : 75 minutes.
Étape 1 — Ajouter Dio et organiser le module réseau
Le package officiel dio est en version 5.9.2 au moment de cette rédaction. Il s’installe comme n’importe quel package Dart et n’a pas de dépendance native sur Android ou iOS — pas de configuration Gradle ou Xcode supplémentaire.
flutter pub add dio
Vérifiez dans pubspec.yaml que la ligne dio: ^5.9.2 a été ajoutée. Puis créez la structure du module réseau, séparée par responsabilité :
lib/
core/
network/
api_client.dart # configuration Dio + intercepteurs
api_exception.dart # erreurs typées
auth_interceptor.dart # injection du token JWT
retry_interceptor.dart # retry exponentiel
features/
posts/
post_model.dart
posts_repository.dart # appelle l'API
posts_provider.dart # expose les posts à l'UI
L’isolation du réseau dans son propre module est une discipline qui paye sur la durée. Quand l’API change (URL, format, authentification), vous touchez à un dossier, pas à toute l’application. Quand vous voulez écrire des tests, vous mockez une interface plutôt qu’une lib HTTP brute.
Étape 2 — Créer un ApiClient avec BaseOptions
Plutôt que d’instancier un Dio brut à chaque endroit où vous faites un appel, créez un singleton préconfiguré. Les BaseOptions de Dio centralisent la base URL, les timeouts, les headers par défaut et la stratégie de gestion des erreurs. C’est ce qui transforme Dio en bibliothèque utilisable plutôt qu’en simple wrapper.
Créez lib/core/network/api_client.dart :
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'auth_interceptor.dart';
import 'retry_interceptor.dart';
class ApiClient {
ApiClient({required String baseUrl, required this.tokenProvider}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 15),
contentType: 'application/json',
responseType: ResponseType.json,
));
_dio.interceptors.addAll([
AuthInterceptor(tokenProvider: tokenProvider),
RetryInterceptor(dio: _dio),
LogInterceptor(
request: false,
responseBody: false,
error: true,
),
]);
}
late final Dio _dio;
final Future<String?> Function() tokenProvider;
Future<T> get<T>(String path, {Map<String, Object?>? query}) async {
try {
final r = await _dio.get<T>(path, queryParameters: query);
return r.data as T;
} on DioException catch (e) {
throw ApiException.fromDio(e);
}
}
Future<T> post<T>(String path, {Object? body}) async {
try {
final r = await _dio.post<T>(path, data: body);
return r.data as T;
} on DioException catch (e) {
throw ApiException.fromDio(e);
}
}
}
Trois choix méritent un commentaire. D’abord, le baseUrl est passé en paramètre du constructeur — pas codé en dur. Cela permet de changer d’environnement (staging, prod) sans modifier l’ApiClient. Ensuite, le tokenProvider est une fonction asynchrone, ce qui permet de lire le token depuis n’importe quelle source (storage, Riverpod) sans coupler ApiClient à une couche supérieure. Enfin, les méthodes get/post attrapent les DioException et les transforment en ApiException typées — l’UI ne verra jamais d’exception Dio brute.
Étape 3 — Définir une exception typée
Une DioException contient beaucoup de bruit (objet RequestOptions, stack trace, type d’erreur, etc.). Pour que l’UI puisse réagir intelligemment — afficher un message localisé, déclencher un re-login en cas de 401, retenter en cas de timeout —, on enrichit le tout dans une exception métier.
Créez lib/core/network/api_exception.dart :
import 'package:dio/dio.dart';
sealed class ApiException implements Exception {
const ApiException(this.message);
final String message;
factory ApiException.fromDio(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException();
case DioExceptionType.connectionError:
return NoNetworkException();
case DioExceptionType.cancel:
return CanceledException();
case DioExceptionType.badResponse:
final code = e.response?.statusCode ?? 0;
if (code == 401) return UnauthorizedException();
if (code == 403) return ForbiddenException();
if (code >= 500) return ServerException(code);
return BadRequestException(code, e.response?.data);
case DioExceptionType.badCertificate:
case DioExceptionType.unknown:
return UnknownException(e.message ?? 'Erreur inconnue');
}
}
}
class TimeoutException extends ApiException { const TimeoutException() : super('Le serveur met trop de temps à répondre'); }
class NoNetworkException extends ApiException { const NoNetworkException() : super('Aucune connexion réseau'); }
class CanceledException extends ApiException { const CanceledException() : super('Requête annulée'); }
class UnauthorizedException extends ApiException { const UnauthorizedException() : super('Session expirée'); }
class ForbiddenException extends ApiException { const ForbiddenException() : super('Accès refusé'); }
class ServerException extends ApiException { final int code; const ServerException(this.code) : super('Erreur serveur'); }
class BadRequestException extends ApiException { final int code; final Object? body; const BadRequestException(this.code, this.body) : super('Requête invalide'); }
class UnknownException extends ApiException { const UnknownException(super.message); }
Le mot-clé sealed de Dart 3 est le secret ici. Quand votre UI fait switch (e) { case TimeoutException() => ... }, le compilateur exige que tous les variants soient traités — ou refuse de compiler. Vous ne pouvez plus oublier un cas d’erreur. C’est un mécanisme léger qui empêche des bugs très ennuyeux.
Étape 4 — Intercepteur d’authentification JWT
L’intercepteur Auth est typiquement la pièce que vous écrirez une seule fois et que vous copierez sur trois projets. Sa mission : ajouter automatiquement le header Authorization: Bearer ... à chaque requête sortante, et déclencher un rafraîchissement de token quand le serveur renvoie 401.
Créez lib/core/network/auth_interceptor.dart :
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
AuthInterceptor({required this.tokenProvider});
final Future<String?> Function() tokenProvider;
@override
Future<void> onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
final token = await tokenProvider();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
// Idéalement : refresh token + retry. Pour ce tutoriel,
// on laisse l'UI gérer le re-login via ApiException.
}
handler.next(err);
}
}
Le rafraîchissement automatique de token est volontairement omis pour rester simple — c’est un sujet qui mérite son propre tutoriel. Pour un produit fini, vous voudrez stocker le refresh token séparément, le détecter en cas de 401, faire un appel /auth/refresh qui ne traverse pas l’intercepteur (pour éviter une boucle), et rejouer la requête originale avec le nouveau token. Le pattern complet est documenté dans le wiki Dio.
Étape 5 — Intercepteur de retry avec backoff exponentiel
Les réseaux mobiles sont capricieux : la 4G qui passe en 3G, le tunnel qui coupe pendant 200 ms, le serveur qui répond 502 transitoirement. Un retry intelligent absorbe ces glitches sans déranger l’utilisateur. La règle d’or : retry uniquement les erreurs idempotentes (GET) et les erreurs transitoires (timeout, 502, 503, 504), jamais les 4xx fonctionnels.
Créez lib/core/network/retry_interceptor.dart :
import 'package:dio/dio.dart';
class RetryInterceptor extends Interceptor {
RetryInterceptor({required this.dio, this.maxAttempts = 3});
final Dio dio;
final int maxAttempts;
@override
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
final attempt = (err.requestOptions.extra['retry_attempt'] as int?) ?? 0;
final isIdempotent = err.requestOptions.method == 'GET';
final isTransient = err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.connectionError ||
(err.response?.statusCode != null &&
[502, 503, 504].contains(err.response!.statusCode));
if (isIdempotent && isTransient && attempt < maxAttempts) {
final delay = Duration(milliseconds: 300 * (1 << attempt));
await Future.delayed(delay);
final newOpts = err.requestOptions
..extra['retry_attempt'] = attempt + 1;
try {
final r = await dio.fetch(newOpts);
return handler.resolve(r);
} on DioException catch (e) {
return handler.next(e);
}
}
handler.next(err);
}
}
Le backoff exponentiel — 300 ms, 600 ms, 1200 ms — laisse le temps au réseau de se rétablir sans bombarder le serveur. Trois tentatives suffisent pour 99 % des cas ; au-delà, autant remonter l’erreur à l’utilisateur. Notez l’astuce du compteur dans options.extra : c’est le moyen propre de stocker un état entre les passes d’intercepteur.
Étape 6 — Définir un modèle et un repository
Maintenant que la plomberie est en place, écrivons un cas concret : récupérer la liste des posts depuis JSONPlaceholder. Un repository traduit les JSON bruts en objets Dart manipulables par l’UI.
// lib/features/posts/post_model.dart
class Post {
const Post({required this.id, required this.title, required this.body});
final int id;
final String title;
final String body;
factory Post.fromJson(Map<String, Object?> json) => Post(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}
// lib/features/posts/posts_repository.dart
import '../../core/network/api_client.dart';
import 'post_model.dart';
class PostsRepository {
PostsRepository(this._api);
final ApiClient _api;
Future<List<Post>> fetchAll() async {
final raw = await _api.get<List<dynamic>>('/posts');
return raw
.map((e) => Post.fromJson(e as Map<String, Object?>))
.toList(growable: false);
}
Future<Post> fetchById(int id) async {
final raw = await _api.get<Map<String, Object?>>('/posts/$id');
return Post.fromJson(raw);
}
}
Le repository ne sait rien du BuildContext, de Riverpod, ou du widget qui va l’appeler. Il est testable en isolation avec un mock d’ApiClient. Cette séparation des responsabilités est la clé pour qu’une application puisse grandir sans devenir un plat de spaghetti.
Étape 7 — Exposer le repository via Riverpod
Si vous suivez la première app Flutter avec Riverpod 3, l’injection se fait par un Provider classique et un FutureProvider pour exposer la liste asynchrone aux widgets.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_client.dart';
import 'posts_repository.dart';
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(
baseUrl: 'https://jsonplaceholder.typicode.com',
tokenProvider: () async => null, // pas d'auth pour ce tuto
);
});
final postsRepositoryProvider = Provider<PostsRepository>(
(ref) => PostsRepository(ref.watch(apiClientProvider)));
final postsProvider = FutureProvider<List<Post>>((ref) async {
return ref.watch(postsRepositoryProvider).fetchAll();
});
Dans votre widget, consommez le FutureProvider avec ref.watch et décomposez l’AsyncValue via when. C’est l’idiome Flutter standard pour afficher loading, erreur ou données. À chaque pull-to-refresh, appelez ref.invalidate(postsProvider) pour forcer un rappel à l’API.
Étape 8 — Upload multipart et progression
L’envoi d’un fichier — photo, document, archive — passe par un FormData chez Dio. Le pattern reste lisible même pour des envois lourds avec callback de progression. Voici un exemple complet :
Future<String> uploadAvatar(String filePath) async {
final form = FormData.fromMap({
'avatar': await MultipartFile.fromFile(filePath, filename: 'avatar.jpg'),
'description': 'profile picture',
});
final response = await _dio.post('/upload',
data: form,
onSendProgress: (sent, total) {
final percent = (sent / total * 100).toStringAsFixed(0);
print('Upload : $percent %');
},
);
return response.data['url'] as String;
}
Le callback onSendProgress est appelé en continu pendant le téléversement. Connectez-le à un StreamController ou à un NotifierProvider pour alimenter une LinearProgressIndicator dans l’UI. Pour des fichiers très volumineux, considérez aussi CancelToken qui permet à l’utilisateur d’annuler proprement la requête en cours.
Étape 9 — Vérification
Lancez l’application sur émulateur, ouvrez l’écran de liste. Vous devez voir les cent posts de JSONPlaceholder apparaître après ~300 ms (le temps du round-trip). Coupez le wifi, faites un pull-to-refresh — vous devez voir le message « Aucune connexion réseau » de votre NoNetworkException, traduit proprement, sans plantage. Remettez le wifi, refresh — les données reviennent.
Pour un test de retry, simulez une erreur 502 en pointant temporairement votre baseUrl vers https://httpstat.us/502. Vous devez voir trois requêtes successives dans les logs avant l’échec final. C’est ce qu’on attend.
Pourquoi Dio plutôt que le package http
Le SDK Dart fournit déjà package:http, une libairie HTTP minimaliste maintenue par l’équipe Dart. Elle convient parfaitement pour un script ou une application qui fait deux ou trois appels. Mais dès que vous écrivez une vraie application mobile, vous heurtez rapidement ses limites : pas d’intercepteurs, pas de retry, pas de progression d’upload, pas de cancellation, pas de timeout fin par requête. Vous finissez par tout réimplémenter à la main avec des wrappers maison qui dérivent au fil des projets.
Dio résout ces problèmes en une seule API cohérente. Il s’est imposé en six ans comme la référence dans l’écosystème Flutter, avec plusieurs millions de téléchargements et un nombre de likes parmi les plus élevés du registre pub.dev. Le projet est porté par cfug, la Chinese Flutter User Group, et reste actif avec des releases régulières. La courbe d’apprentissage est de l’ordre de l’heure pour un développeur qui connaît déjà http.
Le seul vrai défaut de Dio est sa taille — quelques dizaines de kilo-octets ajoutés au bundle compilé, ce qui reste négligeable pour une application mobile. Si vous écrivez un tool en pure Dart où la taille compte, restez sur http.
Design de la couche réseau : repository ou direct ?
Deux écoles s’affrontent en architecture mobile. La première consacre un repository par feature, qui traduit JSON ↔ objets Dart, et l’UI parle au repository — jamais directement à l’ApiClient. La seconde appelle Dio directement depuis les notifiers Riverpod et considère que le repository est une couche d’abstraction superflue.
Pour un projet jetable ou un prototype, l’approche directe est plus rapide. Pour tout ce qui doit durer plus de six mois, le repository paye : il découple le format réseau du format domaine (vous pouvez changer un userName en username côté API sans toucher à l’UI), il rend les tests unitaires triviaux (mockez le repository, pas Dio), et il sert de point d’entrée naturel pour ajouter du caching ou de l’optimistic update.
Notre tutoriel a choisi le repository pour cette raison. La discipline est légère — quelques lignes de plus par feature — et le bénéfice s’apprécie quand l’application grandit.
Stratégies de cache HTTP
Dans une application mobile, le réseau coûte de la batterie, du temps, et parfois de l’argent à l’utilisateur. Mettre en cache les réponses lues récemment est presque toujours la bonne stratégie. Trois niveaux sont possibles, du plus simple au plus sophistiqué.
Niveau 1 : cache mémoire dans le repository. Stockez la dernière liste de posts dans un champ _cachedPosts, retournez-la immédiatement si elle existe, et déclenchez un refresh en arrière-plan. Trois lignes de code, suffisant pour la majorité des écrans.
Niveau 2 : cache disque persistant via dio_cache_interceptor. Ce package ajoute un intercepteur qui stocke les réponses dans SQLite ou Hive, respecte les headers Cache-Control du serveur, et permet de spécifier des stratégies par requête (cache-first, network-first, cache-then-network). C’est l’option indiquée pour des applications qui doivent fonctionner partiellement hors-ligne.
Niveau 3 : caching applicatif via un état partagé (Riverpod, Drift). C’est plus invasif, mais c’est la seule façon de gérer proprement un mode offline complet avec écriture locale et synchronisation différée.
Bonnes pratiques sécurité
Une couche réseau qui ignore la sécurité expose l’application à des attaques classiques : interception MITM, vol de token, falsification de réponse. Plusieurs réflexes coûtent peu et apportent beaucoup.
D’abord, n’autorisez le HTTP clair que strictement en debug. Sur Android, configurez le Network Security Config pour interdire HTTP en production. Sur iOS, App Transport Security est strict par défaut, ne le relâchez pas.
Ensuite, considérez le certificate pinning pour des applications très sensibles (bancaire, médical). Dio fournit un HttpClientAdapter personnalisable où vous validez vous-même l’empreinte SHA-256 du certificat serveur, refusant la connexion si elle ne correspond pas à votre liste blanche. Cela bloque les attaques par certificat racine compromis.
Enfin, ne loggez jamais les bodies de réponses en production — un token, une donnée personnelle ou un identifiant de session peut atterrir dans Logcat puis dans des rapports de crash. Le LogInterceptor de Dio doit avoir responseBody: false en release, ce que fait notre ApiClient ci-dessus.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
SocketException: Failed host lookup |
Pas de DNS — typiquement émulateur mal configuré. | Vérifier que l’émulateur a accès à Internet (un navigateur intégré charge bien Google). |
| Cleartext HTTP not permitted | Appel HTTP non-TLS bloqué par Android 9+ par défaut. | Soit basculer en HTTPS (recommandé), soit ajouter android:usesCleartextTraffic="true" dans AndroidManifest.xml en debug. |
| Intercepteur jamais exécuté | Erreur dans onRequest qui oublie d’appeler handler.next(...). |
Chaque branche d’un intercepteur doit appeler handler.next, handler.resolve ou handler.reject exactement une fois. |
| Timeout systématique sur émulateur lent | Timeouts trop courts. | Augmenter connectTimeout et receiveTimeout en debug, garder des valeurs strictes en release. |
| JSON tronqué en upload | contentType à application/json alors que c’est du multipart. |
Ne pas forcer le content-type quand vous envoyez un FormData, Dio le calcule. |
Tutoriels associés
- 🔝 Guide principal Flutter 3 et Dart 3
- Première app Flutter avec Riverpod 3 pas à pas
- Persistance locale en Flutter : Hive et Drift pas à pas
Foire aux questions
Dio ou le package http standard ?
Pour un appel simple, le package http du SDK suffit. Dès que vous avez un token JWT à injecter, du retry, de la cancellation ou de la progression d’upload, Dio devient rentable. La bascule prend une demi-heure ; conservez la liberté de le faire plus tard.
Comment mocker Dio en test ?
Préférez mocker votre ApiClient ou PostsRepository plutôt que Dio directement. Pour un test d’intégration qui frappe une vraie URL, utilisez http_mock_adapter qui intercepte les appels Dio sans toucher au réseau.
Faut-il chiffrer le token JWT stocké ?
Oui. Sur Android, utilisez flutter_secure_storage qui s’appuie sur le Keystore système. Sur iOS, idem via le Keychain. Ne stockez jamais un token dans shared_preferences en clair — c’est lisible par toute autre application sur Android rooté.
Comment gérer plusieurs environnements (dev, staging, prod) ?
Définissez un const compilé via --dart-define=API_URL=https://... au lancement de flutter run ou flutter build. Le code lit String.fromEnvironment('API_URL'). C’est plus simple qu’un système de configuration à chargement runtime, et le compilateur élimine les chemins morts.
Comment intercepter une erreur 401 pour relancer le re-login ?
Dans AuthInterceptor.onError, détectez le 401, ouvrez un écran de re-login, attendez que l’utilisateur saisisse à nouveau ses identifiants, mettez à jour le token, puis handler.resolve(await dio.fetch(err.requestOptions)). Pour éviter une boucle, vérifiez que la requête originale n’est pas elle-même /auth/refresh.