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

تخزين الاستجابات مؤقتاً للقراءة دون اتصال

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

تخزين الاستجابات مؤقتاً للقراءة دون اتصال

التطبيق الاحترافي المبني بـ Flutter لا يعرض شاشة فارغة أو مؤشر تحميل في كل مرة يُشغَّل فيها. بدلاً من ذلك، يعرض فوراً آخر بيانات معروفة من ذاكرة التخزين المؤقت المحلية، ثم يجلب البيانات الجديدة في الخلفية — وهو نمط يُعرف بـ stale-while-revalidate. يعلمك هذا الدرس كيفية تطبيق هذا النمط باستخدام shared_preferences (لحمولات JSON الصغيرة) أو Hive (للبيانات الأكبر والأكثر تنظيماً)، حتى يتمكن المستخدمون من قراءة المحتوى حتى عندما يكون الجهاز غير متصل بالإنترنت.

لماذا يهم التخزين المؤقت؟

بدون التخزين المؤقت، كل تشغيل للتطبيق يستلزم رحلة شبكة قبل ظهور أي شيء مفيد. يؤدي ذلك إلى ثلاث نقاط ألم شائعة:

  • بطء بدء التشغيل المُتصوَّر — يحدق المستخدمون في مؤشرات التحميل حتى على الاتصالات السريعة.
  • غياب دعم وضع عدم الاتصال — يُظهر خطأ الشبكة شاشة فارغة بدلاً من آخر محتوى تم جلبه.
  • إهدار النطاق الترددي — تُعاد تنزيل البيانات غير المتغيرة في كل تشغيل.

يعالج التخزين المؤقت الثلاثة من خلال حفظ استجابات API محلياً وقراءتها فوراً عند بدء التشغيل.

استراتيجية Stale-While-Revalidate

تتبع استراتيجية التخزين المؤقت الموصى بها لمعظم الشاشات كثيفة القراءة ثلاث خطوات:

  • 1. اقرأ التخزين المؤقت فوراً — عند تشغيل التطبيق، حمِّل ما تم تخزينه من آخر استدعاء شبكة ناجح واعرضه مباشرة.
  • 2. اجلب البيانات الجديدة في الخلفية — أرسل طلب الشبكة دون حجب واجهة المستخدم.
  • 3. حدِّث التخزين المؤقت وواجهة المستخدم — عندما تصل الاستجابة، اكتب فوق التخزين المؤقت وأعد بناء الودجت بالبيانات الجديدة.

إذا فشل طلب الشبكة (لا اتصال، خطأ في الخادم)، لا يزال المستخدم يرى البيانات المخزنة مؤقتاً بدلاً من شاشة خطأ.

التخزين المؤقت باستخدام shared_preferences

يخزن shared_preferences أزواج مفتاح-قيمة وهو مثالي لتخزين سلاسل JSON الصغيرة مؤقتاً (بضعة كيلوبايت). أضفه إلى pubspec.yaml:

تبعية pubspec.yaml

dependencies:
  shared_preferences: ^2.2.3
  http: ^1.2.1

النمط التالي يغلف منطق التخزين المؤقت في فئة خدمة للحفاظ على نظافة كود واجهة المستخدم:

ApiCacheService باستخدام shared_preferences

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

class ApiCacheService {
  static const String _postsKey = 'cached_posts';
  static const String _postsTsKey = 'cached_posts_ts';

  /// يعيد القائمة المخزنة مؤقتاً إن وُجدت، وإلا null.
  Future<List<Map<String, dynamic>>>? loadCache() async {
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getString(_postsKey);
    if (raw == null) return null;
    final List<dynamic> decoded = jsonDecode(raw);
    return decoded.cast<Map<String, dynamic>>();
  }

  /// يجلب من الشبكة ويكتب فوق التخزين المؤقت عند النجاح.
  Future<List<Map<String, dynamic>>> fetchAndCache() async {
    final response = await http.get(
      Uri.parse('https://jsonplaceholder.typicode.com/posts'),
    );
    if (response.statusCode != 200) {
      throw Exception('HTTP ${response.statusCode}');
    }
    final List<dynamic> data = jsonDecode(response.body);
    final posts = data.cast<Map<String, dynamic>>();

    // حفظ في التخزين المؤقت مع طابع زمني
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_postsKey, response.body);
    await prefs.setInt(
      _postsTsKey,
      DateTime.now().millisecondsSinceEpoch,
    );
    return posts;
  }

  /// يعيد عمر التخزين المؤقت، أو null إن لم يوجد تخزين.
  Future<Duration?> cacheAge() async {
    final prefs = await SharedPreferences.getInstance();
    final ts = prefs.getInt(_postsTsKey);
    if (ts == null) return null;
    return DateTime.now().difference(
      DateTime.fromMillisecondsSinceEpoch(ts),
    );
  }
}

ربط التخزين المؤقت بالودجت

يستدعي الودجت loadCache() أثناء initState لعرض البيانات فوراً، ثم يطلق fetchAndCache() للتحديث في الخلفية:

PostsScreen مع نمط stale-while-revalidate

import 'package:flutter/material.dart';

class PostsScreen extends StatefulWidget {
  const PostsScreen({super.key});

  @override
  State<PostsScreen> createState() => _PostsScreenState();
}

