تكامل Firebase

مزامنة البيانات في الوقت الفعلي مع تدفقات Firestore

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

مزامنة البيانات في الوقت الفعلي مع تدفقات Firestore

يوفر Cloud Firestore آلية قوية تُسمى تدفقات اللقطات (snapshots streams) تُمكّن تطبيق Flutter من استقبال التحديثات المباشرة في أي وقت تتغير فيه البيانات في قاعدة البيانات. بدلاً من إجراء استعلام لمرة واحدة، تقوم بـالاشتراك في تدفق وتقوم Firestore بدفع كل تغيير — إنشاء وتعديل وحذف — مباشرةً إلى الودجت الخاص بك في الوقت الفعلي. هذا يلغي الحاجة إلى الاستطلاع اليدوي ويُبقي واجهة المستخدم دائماً متزامنة مع الخادم.

ملاحظة: تستخدم تدفقات Firestore بروتوكول WebSocket داخلياً. عند استدعاء snapshots() على مرجع استعلام أو مستند، يفتح SDK اتصالاً دائماً يُرسل أحداث التغيير حتى تُلغي الاشتراك. على الأجهزة المحمولة، يخزن SDK الأحداث مؤقتاً لدعم العمل بدون اتصال، ثم يستأنف التدفق عند استعادة الاتصال.

الاشتراك في تدفق مستند

استخدم DocumentReference.snapshots() للاستماع إلى مستند واحد. يُصدر التدفق DocumentSnapshot في كل مرة يُكتب فيها ذلك المستند، حتى لو تغير حقل واحد فقط.

تدفق لقطات المستند

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

class UserProfileWidget extends StatelessWidget {
  final String userId;
  const UserProfileWidget({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    // snapshots() تُعيد Stream<DocumentSnapshot>
    final Stream<DocumentSnapshot<Map<String, dynamic>>> stream =
        FirebaseFirestore.instance
            .collection('users')
            .doc(userId)
            .snapshots();

    return StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
      stream: stream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }
        if (snapshot.hasError) {
          return Text('خطأ: ${snapshot.error}');
        }
        if (!snapshot.hasData || !snapshot.data!.exists) {
          return const Text('المستخدم غير موجود.');
        }

        final data = snapshot.data!.data()!;
        return ListTile(
          title: Text(data['name'] ?? 'بدون اسم'),
          subtitle: Text(data['email'] ?? ''),
        );
      },
    );
  }
}

الاشتراك في تدفق مجموعة

استخدم CollectionReference.snapshots() أو Query.snapshots() للاستماع إلى مستندات متعددة في آنٍ واحد. يُصدر التدفق QuerySnapshot يحمل مجموعة النتائج الكاملة إضافةً إلى قائمة بكائنات DocumentChange تصف بالضبط ما أُضيف أو عُدِّل أو حُذف منذ آخر حدث.

تدفق لقطات المجموعة مع التصفية

class MessagesWidget extends StatelessWidget {
  final String chatId;
  const MessagesWidget({super.key, required this.chatId});

  @override
  Widget build(BuildContext context) {
    // استعلام مُرتَّب — يُعيد Stream<QuerySnapshot>
    final stream = FirebaseFirestore.instance
        .collection('chats')
        .doc(chatId)
        .collection('messages')
        .orderBy('sentAt', descending: true)
        .limit(50)
        .snapshots();

    return StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
      stream: stream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('خطأ: ${snapshot.error}'));
        }

        final docs = snapshot.data?.docs ?? [];

        if (docs.isEmpty) {
          return const Center(child: Text('لا توجد رسائل بعد.'));
        }

        return ListView.builder(
          reverse: true,
          itemCount: docs.length,
          itemBuilder: (context, index) {
            final msg = docs[index].data();
            return ListTile(
              title: Text(msg['text'] ?? ''),
              subtitle: Text(msg['senderName'] ?? 'مجهول'),
            );
          },
        );
      },
    );
  }
}

حالات اتصال StreamBuilder

يُعيد بناء ودجت StreamBuilder في كل مرة تصل فيها لقطة جديدة. تستقبل دالة builder كائن AsyncSnapshot يكشف عن أربع حالات اتصال يجب التعامل معها لبناء واجهة مستخدم متينة:

  • ConnectionState.none — لم يُقدَّم تدفق بعد.
  • ConnectionState.waiting — فُتح التدفق لكن الحدث الأول لم يصل بعد؛ اعرض مؤشر تحميل.
  • ConnectionState.active — التدفق يُرسل الأحداث؛ snapshot.data صالح للاستخدام.
  • ConnectionState.done — أُغلق التدفق؛ نادر مع Firestore إلا إذا ألغيته عمداً.
