تخطَّ إلى المحتوى

عرض البيانات غير المتزامنة في Flutter: FutureBuilder

الدرس 28 من 33 · Flutter

في كورس Dart تعلمت أن العمليات الطويلة تُرجع Future: وعداً بقيمة ستصل لاحقاً. لكن الواجهة لا تنتظر. ماذا تعرض الشاشة قبل وصول البيانات؟ وماذا لو فشل الطلب؟ كل شاشة تجلب بيانات لها ثلاث حالات: تحميل، ونجاح، وفشل. في هذا الدرس ستتعلم FutureBuilder، وهو الـ Widget الذي يربط Future بالواجهة ويتابع هذه الحالات الثلاث عنك.

دالة build يجب أن تُرجع Widget فوراً. لا يمكنك كتابة await بداخلها، لأنها ليست async. إذن كيف نعرض بيانات تحتاج ثانيتين لتصل؟

تخيّل مطعماً: تطلب وجبتك، فلا يتركك النادل أمام طاولة فارغة بلا كلمة. يقول «طلبك يُحضَّر» (تحميل)، ثم يحضر الطبق (نجاح)، أو يعتذر لأن الصنف انتهى (فشل). FutureBuilder هو هذا النادل.

دالة تحاكي جلب البيانات

Section titled “دالة تحاكي جلب البيانات”

لنكتب دالة تنتظر ثانيتين ثم تُرجع اسماً، كأنها تجلبه من خادم:

Future<String> fetchUserName() async {
await Future.delayed(const Duration(seconds: 2));
return 'سارة أحمد';
}
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;
@override
void initState() {
super.initState();
_userFuture = fetchUserName();
}

ثم أعطِ FutureBuilder المتغير: future: _userFuture. الآن مهما أُعيد البناء يبقى الوعد نفسه، ولا يتكرر الطلب.

عند الفشل يجب أن يجد المستخدم زر «إعادة المحاولة». والفكرة بسيطة: ننشئ وعداً جديداً ونضعه في المتغير داخل 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 “المشروع: شاشة ملف شخصي بالحالات الثلاث”

لنحاكِ خادماً يفشل في أول محاولة وينجح في الثانية، حتى نرى الحالات كلها:

lib/profile_loader.dart
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),
),
],
),
);
},
),
);
}
}

تسلسل ما يراه المستخدم:

  1. دائرة تحميل لمدة ثانيتين.
  2. تفشل المحاولة الأولى، فتظهر أيقونة انقطاع الاتصال، ورسالة، وزر «إعادة المحاولة».
  3. يضغط الزر، فتعود دائرة التحميل ثانيتين.
  4. تنجح المحاولة الثانية، فيظهر الاسم مع الصورة الشخصية.

في تطبيق حقيقي ستستبدل _fetchProfile بطلب من الإنترنت، وهذا موضوع الدرس القادم. أما هيكل الشاشة بحالاتها الثلاث فسيبقى كما هو.

  1. شغّل الشاشة وتابع الخطوات الأربع بالترتيب.
  2. اعرض نص الخطأ الحقيقي في شاشة الفشل باستخدام snapshot.error.
  3. انقل استدعاء _fetchProfile() مؤقتاً إلى خاصية future مباشرة، وأضف زراً يستدعي setState فارغة، وشاهد الشاشة وهي تعود للتحميل بلا سبب. ثم أصلحه.
  4. تحدٍّ: اجعل الدالة تُرجع قائمة من خمسة أسماء، واعرضها في ListView، مع حالة للقائمة الفارغة.
  • استدعاء الدالة داخل خاصية future: يتكرر الطلب مع كل إعادة بناء. خزّن الوعد في initState.
  • نسيان حالة الخطأ: عند الفشل تبقى دائرة التحميل تدور إلى الأبد، أو تظهر شاشة فارغة.
  • استخدام snapshot.data! دون فحص: إذا كانت null يتوقف التطبيق. افحص الحالات أولاً، أو استخدم ??.

سؤال

لماذا لا نكتب future: fetchData() مباشرة داخل FutureBuilder؟

سؤال

ما الترتيب الصحيح لفحص الحالات داخل builder؟

  • كل شاشة تجلب بيانات لها ثلاث حالات: تحميل ونجاح وفشل، ومعها حالة القائمة الفارغة.
  • FutureBuilder يعيد البناء عند تغيّر حالة الوعد، ويعطيك snapshot.
  • افحص connectionState ثم hasError ثم استخدم data.
  • أنشئ الوعد في initState واحفظه في متغير، ولا تنشئه داخل build.
  • لإعادة المحاولة، ضع وعداً جديداً في المتغير داخل setState.