Flutter: الشبكات وREST API

تطبيق بدون شبكة هو جزيرة — طلبات API هي الجسر نحو البر الرئيسي.

📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا

1. ما ستتعلمه


2. قصة حقيقية عن فشل طلبات الشبكة

(1) المشكلة: أخطاء الشبكة تُعطل التطبيق

في يوم إطلاق ShopApp، حصل المستخدمون على اتصالات غير مستقرة على شاشات بيضاء متعطلة — لا try-catch، لا مهلة زمنية، لا إعادة محاولة. الأسوأ، عندما انتهت صلاحية Token، أرجعت جميع الطلبات 401 ولم يكن للتطبيق آلية تحديث تلقائية، مما أجبر المستخدمين على تسجيل الدخول مجددًا. ألفا تقييم سلبي في يوم واحد.

(2) حل معترضات Dio

آلية معترضات Dio تُرفق الرموز بشكل موحد قبل خروج الطلبات، وتتعامل تلقائيًا مع استجابات 401 بتحديث الرموز، وتُعيد المحاولة عند انتهاء مهلة الشبكة.

DART
import 'package:dio/dio.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

// Assume token and refreshToken are defined elsewhere
// String token = '...';
// Future<String> refreshToken() async { ... }

// Auth interceptor: auto-attach token + auto-refresh on 401
dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  },
  onError: (error, handler) async {
    if (error.response?.statusCode == 401) {
      token = await refreshToken();
      return handler.resolve(await dio.fetch(error.requestOptions));
    }
    handler.next(error);
  },
));
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: صفر أعطال متعلقة بالشبكة

بعد استخدام معترضات Dio، يحصل بوب على تحديث تلقائي لرمز 401، وإعادة محاولة 3 مرات عند انتهاء المهلة، وعرض موحد للأخطاء. ينخفض معدل الأعطال المتعلقة بالشبكة من 5% إلى 0.01%.


3. طلبات HTTP الأساسية

(1) عمليات CRUD بحزمة http

الطريقة فعل HTTP الغرض
http.get GET استرجاع الموارد
http.post POST إنشاء موارد
http.put PUT تحديث موارد
http.delete DELETE حذف موارد

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: طلبات أساسية بحزمة http

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

// ⚙️ تثبيت التبعية: flutter pub add http

// Custom class definition sources:
// - Product: see Lesson 5 json_serializable model in this lesson
// - ApiException: see Lesson 6 unified error handling in this lesson

// Assume token is defined elsewhere
// String token = '...';

class ProductApi {
  static const String baseUrl = 'https://api.shopapp.com/v1';

  Future<List<Product>> getProducts({int page = 1}) async {
    final response = await http.get(
      Uri.parse('$baseUrl/products?page=$page'),
      headers: {'Authorization': 'Bearer $token'},
    );
    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      return (data['items'] as List).map((j) => Product.fromJson(j)).toList();
    }
    throw ApiException(response.statusCode, response.body);
  }

  Future<Product> createProduct(Product product) async {
    final response = await http.post(
      Uri.parse('$baseUrl/products'),
      headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'},
      body: jsonEncode(product.toJson()),
    );
    if (response.statusCode == 201) {
      return Product.fromJson(jsonDecode(response.body));
    }
    throw ApiException(response.statusCode, response.body);
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. Dio بعمق

(1) مقارنة http مقابل Dio

الميزة http Dio
CRUD الأساسي
المعترضات
CancelToken
رفع FormData يدوي
إعادة محاولة المهلة يدوي
تسجيل الطلبات يدوي ✅ (dio_logger)
إعدادات عامة ✅ BaseOptions

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: إعدادات Dio الأساسية

DART
import 'package:dio/dio.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

// Custom class definition sources:
// - Product: see Lesson 5 json_serializable model in this lesson
// - Order/Cart: see Lesson 14 Phase 2 practice

final dio = Dio(BaseOptions(
  baseUrl: 'https://api.shopapp.com/v1',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 30),
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
));

