أساسيات إدارة الحالة

Provider: القراءة والمراقبة والتحديد

50 دقيقة الدرس 9 من 14

فهم طرق الوصول في Provider

يوفر Provider ثلاث طرق مختلفة للوصول إلى الحالة من الودجات: context.watch وcontext.read وcontext.select. كل طريقة تخدم غرضًا مختلفًا ولها خصائص أداء فريدة. اختيار الطريقة الصحيحة ضروري لبناء تطبيقات Flutter فعالة ومتجاوبة.

المبدأ الأساسي: الطريقة التي تصل بها إلى المزود تحدد متى وكم مرة يُعاد بناء الودجت الخاصة بك. استخدام طريقة الوصول الخاطئة هو أحد أكثر مصادر مشاكل الأداء شيوعًا في التطبيقات القائمة على Provider.

context.watch — إعادة البناء عند كل تغيير

طريقة context.watch<T>() تشترك الودجت في المزود وتُفعّل إعادة البناء في كل مرة تتغير فيها قيمة المزود. هذه هي طريقة الوصول الأكثر استخدامًا وهي مثالية عندما تحتاج الودجت لعرض بيانات تتحدث بشكل متكرر.

الاستخدام الأساسي لـ context.watch

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

class CounterDisplay extends StatelessWidget {
  const CounterDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    // يُعاد البناء في كل مرة يستدعي فيها CounterModel notifyListeners()
    final counter = context.watch<CounterModel>();

    return Text(
      'العدد: \${counter.count}',
      style: const TextStyle(fontSize: 24),
    );
  }
}

عند استدعاء context.watch<CounterModel>()، تسجل الودجت نفسها كمستمع. في كل مرة يتم فيها استدعاء notifyListeners() على CounterModel، يتم تشغيل طريقة build للودجت مرة أخرى، مما يضمن بقاء واجهة المستخدم متزامنة مع أحدث حالة.

نصيحة: يمكنك أيضًا استخدام Provider.of<T>(context) كمكافئ لـ context.watch<T>(). كلاهما يشترك في التغييرات ويُفعّل إعادة البناء. صيغة context.watch أقصر وأكثر حداثة.

context.read — وصول لمرة واحدة بدون إعادة بناء

طريقة context.read<T>() تسترد القيمة الحالية للمزود بدون الاشتراك في التغييرات. الودجت لن تُعاد بناءها عندما تتغير قيمة المزود. هذا مثالي لاستدعاء الطرق على المزود (مثل تفعيل الإجراءات) دون الحاجة لمراقبة حالته.

استخدام context.read للإجراءات

class CounterActions extends StatelessWidget {
  const CounterActions({super.key});

  @override
  Widget build(BuildContext context) {
    // لا حاجة لـ watch هنا — نستدعي الطرق فقط
    return ElevatedButton(
      onPressed: () {
        // قراءة المزود لاستدعاء طريقة
        context.read<CounterModel>().increment();
      },
      child: const Text('زيادة'),
    );
  }
}

في هذا المثال، الزر يحتاج فقط لـتفعيل إجراء. لا يعرض أي بيانات من المزود، لذلك لا يوجد سبب للاشتراك في التغييرات. استخدام context.read يمنع إعادة البناء غير الضرورية لودجت الزر.

تحذير: لا تستخدم أبدًا context.read داخل طريقة build لعرض البيانات. إذا قرأت قيمة بدون مراقبتها، ستعرض الودجت بيانات قديمة لأنها لن تُعاد بناءها عندما تتغير القيمة. استخدم context.watch أو context.select لأي قيمة تعرضها في واجهة المستخدم.

context.select — إعادة البناء عند تغيير حقل محدد

طريقة context.select<T, R>() هي أقوى أداة تحسين أداء في Provider. تتيح لك تحديد جزء معين من حالة المزود، والودجت تُعاد بناءها فقط عندما يتغير ذلك الجزء بالتحديد. هذا حاسم للنماذج المعقدة ذات الحقول المتعددة.

إعادة البناء الانتقائية مع context.select

class UserModel extends ChangeNotifier {
  String _name = 'إدريس';
  String _email = 'edrees@example.com';
  int _loginCount = 0;

  String get name => _name;
  String get email => _email;
  int get loginCount => _loginCount;

  void updateName(String newName) {
    _name = newName;
    notifyListeners();
  }

  void updateEmail(String newEmail) {
    _email = newEmail;
    notifyListeners();
  }

  void recordLogin() {
    _loginCount++;
    notifyListeners();
  }
}

class UserNameDisplay extends StatelessWidget {
  const UserNameDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    // يُعاد البناء فقط عند تغيير الاسم
    // لا يُعاد البناء عند تغيير البريد أو عدد تسجيلات الدخول
    final name = context.select<UserModel, String>(
      (user) => user.name,
    );

    return Text('مرحبًا، \$name');
  }
}

class UserEmailDisplay extends StatelessWidget {
  const UserEmailDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    // يُعاد البناء فقط عند تغيير البريد الإلكتروني
    final email = context.select<UserModel, String>(
      (user) => user.email,
    );

