إدارة الحالة في Flutter — ما أستخدمه فعلاً ولماذا

إدارة الحالة في Flutter — ما أستخدمه فعلاً ولماذا

ليست مقارنة أخرى بين Riverpod و Bloc. نظرة صريحة على قرارات إدارة الحالة التي اتخذتها في تطبيقات Flutter إنتاجية متعددة، والمقايضات كما بدت في الواقع.

10 دقائق للقراءة
تم التحديث ٣ يونيو ٢٠٢٦

إذا كنت في عالم Flutter أكثر من أسبوع، فقد صادفت بالتأكيد نقاش إدارة الحالة. Riverpod أم Bloc؟ GetX أم Provider؟ هل setState سيء حقاً؟ يناقش مجتمع Flutter هذا الأمر بلا توقف، لكن معظم الإجابات المتاحة على الإنترنت مكتوبة من قبل أشخاص يقيّمون الحلول في تطبيقات تجريبية — عداد، قائمة مهام، شاشة طقس.

هذا المقال مختلف. لقد شحنت تطبيقات Flutter متعددة — مرافق إسلامي شامل (هدى)، ومنصة تجارة إلكترونية (Sire)، وتطبيق تواصل اجتماعي (Paloma)، وغيرها. علّمني كل منها شيئاً مختلفاً عن إدارة الحالة. إليك ما استقريت عليه — ليس لأنه مثالي من الناحية النظرية، بل لأنه يعمل في الواقع دون أن يجعلني أكره قاعدة الكود.

النموذج الذهني الذي يساعد فعلاً

قبل اختيار مكتبة، صنّف الحالة حسب نطاقها:

  • الحالة المؤقتة — تعيش في ودجت واحدة ولا تحتاج للمشاركة. هل الزر مضغوط، هل القائمة المنسدلة مفتوحة، هل حقل النص في التركيز.
  • حالة الميزة — تعيش داخل شاشة أو حدود ميزة محددة. قيم حقول النموذج، أي تبويب نشط، هل القائمة تحمّل.
  • حالة التطبيق — تتجاوز حدود الميزات وتعيش طوال الجلسة أو أطول. المستخدم المسجل دخوله، إعدادات المستخدم، سلة التسوق.

الخطأ الذي يقع فيه المطورون مبكراً هو اللجوء إلى حل حالة عالمية للحالة المؤقتة. ينتهي بهم الأمر بـ Riverpod providers لكل شيء بما فيه ما إذا كانت تلميحة مرئية. هذا يخلق ضوضاء تجعل الحالة الحقيقية أصعب إيجاداً وفهماً.

setState — ليست كلمة سيئة

لـ setState سمعة سيئة لا تستحقها بالكامل. للحالة المؤقتة — الرسوم المتحركة، مفاتيح التبديل المحلية في الواجهة، التحقق من النماذج في ودجت واحدة — هي الأداة الصحيحة.

class _ExpandableCardState extends State<ExpandableCard> {
  bool _isExpanded = false;
 
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _isExpanded = !_isExpanded),
      child: AnimatedContainer(
        height: _isExpanded ? 200 : 80,
        duration: const Duration(milliseconds: 250),
        child: widget.child,
      ),
    );
  }
}

أستخدم setState في كل تطبيق أشحنه. من يخبرك بأن تستبدل هذا بـ Provider يُحسّن الاتساق على حساب البساطة.

حيث تفشل setState هو عندما تحتاج ودجتان شقيقتان لنفس القيمة. في اللحظة التي تجد نفسك فيها تمرر callbacks للأعلى وبيانات للأسفل أكثر من مستوى واحد، فقد تجاوزت حدودها.

Riverpod — العمود الفقري لمعظم الأشياء

لكل ما ليس حالة محلية مؤقتة في واجهة المستخدم، أستخدم Riverpod. تحديداً flutter_riverpod مع توليد الكود عبر riverpod_annotation في أي تطبيق أكبر من عرض توضيحي.

أكبر ميزة عملية لـ Riverpod ليست سلامة الأنواع (وإن كانت حقيقية) — بل هي قابلية التأليف والاختبار مع كاد لا يوجد كود متكرر. يمكن للـ providers مشاهدة providers أخرى. تمنحك الـ providers غير المتزامنة AsyncValue الذي يتعامل مع حالات التحميل والخطأ دفعة واحدة. يمكنك تجاوز أي provider في الاختبارات باستخدام ProviderContainer دون frameworks للـ mocking.

إليك كيف يبدو provider غير متزامن نموذجي في ميزة أوقات الصلاة:

lib/features/prayer/providers/prayer_times_provider.dart
@riverpod
Future<PrayerTimes> prayerTimes(PrayerTimesRef ref) async {
  final location = await ref.watch(userLocationProvider.future);
  final settings = ref.watch(prayerSettingsProvider);
  return PrayerTimesCalculator.calculate(location, settings);
}

وفي واجهة المستخدم:

class PrayerTimesWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final prayerTimes = ref.watch(prayerTimesProvider);
    return prayerTimes.when(
      data: (times) => PrayerList(times: times),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => ErrorView(message: e.toString()),
    );
  }
}

