الشبكات وتكامل REST API

اكتشاف الاتصال وبنية العمل دون اتصال أولاً

16 دقيقة الدرس 12 من 13

اكتشاف الاتصال وبنية العمل دون اتصال أولاً

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

تمنحك حزمة connectivity_plus في Flutter تدفقاً (stream) لقيم ConnectivityResult في الوقت الفعلي حتى تتمكن من الاستجابة فوراً لتحولات حالة الشبكة — دون الحاجة إلى استطلاع دوري.

ملاحظة: تُخبرك ConnectivityResult بنوع واجهة الشبكة المتاحة (واي فاي، بيانات الجوال، إيثرنت، لا شيء). لكنها لا تضمن إمكانية الوصول إلى الخادم الذي تحتاجه. اجمع دائماً اكتشاف الاتصال مع معالجة أخطاء الطلبات الفعلية لتحقيق منطق عمل دون اتصال قوي.

إضافة التبعية

أضف connectivity_plus إلى pubspec.yaml، ثم أضف طابور محلي باستخدام sqflite (SQLite لـ Flutter) أو ملف JSON بسيط عبر path_provider:

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  connectivity_plus: ^6.0.3
  sqflite: ^2.3.3
  path_provider: ^2.1.3

الاستماع إلى تغييرات الاتصال

تُوفر فئة Connectivity واجهتَي برمجة تطبيقين:

  • checkConnectivity() — استعلام أحادي يعيد Future بالحالة الحالية.
  • onConnectivityChangedStream<List<ConnectivityResult>> يبثّ قيمة في كل مرة تتغير فيها حالة الشبكة.

أفضل نمط هو الفحص مرة واحدة عند البدء، ثم الاشتراك في التدفق للتغييرات المستمرة. ألغِ الاشتراك في dispose() لمنع تسريب الذاكرة.

ConnectivityService — مفرد (singleton)

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';

class ConnectivityService {
  ConnectivityService._();
  static final ConnectivityService instance = ConnectivityService._();

  final Connectivity _connectivity = Connectivity();
  final StreamController<bool> _controller =
      StreamController<bool>.broadcast();

  StreamSubscription<List<ConnectivityResult>>? _subscription;

  /// يعرض تدفق bool بسيط: true = متصل، false = غير متصل.
  Stream<bool> get onStatusChanged => _controller.stream;

  bool _isOnline = false;
  bool get isOnline => _isOnline;

  Future<void> init() async {
    // فحص واحد عند البدء
    final results = await _connectivity.checkConnectivity();
    _updateStatus(results);

    // الاشتراك في التغييرات المستقبلية
    _subscription = _connectivity.onConnectivityChanged.listen(
      _updateStatus,
    );
  }

  void _updateStatus(List<ConnectivityResult> results) {
    final online = results.any(
      (r) => r != ConnectivityResult.none,
    );
    if (online != _isOnline) {
      _isOnline = online;
      _controller.add(_isOnline);
    }
  }

  void dispose() {
    _subscription?.cancel();
    _controller.close();
  }
}

نمط الطابور دون اتصال

عمليات الكتابة (POST, PUT, DELETE) التي تفشل عند انقطاع الاتصال يجب ألا تُهمَل بصمت. بدلاً من ذلك، احتفظ بها في طابور عمليات معلقة محلي. عند استعادة الاتصال، استنزف الطابور بالترتيب.

يجب أن تخزّن كل عملية في الطابور ما يلي:

  • طريقة HTTP ومسار نقطة النهاية النسبية.
  • جسم الطلب مُشفَّراً بصيغة JSON.
  • طابع زمني وعدد إعادة المحاولات الاختياري.
  • معرِّف مُولَّد من جانب العميل لضمان اتساق التكرار في حالة إعادة التنفيذ.

نموذج PendingOperation + OfflineQueue (عرض توضيحي في الذاكرة)

import 'dart:convert';
import 'package:uuid/uuid.dart';

class PendingOperation {
  final String id;
  final String method;   // 'POST', 'PUT', 'DELETE'
  final String path;
  final Map<String, dynamic> body;
  final DateTime createdAt;
  int retries;

  PendingOperation({
    String? id,
    required this.method,
    required this.path,
    required this.body,
    DateTime? createdAt,
    this.retries = 0,
  })  : id = id ?? const Uuid().v4(),
        createdAt = createdAt ?? DateTime.now();

  Map<String, dynamic> toJson() => {
        'id': id,
        'method': method,
        'path': path,
        'body': body,
        'createdAt': createdAt.toIso8601String(),
        'retries': retries,
      };

