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

التصفح بالصفحات: واجهات برمجة التطبيقات بالمؤشر والإزاحة

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

التصفح بالصفحات: واجهات برمجة التطبيقات بالمؤشر والإزاحة

عند العمل مع مجموعات بيانات كبيرة من واجهة REST API، من غير العملي تحميل جميع السجلات في طلب واحد. يقسم التصفح بالصفحات البيانات إلى صفحات ويتيح للعميل طلب صفحة واحدة في كل مرة. تطبيقات Flutter تُنفّذ عادةً نمط التمرير اللانهائي: يتمرر المستخدم في ListView وتُحمَّل العناصر الجديدة تلقائياً عند الاقتراب من الأسفل. يغطي هذا الدرس استراتيجيتي التصفح السائدتين — الإزاحة/رقم الصفحة والمؤشر (Cursor) — وكيفية ربطهما بودجت Flutter.

التصفح بالإزاحة / رقم الصفحة

مع تصفح الإزاحة، تقبل الـ API معامل page (أو offset) مع قيمة limit/per_page. يُعيد الخادم شريحة ثابتة من النتائج. يمكن اكتشاف نهاية البيانات عندما تكون القائمة المُعادة أقصر من حجم الصفحة المطلوب، أو عندما تُشير الـ API صراحةً إلى "has_more": false.

  • الطلب: GET /posts?page=2&per_page=20
  • شكل الاستجابة: { "data": [...], "total": 200, "page": 2, "last_page": 10 }
  • نهاية البيانات: page >= last_page أو طول القائمة المُعادة < per_page

تصفح الإزاحة — نموذج Dart والخدمة

// النموذج
class Post {
  final int id;
  final String title;
  Post({required this.id, required this.title});

  factory Post.fromJson(Map<String, dynamic> json) =>
      Post(id: json['id'] as int, title: json['title'] as String);
}

// الخدمة
class PostService {
  static const _baseUrl = 'https://api.example.com';
  static const _perPage = 20;
  final http.Client _client;

  PostService(this._client);

  /// تُعيد (posts, hasMore).
  Future<(List<Post>, bool)> fetchPage(int page) async {
    final uri = Uri.parse('$_baseUrl/posts')
        .replace(queryParameters: {
          'page': page.toString(),
          'per_page': _perPage.toString(),
        });

    final response = await _client.get(uri);
    if (response.statusCode != 200) {
      throw Exception('Failed to load posts: ${response.statusCode}');
    }

    final body = jsonDecode(response.body) as Map<String, dynamic>;
    final data = (body['data'] as List)
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();

    final lastPage = body['last_page'] as int;
    return (data, page < lastPage);
  }
}

التصفح بالمؤشر (Cursor)

يستخدم تصفح المؤشر رمزاً غير شفاف (غالباً معرّف أو طابع زمني لآخر عنصر مُعاد) لتحديد موضعك في مجموعة البيانات. تُعيد الـ API حقل next_cursor؛ وعندما يكون null فلا توجد صفحات أخرى. هذا النهج مستقر في ظل عمليات الإدراج والحذف، مما يجعله الاستراتيجية المفضلة للتغذيات الحية.

  • الطلب الأول: GET /posts?limit=20
  • الطلبات اللاحقة: GET /posts?limit=20&cursor=eyJpZCI6NDB9
  • نهاية البيانات: next_cursor يساوي null في الاستجابة

تصفح المؤشر — الخدمة وودجت StatefulWidget

// طريقة الخدمة لتصفح المؤشر
Future<(List<Post>, String?)> fetchCursor(String? cursor) async {
  final params = <String, String>{'limit': '20'};
  if (cursor != null) params['cursor'] = cursor;

  final uri = Uri.parse('$_baseUrl/posts').replace(queryParameters: params);
  final response = await _client.get(uri);
  if (response.statusCode != 200) {
    throw Exception('Failed to load: ${response.statusCode}');
  }

  final body = jsonDecode(response.body) as Map<String, dynamic>;
  final posts = (body['data'] as List)
      .map((e) => Post.fromJson(e as Map<String, dynamic>))
      .toList();

  final nextCursor = body['next_cursor'] as String?; // null = نهاية البيانات
  return (posts, nextCursor);
}

// --- ربط StatefulWidget ---
class PostListPage extends StatefulWidget {
  const PostListPage({super.key});

  @override
  State<PostListPage> createState() => _PostListPageState();
}