    return Text('البريد: \$email');
  }
}

في المثال أعلاه، UserNameDisplay يُعاد بناءه فقط عندما تتغير خاصية name. إذا تم استدعاء recordLogin() وزاد loginCount، فإن UserNameDisplay لا يُعاد بناءه لأن القيمة المحددة (name) لم تتغير. يستخدم Provider عامل == لمقارنة القيم المحددة السابقة والجديدة.

ودجت Selector

بالإضافة إلى context.select، يوفر Provider ودجت Selector التي توفر نفس سلوك إعادة البناء الانتقائية بصيغة قائمة على الودجات. يمكن أن يكون هذا مفيدًا عندما تريد تحكمًا أكثر وضوحًا أو عند العمل مع منطق تحديد معقد.

استخدام ودجت Selector

class OptimizedUserCard extends StatelessWidget {
  const OptimizedUserCard({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ودجت Selector — يُعاد البناء فقط عند تغيير الاسم
        Selector<UserModel, String>(
          selector: (context, user) => user.name,
          builder: (context, name, child) {
            return Text(
              'الاسم: \$name',
              style: const TextStyle(fontSize: 20),
            );
          },
        ),

        // Selector مع عنصر فرعي لا يُعاد بناءه أبدًا
        Selector<UserModel, int>(
          selector: (context, user) => user.loginCount,
          builder: (context, count, child) {
            return Row(
              children: [
                child!, // أيقونة ثابتة — لا تُعاد بناءها أبدًا
                Text('تسجيلات الدخول: \$count'),
              ],
            );
          },
          child: const Icon(Icons.login), // عنصر فرعي مُخزّن
        ),
      ],
    );
  }
}
نصيحة: معامل child في ودجت Selector يعمل مثل child في Consumer — يُبنى مرة واحدة ويُعاد استخدامه عبر عمليات إعادة البناء. استخدمه للأجزاء الثابتة من الشجرة الفرعية التي لا تعتمد على القيمة المحددة.

متى تستخدم كل طريقة

اختيار طريقة الوصول الصحيحة أمر حاسم لكل من الصحة والأداء. إليك دليل واضح:

دليل القرار

// 1. context.watch — عرض البيانات المتغيرة
//    استخدم عندما: الودجت تَعرض بيانات المزود
//    إعادة البناء: عند كل استدعاء لـ notifyListeners()
Widget build(BuildContext context) {
  final model = context.watch<MyModel>(); // اشتراك
  return Text(model.someValue);            // عرض بيانات
}

// 2. context.read — تفعيل الإجراءات
//    استخدم عندما: الودجت تَستدعي طرقًا على المزود
//    إعادة البناء: أبدًا (بدون اشتراك)
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () => context.read<MyModel>().doSomething(),
    child: const Text('إجراء'),         // تسمية ثابتة
  );
}

// 3. context.select — عرض بيانات محددة
//    استخدم عندما: الودجت تعرض جزءًا من بيانات المزود
//    إعادة البناء: فقط عند تغيير القيمة المحددة
Widget build(BuildContext context) {
  final title = context.select<MyModel, String>(
    (m) => m.title,
  );
  return Text(title);                      // هذا الحقل فقط
}

تحسين الأداء مع Select

أكبر مكاسب الأداء تأتي من استخدام context.select في عناصر القوائم والودجات التي تتحدث بشكل متكرر. تأمل سيناريو حيث يتم عرض قائمة منتجات، لكن السعر فقط يتحدث في الوقت الفعلي:

قائمة محسّنة مع select

class ProductCatalog extends ChangeNotifier {
  final List<Product> _products = [];
  List<Product> get products => List.unmodifiable(_products);

  void updatePrice(int index, double newPrice) {
    _products[index] = _products[index].copyWith(price: newPrice);
    notifyListeners();
  }
}

class ProductListView extends StatelessWidget {
  const ProductListView({super.key});

  @override
  Widget build(BuildContext context) {
    // مراقبة طول القائمة الكامل للإضافات/الحذف
    final productCount = context.select<ProductCatalog, int>(
      (catalog) => catalog.products.length,
    );

    return ListView.builder(
      itemCount: productCount,
      itemBuilder: (context, index) {
        return ProductTile(index: index);
      },
    );
  }
}

class ProductTile extends StatelessWidget {
  final int index;
  const ProductTile({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    // كل عنصر يُعاد بناءه فقط عندما يتغير منتجه
    final product = context.select<ProductCatalog, Product>(
      (catalog) => catalog.products[index],
    );

    return ListTile(
      title: Text(product.name),
      subtitle: Text('\$\${product.price.toStringAsFixed(2)}'),
      trailing: IconButton(
        icon: const Icon(Icons.edit),
        onPressed: () => context.read<ProductCatalog>().updatePrice(
          index, product.price + 1.0,
        ),
      ),
    );
  }
}

الأخطاء الشائعة

فهم ما لا يجب فعله لا يقل أهمية عن معرفة الأنماط الصحيحة. إليك أكثر الأخطاء شيوعًا التي يرتكبها المطورون مع طرق وصول Provider:

الخطأ 1: استخدام watch في الاستدعاءات الراجعة

خطأ مقابل صحيح: watch في الاستدعاءات الراجعة

// خطأ — context.watch داخل استدعاء راجع
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () {
      // سيرمي خطأ في وقت التشغيل!
      final model = context.watch<MyModel>();
      model.doSomething();
    },
    child: const Text('انقر'),
  );
}

