إدارة الحالة المتقدمة (Bloc و Riverpod)

اختيار الحل المناسب: BLoC مقابل Riverpod في التطبيق العملي

16 دقيقة الدرس 14 من 14

اختيار الحل المناسب: BLoC مقابل Riverpod في التطبيق العملي

بعد تعلم BLoC وCubit وRiverpod كلٍّ على حدة، يصبح السؤال الأكثر إلحاحاً في العمل الفعلي: أيٌّ منها يجب استخدامه فعلاً؟ لا توجد إجابة شاملة، لكن توجد إطار قرار منظّم. يقارن هذا الدرس بين الحلَّين عبر خمسة أبعاد حاسمة، ثم يتناول تفصيلاً عملياً يُظهر كيفية ترحيل ميزة مبنية بـCubit إلى Notifier في Riverpod.

البُعد الأول — الكود المتكرر والإسهاب

يفرض BLoC أكبر قدر من البنية. حتى ميزة بسيطة كالمستخدم المصادق عليه تتطلب تسلسلاً هرمياً لفئات الأحداث، وتسلسلاً هرمياً لفئات الحالة، وفئة Bloc ذاتها. يُزيل Cubit طبقة الأحداث لكنه لا يزال يحتاج إلى فئة حالة منفصلة. تُدمج Notifier في Riverpod كل شيء في فئة واحدة:

نفس الميزة — Cubit مقابل Riverpod Notifier

// --- نهج CUBIT (3 ملفات على الأقل) ---

// auth_state.dart
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
  final User user;
  AuthAuthenticated(this.user);
}
class AuthError extends AuthState {
  final String message;
  AuthError(this.message);
}

// auth_cubit.dart
class AuthCubit extends Cubit<AuthState> {
  final AuthRepository _repo;
  AuthCubit(this._repo) : super(AuthInitial());

  Future<void> login(String email, String password) async {
    emit(AuthLoading());
    try {
      final user = await _repo.login(email, password);
      emit(AuthAuthenticated(user));
    } catch (e) {
      emit(AuthError(e.toString()));
    }
  }
}

// --- نهج RIVERPOD NOTIFIER (ملف واحد) ---

// auth_provider.dart
@riverpod
class Auth extends _$Auth {
  @override
  AsyncValue<User?> build() => const AsyncValue.data(null);

  Future<void> login(String email, String password) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(
      () => ref.read(authRepositoryProvider).login(email, password),
    );
  }
}
ملاحظة: AsyncValue<T> في Riverpod هو اتحاد مُغلَق من البيانات/التحميل/الخطأ مدمج في الإطار. تحصل على نفس التغطية الثلاثية التي يحتاج Cubit إلى أربع فئات مكتوبة يدوياً للتعبير عنها — دون أي تكلفة إضافية.

البُعد الثاني — قابلية الاختبار

كلا النهجين قابلان للاختبار بدرجة عالية، لكنهما يختلفان في تعقيد الإعداد. تستخدم اختبارات Cubit مساعد blocTest من مكتبة bloc_test للتحقق من تسلسلات الحالة المُصدَرة. تستخدم اختبارات Riverpod ProviderContainer مع إعادة تعريف التبعيات — لا حاجة لمكتبة اختبار مخصصة للحالات الأساسية.

  • BLoC/Cubit: يُقرأ blocTest بشكل طبيعي كمواصفات BDD؛ ممتاز للفرق التي تفكر بتسلسلات الأحداث. يتطلب تبعية bloc_test.
  • Riverpod: تجعل إعادة تعريف ProviderContainer حقن المحاكيات أمراً تافهاً. السلامة في وقت الترجمة تعني مفاجآت أقل في وقت التشغيل أثناء الاختبار. لا حاجة لحزمة إضافية للاختبارات الأساسية.

البُعد الثالث — حجم الفريق وإنفاذ البنية

العقد الصارم المدفوع بالأحداث في BLoC هو ميزته الكبرى في الفرق الكبيرة. كل انتقال للحالة هو حدث صريح ومسمى يظهر في مراجعة الكود والسجلات والتحليلات. Riverpod أكثر مرونة، وهو ميزة للفرق الصغيرة وقد يكون ثغرة في الفرق الكبيرة (قد تتسرب المنطق إلى المزودين).

مصفوفة القرار

// متى تفضّل BLoC / Cubit:
// - فريق مكوّن من 5 مطورين أو أكثر يعملون على نفس المجال
// - مجال منظّم (مالية، رعاية صحية) يتطلب مسارات تدقيق
// - تستخدم flutter_bloc بالفعل ولديك أنماط راسخة
// - تدفقات معقدة مدفوعة بالأحداث (تدفقات WebSocket، تراجع/إعادة)

