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

إدارة الحالة في Flutter: رفع الحالة و ChangeNotifier و Provider

الدرس 31 من 33 · Flutter

setState ممتازة ما دامت البيانات تخص شاشة واحدة. لكن فكّر في سلة المشتريات: شاشة المنتجات تضيف إليها، وأيقونة في الشريط العلوي تعرض عددها، وشاشة السلة تعرض محتواها، وشاشة الدفع تحسب إجماليها. أربعة أماكن تحتاج نفس البيانات، ويجب أن تتحدث كلها معاً. أين نضع هذه البيانات؟ هذا السؤال هو موضوع إدارة الحالة (State Management).

الحالة المحلية الحالة المشتركة
من يحتاجها 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 إلى البيانات مباشرة، مهما كان عمقه.

الخطوة الأولى أن نُخرج البيانات ومنطقها من الواجهة إلى كلاس مستقل:

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 خالص، لا يعرف شيئاً عن الواجهة، فيسهل اختباره وإعادة استخدامه.

بقي أن نوصل 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 “المشروع: متجر بسلة مشتركة”
lib/main.dart
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 و GetX وغيرها. كلها تحل نفس المشكلة بأساليب مختلفة: أين تعيش البيانات المشتركة، وكيف تعرف الواجهة أنها تغيّرت؟ لا تتشتت بينها الآن. أتقن setState ثم Provider، وابنِ بهما تطبيقاً أو اثنين. بعدها ستفهم أي أداة أخرى في يوم واحد، لأن الأفكار نفسها.

  1. شغّل المتجر وأضف منتجات واحذفها، وراقب الشارة والملخص.
  2. أضف إلى CartModel دالة clear تفرغ السلة، وزراً يستدعيها.
  3. احذف notifyListeners() من toggle مؤقتاً وشاهد ماذا يحدث، ثم أعدها.
  4. تحدٍّ: أضف شاشة ثانية «السلة» تفتح بـ Navigator.push وتعرض العناصر في قائمة مع زر حذف لكل عنصر. لاحظ أنك لن تمرر السلة إليها في المُنشئ.
  • نسيان notifyListeners: البيانات تتغير فعلاً، لكن لا أحد يعلم، فتبقى الشاشة كما هي.
  • استخدام context.watch داخل onPressed: يسبب خطأ. داخل دوال الأحداث استخدم context.read.
  • وضع Provider تحت الـ Widget الذي يحتاجه: يظهر الخطأ «Could not find the correct Provider». المزوّد يجب أن يكون فوق كل من يستخدمه في الشجرة.

سؤال

ما وظيفة notifyListeners() في كلاس يرث ChangeNotifier؟

سؤال

أي دالة تستخدم داخل onPressed لتنفيذ إجراء على النموذج؟

  • الحالة المحلية مكانها setState، والحالة المشتركة تحتاج حلاً أعلى.
  • رفع الحالة: البيانات تنزل عبر المُنشئ، والأحداث تصعد عبر الدوال الراجعة.
  • ChangeNotifier يفصل البيانات والمنطق عن الواجهة، و notifyListeners تعلن التغيير.
  • ChangeNotifierProvider يوفر النموذج لكل الشجرة تحته.
  • context.watch للعرض داخل build، و context.read للإجراءات داخل الأحداث.