إدارة الحالة في Flutter: رفع الحالة و ChangeNotifier و Provider
setState ممتازة ما دامت البيانات تخص شاشة واحدة. لكن فكّر في سلة المشتريات: شاشة المنتجات تضيف إليها، وأيقونة في الشريط العلوي تعرض عددها، وشاشة السلة تعرض محتواها، وشاشة الدفع تحسب إجماليها. أربعة أماكن تحتاج نفس البيانات، ويجب أن تتحدث كلها معاً. أين نضع هذه البيانات؟ هذا السؤال هو موضوع إدارة الحالة (State Management).
نوعان من الحالة
Section titled “نوعان من الحالة”| الحالة المحلية | الحالة المشتركة | |
|---|---|---|
| من يحتاجها | Widget واحد | عدة شاشات |
| أمثلة | هل كلمة المرور ظاهرة؟ رقم التبويب الحالي | السلة، بيانات المستخدم، الوضع الليلي |
| الأداة | setState |
رفع الحالة، أو Provider |
لا تعقّد الأمور: الحالة المحلية تبقى في مكانها مع setState. نحتاج الأدوات الأخرى فقط عندما تتشارك البيانات عدةُ أماكن.
الحل الأول: رفع الحالة للأعلى
Section titled “الحل الأول: رفع الحالة للأعلى”البيانات في Flutter تنزل من الأب إلى الأبناء عبر المُنشئ. فإذا احتاج Widgetان شقيقان نفس البيانات، نضعها في أقرب أب مشترك لهما، وهو يمررها للاثنين.
تخيّل أخوين يتشاركان مصروفاً: لا يحتفظ به أحدهما، بل يبقى مع الأب، وكل واحد يطلب منه ما يريد.
class CartPage extends StatefulWidget { const CartPage({super.key});
@override State<CartPage> createState() => _CartPageState();}
class _CartPageState extends State<CartPage> { int _items = 0;
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('السلة ($_items)')), body: Center( child: AddButton( onAdd: () => setState(() => _items++), ), ), ); }}
class AddButton extends StatelessWidget { const AddButton({super.key, required this.onAdd});
final VoidCallback onAdd;
@override Widget build(BuildContext context) { return FilledButton( onPressed: onAdd, child: const Text('أضف إلى السلة'), ); }}الحركة في اتجاهين:
- البيانات تنزل: الأب يملك
_itemsويعرضها في العنوان. - الأحداث تصعد:
AddButtonلا يملك الحالة ولا يغيّرها. يستقبل دالةonAddمن الأب (تسمى دالة راجعة، Callback) ويستدعيها عند الضغط. الأب هو من ينفذsetState.
VoidCallback نوع جاهز معناه «دالة بلا معاملات ولا قيمة مرجعة». ولدالة تستقبل قيمة يوجد ValueChanged<T>، وقد استخدمته في درس الثيمات.
المشكلة: تمرير عبر طبقات كثيرة
Section titled “المشكلة: تمرير عبر طبقات كثيرة”رفع الحالة ممتاز لمسافة قصيرة. لكن إذا كان الزر على عمق خمس طبقات تحت الأب، فكل طبقة في الطريق يجب أن تستقبل البيانات والدالة وتمررها لمن تحتها، مع أنها لا تستخدمها. هذه المشكلة تسمى Prop Drilling، وتجعل الكود طويلاً وهشاً.
نريد طريقة يصل بها أي Widget إلى البيانات مباشرة، مهما كان عمقه.
الحل الثاني: ChangeNotifier
Section titled “الحل الثاني: ChangeNotifier”الخطوة الأولى أن نُخرج البيانات ومنطقها من الواجهة إلى كلاس مستقل:
class CartModel extends ChangeNotifier { final List<String> _items = [];
List<String> get items => List.unmodifiable(_items); int get count => _items.length;
void add(String name) { _items.add(name); notifyListeners(); }
void remove(String name) { _items.remove(name); notifyListeners(); }}ChangeNotifier: كلاس جاهز في Flutter يعطي كلاسك قدرة على إبلاغ المستمعين عند التغيير.notifyListeners(): تنادي: «تغيّرت، أعيدوا البناء». هي بديلsetStateخارج الواجهة. إذا نسيتها تتغير البيانات ولا تتحدث الشاشة.- القائمة
_itemsخاصة، ونعرضها للخارج بنسخة لا تقبل التعديل. بهذا لا يستطيع أحد تغيير السلة إلا عبرaddوremove، فيمر كل تغيير علىnotifyListeners.
فائدة إضافية: هذا الكلاس Dart خالص، لا يعرف شيئاً عن الواجهة، فيسهل اختباره وإعادة استخدامه.
الحل الثالث: Provider
Section titled “الحل الثالث: Provider”بقي أن نوصل CartModel إلى كل الشاشات. هذه وظيفة حزمة provider، وهي من أشهر حزم إدارة الحالة وأبسطها:
flutter pub add providerتعمل في ثلاث خطوات.
1. وفّر النموذج في أعلى الشجرة:
void main() { runApp( ChangeNotifierProvider( create: (context) => CartModel(), child: const ShopApp(), ), );}ChangeNotifierProvider ينشئ CartModel واحداً، ويجعله متاحاً لكل ما تحته.
2. اقرأ وراقب بـ context.watch:
final cart = context.watch<CartModel>();watch تعطيك النموذج، وتسجّل هذا الـ Widget مستمعاً: كلما استُدعيت notifyListeners، أُعيد بناؤه تلقائياً.
3. نفّذ إجراء بـ context.read:
context.read<CartModel>().add('سماعة');read تعطيك النموذج بدون تسجيل استماع. استخدمها داخل دوال الأحداث مثل onPressed، حيث تريد تنفيذ شيء فقط.
context.watch |
context.read |
|
|---|---|---|
| تعيد البناء عند التغيير | نعم | لا |
| أين تُستخدم | داخل build لعرض البيانات |
داخل onPressed وأمثالها |
تخيّل Provider مثل إذاعة المدرسة: المذيع (CartModel) يعلن الخبر مرة واحدة، وكل فصل شغّل السماعة (watch) يسمعه فوراً، دون أن يمرّ الخبر من فصل إلى فصل.
المشروع: متجر بسلة مشتركة
Section titled “المشروع: متجر بسلة مشتركة”import 'package:flutter/material.dart';import 'package:provider/provider.dart';
class CartModel extends ChangeNotifier { final List<String> _items = [];
List<String> get items => List.unmodifiable(_items); int get count => _items.length;
bool contains(String name) => _items.contains(name);
void toggle(String name) { if (_items.contains(name)) { _items.remove(name); } else { _items.add(name); } notifyListeners(); }}
void main() { runApp( ChangeNotifierProvider( create: (context) => CartModel(), child: const ShopApp(), ), );}
class ShopApp extends StatelessWidget { const ShopApp({super.key});
@override Widget build(BuildContext context) { return const MaterialApp( debugShowCheckedModeBanner: false, home: Directionality( textDirection: TextDirection.rtl, child: ProductsScreen(), ), ); }}
class ProductsScreen extends StatelessWidget { const ProductsScreen({super.key});
static const products = ['سماعة', 'ساعة ذكية', 'لوحة مفاتيح', 'فأرة'];
@override Widget build(BuildContext context) { final cart = context.watch<CartModel>();
return Scaffold( appBar: AppBar( title: const Text('المنتجات'), actions: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Center( child: Badge( label: Text('${cart.count}'), child: const Icon(Icons.shopping_cart_outlined), ), ), ), ], ), body: ListView( children: [ for (final product in products) ListTile( title: Text(product), trailing: IconButton( icon: Icon( cart.contains(product) ? Icons.remove_shopping_cart : Icons.add_shopping_cart, ), tooltip: 'إضافة أو إزالة', onPressed: () => context.read<CartModel>().toggle(product), ), ), const Divider(), const CartSummary(), ], ), ); }}
class CartSummary extends StatelessWidget { const CartSummary({super.key});
@override Widget build(BuildContext context) { final cart = context.watch<CartModel>();
return Padding( padding: const EdgeInsets.all(16), child: Text( cart.count == 0 ? 'السلة فارغة' : 'في السلة: ${cart.items.join('، ')}', ), ); }}انتبه إلى CartSummary: لم يستقبل أي شيء في مُنشئه، ومع ذلك يعرض محتوى السلة ويتحدث مع كل ضغطة. وصل إلى البيانات مباشرة بـ context.watch، وهذا بالضبط ما أردناه. والشارة فوق أيقونة السلة تتحدث في نفس اللحظة، لأنها تراقب نفس النموذج.
وماذا عن Riverpod و Bloc؟
Section titled “وماذا عن Riverpod و Bloc؟”ستسمع أسماء كثيرة: Riverpod و Bloc و GetX وغيرها. كلها تحل نفس المشكلة بأساليب مختلفة: أين تعيش البيانات المشتركة، وكيف تعرف الواجهة أنها تغيّرت؟ لا تتشتت بينها الآن. أتقن setState ثم Provider، وابنِ بهما تطبيقاً أو اثنين. بعدها ستفهم أي أداة أخرى في يوم واحد، لأن الأفكار نفسها.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل المتجر وأضف منتجات واحذفها، وراقب الشارة والملخص.
- أضف إلى
CartModelدالةclearتفرغ السلة، وزراً يستدعيها. - احذف
notifyListeners()منtoggleمؤقتاً وشاهد ماذا يحدث، ثم أعدها. - تحدٍّ: أضف شاشة ثانية «السلة» تفتح بـ
Navigator.pushوتعرض العناصر في قائمة مع زر حذف لكل عنصر. لاحظ أنك لن تمرر السلة إليها في المُنشئ.
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان notifyListeners: البيانات تتغير فعلاً، لكن لا أحد يعلم، فتبقى الشاشة كما هي.
- استخدام context.watch داخل onPressed: يسبب خطأ. داخل دوال الأحداث استخدم
context.read. - وضع Provider تحت الـ Widget الذي يحتاجه: يظهر الخطأ «Could not find the correct Provider». المزوّد يجب أن يكون فوق كل من يستخدمه في الشجرة.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
ما وظيفة notifyListeners() في كلاس يرث ChangeNotifier؟
notifyListeners تنبه كل Widget يراقب النموذج، فيُعاد بناؤه ويعرض القيم الجديدة.
سؤال
أي دالة تستخدم داخل onPressed لتنفيذ إجراء على النموذج؟
context.read تجلب النموذج دون تسجيل استماع، وهي المناسبة لدوال الأحداث. أما watch فمكانها داخل build.
الخلاصة
Section titled “الخلاصة”- الحالة المحلية مكانها
setState، والحالة المشتركة تحتاج حلاً أعلى. - رفع الحالة: البيانات تنزل عبر المُنشئ، والأحداث تصعد عبر الدوال الراجعة.
ChangeNotifierيفصل البيانات والمنطق عن الواجهة، وnotifyListenersتعلن التغيير.ChangeNotifierProviderيوفر النموذج لكل الشجرة تحته.context.watchللعرض داخلbuild، وcontext.readللإجراءات داخل الأحداث.