class _PostListPageState extends State<PostListPage> {
  final _service = PostService(http.Client());
  final _posts = <Post>[];
  final _scrollController = ScrollController();

  String? _nextCursor; // null يعني "أول جلب" أو "لا مزيد من البيانات"
  bool _hasMore = true;
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _fetchNext();
    _scrollController.addListener(_onScroll);
  }

  void _onScroll() {
    final pos = _scrollController.position;
    // تشغيل الجلب عند الاقتراب بمقدار 200 بكسل من الأسفل
    if (pos.pixels >= pos.maxScrollExtent - 200 && !_isLoading && _hasMore) {
      _fetchNext();
    }
  }

  Future<void> _fetchNext() async {
    setState(() => _isLoading = true);
    try {
      final (newPosts, cursor) = await _service.fetchCursor(_nextCursor);
      setState(() {
        _posts.addAll(newPosts);
        _nextCursor = cursor;
        _hasMore = cursor != null;
      });
    } catch (e) {
      // معالجة الخطأ
    } finally {
      setState(() => _isLoading = false);
    }
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('المنشورات')),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: _posts.length + (_hasMore ? 1 : 0),
        itemBuilder: (context, index) {
          if (index == _posts.length) {
            return const Center(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: CircularProgressIndicator(),
              ),
            );
          }
          return ListTile(
            leading: Text('${_posts[index].id}'),
            title: Text(_posts[index].title),
          );
        },
      ),
    );
  }
}

اكتشاف نهاية البيانات

التوقف الصحيح عن إرسال المزيد من الطلبات يمنع استدعاءات الشبكة غير الضرورية وتكرار عرض المؤشر الدوار:

  • الإزاحة: اضبط _hasMore = false عندما يكون page >= last_page أو عندما تكون القائمة المُعادة فارغة.
  • المؤشر: اضبط _hasMore = false عندما يكون next_cursor يساوي null.
  • احرص دائماً على الحماية باستخدام if (_isLoading || !_hasMore) return; لمنع الطلبات المتزامنة.
ملاحظة: بالنسبة لتصفح الإزاحة، يجب زيادة عداد _page فقط بعد استجابة ناجحة، وليس قبل الطلب. الزيادة المبكرة تسبب فجوات عند فشل الطلب وإعادة المحاولة.

اكتشاف التمرير باستخدام ScrollController

أرفق ScrollController بـ ListView واستمع إلى position الخاصة به. نقطة التشغيل المعيارية هي عندما يكون pixels >= maxScrollExtent - threshold. عتبة من 200 إلى 400 بكسل توفر مخزناً مريحاً للتحميل المسبق دون الجلب المبكر.

نصيحة: احرص دائماً على استدعاء _scrollController.dispose() في طريقة dispose() للودجت لمنع تسربات الذاكرة. نسيان ذلك مصدر شائع جداً للأخطاء الدقيقة في تطبيقات الإنتاج.
تحذير: لا تستدعِ setState داخل مستمع التمرير بشكل متزامن عند كل تغيير في البكسل. احمِ التشغيل خلف علامتَي !_isLoading && _hasMore حتى لا تطلق أكثر من طلب واحد متزامن في كل مرة.

الاختيار بين الاستراتيجيتين

  • استخدم تصفح الإزاحة للجداول الإدارية ونتائج البحث أو أي مجموعة بيانات لا تتغير أثناء التصفح (رياضيات أبسط، سهولة “القفز إلى صفحة N”).
  • استخدم تصفح المؤشر للتغذيات الاجتماعية وسجلات النشاط أو البيانات الحية حيث تُدرَج الصفوف أو تُحذف باستمرار (لا عناصر مكررة أو مفقودة بسبب تحول الإزاحات).

ملخص

التصفح بالصفحات ضروري لأي تطبيق Flutter يستهلك واجهات API للقوائم. تتبع كلتا الاستراتيجيتين — الإزاحة والمؤشر — نفس نمط Flutter: حافظ على ScrollController، واكتشف الاقتراب من الأسفل، واضبط حارساً _isLoading، وأضف النتائج إلى القائمة الموجودة، واعرض مؤشر التحميل كآخر عنصر طالما تتوفر بيانات إضافية. الاختلاف الرئيسي هو كيفية طلب الصفحة التالية — رقم صفحة مقابل رمز مؤشر غير شفاف — وكيفية اكتشاف نهاية مجموعة البيانات.