Flutter: التخزين المحلي
البيانات بدون استمرارية هي ندى الصباح — تختفي عند إغلاق التطبيق. التخزين المحلي يُعطي البيانات بيتًا على الجهاز.
📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا
- الدرس 12: إدارة الحالة — Riverpod
1. ما ستتعلمه
- SharedPreferences: تخزين أزواج المفتاح-القيمة (إعدادات المستخدم، تفضيلات المظهر، علامة أول تشغيل)
- Hive: قاعدة بيانات NoSQL خفيفة، TypeAdapter لتسلسل كائنات مخصصة
- sqflite / Drift: قاعدة بيانات علائقية، نمط DAO واستراتيجية الترحيل
- flutter_secure_storage: تخزين مشفر (رموز، مفاتيح)
- ShopApp: تخزين مؤقت لمنتجات Hive + SecureStorage لـ JWT + SharedPreferences لتفضيلات المستخدم
2. قصة فقدان البيانات دون اتصال
(1) المشكلة: البيانات تُعاد تعيينها عند كل تشغيل
يُبلغ مستخدمو ShopApp لبوب أن كل مرة يفتحون فيها التطبيق، السلة فارغة، والمنتجات المفضلة اختفت، وإعدادات المظهر عادت للافتراضي. هذا لأن جميع البيانات موجودة فقط في الذاكرة — إغلاق التطبيق يعني فقدان كل شيء. الأسوأ، JWT Token مخزن أيضًا في الذاكرة ويضيع عند تبديل الصفحات، مما يُجبر المستخدمين على تسجيل الدخول مرارًا.
(2) حل التخزين الطبقي
بيانات مختلفة لها احتياجات تخزين مختلفة: الرموز تحتاج تشفيرًا، وذاكرة المنتجات المؤقتة تحتاج تخزينًا مهيكلًا، وإعدادات المستخدم تحتاج فقط أزواج مفتاح-قيمة بسيطة.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ⚙️ تثبيت التبعية: flutter pub add flutter_secure_storage hive hive_flutter shared_preferences
// Layered storage strategy
final token = await SecureStorage.getToken(); // Encrypted
final cached = await HiveBox.getProducts(); // NoSQL
final theme = await SharedPreferences.getTheme(); // KV
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) الفائدة: قابل للاستخدام دون اتصال + تسجيل دخول سلس
بعد تنفيذ التخزين المحلي، يحصل بوب على استمرارية الرمز المشفر لتسجيل دخول سلس، وتخزين مؤقت للمنتجات للتصفح دون اتصال، وتفضيلات تصمد إعادة تشغيل التطبيق.
3. اختيار حل التخزين
graph LR
subgraph Storage Selection
SP[SharedPreferences] --> |KV lightweight| Settings[User Settings]
HV[Hive] --> |NoSQL| Cache[Product Cache]
DR[Drift/SQLite] --> |SQL| Orders[Order History]
FSS[SecureStorage] --> |Encrypted| Token[JWT Token]
end
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
| الحل | نوع البيانات | مشفر | السعة | حالة الاستخدام |
|---|---|---|---|---|
| SharedPreferences | أزواج KV بسيطة | ❌ | صغيرة | إعدادات، علامات |
| Hive | مستندات NoSQL | اختياري | متوسطة | تخزين مؤقت، كائنات |
| Drift/SQLite | علائقي SQL | ❌ | كبيرة | طلبات، سجل |
| SecureStorage | أزواج KV مشفرة | ✅ | صغيرة | رموز، مفاتيح |
4. SharedPreferences
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تفضيلات المستخدم
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ⚙️ تثبيت التبعية: flutter pub add shared_preferences
class UserPreferences {
static const _keyTheme = 'theme_mode';
static const _keyLocale = 'locale';
static const _keyFirstLaunch = 'first_launch';
static const _keyCurrency = 'currency';
static Future<ThemeMode> getTheme() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_keyTheme);
return ThemeMode.values.firstWhere((m) => m.name == value, orElse: () => ThemeMode.system);
}
static Future<void> setTheme(ThemeMode mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyTheme, mode.name);
}
static Future<bool> isFirstLaunch() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyFirstLaunch) ?? true;
}
static Future<void> setFirstLaunchDone() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyFirstLaunch, false);
}
static Future<String> getCurrency() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyCurrency) ?? 'USD';
}
static Future<void> setCurrency(String currency) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyCurrency, currency);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: صفحة التعريف عند أول تشغيل
import 'package:flutter/material.dart';
// Custom class definition source:
// - UserPreferences: see Lesson 4 SharedPreferences example in this lesson
class SplashPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
_checkFirstLaunch(context);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
Future<void> _checkFirstLaunch(BuildContext context) async {
final isFirst = await UserPreferences.isFirstLaunch();
if (isFirst) {
Navigator.pushReplacementNamed(context, '/onboarding');
await UserPreferences.setFirstLaunchDone();
} else {
Navigator.pushReplacementNamed(context, '/home');
}
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. قاعدة بيانات Hive NoSQL
(1) المفاهيم الأساسية لـ Hive
| المفهوم | الوصف |
|---|---|
| Hive | نسخة قاعدة البيانات |
| Box | مشابه لجدول، يخزن أزواج مفتاح-قيمة |
| TypeAdapter | تسلسل/إلغاء تسلسل كائنات مخصصة |
| HiveObject | كائن مستمر بمفتاح يُدار تلقائيًا |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تخزين مؤقت لمنتجات Hive
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
// ⚙️ تثبيت التبعية: flutter pub add hive hive_flutter
// ⚙️ تبعية تطوير: flutter pub add --dev hive_generator build_runner
// Custom class definition source:
// - Product: see Lesson 11 json_serializable model (simplified version below)
/*
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, required this.category});
}
*/
// Model with Hive adapter
@HiveType(typeId: 0)
class ProductHive extends HiveObject {
@HiveField(0) late int id;
@HiveField(1) late String name;
@HiveField(2) late double price;
@HiveField(3) late String imageUrl;
@HiveField(4) late String category;
}
// Initialize Hive
Future<void> initHive() async {
await Hive.initFlutter();
Hive.registerAdapter(ProductHiveAdapter());
await Hive.openBox<ProductHive>('products');
await Hive.openBox('cart');
}
// Product cache repository
class ProductCache {
static const _boxName = 'products';
static Future<void> saveProducts(List<Product> products) async {
final box = Hive.box<ProductHive>(_boxName);
await box.clear();
for (final p in products) {
await box.put(p.id, ProductHive()
..id = p.id
..name = p.name
..price = p.price
..imageUrl = p.imageUrl
..category = p.category);
}
}
static Future<List<Product>> getProducts() async {
final box = Hive.box<ProductHive>(_boxName);
if (box.isEmpty) return [];
return box.values.map((h) => Product(
id: h.id, name: h.name, price: h.price,
imageUrl: h.imageUrl, category: h.category,
)).toList();
}
static Future<void> clearCache() async {
final box = Hive.box<ProductHive>(_boxName);
await box.clear();
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: استمرارية سلة Hive
import 'package:hive/hive.dart';
// ⚙️ تثبيت التبعية: flutter pub add hive hive_flutter
// Custom class definition source:
// - CartItem: see Lesson 12 CartNotifier example
class CartStorage {
static const _boxName = 'cart';
static Future<void> saveCart(List<CartItem> items) async {
final box = Hive.box(_boxName);
await box.clear();
await box.put('items', items.map((i) => {
'product_id': i.product.id,
'quantity': i.quantity,
}).toList());
}
static Future<List<Map<String, dynamic>>> loadCart() async {
final box = Hive.box(_boxName);
final data = box.get('items');
return data != null ? List<Map<String, dynamic>>.from(data) : [];
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. قاعدة بيانات Drift (SQLite) العلائقية
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: قاعدة بيانات طلبات Drift
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
// ⚙️ تثبيت التبعية: flutter pub add drift drift_flutter sqlite3_flutter_libs
// ⚙️ تبعية تطوير: flutter pub add --dev drift_dev build_runner
// Table definition
class Orders extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get orderNumber => text().withLength(min: 8, max: 20)();
RealColumn get total => real()();
TextColumn get status => text().withDefault(const Constant('pending'))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get currency => text().withDefault(const Constant('USD'))();
}
class OrderItems extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get orderId => integer().references(Orders, #id)();
TextColumn get productName => text()();
RealColumn get price => real()();
IntColumn get quantity => integer()();
}
// Database class
@DriftDatabase(tables: [Orders, OrderItems])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(NativeDatabase.memory());
@override
int get schemaVersion => 1;
// Create order
Future<int> createOrder(OrdersCompanion order) =>
into(orders).insert(order);
// Get order with items
Future<List<OrderWithItems>> getOrderWithItems(int orderId) {
final query = select(orders).join([
leftOuterJoin(orderItems, orderItems.orderId.equalsExp(orders.id)),
])..where(orders.id.equals(orderId));
return query.map((row) {
final order = row.readTable(orders);
final item = row.readTableOrNull(orderItems);
return OrderWithItems(order: order, item: item);
}).toList();
}
// Get recent orders
Future<List<Order>> getRecentOrders() =>
(select(orders)..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
..limit(20)).get();
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. Flutter Secure Storage
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تخزين JWT Token مشفر
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
// ⚙️ تثبيت التبعية: flutter pub add flutter_secure_storage
class SecureStorage {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _keyAccessToken = 'access_token';
static const _keyRefreshToken = 'refresh_token';
static const _keyUserId = 'user_id';
static Future<void> saveTokens({
required String accessToken,
required String refreshToken,
required String userId,
}) async {
await _storage.write(key: _keyAccessToken, value: accessToken);
await _storage.write(key: _keyRefreshToken, value: refreshToken);
await _storage.write(key: _keyUserId, value: userId);
}
static Future<String?> getAccessToken() =>
_storage.read(key: _keyAccessToken);
static Future<String?> getRefreshToken() =>
_storage.read(key: _keyRefreshToken);
static Future<void> clearAll() => _storage.deleteAll();
static Future<bool> isLoggedIn() async {
final token = await getAccessToken();
return token != null && token.isNotEmpty;
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
8. مثال كامل: طبقة تخزين ShopApp
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ⚙️ تثبيت التبعية: flutter pub add flutter_riverpod riverpod_annotation shared_preferences
// ⚙️ تبعية تطوير: flutter pub add --dev riverpod_generator build_runner
// Custom class definition sources:
// - SecureStorage: see Lesson 7 Flutter Secure Storage in this lesson
// - Product: see Lesson 11 json_serializable model
// - User/AuthService: see Lesson 14 Auth Notifier
// - productRepositoryProvider: see Lesson 14 ProductRepository
// storage_provider.dart - Riverpod integration
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in main');
});
final secureStorageProvider = Provider<SecureStorage>((ref) => SecureStorage());
final productCacheProvider = Provider<ProductCache>((ref) => ProductCache());
// Auth state with persistent token
@riverpod
class Auth extends _$Auth {
@override
Future<User?> build() async {
final storage = ref.read(secureStorageProvider);
final token = await storage.getAccessToken();
if (token == null) return null;
// Validate token with API
try {
final user = await AuthService.validateToken(token);
return user;
} catch (_) {
await storage.clearAll();
return null;
}
}
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final result = await AuthService.login(email, password);
await ref.read(secureStorageProvider).saveTokens(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
userId: result.user.id,
);
return result.user;
});
}
Future<void> logout() async {
await ref.read(secureStorageProvider).clearAll();
state = const AsyncData(null);
}
}
// Product with offline cache
@riverpod
class Products extends _$Products {
@override
Future<List<Product>> build() async {
final cache = ref.read(productCacheProvider);
// Try cache first
final cached = await cache.getProducts();
if (cached.isNotEmpty) return cached;
// Fallback to API
return _loadFromApi();
}
Future<List<Product>> _loadFromApi() async {
final repo = ref.read(productRepositoryProvider);
final products = await repo.getProducts();
await ref.read(productCacheProvider).saveProducts(products);
return products;
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _loadFromApi());
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
onUpgrade للترحيل. Hive يستخدم box.deleteAndSaveFromStorage() أو تحويل بيانات يدوي.📖 ملخص
- SharedPreferences يخزن أزواج مفتاح-قيمة بسيطة (إعدادات/علامات)، غير مناسب للبيانات الكبيرة
- Hive NoSQL مناسب للتخزين المؤقت للكائنات، TypeAdapter يدعم التسلسل المخصص
- Drift (SQLite) مناسب للبيانات العلائقية (طلبات/سجل)، آمن الأنواع + دعم الترحيل
- SecureStorage يُشفر المعلومات الحساسة (رموز/مفاتيح)، Android يستخدم EncryptedSharedPreferences
- استراتيجية أولوية عدم الاتصال: التخزين المؤقت أولًا + الشبكة كاحتياط، تكامل Riverpod للتبديل السلس
📝 تمارين
- أساسي (⭐): استخدم SharedPreferences للاحتفاظ بخيار الوضع الداكن، مع بقاء الاختيار بعد إعادة تشغيل التطبيق.
- متوسط (⭐⭐): استخدم Hive للاحتفاظ بالسلة محليًا — بيانات السلة تصمد إغلاق وإعادة فتح التطبيق.
- متقدم (⭐⭐⭐): نفّذ استراتيجية تخزين مؤقت كاملة دون اتصال: قراءة تخزين Hive المؤقت أولًا للعرض، جلب من API في الخلفية، ثم تحديث UI والتخزين المؤقت بعد التحديث.