التخزين المحلي للبيانات

تخزين الملفات باستخدام path_provider

15 دقيقة الدرس 4 من 12

تخزين الملفات باستخدام path_provider

كثيراً ما تحتاج تطبيقات الهاتف المحمول إلى استمرارية البيانات بين الجلسات — ملفات الإعدادات، واستجابات API المخزّنة مؤقتاً، والوسائط المُنزَّلة، والمحتوى الذي ينتجه المستخدم. توفّر مكتبة dart:io في Flutter واجهة برمجة File الخام، لكن عليك أولاً معرفة أين تكتب. ترميز مسار مثل /data/user/0/... بشكل ثابت يفشل على iOS وعبر إصدارات Android المختلفة. تحلّ حزمة path_provider هذه المشكلة بتوفير دوال وصول إلى مسارات الأدلة الصحيحة لكل منصة، وتعمل على Android وiOS وmacOS وWindows وLinux.

ملاحظة: path_provider ليست جزءاً من Flutter SDK. أضفها إلى pubspec.yaml قبل استيرادها. اعتباراً من عام 2024، الحزمة الرسمية هي path_provider: ^2.1.0 المنشورة من قِبَل فريق Flutter على pub.dev.

إضافة التبعية

افتح pubspec.yaml وأضف الحزمة تحت dependencies:

dependencies:
  flutter:
    sdk: flutter
  path_provider: ^2.1.0

ثم نفّذ flutter pub get لجلب الحزمة.

الأدلة الثلاثة الرئيسية

توفّر path_provider عدة دوال للحصول على الأدلة. الثلاثة التي ستستخدمها في معظم التطبيقات هي:

  • getApplicationDocumentsDirectory() — مساحة تخزين دائمة موجّهة للمستخدم. تبقى الملفات عبر تحديثات التطبيق ويتم نسخها احتياطياً عبر iCloud وGoogle Backup على المنصات المعنية. استخدمها للمستندات التي ينشئها المستخدم أو يحفظها بشكل صريح.
  • getApplicationCacheDirectory() — مساحة تخزين دائمة لكنها قابلة للحذف. قد يحذف نظام التشغيل هذه الملفات عند انخفاض مساحة القرص. استخدمها للصور المصغّرة المُنزَّلة، وJSON الخاص بـ API المخزّن مؤقتاً، أو البيانات التي يمكنك جلبها مجدداً.
  • getTemporaryDirectory() — مساحة عمل مؤقتة. لا يُضمن بقاء الملفات هنا بين تشغيلات التطبيق، ويتم تنظيفها بانتظام من قِبَل نظام التشغيل. استخدمها للتنزيلات قيد التقدم، والأرشيفات المُستخرَجة، أو المخازن المؤقتة للمعالجة الوسيطة.
نصيحة: افضّل دائماً getApplicationDocumentsDirectory() للبيانات التي لا يمكنك تحمّل فقدانها (تفضيلات المستخدم، المحتوى غير المتصل). احجز getApplicationCacheDirectory() للبيانات التي يمكنك إعادة بنائها، وgetTemporaryDirectory() للمخازن المؤقتة المؤقتة.

قراءة الملفات النصية وكتابتها

بعد الحصول على الدليل، أنشئ مسار File باستخدام path.join() من حزمة path (أو p.join)، ثم استدعِ دوال القراءة/الكتابة اللاتزامنية على dart:io File:

import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

/// يُرجع المسار إلى ملف باسم [filename] داخل دليل المستندات.
Future<File> _localFile(String filename) async {
  final dir = await getApplicationDocumentsDirectory();
  return File(p.join(dir.path, filename));
}

/// يكتب [content] إلى notes.txt، مستبدلاً أي محتوى موجود.
Future<void> writeNote(String content) async {
  final file = await _localFile('notes.txt');
  await file.writeAsString(content);
}

/// يقرأ notes.txt ويُرجع محتواه، أو سلسلة فارغة إن لم يكن موجوداً.
Future<String> readNote() async {
  try {
    final file = await _localFile('notes.txt');
    return await file.readAsString();
  } on PathNotFoundException {
    return '';          // الملف غير موجود بعد
  } catch (e) {
    return '';          // معالجة أخطاء I/O الأخرى بشكل أنيق
  }
}

نقاط رئيسية حول المثال السابق:

  • جميع عمليات الملفات في dart:io لاتزامنية (مبنية على Future). احرص دائماً على استخدام await أو تسلسلها مع .then().
  • writeAsString تُنشئ الملف إن لم يكن موجوداً وتُكتب فوقه إن كان موجوداً. مرّر mode: FileMode.append للإضافة بدلاً من الاستبدال.
  • أحِط عمليات القراءة بـ try/catch: يُطلق PathNotFoundException عند التشغيل الأول قبل إنشاء الملف.