لا إدارة يدوية لـ Future. لا StreamBuilder. حالات التحميل والخطأ مستحيل نسيانها لأن AsyncValue.when يتطلب الثلاثة.

أين يوجد احتكاك في Riverpod

توليد الكود يضيف خطوة بناء — build_runner watch يصبح جزءاً من سير عملك. تصحيح سلاسل التفاعل قد يكون غير واضح عندما يُعاد بناء provider بشكل غير متوقع. وإذا كنت تبني شيئاً تسلسلياً بشكل صريح — تدفق معالج، عملية دفع متعددة الخطوات — تجد نفسك تقاوم نموذج Riverpod التفاعلي للتعبير عن شيء أساسه ضروري.

هنا يأتي دور Cubit.

Cubit — عندما تحتاج آلة حالة

للميزات ذات انتقالات الحالة غير التافهة، أصل إلى Cubit من حزمة bloc. ليس Bloc الكامل — لم أحتج قط إلى فصل الأحداث/المعالجات الرسمي في أي تطبيق شحنته. يمنحك Cubit آلة الحالة دون التعقيد.

الحالة النموذجية هي المصادقة. Auth ليست مجرد bool isLoggedIn. إنها آلة حالة:

Initial → Loading → Authenticated
                  ↘ Unauthenticated
                  ↘ Error

Authenticated → Loading (تحديث Token) → Authenticated
                                        ↘ Unauthenticated (انتهت الصلاحية)

يجعل Cubit هذا صريحاً:

lib/features/auth/cubit/auth_cubit.dart
class AuthCubit extends Cubit<AuthState> {
  AuthCubit(this._authRepository) : super(const AuthState.initial());
 
  final AuthRepository _authRepository;
 
  Future<void> signIn(String email, String password) async {
    emit(const AuthState.loading());
    try {
      final user = await _authRepository.signIn(email, password);
      emit(AuthState.authenticated(user));
    } on AuthException catch (e) {
      emit(AuthState.error(e.message));
    }
  }
 
  Future<void> signOut() async {
    await _authRepository.signOut();
    emit(const AuthState.unauthenticated());
  }
}

في واجهة المستخدم، يمنحك BlocBuilder نفس المطابقة الشاملة بأسلوب when:

BlocBuilder<AuthCubit, AuthState>(
  builder: (context, state) => state.when(
    initial: () => const SplashScreen(),
    loading: () => const LoadingScreen(),
    authenticated: (user) => HomeScreen(user: user),
    unauthenticated: () => const LoginScreen(),
    error: (msg) => ErrorScreen(message: msg),
  ),
)

التمييز الذي أعود إليه مراراً: Riverpod ممتاز للبيانات والحالة المشتقة. Cubit ممتاز للتدفقات وآلات الحالة. إنهما ليسا متنافسين. أستخدم كليهما في نفس التطبيق.

كيف أجمع بينهما فعلاً

في تطبيق إنتاجي نموذجي، تبدو الطبقات هكذا:

  • طبقة المستودع — فئات Dart خالصة، لا تبعيات على أي framework
  • Riverpod providers — يعرضون مثيلات المستودع والبيانات غير المتزامنة لشجرة الودجات
  • Cubits — يديرون آلات الحالة على مستوى واجهة المستخدم: المصادقة، تدفقات الدفع، النماذج متعددة الخطوات
  • setState — حالة الودجت المؤقتة المحلية
ConsumerWidget (يقرأ Riverpod للبيانات)
  └── BlocBuilder (يستمع إلى Cubit لحالة واجهة المستخدم)
       └── واجهة المستخدم تستدعي cubit.method() عند تفاعل المستخدم
            └── Cubit يستدعي المستودع (محقون عبر المُنشئ أو Riverpod ref)

في الواقع، معظم الشاشات تلمس طبقة أو اثنتين فقط من هذه الطبقات. شاشة قراءة فقط قد تكون مجرد ConsumerWidget مع provider Riverpod واحد — لا Cubit مطلوب. تدفق الدفع هو آلة حالة مدفوعة بـ Cubit تقرأ بيانات المنتج من providers Riverpod.

أشياء تعلمتها بالطريقة الصعبة

استخدم autoDispose بشكل افتراضي. إذا كانت حالة provider مطلوبة فقط خلال دورة حياة شاشة، يجب التخلص منها عندما تختفي الشاشة. وإلا تراكم لديك حالة قديمة عبر التنقل وتحصل على أخطاء خفية — مثل نتائج البحث من جلسة مستخدم سابقة تظهر لوهلة عند فتح المستخدم الجديد للتطبيق.

@riverpod  // توليد الكود ينشئ autoDispose بشكل افتراضي
class SearchNotifier extends _$SearchNotifier {
  @override
  SearchState build() => const SearchState.empty();
 
  void search(String query) { /* ... */ }
}

الكلاسات المغلقة تجعل الحالات المستحيلة مستحيلة. استخدام bool isLoading إلى جانب String? error و T? data في نفس الكلاس يؤدي إلى حالات لا يمكن أن توجد (isLoading: true و error != null — أيهما؟). الكلاس المغلق أو union بـ freezed يجبرك على تمثيل التوليفات الصالحة فقط.

