الشبكات وتكامل REST API

رفع الملفات والصور باستخدام طلبات متعددة الأجزاء

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

رفع الملفات والصور باستخدام طلبات متعددة الأجزاء

تحتاج معظم التطبيقات الإنتاجية إلى السماح للمستخدمين برفع صور الملف الشخصي والوثائق والوسائط إلى الخادم. يُعدّ ترميز multipart/form-data في HTTP المعيار لرفع الملفات الثنائية، إذ يُغلّف ملفاً واحداً أو أكثر مع حقول البيانات الوصفية في جسم طلب واحد. في Flutter، تجعل حزمة Dio هذه العملية سهلة من خلال واجهتَي FormData وMultipartFile، بينما تتولى إضافة image_picker إظهار مربع حوار اختيار الملف على مستوى نظام التشغيل.

التبعيات

أضف ما يلي إلى pubspec.yaml:

  • dio: ^5.4.0 — عميل HTTP مع دعم multipart واستدعاءات التقدم
  • image_picker: ^1.0.7 — الوصول إلى الكاميرا والمعرض على iOS وAndroid

على iOS، أضف المفاتيح التالية إلى Info.plist:

  • NSPhotoLibraryUsageDescription — يشرح سبب الوصول إلى المعرض
  • NSCameraUsageDescription — يشرح سبب الوصول إلى الكاميرا

على Android (API 33 فأعلى)، تُعلن الإضافة عن إذن READ_MEDIA_IMAGES تلقائياً.

اختيار صورة باستخدام image_picker

تُعيد الدالة ImagePicker.pickImage() كائناً من النوع XFile? — وهو تجريد ملف متعدد المنصات يُتيح مسار النص path على الأجهزة المحمولة. تختار المصدر (المعرض أو الكاميرا) وخاصية imageQuality الاختيارية (0–100) لتقليص حجم الملف قبل رفعه من الجهاز.

اختيار ملف من المعرض

import 'package:image_picker/image_picker.dart';

final ImagePicker _picker = ImagePicker();

Future<XFile?> pickImage() async {
  final XFile? file = await _picker.pickImage(
    source: ImageSource.gallery,
    imageQuality: 80,   // ضغط إلى 80 % قبل الرفع
    maxWidth: 1280,
    maxHeight: 1280,
  );
  return file;
}

بناء طلب متعدد الأجزاء باستخدام Dio

بمجرد حصولك على مسار الملف، غلّفه في MultipartFile وأرفقه بكائن FormData. يقبل FormData حقول نصية عادية (تسميات توضيحية ومعرّفات مستخدم وما إلى ذلك) إلى جانب مدخلات الملفات في آنٍ واحد.

الرفع باستخدام FormData و onSendProgress

import 'package:dio/dio.dart';

final Dio _dio = Dio();

Future<void> uploadProfilePhoto({
  required String filePath,
  required String userId,
  required void Function(int sent, int total) onProgress,
}) async {
  final formData = FormData.fromMap({
    'user_id': userId,
    'avatar': await MultipartFile.fromFile(
      filePath,
      filename: 'avatar.jpg',       // اسم الملف المرئي للخادم
      contentType: DioMediaType('image', 'jpeg'),
    ),
  });

  final response = await _dio.post(
    'https://api.example.com/upload/avatar',
    data: formData,
    onSendProgress: (int sent, int total) {
      // قد تكون total تساوي -1 إذا كان Content-Length مجهولاً
      if (total != -1) {
        onProgress(sent, total);
      }
    },
    options: Options(
      headers: {'Authorization': 'Bearer \$_token'},
      receiveTimeout: const Duration(seconds: 60),
      sendTimeout: const Duration(seconds: 120),
    ),
  );

  if (response.statusCode != 200) {
    throw Exception('فشل الرفع: \${response.statusCode}');
  }
}
ملاحظة: الدالة MultipartFile.fromFile() غير متزامنة لأنها تفتح واصف الملف. استخدم await دائماً داخل استدعاء FormData.fromMap(). نسيان await مصدر شائع لعمليات الرفع الفارغة.