  factory PendingOperation.fromJson(Map<String, dynamic> json) =>
      PendingOperation(
        id: json['id'] as String,
        method: json['method'] as String,
        path: json['path'] as String,
        body: Map<String, dynamic>.from(json['body'] as Map),
        createdAt: DateTime.parse(json['createdAt'] as String),
        retries: json['retries'] as int? ?? 0,
      );
}

/// طابور بسيط في الذاكرة؛ استبدله بـ SQLite في الإنتاج.
class OfflineQueue {
  final List<PendingOperation> _ops = [];

  void enqueue(PendingOperation op) => _ops.add(op);

  List<PendingOperation> drain() {
    final copy = List<PendingOperation>.from(_ops);
    _ops.clear();
    return copy;
  }

  bool get isEmpty => _ops.isEmpty;
  int get length => _ops.length;
}

المزامنة التلقائية عند إعادة الاتصال

اربط تدفق ConnectivityService بمعالج المزامنة. عندما يعود الجهاز إلى الاتصال بالإنترنت، استدعِ واجهة برمجة التطبيقات لكل عملية في الطابور. استخدم التراجع الأسي وحداً أقصى لعدد إعادة المحاولات لتجنب إرهاق اتصال متقطع.

SyncManager — يستنزف الطابور عند الاتصال

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

class SyncManager {
  final OfflineQueue _queue;
  final ConnectivityService _connectivity;
  StreamSubscription<bool>? _sub;

  static const String _baseUrl = 'https://api.example.com';
  static const int _maxRetries = 3;

  SyncManager(this._queue, this._connectivity);

  void start() {
    _sub = _connectivity.onStatusChanged.listen((isOnline) {
      if (isOnline) _syncPending();
    });
  }

  Future<void> _syncPending() async {
    if (_queue.isEmpty) return;

    final ops = _queue.drain();
    for (final op in ops) {
      final success = await _replay(op);
      if (!success && op.retries < _maxRetries) {
        op.retries++;
        _queue.enqueue(op); // إعادة التخزين للمحاولة التالية
      }
    }
  }

  Future<bool> _replay(PendingOperation op) async {
    try {
      final uri = Uri.parse('$_baseUrl${op.path}');
      final headers = {'Content-Type': 'application/json'};
      final encoded = jsonEncode(op.body);
      http.Response response;

      switch (op.method) {
        case 'POST':
          response = await http.post(uri, headers: headers, body: encoded);
        case 'PUT':
          response = await http.put(uri, headers: headers, body: encoded);
        case 'DELETE':
          response = await http.delete(uri, headers: headers, body: encoded);
        default:
          return false;
      }
      return response.statusCode >= 200 && response.statusCode < 300;
    } catch (_) {
      return false;
    }
  }

  void stop() => _sub?.cancel();
}

عرض حالة عدم الاتصال في واجهة المستخدم

لفّ جسم الـ scaffold بـ StreamBuilder (أو ValueListenableBuilder إذا كنت تعرّض ValueNotifier) لعرض شريط إشعار مستمر كلما كان الجهاز غير متصل بالإنترنت. يمنح ذلك المستخدمين ردود فعل بصرية فورية دون تعطيل عملهم.

نصيحة: اعرض شريطاً غير مانع للتفاعل (مثل MaterialBanner أو شريط ملوّن أسفل شريط التطبيق) بدلاً من مربع حوار مانع. يجب أن يتمكن المستخدمون من قراءة المحتوى المخزّن مؤقتاً وإنشاء إدخالات جديدة أثناء وضع عدم الاتصال.

أذونات المنصة

على Android، أضف ما يلي إلى AndroidManifest.xml داخل وسم <manifest>:

إذن AndroidManifest.xml

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

لا يلزم أي تفويض خاص على iOS — تتعامل إضافة connectivity_plus مع استدعاءات إطار Network تلقائياً.

تحذير: لا تخزّن بيانات اعتماد حساسة (رموز API، كلمات مرور) في ملف JSON المستمر لطابور عمليات عدم الاتصال. إذا كان الطابور مخزناً على القرص، فقم بتشفيره أو خزّن الحد الأدنى فقط من البيانات اللازمة لإعادة بناء الطلب وقت المزامنة.

ملخص

يعتمد تطبيق Flutter الذي يعمل دون اتصال أولاً على ثلاثة مكونات: اكتشاف الاتصال (عبر تدفقات connectivity_plusطابور عمليات معلقة محلي (مدعوم بـ SQLite أو ملف)، ومدير مزامنة تلقائي يستنزف الطابور عند إعادة اتصال الجهاز. تضمن هذه المكونات مجتمعةً عدم ضياع عمليات الكتابة بصمت وتجربة سلسة للمستخدمين بغض النظر عن جودة الشبكة.