class _PostsScreenState extends State<PostsScreen> {
  final ApiCacheService _cache = ApiCacheService();
  List<Map<String, dynamic>> _posts = [];
  bool _isRefreshing = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  Future<void> _loadData() async {
    // الخطوة 1 — عرض التخزين المؤقت فوراً (بدون مؤشر تحميل)
    final cached = await _cache.loadCache();
    if (cached != null && mounted) {
      setState(() => _posts = cached);
    }

    // الخطوة 2 — جلب البيانات الجديدة في الخلفية
    setState(() => _isRefreshing = true);
    try {
      final fresh = await _cache.fetchAndCache();
      if (mounted) setState(() => _posts = fresh);
    } catch (e) {
      // فشل الشبكة — التخزين المؤقت القديم لا يزال مرئياً
      if (mounted) setState(() => _error = 'غير متصل — يتم عرض البيانات المخزنة');
    } finally {
      if (mounted) setState(() => _isRefreshing = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('المنشورات'),
        actions: [
          if (_isRefreshing)
            const Padding(
              padding: EdgeInsets.all(16),
              child: SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(strokeWidth: 2),
              ),
            ),
        ],
      ),
      body: Column(
        children: [
          if (_error != null)
            MaterialBanner(
              content: Text(_error!),
              actions: [
                TextButton(
                  onPressed: () => setState(() => _error = null),
                  child: const Text('تجاهل'),
                ),
              ],
            ),
          Expanded(
            child: _posts.isEmpty
                ? const Center(child: CircularProgressIndicator())
                : ListView.builder(
                    itemCount: _posts.length,
                    itemBuilder: (context, index) {
                      final post = _posts[index];
                      return ListTile(
                        title: Text(post['title'] as String),
                        subtitle: Text(post['body'] as String),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}
ملاحظة: تحقق دائماً من mounted قبل استدعاء setState() داخل دالة غير متزامنة. إذا تنقل المستخدم بعيداً أثناء تنفيذ طلب الشبكة، يُتلف الودجت واستدعاء setState() عليه يُلقي خطأ.

التخزين المؤقت باستخدام Hive

Hive قاعدة بيانات NoSQL خفيفة الوزن وسريعة تخزن الكائنات المحددة الأنواع. تُعد الخيار الأفضل عند تخزين قوائم كبيرة أو بيانات ثنائية أو عندما تحتاج قراءات أسرع من فك ترميز JSON. أضف التبعيات:

إعداد Hive في pubspec.yaml

dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.0

dev_dependencies:
  hive_generator: ^2.0.1
  build_runner: ^2.4.9

افتح صندوق Hive في main() قبل runApp()، ثم اقرأه واكتب فيه مثل خريطة محددة الأنواع:

تخزين Hive المؤقت في main.dart والخدمة

import 'package:hive_flutter/hive_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  await Hive.openBox<String>('apiCache');
  runApp(const MyApp());
}

// في الخدمة:
class HiveCacheService {
  Box<String> get _box => Hive.box<String>('apiCache');

  List<Map<String, dynamic>>? loadCache(String key) {
    final raw = _box.get(key);
    if (raw == null) return null;
    return (jsonDecode(raw) as List)
        .cast<Map<String, dynamic>>();
  }

  Future<void> saveCache(String key, String jsonString) async {
    await _box.put(key, jsonString);
  }
}

إلغاء صلاحية التخزين المؤقت وTTL

تصبح البيانات المخزنة مؤقتاً قديمة. يفرض TTL (وقت الحياة) تحديثاً كاملاً بعد فترة قابلة للتهيئة، مما يمنع المستخدمين من رؤية بيانات قديمة إلى أجل غير مسمى:

  • خزِّن طابعاً زمنياً مع الحمولة المخزنة مؤقتاً.
  • عند بدء التشغيل، قارن DateTime.now() بالطابع الزمني المخزن.
  • إذا تجاوز العمر TTL، تخطَّ قراءة التخزين المؤقت وأجبر على جلب جديد.
  • TTL نموذجية: تغذيات الأخبار (5 دقائق)، كتالوجات المنتجات (ساعة)، الإعدادات الثابتة (24 ساعة).
نصيحة: استخدم shared_preferences لحمولات JSON الصغيرة (ملف تعريف المستخدم، الإعدادات، عدد قليل من العناصر). انتقل إلى Hive أو sqflite عند تخزين أكثر من بضع مئات من الصفوف أو عندما تحتاج إلى الاستعلام عن التخزين المؤقت حسب الحقل بدلاً من مفتاح ثابت.
تحذير: لا تخزن مؤقتاً أبداً البيانات الحساسة (كلمات المرور، رموز المصادقة، المعلومات الصحية الشخصية) في shared_preferences — يتم تخزينها كنص عادي على الجهاز. استخدم flutter_secure_storage للأسرار.

ملخص

تخزين استجابات API مؤقتاً هو أحد أعلى تحسينات تجربة المستخدم تأثيراً في تطبيق Flutter. الأفكار الرئيسية من هذا الدرس هي:

  • استخدم نمط stale-while-revalidate: اعرض التخزين المؤقت فوراً، حدِّث في الخلفية.
  • shared_preferences كافٍ لحمولات JSON الصغيرة؛ استخدم Hive للبيانات الأكبر أو الأكثر تنظيماً.
  • خزِّن طابعاً زمنياً مع كل حمولة مخزنة مؤقتاً ونفِّذ سياسة TTL.
  • تعامل دائماً مع حالة فشل الشبكة — بياناتك المخزنة مؤقتاً هي آخر خط دفاع ضد الشاشة الفارغة.
  • تحقق من mounted قبل كل استدعاء setState() داخل الدوال غير المتزامنة.