// GET request
Future<List<Product>> getProducts({int page = 1}) async {
  final response = await dio.get('/products', queryParameters: {'page': page});
  return (response.data['items'] as List).map((j) => Product.fromJson(j)).toList();
}

// POST request
Future<Order> createOrder(Cart cart) async {
  final response = await dio.post('/orders', data: cart.toJson());
  return Order.fromJson(response.data);
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: معترض المصادقة + تحديث الرمز

DART
import 'package:dio/dio.dart';

// Custom class definition sources:
// - SecureStorage: see Lesson 13 Flutter Secure Storage
// - AuthService: see Lesson 14 Auth Notifier

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = SecureStorage.getToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      try {
        final newToken = await _refreshToken();
        SecureStorage.saveToken(newToken);
        // Retry original request with new token
        err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
        final response = await dio.fetch(err.requestOptions);
        return handler.resolve(response);
      } catch (_) {
        // Refresh failed, redirect to login
        AuthService.logout();
      }
    }
    handler.next(err);
  }

  Future<String> _refreshToken() async {
    final refreshToken = SecureStorage.getRefreshToken();
    final response = await Dio().post(
      'https://api.shopapp.com/v1/auth/refresh',
      data: {'refresh_token': refreshToken},
    );
    return response.data['access_token'];
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: إلغاء طلب CancelToken

DART
import 'package:dio/dio.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

CancelToken? _cancelToken;

void searchProducts(String query) {
  _cancelToken?.cancel('New search request');
  _cancelToken = CancelToken();
  dio.get('/products/search', queryParameters: {'q': query},
    cancelToken: _cancelToken);
}

// Cancel on dispose
@override
void dispose() {
  _cancelToken?.cancel();
  super.dispose();
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. تسلسل JSON

(1) مقارنة طرق التسلسل

الطريقة المزايا العيوب حالة الاستخدام
fromJson/toJson يدوي بدون تبعيات، تحكم كامل متكرر، عُرضة للأخطاء نماذج بسيطة قليلة
json_serializable آمن الأنواع، توليد تلقائي يحتاج build_runner نماذج كثيرة
freezed ثابت + copyWith + اتحاد تبعيات أكثر نماذج مجال معقدة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: نموذج json_serializable

DART
import 'package:json_annotation/json_annotation.dart';

// ⚙️ تثبيت التبعية: flutter pub add json_annotation
// ⚙️ تبعية تطوير: flutter pub add --dev json_serializable build_runner

part 'product.g.dart';

@JsonSerializable()
class Product {
  final int id;
  final String name;
  @JsonKey(name: 'unit_price')
  final double price;
  @JsonKey(name: 'image_url')
  final String? imageUrl;
  final double rating;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    this.imageUrl,
    this.rating = 0.0,
  });

  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
  Map<String, dynamic> toJson() => _$ProductToJson(this);
}

// Run: dart run build_runner build --delete-conflicting-outputs
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. معالجة الأخطاء الموحدة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: ApiException + معترض الأخطاء

DART
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

class ApiException implements Exception {
  final int? statusCode;
  final String message;
  final String? errorCode;

  ApiException(this.statusCode, this.message, {this.errorCode});

  factory ApiException.fromDioError(DioException error) {
    switch (error.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return ApiException(null, 'Connection timeout. Please try again.');
      case DioExceptionType.connectionError:
        return ApiException(null, 'No internet connection.');
      case DioExceptionType.badResponse:
        final status = error.response?.statusCode;
        final msg = error.response?.data['message'] ?? 'Server error';
        if (status == 401) return ApiException(401, 'Session expired. Please login again.');
        if (status == 403) return ApiException(403, 'Access denied.');
        if (status == 404) return ApiException(404, 'Resource not found.');
        if (status == 422) return ApiException(422, msg, errorCode: error.response?.data['code']);
        if (status != null && status >= 500) return ApiException(status, 'Server error. Please try later.');
        return ApiException(status, msg);
      default:
        return ApiException(null, 'Unexpected error.');
    }
  }
}