// صحيح — استخدم context.read في الاستدعاءات الراجعة
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () {
      // read مصممة للوصول لمرة واحدة
      final model = context.read<MyModel>();
      model.doSomething();
    },
    child: const Text('انقر'),
  );
}

الخطأ 2: استخدام read في build للعرض

خطأ مقابل صحيح: read في build

// خطأ — الودجت ستعرض بيانات قديمة
Widget build(BuildContext context) {
  final counter = context.read<CounterModel>();
  return Text('العدد: \${counter.count}'); // لا يتحدث أبدًا!
}

// صحيح — استخدم watch للبقاء متزامنًا
Widget build(BuildContext context) {
  final counter = context.watch<CounterModel>();
  return Text('العدد: \${counter.count}'); // يتحدث عند التغيير
}

// الأفضل — استخدم select إذا كنت تحتاج حقلًا واحدًا فقط
Widget build(BuildContext context) {
  final count = context.select<CounterModel, int>(
    (c) => c.count,
  );
  return Text('العدد: \$count'); // يتحدث فقط عند تغيير العدد
}
تحذير: استخدام context.watch داخل initState أو dispose أو أي استدعاء راجع غير متزامن (مثل Future.then أو استدعاءات Timer) سيرمي خطأ. استخدم دائمًا context.read خارج طريقة build.

مثال عملي: عربة تسوق محسّنة

لنبني مثالًا كاملًا يوضح جميع طرق الوصول الثلاث تعمل معًا في سيناريو عربة التسوق:

مثال عربة التسوق الكامل

class CartModel extends ChangeNotifier {
  final List<CartItem> _items = [];
  double _taxRate = 0.15;

  List<CartItem> get items => List.unmodifiable(_items);
  int get itemCount => _items.length;
  double get taxRate => _taxRate;

  double get subtotal =>
      _items.fold(0, (sum, item) => sum + item.price * item.quantity);
  double get tax => subtotal * _taxRate;
  double get total => subtotal + tax;

  void addItem(CartItem item) {
    _items.add(item);
    notifyListeners();
  }

  void removeItem(int index) {
    _items.removeAt(index);
    notifyListeners();
  }

  void updateQuantity(int index, int quantity) {
    _items[index] = _items[index].copyWith(quantity: quantity);
    notifyListeners();
  }
}

// الشارة تهتم فقط بعدد العناصر
class CartBadge extends StatelessWidget {
  const CartBadge({super.key});

  @override
  Widget build(BuildContext context) {
    // select: يُعاد البناء فقط عند تغيير عدد العناصر
    final count = context.select<CartModel, int>(
      (cart) => cart.itemCount,
    );

    return Badge(
      label: Text('\$count'),
      child: const Icon(Icons.shopping_cart),
    );
  }
}

// عرض الإجمالي يهتم فقط بالمجموع
class CartTotalDisplay extends StatelessWidget {
  const CartTotalDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    // select: يُعاد البناء فقط عند تغيير المجموع
    final total = context.select<CartModel, double>(
      (cart) => cart.total,
    );

    return Text(
      'المجموع: \$\${total.toStringAsFixed(2)}',
      style: const TextStyle(
        fontSize: 24,
        fontWeight: FontWeight.bold,
      ),
    );
  }
}

// زر الدفع يحتاج فقط لتفعيل الإجراءات
class CheckoutButton extends StatelessWidget {
  const CheckoutButton({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        // read: وصول لمرة واحدة لاستدعاء طريقة
        final cart = context.read<CartModel>();
        processCheckout(cart.items, cart.total);
      },
      child: const Text('الدفع'),
    );
  }
}

// صفحة العربة تراقب النموذج الكامل لقائمة العناصر
class CartPage extends StatelessWidget {
  const CartPage({super.key});

  @override
  Widget build(BuildContext context) {
    // watch: يُعاد البناء عند أي تغيير في العربة
    final cart = context.watch<CartModel>();

    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            itemCount: cart.items.length,
            itemBuilder: (context, index) {
              final item = cart.items[index];
              return ListTile(
                title: Text(item.name),
                subtitle: Text('الكمية: \${item.quantity}'),
                trailing: IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () =>
                      context.read<CartModel>().removeItem(index),
                ),
              );
            },
          ),
        ),
        const CartTotalDisplay(),
        const CheckoutButton(),
      ],
    );
  }
}
ملخص الأداء: في مثال العربة هذا، الشارة تُعاد بناءها فقط عند إضافة أو إزالة عناصر، وعرض المجموع يُعاد بناءه فقط عند تغيير السعر، وزر الدفع لا يُعاد بناءه أبدًا بسبب تغييرات الحالة. فقط صفحة العربة نفسها تُعاد بناءها عند كل تغيير لأنها تعرض قائمة العناصر الكاملة.