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

جلب البيانات من الإنترنت في Flutter: حزمة http و JSON

الدرس 29 من 33 · Flutter

تطبيق الطقس، وتطبيق الأخبار، والمتجر الإلكتروني: كلها تعرض بيانات ليست مخزنة في الهاتف، بل تأتي من خادم (Server) عبر الإنترنت. في الدرس السابق بنيت شاشة بحالات التحميل والنجاح والفشل ببيانات وهمية. في هذا الدرس ستستبدل الوهمي بالحقيقي: ستطلب بيانات من خادم فعلي وتعرضها.

كيف يتحدث التطبيق مع الخادم؟

Section titled “كيف يتحدث التطبيق مع الخادم؟”

التطبيق يرسل طلباً (Request) إلى عنوان محدد، والخادم يرد بـاستجابة (Response) فيها البيانات. هذا العنوان مع قواعد التعامل معه يسمى API.

تخيّل مطعماً مرة أخرى: أنت لا تدخل المطبخ. تعطي النادل طلبك مكتوباً بصيغة يفهمها، فيعود إليك بالطبق. الـ API هو النادل، والمطبخ هو الخادم وقاعدة بياناته.

والبيانات تسافر غالباً بصيغة JSON، وهي نص يشبه خرائط Dart وقوائمها:

{
"id": 1,
"title": "عنوان المقال",
"body": "نص المقال"
}

أضف حزمة http:

نافذة طرفيّة
flutter pub add http

وعلى أندرويد أضف إذن الإنترنت في android/app/src/main/AndroidManifest.xml قبل وسم application، وإلا فشلت الطلبات في النسخة النهائية:

<uses-permission android:name="android.permission.INTERNET"/>

سنستخدم في هذا الدرس خادماً تجريبياً مجانياً اسمه DummyJSON، صُمم لتعلّم التعامل مع الـ API، ويُرجع مقالات وهمية.

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 خطأ من الخادم.

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>.

التعامل مع 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، والمحرر يكمل لك الاسم ويكشف أخطاءك قبل التشغيل. ونكتب أسماء المفاتيح النصية في مكان واحد فقط، فإذا تغيّر شكل البيانات في الخادم عدّلت هنا فقط.

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 تجمع النتائج في قائمة.

للإضافة نستخدم 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 “المشروع: قائمة مقالات من الإنترنت”
lib/posts_screen.dart
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. الذي تغيّر هو مصدر البيانات فقط.

  1. شغّل شاشة المقالات، ثم جرّبها بدون إنترنت.
  2. أضف ?limit=5 إلى آخر العنوان ليُرجع الخادم خمسة مقالات فقط، وتأكد من العدد في القائمة.
  3. اجعل الضغط على مقال يفتح شاشة تعرض نصه كاملاً.
  4. تحدٍّ: اجلب المستخدمين من https://dummyjson.com/users، واكتب كلاس User فيه firstName و email، واعرضهم في قائمة. (القائمة هنا تحت المفتاح users.)
  • نسيان await قبل http.get: تحصل على Future بدلاً من الاستجابة، ويشتكي المحرر من أن statusCode غير موجودة.
  • عدم فحص statusCode: صفحة خطأ من الخادم تُعامل كأنها بيانات، فيفشل jsonDecode برسالة غامضة.
  • نسيان إذن الإنترنت في أندرويد: يعمل التطبيق في وضع التطوير، ثم تفشل كل الطلبات في النسخة النهائية.

سؤال

ما وظيفة jsonDecode؟

سؤال

ما رمز الحالة الذي يدل على نجاح طلب GET؟

  • التطبيق يرسل طلباً إلى API ويستقبل استجابة، وغالباً بصيغة JSON.
  • http.get مع await للقراءة، وافحص statusCode قبل استخدام body.
  • jsonDecode تحوّل النص إلى خرائط وقوائم، و jsonEncode تفعل العكس.
  • كلاس بمُنشئ fromJson يحوّل الخرائط إلى كائنات آمنة الأنواع.
  • اعرض النتيجة بـ FutureBuilder بحالاته الثلاث، وأضف timeout وإعادة المحاولة.