السلسلة: هذا الدرس جزء من سلسلة Flutter. اقرأ المقال الرئيسي.
كل تطبيق موبايل جدّي يتحدّث عاجلاً أم آجلاً مع backend. هذا الدرس يبني طبقة شبكة Flutter كاملة على Dio 5.9: client مُهيّأ، intercepteur authentification، إدارة أخطاء بأنواع محددة، retry أسي، تنزيل بتقدّم وإلغاء.
المتطلبات
- Flutter SDK 3.41+
- تطبيق Flutter أساسي، نمط Riverpod مفضّل
- API REST اختبار: JSONPlaceholder
- 75 دقيقة
الخطوة 1 — إضافة Dio وتنظيم الموديول
flutter pub add dio
تأكّد من dio: ^5.9.2. ثم أنشئ بنية الموديول:
lib/
core/
network/
api_client.dart # إعداد Dio + intercepteurs
api_exception.dart # أخطاء بأنواع
auth_interceptor.dart # حقن JWT
retry_interceptor.dart # retry أسي
features/
posts/
post_model.dart
posts_repository.dart
posts_provider.dart
الخطوة 2 — ApiClient مع BaseOptions
// 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);
}
}
}
الخطوة 3 — استثناء بأنواع محدّدة
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 a repondre'); }
class NoNetworkException extends ApiException { const NoNetworkException() : super('Aucune connexion reseau'); }
class CanceledException extends ApiException { const CanceledException() : super('Requete annulee'); }
class UnauthorizedException extends ApiException { const UnauthorizedException() : super('Session expiree'); }
class ForbiddenException extends ApiException { const ForbiddenException() : super('Acces refuse'); }
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('Requete invalide'); }
class UnknownException extends ApiException { const UnknownException(super.message); }
الكلمة المفتاحية sealed في Dart 3 هي السر: حين يفعل UI switch (e)، المُصرّف يفرض معالجة كل المتغيّرات.
الخطوة 4 — Intercepteur مصادقة JWT
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) {
// refresh token + retry
}
handler.next(err);
}
}
الخطوة 5 — Intercepteur retry بـ backoff أسي
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);
}
}
backoff أسي — 300 مللي، 600 مللي، 1200 مللي — يمنح الشبكة وقتاً للتعافي. 3 محاولات تكفي لـ 99% من الحالات. القاعدة الذهبية: retry فقط على GET (idempotent) وعلى أخطاء عابرة (timeout، 502، 503، 504).
الخطوة 6 — تعريف نموذج وrepository
// 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);
}
}
الخطوة 7 — عرض repository عبر Riverpod
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,
);
});
final postsRepositoryProvider = Provider<PostsRepository>(
(ref) => PostsRepository(ref.watch(apiClientProvider)));
final postsProvider = FutureProvider<List<Post>>((ref) async {
return ref.watch(postsRepositoryProvider).fetchAll();
});
عند pull-to-refresh، استدع ref.invalidate(postsProvider).
الخطوة 8 — Upload multipart وتقدّم
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;
}
callback onSendProgress يُستدعى باستمرار. وصّله بـ StreamController أو NotifierProvider لـ LinearProgressIndicator. للملفات الضخمة، CancelToken يسمح بإلغاء نظيف.
الخطوة 9 — التحقق
أطلق التطبيق، افتح شاشة القائمة. ترى 100 post من JSONPlaceholder بعد ~300 مللي ثانية. اقطع wifi، pull-to-refresh — يجب أن ترى رسالة «Aucune connexion reseau» من NoNetworkException، بلا انهيار. لاختبار retry، وجّه baseUrl نحو https://httpstat.us/502 — ترى 3 طلبات متتالية في السجلات.
لماذا Dio بدل http
SDK Dart يوفّر package:http الذي يكفي لسكربت أو تطبيق ببضع استدعاءات. لكن مع تطبيق حقيقي، تواجه قيوده: لا intercepteurs، لا retry، لا تقدّم upload، لا cancellation. Dio يحلّ هذه المشاكل في API موحَّد. صار المرجع في نظام Flutter البيئي بمئات الآلاف من التنزيلات.
تصميم طبقة الشبكة: repository أم مباشر؟
مدرستان. الأولى تخصّص repository لكل feature، يترجم JSON ↔ كائنات Dart، وUI يتحدّث مع repository — لا أبداً مع ApiClient مباشرة. الثانية تستدعي Dio مباشرة من notifiers Riverpod.
للنماذج، الأسلوب المباشر أسرع. لكل شيء يجب أن يدوم فوق 6 أشهر، repository يربح: يفصل تنسيق الشبكة عن تنسيق المجال، يجعل اختبارات الوحدة سهلة (mock repository، لا Dio)، ويُتيح إضافة caching.
استراتيجيات cache HTTP
- المستوى 1: cache ذاكرة في repository. خزّن آخر قائمة في
_cachedPosts، أرجعها فوراً، أطلق refresh خلفياً. كافٍ لأغلب الشاشات. - المستوى 2: cache قرص دائم عبر
dio_cache_interceptor. intercepteur يخزّن الردود في SQLite/Hive، يحترم headers Cache-Control. - المستوى 3: caching تطبيقي عبر حالة مشتركة (Riverpod، Drift). أغزى لكن الوحيد لإدارة offline كامل.
ممارسات أمن
- لا تأذن HTTP صرفاً إلا في debug. على Android، اضبط Network Security Config. على iOS، App Transport Security صارم افتراضياً.
- للتطبيقات الحساسة، فكّر في certificate pinning. Dio يوفّر HttpClientAdapter قابل للتخصيص.
- لا تسجّل أبداً bodies الردود في الإنتاج — token، بيانات شخصية قد تنتهي في Logcat.
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
SocketException: Failed host lookup |
لا DNS — emulator سيئ الإعداد | تحقّق أن emulator له وصول إنترنت |
| «Cleartext HTTP not permitted» | استدعاء HTTP غير TLS محجوب من Android 9+ | HTTPS، أو android:usesCleartextTraffic="true" في debug |
| Intercepteur لا يُنفَّذ | خطأ في onRequest ينسى handler.next(...) |
كل فرع يستدعي handler.next أو resolve أو reject مرة واحدة |
| Timeout منهجي على emulator بطيء | timeouts قصيرة جداً | زدها في debug، أبقها صارمة في release |
| JSON مبتور في upload | contentType application/json مع multipart |
لا تفرض content-type عند إرسال FormData، Dio يحسبه |
أسئلة شائعة
Dio أم http المعياري؟ Dio حالما يكون لديك JWT، retry، cancellation أو تقدّم upload.
كيف نختبر Dio؟ mock ApiClient أو repository بدلاً من Dio. لاختبار تكامل، http_mock_adapter.
هل نشفّر JWT المخزَّن؟ نعم. على Android، flutter_secure_storage يستخدم Keystore. على iOS، Keychain. لا تخزّن أبداً token في shared_preferences صريحاً.
عدة بيئات (dev/staging/prod)؟ --dart-define=API_URL=https://... ثم String.fromEnvironment('API_URL').
كيف نلتقط 401 لإعادة login؟ في AuthInterceptor.onError، اكتشف 401، افتح شاشة re-login، حدّث token، ثم handler.resolve(await dio.fetch(err.requestOptions)). تجنّب الحلقة بفحص أن الطلب الأصلي ليس /auth/refresh.