تطوير الويب

استهلاك API REST في Flutter مع Dio خطوة بخطوة

6 min de lecture

السلسلة: هذا الدرس جزء من سلسلة 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.

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité