عرض البيانات غير المتزامنة في Flutter: FutureBuilder
في كورس Dart تعلمت أن العمليات الطويلة تُرجع Future: وعداً بقيمة ستصل لاحقاً. لكن الواجهة لا تنتظر. ماذا تعرض الشاشة قبل وصول البيانات؟ وماذا لو فشل الطلب؟ كل شاشة تجلب بيانات لها ثلاث حالات: تحميل، ونجاح، وفشل. في هذا الدرس ستتعلم FutureBuilder، وهو الـ Widget الذي يربط Future بالواجهة ويتابع هذه الحالات الثلاث عنك.
المشكلة
Section titled “المشكلة”دالة build يجب أن تُرجع Widget فوراً. لا يمكنك كتابة await بداخلها، لأنها ليست async. إذن كيف نعرض بيانات تحتاج ثانيتين لتصل؟
تخيّل مطعماً: تطلب وجبتك، فلا يتركك النادل أمام طاولة فارغة بلا كلمة. يقول «طلبك يُحضَّر» (تحميل)، ثم يحضر الطبق (نجاح)، أو يعتذر لأن الصنف انتهى (فشل). FutureBuilder هو هذا النادل.
دالة تحاكي جلب البيانات
Section titled “دالة تحاكي جلب البيانات”لنكتب دالة تنتظر ثانيتين ثم تُرجع اسماً، كأنها تجلبه من خادم:
Future<String> fetchUserName() async { await Future.delayed(const Duration(seconds: 2)); return 'سارة أحمد';}FutureBuilder
Section titled “FutureBuilder”FutureBuilder<String>( future: _userFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return const Center(child: Text('حدث خطأ أثناء التحميل')); } return Center(child: Text('أهلاً يا ${snapshot.data}')); },)future: الوعد الذي ننتظره.builder: دالة تُستدعى عند كل تغيّر في حالة الوعد: مرة في البداية والوعد معلّق، ومرة عند اكتماله.snapshot: لقطة من حالة الوعد الآن، من نوعAsyncSnapshot.<String>: نوع البيانات المنتظرة، وبه يعرف Dart أنsnapshot.dataنص.
| خاصية اللقطة | معناها |
|---|---|
connectionState |
waiting أثناء الانتظار، و done عند الانتهاء |
hasData |
هل وصلت بيانات؟ |
data |
البيانات نفسها، وقد تكون null قبل وصولها |
hasError |
هل فشل الوعد؟ |
error |
الخطأ الذي حدث |
رتّب الفحص دائماً بهذا الشكل: الانتظار أولاً، ثم الخطأ، ثم البيانات.
الخطأ الأشهر: إنشاء الوعد داخل build
Section titled “الخطأ الأشهر: إنشاء الوعد داخل build”انظر جيداً إلى هذا الكود. يبدو طبيعياً، لكنه خاطئ:
FutureBuilder<String>( future: fetchUserName(), builder: (context, snapshot) { return Text(snapshot.data ?? 'جارٍ التحميل...'); },)المشكلة أن fetchUserName() تُستدعى داخل build. وكما عرفت في درس دورة الحياة، build تتكرر كثيراً. فمع كل إعادة بناء، مثل فتح لوحة المفاتيح أو استدعاء setState لأي سبب، يُنشأ وعد جديد ويُرسل الطلب من جديد، وتعود الشاشة إلى دائرة التحميل.
الحل: أنشئ الوعد مرة واحدة في initState، واحفظه في متغير:
late Future<String> _userFuture;
@overridevoid initState() { super.initState(); _userFuture = fetchUserName();}ثم أعطِ FutureBuilder المتغير: future: _userFuture. الآن مهما أُعيد البناء يبقى الوعد نفسه، ولا يتكرر الطلب.
إعادة المحاولة
Section titled “إعادة المحاولة”عند الفشل يجب أن يجد المستخدم زر «إعادة المحاولة». والفكرة بسيطة: ننشئ وعداً جديداً ونضعه في المتغير داخل setState:
void _retry() { setState(() { _userFuture = fetchUserName(); });}عندما يتغير الوعد، يبدأ FutureBuilder من جديد: تحميل، ثم نتيجة. وهذه المرة التكرار مقصود، لأن المستخدم هو من طلبه.
التعامل مع قائمة بيانات
Section titled “التعامل مع قائمة بيانات”غالباً البيانات قائمة وليست قيمة واحدة:
Future<List<String>> fetchCities() async { await Future.delayed(const Duration(seconds: 1)); return ['القاهرة', 'الإسكندرية', 'أسوان'];}وداخل builder نعامل القائمة الفارغة كحالة رابعة:
final cities = snapshot.data ?? [];if (cities.isEmpty) { return const Center(child: Text('لا توجد مدن'));}return ListView.builder( itemCount: cities.length, itemBuilder: (context, index) => ListTile(title: Text(cities[index])),);الحالة الفارغة مهمة: شاشة بيضاء بلا أي كلمة تجعل المستخدم يظن أن التطبيق معطل.
المشروع: شاشة ملف شخصي بالحالات الثلاث
Section titled “المشروع: شاشة ملف شخصي بالحالات الثلاث”لنحاكِ خادماً يفشل في أول محاولة وينجح في الثانية، حتى نرى الحالات كلها:
class ProfileLoader extends StatefulWidget { const ProfileLoader({super.key});
@override State<ProfileLoader> createState() => _ProfileLoaderState();}
class _ProfileLoaderState extends State<ProfileLoader> { late Future<String> _profileFuture; int _attempts = 0;
Future<String> _fetchProfile() async { _attempts++; await Future.delayed(const Duration(seconds: 2)); if (_attempts == 1) { throw Exception('تعذر الاتصال بالخادم'); } return 'سارة أحمد'; }
@override void initState() { super.initState(); _profileFuture = _fetchProfile(); }
void _retry() { setState(() { _profileFuture = _fetchProfile(); }); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('الملف الشخصي')), body: FutureBuilder<String>( future: _profileFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); }
if (snapshot.hasError) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.wifi_off, size: 48), const SizedBox(height: 8), const Text('تعذر تحميل البيانات'), const SizedBox(height: 8), FilledButton( onPressed: _retry, child: const Text('إعادة المحاولة'), ), ], ), ); }
return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircleAvatar(radius: 40, child: Icon(Icons.person, size: 40)), const SizedBox(height: 12), Text( snapshot.data ?? '', style: const TextStyle(fontSize: 22), ), ], ), ); }, ), ); }}تسلسل ما يراه المستخدم:
- دائرة تحميل لمدة ثانيتين.
- تفشل المحاولة الأولى، فتظهر أيقونة انقطاع الاتصال، ورسالة، وزر «إعادة المحاولة».
- يضغط الزر، فتعود دائرة التحميل ثانيتين.
- تنجح المحاولة الثانية، فيظهر الاسم مع الصورة الشخصية.
في تطبيق حقيقي ستستبدل _fetchProfile بطلب من الإنترنت، وهذا موضوع الدرس القادم. أما هيكل الشاشة بحالاتها الثلاث فسيبقى كما هو.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل الشاشة وتابع الخطوات الأربع بالترتيب.
- اعرض نص الخطأ الحقيقي في شاشة الفشل باستخدام
snapshot.error. - انقل استدعاء
_fetchProfile()مؤقتاً إلى خاصيةfutureمباشرة، وأضف زراً يستدعيsetStateفارغة، وشاهد الشاشة وهي تعود للتحميل بلا سبب. ثم أصلحه. - تحدٍّ: اجعل الدالة تُرجع قائمة من خمسة أسماء، واعرضها في
ListView، مع حالة للقائمة الفارغة.
أخطاء شائعة
Section titled “أخطاء شائعة”- استدعاء الدالة داخل خاصية future: يتكرر الطلب مع كل إعادة بناء. خزّن الوعد في
initState. - نسيان حالة الخطأ: عند الفشل تبقى دائرة التحميل تدور إلى الأبد، أو تظهر شاشة فارغة.
- استخدام snapshot.data! دون فحص: إذا كانت
nullيتوقف التطبيق. افحص الحالات أولاً، أو استخدم??.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
لماذا لا نكتب future: fetchData() مباشرة داخل FutureBuilder؟
build تُستدعى كثيراً، وكل استدعاء ينشئ وعداً جديداً ويعيد الطلب. الحل تخزين الوعد في initState.
سؤال
ما الترتيب الصحيح لفحص الحالات داخل builder؟
نبدأ بالانتظار لأن البيانات لم تصل بعد، ثم نفحص الفشل، وما بقي هو حالة النجاح.
الخلاصة
Section titled “الخلاصة”- كل شاشة تجلب بيانات لها ثلاث حالات: تحميل ونجاح وفشل، ومعها حالة القائمة الفارغة.
FutureBuilderيعيد البناء عند تغيّر حالة الوعد، ويعطيكsnapshot.- افحص
connectionStateثمhasErrorثم استخدمdata. - أنشئ الوعد في
initStateواحفظه في متغير، ولا تنشئه داخلbuild. - لإعادة المحاولة، ضع وعداً جديداً في المتغير داخل
setState.