// متى تفضّل Riverpod:
// - فريق منفرد أو صغير (<= 4 مطورين)
// - مشروع جديد بلا استثمار سابق في BLoC
// - تريد سلامة وقت الترجمة (توليد الكود عبر @riverpod)
// - مزودون صغيرون قابلون للتركيب بدلاً من blocs كبيرة
// - تبعيات غير متزامنة بين المزودين (سلاسل ref.watch)

البُعد الرابع — التعقيد ومنحنى التعلم

Cubit هو أكثر نقاط الدخول سلاسة في عائلة BLoC. يُضيف BLoC الكامل تجريد الأحداث. أبرز عقبة مبكرة في Riverpod هي فهم أنواع المزودين (Provider وStateProvider وFutureProvider وNotifierProvider) وكائن ref. بمجرد تجاوز ذلك، يصبح نموذج التركيب في Riverpod بديهياً جداً.

نصيحة: إذا كان فريقك جديداً على إدارة الحالة، ابدأ بـCubit. سطح واجهة برمجته صغير (فئة واحدة، emit()، ومطابقة الأنماط على الحالة). يمكنك دائماً ترحيل Cubit إلى Riverpod Notifier بشكل تدريجي — فهما يتعيّنان تقريباً 1-إلى-1 كما هو موضح أدناه.

تفصيل الترحيل — من Cubit إلى Riverpod Notifier

يُظهر الترحيل التالي CartCubit يُعاد هيكلته إلى CartNotifier. المنطق متطابق؛ التغيير فقط في الأنابيب التقنية.

الترحيل خطوة بخطوة

// قبل — CartCubit
class CartState {
  final List<CartItem> items;
  final bool isLoading;
  const CartState({required this.items, this.isLoading = false});

  CartState copyWith({List<CartItem>? items, bool? isLoading}) {
    return CartState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

class CartCubit extends Cubit<CartState> {
  CartCubit() : super(const CartState(items: []));

  void addItem(CartItem item) {
    emit(state.copyWith(items: [...state.items, item]));
  }

  void removeItem(String itemId) {
    emit(state.copyWith(
      items: state.items.where((i) => i.id != itemId).toList(),
    ));
  }

  double get total =>
      state.items.fold(0.0, (sum, item) => sum + item.price);
}

// بعد — CartNotifier (Riverpod)
class CartState {
  final List<CartItem> items;
  final bool isLoading;
  const CartState({required this.items, this.isLoading = false});

  CartState copyWith({List<CartItem>? items, bool? isLoading}) {
    return CartState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

@riverpod
class Cart extends _$Cart {
  @override
  CartState build() => const CartState(items: []);

  void addItem(CartItem item) {
    state = state.copyWith(items: [...state.items, item]);
  }

  void removeItem(String itemId) {
    state = state.copyWith(
      items: state.items.where((i) => i.id != itemId).toList(),
    );
  }

  double get total =>
      state.items.fold(0.0, (sum, item) => sum + item.price);
}

// استهلاك واجهة المستخدم — Riverpod
class CartIcon extends ConsumerWidget {
  const CartIcon({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(cartProvider).items.length;
    return Badge(label: Text('$count'), child: const Icon(Icons.shopping_cart));
  }
}
تحذير: تجنّب الخطأ الشائع المتمثل في استدعاء ref.watch() داخل رد نداء onPressed للزر أو داخل إحدى الدوال — استخدم ref.read() فقط في الاستجابة للأحداث. ref.watch() ينتمي إلى build() للاشتراك في التحديثات التفاعلية.

الملخص — ورقة الغش للقرار

استخدم هذا المرجع السريع عند اختيار حل لميزة جديدة:

  • اصطلاحات فريق صارمة + الحاجة إلى مسار تدقيق → BLoC الكامل مع الأحداث
  • كود متكرر أقل مع بنية لا تزال منظمة → Cubit
  • سلامة وقت الترجمة + تبعيات غير متزامنة قابلة للتركيب → Riverpod Notifier
  • قيم مشتقة/محسوبة بسيطة بدون تحولات → Riverpod Provider أو FutureProvider
  • ترحيل Cubit قائم بشكل تدريجي → استبدل الفئة الأساسية، واستبدل emit() بـstate =، ولفّ الودجت بـConsumerWidget
النقطة الرئيسية: BLoC وRiverpod فلسفتان متكاملتان، لا متنافستان. يُحسّن BLoC الوضوح وإمكانية التتبع؛ ويُحسّن Riverpod سلامة الأنواع والتركيبية. أفضل الفرق تفهم كليهما، تختار أحدهما أداةً رئيسية، وتُرحّل بتأنٍّ عند تغيير المتطلبات.