جلب البيانات من الإنترنت في Flutter: حزمة http و JSON
تطبيق الطقس، وتطبيق الأخبار، والمتجر الإلكتروني: كلها تعرض بيانات ليست مخزنة في الهاتف، بل تأتي من خادم (Server) عبر الإنترنت. في الدرس السابق بنيت شاشة بحالات التحميل والنجاح والفشل ببيانات وهمية. في هذا الدرس ستستبدل الوهمي بالحقيقي: ستطلب بيانات من خادم فعلي وتعرضها.
كيف يتحدث التطبيق مع الخادم؟
Section titled “كيف يتحدث التطبيق مع الخادم؟”التطبيق يرسل طلباً (Request) إلى عنوان محدد، والخادم يرد بـاستجابة (Response) فيها البيانات. هذا العنوان مع قواعد التعامل معه يسمى API.
تخيّل مطعماً مرة أخرى: أنت لا تدخل المطبخ. تعطي النادل طلبك مكتوباً بصيغة يفهمها، فيعود إليك بالطبق. الـ API هو النادل، والمطبخ هو الخادم وقاعدة بياناته.
والبيانات تسافر غالباً بصيغة JSON، وهي نص يشبه خرائط Dart وقوائمها:
{ "id": 1, "title": "عنوان المقال", "body": "نص المقال"}التجهيز
Section titled “التجهيز”أضف حزمة http:
flutter pub add httpوعلى أندرويد أضف إذن الإنترنت في android/app/src/main/AndroidManifest.xml قبل وسم application، وإلا فشلت الطلبات في النسخة النهائية:
<uses-permission android:name="android.permission.INTERNET"/>سنستخدم في هذا الدرس خادماً تجريبياً مجانياً اسمه DummyJSON، صُمم لتعلّم التعامل مع الـ API، ويُرجع مقالات وهمية.
أول طلب: GET
Section titled “أول طلب: GET”import 'package:http/http.dart' as http;
Future<String> fetchRawPost() async { final url = Uri.parse('https://dummyjson.com/posts/1'); final response = await http.get(url);
if (response.statusCode == 200) { return response.body; } throw Exception('فشل الطلب: ${response.statusCode}');}as http: نعطي الحزمة اسماً مستعاراً، فنكتبhttp.getبوضوح.Uri.parse: تحوّل نص العنوان إلى كائنUri.http.get: ترسل طلب قراءة، وتُرجعFuture، فننتظرها بـawait.response.statusCode: رمز الحالة، وهو رقم يلخص نتيجة الطلب.response.body: نص الاستجابة، وهو هنا نص JSON.
| الرمز | معناه |
|---|---|
200 |
نجح الطلب |
201 |
نجح، وأُنشئ عنصر جديد |
400 |
الطلب نفسه خاطئ |
401 |
غير مصرّح: يجب تسجيل الدخول |
404 |
العنوان غير موجود |
500 |
خطأ داخل الخادم |
القاعدة السريعة: ما يبدأ بـ 2 نجاح، وما يبدأ بـ 4 خطأ منك، وما يبدأ بـ 5 خطأ من الخادم.
من نص JSON إلى Dart: jsonDecode
Section titled “من نص JSON إلى Dart: jsonDecode”response.body مجرد نص طويل. لتحويله إلى شيء تستطيع التعامل معه، استخدم jsonDecode من مكتبة dart:convert المدمجة:
import 'dart:convert';
void main() { const body = '{"id": 1, "title": "مرحباً", "likes": 25}'; final Map<String, dynamic> data = jsonDecode(body);
print(data['title']); print(data['likes'] + 1);}مرحباً26كائن JSON يتحول إلى Map<String, dynamic>: المفاتيح نصوص، والقيم dynamic لأنها قد تكون نصاً أو رقماً أو قائمة أو خريطة أخرى. وقائمة JSON تتحول إلى List<dynamic>.
كلاس للبيانات: fromJson
Section titled “كلاس للبيانات: fromJson”التعامل مع data['title'] في كل مكان خطير: خطأ إملائي واحد في اسم المفتاح، ولن ينبهك المحرر، وستكتشفه عند التشغيل. الحل أن تحوّل الخريطة إلى كائن من كلاس تعرّفه أنت:
class Post { const Post({required this.id, required this.title, required this.body});
final int id; final String title; final String body;
factory Post.fromJson(Map<String, dynamic> json) { return Post( id: json['id'] as int, title: json['title'] as String, body: json['body'] as String, ); }}factory Post.fromJson مُنشئ مسمى يأخذ الخريطة ويبني منها كائناً. بعد ذلك تكتب post.title، والمحرر يكمل لك الاسم ويكشف أخطاءك قبل التشغيل. ونكتب أسماء المفاتيح النصية في مكان واحد فقط، فإذا تغيّر شكل البيانات في الخادم عدّلت هنا فقط.
جلب قائمة كاملة
Section titled “جلب قائمة كاملة”Future<List<Post>> fetchPosts() async { final url = Uri.parse('https://dummyjson.com/posts'); final response = await http.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) { throw Exception('فشل تحميل المقالات: ${response.statusCode}'); }
final Map<String, dynamic> data = jsonDecode(response.body); final List<dynamic> list = data['posts']; return list .map((item) => Post.fromJson(item as Map<String, dynamic>)) .toList();}timeout: إذا لم يرد الخادم خلال 10 ثوانٍ نتوقف عن الانتظار ونرمي خطأ، بدلاً من دائرة تحميل تدور بلا نهاية.- هذا الخادم لا يُرجع القائمة مباشرة، بل يضعها داخل كائن تحت المفتاح
posts، ومعها معلومات أخرى مثل العدد الكليtotal. هذا شكل شائع جداً في الـ API الحقيقية، فاقرأ دائماً شكل الاستجابة قبل أن تكتب الكود. data['posts']تعطينا قائمة من الخرائط.mapتحوّل كل خريطة إلىPost، وtoListتجمع النتائج في قائمة.
إرسال بيانات: POST
Section titled “إرسال بيانات: POST”للإضافة نستخدم http.post، ونرسل البيانات نص JSON بعد تحويلها بـ jsonEncode:
Future<void> createPost(String title, String body) async { final url = Uri.parse('https://dummyjson.com/posts/add'); final response = await http.post( url, headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({'title': title, 'body': body, 'userId': 1}), );
if (response.statusCode != 201) { throw Exception('فشل الإرسال: ${response.statusCode}'); }}headers: معلومات عن الطلب.Content-Typeتخبر الخادم أن المحتوى JSON.jsonEncode: عكسjsonDecode، تحوّل الخريطة إلى نص.- نتوقع الرمز
201لأن عنصراً جديداً أُنشئ.
وبنفس الشكل توجد http.put للتعديل و http.delete للحذف.
المشروع: قائمة مقالات من الإنترنت
Section titled “المشروع: قائمة مقالات من الإنترنت”import 'dart:convert';
import 'package:flutter/material.dart';import 'package:http/http.dart' as http;
class Post { const Post({required this.id, required this.title, required this.body});
final int id; final String title; final String body;
factory Post.fromJson(Map<String, dynamic> json) { return Post( id: json['id'] as int, title: json['title'] as String, body: json['body'] as String, ); }}
Future<List<Post>> fetchPosts() async { final url = Uri.parse('https://dummyjson.com/posts'); final response = await http.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) { throw Exception('فشل تحميل المقالات: ${response.statusCode}'); }
final Map<String, dynamic> data = jsonDecode(response.body); final List<dynamic> list = data['posts']; return list .map((item) => Post.fromJson(item as Map<String, dynamic>)) .toList();}
class PostsScreen extends StatefulWidget { const PostsScreen({super.key});
@override State<PostsScreen> createState() => _PostsScreenState();}
class _PostsScreenState extends State<PostsScreen> { late Future<List<Post>> _postsFuture;
@override void initState() { super.initState(); _postsFuture = fetchPosts(); }
void _reload() { setState(() { _postsFuture = fetchPosts(); }); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('المقالات'), actions: [ IconButton( icon: const Icon(Icons.refresh), tooltip: 'تحديث', onPressed: _reload, ), ], ), body: FutureBuilder<List<Post>>( future: _postsFuture, 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 Text('تعذر تحميل المقالات'), const SizedBox(height: 8), FilledButton( onPressed: _reload, child: const Text('إعادة المحاولة'), ), ], ), ); }
final posts = snapshot.data ?? []; if (posts.isEmpty) { return const Center(child: Text('لا توجد مقالات')); } return ListView.separated( itemCount: posts.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final post = posts[index]; return ListTile( leading: CircleAvatar(child: Text('${post.id}')), title: Text( post.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( post.body, maxLines: 2, overflow: TextOverflow.ellipsis, ), ); }, ); }, ), ); }}عند التشغيل تظهر دائرة التحميل لحظة، ثم قائمة من 30 مقالاً، ولكل مقال رقمه وعنوانه وأول سطرين من نصه. النصوص بالإنجليزية لأن بيانات الخادم التجريبي وهمية. جرّب أن تطفئ الإنترنت وتضغط زر التحديث: ستظهر رسالة الفشل وزر إعادة المحاولة.
لاحظ أن الشاشة هي نفسها شاشة الدرس السابق: ثلاث حالات داخل FutureBuilder. الذي تغيّر هو مصدر البيانات فقط.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل شاشة المقالات، ثم جرّبها بدون إنترنت.
- أضف
?limit=5إلى آخر العنوان ليُرجع الخادم خمسة مقالات فقط، وتأكد من العدد في القائمة. - اجعل الضغط على مقال يفتح شاشة تعرض نصه كاملاً.
- تحدٍّ: اجلب المستخدمين من
https://dummyjson.com/users، واكتب كلاسUserفيهfirstNameوemail، واعرضهم في قائمة. (القائمة هنا تحت المفتاحusers.)
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان await قبل http.get: تحصل على
Futureبدلاً من الاستجابة، ويشتكي المحرر من أنstatusCodeغير موجودة. - عدم فحص statusCode: صفحة خطأ من الخادم تُعامل كأنها بيانات، فيفشل
jsonDecodeبرسالة غامضة. - نسيان إذن الإنترنت في أندرويد: يعمل التطبيق في وضع التطوير، ثم تفشل كل الطلبات في النسخة النهائية.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
ما وظيفة jsonDecode؟
jsonDecode تحلل نص JSON وتُرجع Map للكائنات و List للقوائم، وعكسها jsonEncode.
سؤال
ما رمز الحالة الذي يدل على نجاح طلب GET؟
الرمز 200 يعني نجاح الطلب. أما 404 فيعني أن العنوان غير موجود، و 500 خطأ في الخادم.
الخلاصة
Section titled “الخلاصة”- التطبيق يرسل طلباً إلى API ويستقبل استجابة، وغالباً بصيغة JSON.
http.getمعawaitللقراءة، وافحصstatusCodeقبل استخدامbody.jsonDecodeتحوّل النص إلى خرائط وقوائم، وjsonEncodeتفعل العكس.- كلاس بمُنشئ
fromJsonيحوّل الخرائط إلى كائنات آمنة الأنواع. - اعرض النتيجة بـ
FutureBuilderبحالاته الثلاث، وأضفtimeoutوإعادة المحاولة.