تتبع التقدم في واجهة المستخدم

يُطلَق استدعاء onSendProgress بصورة متكررة كلما أُرسلت قطع بيانات. خزّن النسبة sent / total في متغير حالة من نوع double ومرره إلى LinearProgressIndicator. استدعِ setState() دائماً داخل الاستدعاء الراجع لكي يُعاد بناء شجرة الودجات مع كل تحديث.

ودجت مؤشر التقدم

class UploadScreen extends StatefulWidget {
  const UploadScreen({super.key});

  @override
  State<UploadScreen> createState() => _UploadScreenState();
}

class _UploadScreenState extends State<UploadScreen> {
  XFile? _pickedFile;
  double _uploadProgress = 0.0;   // من 0.0 إلى 1.0
  bool _isUploading = false;
  String? _errorMessage;

  Future<void> _pickAndUpload() async {
    final file = await pickImage();
    if (file == null) return;

    setState(() {
      _pickedFile = file;
      _isUploading = true;
      _uploadProgress = 0.0;
      _errorMessage = null;
    });

    try {
      await uploadProfilePhoto(
        filePath: file.path,
        userId: 'user_42',
        onProgress: (sent, total) {
          setState(() {
            _uploadProgress = sent / total;
          });
        },
      );
      setState(() => _isUploading = false);
    } catch (e) {
      setState(() {
        _isUploading = false;
        _errorMessage = e.toString();
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('رفع صورة')),
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (_pickedFile != null)
              Image.network(_pickedFile!.path, height: 200),
            const SizedBox(height: 16),
            if (_isUploading) ...[
              LinearProgressIndicator(value: _uploadProgress),
              const SizedBox(height: 8),
              Text('${(_uploadProgress * 100).toStringAsFixed(1)} %'),
            ],
            if (_errorMessage != null)
              Text(_errorMessage!, style: const TextStyle(color: Colors.red)),
            const SizedBox(height: 24),
            ElevatedButton.icon(
              onPressed: _isUploading ? null : _pickAndUpload,
              icon: const Icon(Icons.upload_file),
              label: const Text('اختر وارفع'),
            ),
          ],
        ),
      ),
    );
  }
}
نصيحة: عطّل زر الرفع (onPressed: null) أثناء جريان عملية الرفع لمنع الإرسال المزدوج. أعد تفعيله في كلٍّ من فرع النجاح وفرع الخطأ في بنية try/catch.
تحذير: لا ترفع الملفات على المُعزِل الرئيسي دون ضبط مهلة إرسال على الأقل. سيؤدي رفع متوقف إلى تجميد واجهة المستخدم إلى أجل غير مسمى. اضبط sendTimeout وreceiveTimeout في Dio Options وغلّف الاستدعاء دائماً في try/catch يعالج DioException.

رفع ملفات متعددة

مرّر List<MultipartFile> تحت المفتاح ذاته لرفع مجموعة من الملفات في طلب واحد. يستقبل الخادم هذه الملفات بوصفها حقلاً بقيم متعددة:

  • استخدم MultipartFile.fromFileSync() للإنشاء المتزامن حين تكون جميع المسارات محسوبة مسبقاً.
  • سمِّ كل ملف بشكل وصفي (photo_0.jpg، photo_1.jpg، ...) حتى يتمكن الخادم من تمييزها عبر حدود multipart.

ملخص

لرفع الملفات في Flutter: (1) أضف image_picker وdio إلى مشروعك؛ (2) استدعِ ImagePicker.pickImage() للحصول على XFile؛ (3) غلّف الملف في MultipartFile.fromFile() وأرفقه بـ FormData؛ (4) مرّر استدعاء onSendProgress إلى Dio.post()؛ و(5) حدّث LinearProgressIndicator في واجهة المستخدم باستدعاء setState() داخل الاستدعاء الراجع. يُعدّ ضبط المهل الزمنية ومعالجة الأخطاء بشكل صحيح أمراً غير قابل للتفاوض في بيئة الإنتاج.