قراءة الملفات الثنائية وكتابتها

للصور وملفات PDF والبيانات المتسلسلة، استخدم readAsBytes() وwriteAsBytes() اللتان تعملان مع Uint8List:

import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

/// تُخزّن [bytes] (مثل بيانات الصورة) مؤقتاً في دليل الذاكرة المؤقتة.
Future<File> cacheImage(String filename, Uint8List bytes) async {
  final cacheDir = await getApplicationCacheDirectory();
  final file = File(p.join(cacheDir.path, filename));
  return file.writeAsBytes(bytes);
}

/// تحمّل صورة مخزّنة مؤقتاً، وتُرجع null إن لم تكن موجودة.
Future<Uint8List?> loadCachedImage(String filename) async {
  try {
    final cacheDir = await getApplicationCacheDirectory();
    final file = File(p.join(cacheDir.path, filename));
    if (await file.exists()) {
      return await file.readAsBytes();
    }
    return null;
  } catch (e) {
    return null;
  }
}

أنماط معالجة الأخطاء اللاتزامنية

يمكن أن يفشل I/O للملفات لأسباب عديدة: رفض الصلاحيات، امتلاء القرص، مسار غير موجود، أو وصول متزامن. أفضل ممارسات Flutter هي معالجة الأخطاء بشكل صريح بدلاً من السماح للاستثناءات بالوصول إلى واجهة المستخدم:

  • استخدم try/catch مع أنواع استثناءات محددة (PathNotFoundException، FileSystemException) قبل الإمساك العام.
  • تحقق من await file.exists() قبل القراءة إن فضّلت المنطق الشرطي على الاستثناءات.
  • سجّل الأخطاء في التحليلات أو وحدة تحكم التصحيح بدلاً من ابتلاعها بصمت.
  • اعرض رسائل صديقة للمستخدم في واجهة المستخدم بدلاً من سلاسل الاستثناء الخام.
تحذير: لا تستدعِ أبداً I/O الملفات بشكل متزامن (مثل file.readAsStringSync()) على المسار الرئيسي في تطبيق Flutter. إعاقة الخيط الرئيسي تُسبّب تقطّعاً وإسقاطاً للإطارات. استخدم دائماً إصدارات async/await.

تجميع كل شيء في ودجت

النمط الشائع هو تحميل البيانات المحفوظة في initState وحفظها عند تفاعل المستخدم مع التطبيق:

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

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

  @override
  State<NotepadScreen> createState() => _NotepadScreenState();
}

class _NotepadScreenState extends State<NotepadScreen> {
  final _controller = TextEditingController();
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    _loadNote();
  }

  Future<File> get _noteFile async {
    final dir = await getApplicationDocumentsDirectory();
    return File(p.join(dir.path, 'note.txt'));
  }

  Future<void> _loadNote() async {
    try {
      final file = await _noteFile;
      if (await file.exists()) {
        final text = await file.readAsString();
        _controller.text = text;
      }
    } on FileSystemException catch (e) {
      debugPrint('Could not load note: $e');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  Future<void> _saveNote() async {
    try {
      final file = await _noteFile;
      await file.writeAsString(_controller.text);
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(const SnackBar(content: Text('Saved!')));
      }
    } on FileSystemException catch (e) {
      debugPrint('Could not save note: $e');
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return const Center(child: CircularProgressIndicator());
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Note'),
        actions: [
          IconButton(icon: const Icon(Icons.save), onPressed: _saveNote),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: TextField(
          controller: _controller,
          maxLines: null,
          expands: true,
          decoration: const InputDecoration(
            hintText: 'Start typing...',
            border: InputBorder.none,
          ),
        ),
      ),
    );
  }
}

ملخص

حزمة path_provider هي الطريقة القياسية للحصول على مسارات الأدلة الآمنة للمنصات في Flutter. استخدم getApplicationDocumentsDirectory() للبيانات الدائمة للمستخدم، وgetApplicationCacheDirectory() للبيانات المخزّنة مؤقتاً القابلة للإعادة، وgetTemporaryDirectory() للملفات المؤقتة. ادمج هذه المسارات مع dart:io File لقراءة البيانات النصية أو الثنائية وكتابتها بشكل لاتزامني، مع تغليف العمليات دائماً في try/catch لمعالجة أخطاء نظام الملفات بشكل أنيق والحفاظ على استجابة واجهة المستخدم.

النقطة الرئيسية: لا تُرمّز مسارات الملفات بشكل ثابت في تطبيق Flutter. احصل عليها دائماً في وقت التشغيل من path_provider، وأجرِ I/O دائماً باستخدام async/await لتجنّب إعاقة خيط واجهة المستخدم.