نصيحة: تحقق دائماً من snapshot.hasError قبل الوصول إلى snapshot.data. تظهر هنا أخطاء الشبكة وأحداث رفض الصلاحية والمستندات المشوهة. سجّلها وأظهر رسالة بديلة مناسبة للمستخدم بدلاً من السماح للتطبيق بالتوقف بصمت.

إدارة دورة حياة الاشتراك في StatefulWidgets

عندما تحتاج إلى بدء الاستماع بشكل أمري — مثلاً عند الاستجابة لضغطة زر أو لتسلسل استدعاءات Firestore — استخدم StatefulWidget، احفظ StreamSubscription، وألغِه في dispose(). الإخفاق في الإلغاء يُسبب تسرب الذاكرة وقد يؤدي إلى أعطال setState called after dispose.

اشتراك يدوي مع إدارة صحيحة لدورة الحياة

import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

class LiveScoreBoard extends StatefulWidget {
  final String matchId;
  const LiveScoreBoard({super.key, required this.matchId});

  @override
  State<LiveScoreBoard> createState() => _LiveScoreBoardState();
}

class _LiveScoreBoardState extends State<LiveScoreBoard> {
  // احتفظ بالمرجع لإلغائه في dispose()
  StreamSubscription<DocumentSnapshot>? _subscription;
  Map<String, dynamic>? _scoreData;
  String? _error;

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

  void _subscribe() {
    _subscription = FirebaseFirestore.instance
        .collection('matches')
        .doc(widget.matchId)
        .snapshots()
        .listen(
          (snapshot) {
            if (!mounted) return; // حماية قبل setState
            setState(() {
              _scoreData = snapshot.data() as Map<String, dynamic>?;
              _error = null;
            });
          },
          onError: (Object e) {
            if (!mounted) return;
            setState(() {
              _error = e.toString();
            });
          },
        );
  }

  @override
  void dispose() {
    // ضروري: ألغِ الاشتراك لتجنب تسرب الذاكرة
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return Text('خطأ: $_error');
    }
    if (_scoreData == null) {
      return const CircularProgressIndicator();
    }
    return Column(
      children: [
        Text('الفريق المضيف: ${_scoreData!['homeScore'] ?? 0}'),
        Text('الفريق الزائر: ${_scoreData!['awayScore'] ?? 0}'),
      ],
    );
  }
}
تحذير: لا تُمرر Stream منشأة مضمّنة (مثلاً stream: ref.snapshots()) مباشرةً داخل دالة build() لـ StatefulWidget — كل إعادة بناء تُنشئ تدفقاً جديداً مما يُسبب وميضاً وقراءات إضافية وتكاليف فوترة. إما احفظ التدفق في initState() أو استخدم StatelessWidget. يُعيد StreamBuilder الاشتراك فقط عند تغيُّر مرجع كائن التدفق، لذا أبقِه ثابتاً.

استخدام DocumentChanges للتحديثات التزايدية

في المجموعات الكبيرة حيث تكون إعادة رسم القائمة بالكامل عند كل لقطة مكلفة، كرّر على QuerySnapshot.docChanges لمعالجة العناصر التي تغيرت فقط:

  • DocumentChangeType.added — دخل مستند جديد إلى الاستعلام.
  • DocumentChangeType.modified — تغيرت بيانات مستند موجود.
  • DocumentChangeType.removed — غادر مستند الاستعلام (حُذف أو لم يعد يطابق المرشحات).

الخلاصة

تحوّل تدفقات Firestore تطبيقك إلى تجربة حقيقية في الوقت الفعلي. استخدم snapshots() على مراجع المستندات أو المجموعات للاشتراك، وصِّل التدفق بـStreamBuilder للودجات التصريحية عديمة الحالة، أو احفظ StreamSubscription في StatefulWidget وألغِه في dispose() للتحكم الأمري. تعامل دائماً مع جميع حالات الاتصال والأخطاء، أبقِ مراجع التدفق ثابتة عبر عمليات إعادة البناء، وعوِّل على docChanges لتحديثات القوائم التزايدية عالية الأداء.