// Usage in UI
try {
  final products = await productRepo.getProducts();
} on ApiException catch (e) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(e.message)),
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. مثال كامل: مستودع Dio لـ ShopApp

DART
import 'package:dio/dio.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

// Custom class definition sources:
// - Product: see Lesson 5 json_serializable model in this lesson
// - CartItem/Address/Order: see Lesson 14 Phase 2 practice
// - AuthInterceptor: see Lesson 4 Auth interceptor in this lesson

class ProductRepository {
  final Dio _dio;

  ProductRepository(this._dio);

  Future<List<Product>> getProducts({
    int page = 1,
    int limit = 20,
    String? category,
    String? search,
  }) async {
    final params = {'page': page, 'limit': limit};
    if (category != null) params['category'] = category;
    if (search != null) params['q'] = search;

    final response = await _dio.get('/products', queryParameters: params);
    return (response.data['items'] as List)
        .map((j) => Product.fromJson(j))
        .toList();
  }

  Future<Product> getProductById(int id) async {
    final response = await _dio.get('/products/$id');
    return Product.fromJson(response.data);
  }

  Future<Order> createOrder(List<CartItem> items, Address address) async {
    final response = await _dio.post('/orders', data: {
      'items': items.map((i) => {'product_id': i.product.id, 'quantity': i.quantity}).toList(),
      'shipping_address': address.toJson(),
    });
    return Order.fromJson(response.data);
  }

  Future<String> uploadProductImage(String filePath) async {
    final formData = FormData.fromMap({
      'image': await MultipartFile.fromFile(filePath),
    });
    final response = await _dio.post('/upload', data: formData);
    return response.data['url'];
  }
}

// Dio singleton setup
final dio = Dio(BaseOptions(
  baseUrl: 'https://api.shopapp.com/v1',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 30),
))
  ..interceptors.add(AuthInterceptor())
  ..interceptors.add(LogInterceptor(requestBody: true, responseBody: true));

final productRepo = ProductRepository(dio);
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س كيف أختار بين Dio وحزمة http؟
ج حزمة http كافية للمشاريع الصغيرة؛ اختر Dio عند الحاجة لمعترضات، إلغاء طلبات، إعادة محاولة، رفع FormData، إلخ.
س build_runner بطيء كل مرة؟
ج أثناء التطوير، استخدم dart run build_runner watch --delete-conflicting-outputs لمراقبة تغييرات الملفات والتوليد التلقائي المستمر.
س عدة طلبات تُصيب 401 في وقت واحد أثناء تحديث الرمز؟
ج أضف آلية قفل: أول 401 يُشغّل التحديث، وبقية طلبات 401 تنتظر حتى يكتمل التحديث. استخدم Completer لتنفيذ ذلك.
س هل رفع FormData لملفات كبيرة يُسبب OOM؟
ج MultipartFile.fromFile يُحمّل بكسل ولا يقرأ الملف بالكامل في الذاكرة دفعة واحدة. للملفات الكبيرة، فكر في الرفع المقسم.
س كيف أُنقح طلبات الشبكة أثناء التطوير؟
ج أضف LogInterceptor لطباعة الطلبات/الاستجابات، أو استخدم Charles/Proxyman لالتقاط الحزم. Dio يدعم تعيين وسيط.
س يفشل التحقق من شهادة SSL؟
ج في التطوير، استخدم HttpClientAdapter لتخطي التحقق؛ في الإنتاج، يجب التحقق من الشهادات.

📖 ملخص


📝 تمارين

  1. أساسي (⭐): استخدم حزمة http لتنفيذ طلب GET، جلب قائمة منتجات، وطباعة JSON.
  2. متوسط (⭐⭐): غلّف مستودعًا بـ Dio، أضف معترض مصادقة لإرفاق Bearer Token تلقائيًا، وتحديث تلقائي عند 401.
  3. متقدم (⭐⭐⭐): نفّذ طبقة شبكات كاملة: Dio + معترض مصادقة + معترض سجل + CancelToken لـ debounce البحث + ApiException لمعالجة أخطاء موحدة + نماذج json_serializable.

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%