@freezed
class SearchState with _$SearchState {
  const factory SearchState.empty() = _Empty;
  const factory SearchState.loading() = _Loading;
  const factory SearchState.results(List<Result> items) = _Results;
  const factory SearchState.error(String message) = _Error;
}

الاختبار هو المُميِّز الحقيقي. جربت GetX بإيجاز في مشروع أصغر قبل الالتزام بـ Riverpod. GetX جيد للنماذج الأولية والمشاريع الصغيرة والمتوسطة حيث تحتاج السرعة، لكن دورة حياة controller مرتبطة بشجرة الودجات — اختبار منطق الأعمال بشكل معزول يصبح مؤلماً فعلاً مع نمو قاعدة الكود. مع Riverpod، يمكنني اختبار أي provider في ProviderContainer دون أي ودجات. هذا الاستثمار يعود بالنفع في كل مرة تراجع فيها ميزة بعد أشهر.

لا تُعد الكتابة لتحقيق الاتساق. إذا كانت الشاشة تستخدم نمطاً يعمل، اتركها. مطاردة نمط موحد عبر قاعدة الكود بأكملها تُدخل انتكاسات في الغالب دون فائدة حقيقية.

النقاط الرئيسية

  1. طابق الأداة مع النطاق. حالة واجهة مستخدم مؤقتة ← setState. بيانات غير متزامنة وحقن التبعيات ← Riverpod. آلات الحالة والتدفقات ← Cubit.

  2. توليد الكود لـ Riverpod يستحق ذلك. حمل build_runner صغير مقارنة بسلامة الأنواع وتقليل الكود المتكرر الذي تحصل عليه.

  3. Cubit لا Bloc. ما لم تكن كائنات الأحداث الصريحة ذات معنى لنطاقك (سجلات التدقيق، إعادة تشغيل الأحداث)، فإن Cubit أبسط ومتكافئ في القدرة.

  4. الحالة المغلقة غير قابلة للتفاوض. isLoading: true إلى جانب data != null هو خطأ ينتظر الظهور في الإنتاج.

  5. autoDispose بشكل افتراضي. كن متعمداً بشأن أي حالة تحتاج فعلاً أن تعيش أطول من شاشة.

  6. لا تطارد الأنماط الموحدة. الكود الذي يعمل ومقروء أفضل من الكود الذي يتسق لمجرد الاتساق.


الأسئلة الشائعة

هل يجب أن أستخدم Riverpod أم Bloc في تطبيق Flutter الخاص بي؟

كلاهما خيارات محكمة ومُثبتة في الإنتاج. Riverpod ممتاز للبيانات التفاعلية غير المتزامنة وحقن التبعيات. Cubit (من حزمة Bloc) ممتاز لآلات الحالة الصريحة. أستخدم كليهما في نفس التطبيق لأغراض مختلفة — إنهما ليسا متنافسين.

هل Provider لا يزال يستحق الاستخدام؟

Provider يعتبر فعلياً متجاوزاً بـ Riverpod، الذي يصلح نقاط ألمه الرئيسية: لا تبعية على BuildContext، سلامة وقت الترجمة، ودعم أفضل للحالة غير المتزامنة. للمشاريع الجديدة، ابدأ بـ Riverpod. إذا كانت قاعدة كود موجودة على Provider مستقرة والفريق يعرفها، لا يوجد سبب عاجل للهجرة.

هل GetX خيار سيئ؟

GetX خيار ممتاز للنماذج الأولية والمشاريع الصغيرة والمتوسطة — إعداده سريع ويغطي التنقل وحقن التبعيات والحالة في حزمة واحدة. المشكلة تظهر مع النمو: دورة حياة controller مرتبطة بشجرة الودجات مما يجعل اختبار الوحدات معزولاً أمراً صعباً، وتجميع التنقل وحقن التبعيات والحالة في حزمة واحدة يجعل كل مصدر قلق أصعب في التفكير به بشكل مستقل. لأي شيء إنتاجي أو طويل الأمد، Riverpod هو الاستثمار الأفضل.

كيف أتعامل مع حالة التطبيق العالمية مثل المصادقة؟

صمّم المصادقة كآلة حالة بكلاس مغلق، وليس كمتغيرات منطقية متفرقة. Cubit<AuthState> موضوع عالياً في شجرة الودجات (أو مُقدَّم عبر Riverpod) يعمل بشكل نظيف. المفتاح هو التمثيل الشامل للحالة — initial و loading و authenticated و unauthenticated و error — بدلاً من الجمع بين حقول nullable.

متى يجب أن أستخدم Redux أو MobX في Flutter؟

نادراً ما للمشاريع الجديدة. Redux يجلب كوداً متكرراً كثيراً ونمط تفكير مصادر الأحداث الذي لا يتوافق طبيعياً مع نموذج ودجات Flutter. MobX جيد لكنه يضيف خطوة توليد كود دون إضافة الكثير فوق Riverpod. ابدأ بـ Riverpod و Cubit — أضف التعقيد فقط عندما يكون لديك سبب محدد.

النموذج الذهني الذي يساعد فعلاً