تعريف الأحداث والحالات في BLoC
تعريف الأحداث والحالات في BLoC
يكمن جوهر نمط BLoC في تسلسلَي هرميَّين من الفئات مصمَّمَين بعناية: الأحداث (Events) والحالات (States). تمثّل الأحداث كل إجراء محتمل يقوم به المستخدم أو أي مُشغِّل للنظام — فهي المدخلات لـ BLoC الخاص بك. أما الحالات فتمثّل كل وضع محتمل يمكن أن تكون عليه واجهة المستخدم — وهي المخرجات. يُعدّ تصميم هذه العقود بشكل صحيح أهم قرار معماري تتخذه عند اعتماد BLoC.
في هذا الدرس ستتعلم كيفية نمذجة كلا التسلسلَين الهرميَّين باستخدام فئات أساسية مغلقة (sealed) أو مجردة (abstract) مع فئات فرعية ملموسة، وكيفية الاستفادة من Equatable لجعل الحالات قابلة للمقارنة، وكيفية تطبيق هذه المفاهيم على ميزة سلة تسوق واقعية.
switch شاملة. إذا كنت تستخدم Dart 2، استخدم abstract class مع @immutable بدلاً من ذلك — النمط متطابق، لكن الشمولية غير مُطبَّقة من قِبل المُترجم.لماذا نفصل الأحداث عن الحالات؟
خطأ شائع هو تخزين علامات قابلة للتغيير داخل BLoC وتبديلها مباشرة. هذا يُخلّ بضمان تدفق البيانات أحادي الاتجاه. بفصل المدخلات (الأحداث) عن المخرجات (الحالات) تكتسب:
- قابلية التتبع — يمكن إرجاع كل تغيير في واجهة المستخدم إلى حدث محدد.
- قابلية الاختبار — يمكنك تغذية تسلسل من الأحداث في BLoC ضمن اختبار والتحقق من التسلسل الدقيق للحالات المُصدَرة.
- الثبات (Immutability) — الحالات هي كائنات قيمة؛ لا يمكن لأي ودجت تعديلها مباشرة.
- عرض واجهة مستخدم شامل — تجعل الحالات المغلقة استحالة نسيان فرع UI في
switch.
نمذجة الأحداث كتسلسل هرمي من الفئات المغلقة
كل فئة حدث تمثّل إجراءً مميزاً واحداً. تُعلن الفئة الأساسية المغلقة عن العقد؛ بينما تحمل الفئات الفرعية الملموسة حمولة البيانات لذلك الإجراء.
تسلسل هرمي CartEvent (فئة sealed في Dart 3)
import 'package:equatable/equatable.dart';
// الفئة الأساسية — لا تُنشأ منها نُسَخ مباشرة
sealed class CartEvent extends Equatable {
const CartEvent();
@override
List<Object?> get props => [];
}
// المستخدم ضغط "إضافة إلى السلة"
final class CartItemAdded extends CartEvent {
final Product product;
final int quantity;
const CartItemAdded({required this.product, required this.quantity});
@override
List<Object?> get props => [product, quantity];
}
// المستخدم ضغط أيقونة الحذف في صف السلة
final class CartItemRemoved extends CartEvent {
final String productId;
const CartItemRemoved({required this.productId});
@override
List<Object?> get props => [productId];
}
// المستخدم ضغط مُدوِّرة "تحديث الكمية"
final class CartItemQuantityChanged extends CartEvent {
final String productId;
final int newQuantity;
const CartItemQuantityChanged({
required this.productId,
required this.newQuantity,
});
@override
List<Object?> get props => [productId, newQuantity];
}
// المستخدم ضغط "إفراغ السلة"
final class CartCleared extends CartEvent {
const CartCleared();
}
CartItemAdded وليس AddCartItem. هذا يجعل سجل الأحداث يُقرأ كتاريخ إجراءات، وهو ذو قيمة لا تُقدَّر عند تصحيح الأخطاء أو إعادة تشغيل الأحداث.نمذجة الحالات كفئات ثابتة قابلة للمقارنة بـ Equatable
تصف الحالات ما يجب أن تعرضه واجهة المستخدم في أي لحظة. استخدم Equatable حتى يتمكن BLoC من تخطي إصدار حالة مكررة (منعاً لإعادة البناء غير الضرورية). اجعل كل حقل final — يجب ألا تتغير الحالات بعد الإنشاء. استخدم copyWith لاشتقاق حالة معدَّلة.
تسلسل هرمي CartState مع Equatable و copyWith
import 'package:equatable/equatable.dart';
// حامل البيانات المشتركة — ليس حالة بحد ذاتها
class CartItem extends Equatable {
final Product product;
final int quantity;
const CartItem({required this.product, required this.quantity});
CartItem copyWith({Product? product, int? quantity}) => CartItem(
product: product ?? this.product,
quantity: quantity ?? this.quantity,
);
@override
List<Object?> get props => [product, quantity];
}
// ── التسلسل الهرمي للحالات المغلقة ────────────────────────────
sealed class CartState extends Equatable {
const CartState();
@override
List<Object?> get props => [];
}
// لم يُحمَّل السلة بعد (أول عرض للشاشة)
final class CartInitial extends CartState {
const CartInitial();
}
// يجري تحميل السلة من التخزين المحلي أو عن بُعد
final class CartLoading extends CartState {
const CartLoading();
}
// حُمِّلت السلة بنجاح — تحتوي على القائمة الكاملة للعناصر
final class CartLoaded extends CartState {
final List<CartItem> items;
const CartLoaded({required this.items});
// مساعدات مشتقة — مُحسَبة من البيانات الثابتة
int get totalItems => items.fold(0, (sum, item) => sum + item.quantity);
double get totalPrice =>
items.fold(0.0, (sum, item) => sum + item.product.price * item.quantity);
CartLoaded copyWith({List<CartItem>? items}) =>
CartLoaded(items: items ?? this.items);
@override
List<Object?> get props => [items];
}
// فشلت عملية في السلة (خطأ شبكة، فحص المخزون، إلخ)
final class CartError extends CartState {
final String message;
const CartError({required this.message});
@override
List<Object?> get props => [message];
}
لماذا يهم Equatable للحالات؟
بشكل افتراضي، كائنَان في Dart متساويان فقط إذا كانا نفس النسخة. بدون Equatable، سيُفعِّل إصدار كائن CartLoaded جديد بعناصر متطابقة إعادة بناء الودجت — سيرى Flutter مرجعَي كائن مختلفَين. يتجاوز Equatable كلاً من == وhashCode بناءً على قائمة props، لذا يُفعِّل BLoC بشكل صحيح قمع الإصدارات المكررة.
List أو Map قابلاً للتغيير مباشرة في props، فقد تتصرف فحوصات التساوي بشكل غير متوقع لأن Dart يقارن هوية القوائم افتراضياً. قم بتغليف المجموعات القابلة للتغيير في const [] أو استخدم حزمة مقارنة عميقة، أو قم بتخزين العناصر كـ UnmodifiableListView.ربط الأحداث والحالات في BLoC
بعد تعريف التسلسلَين الهرميَّين للحدث والحالة، تقوم فئة BLoC ببساطة بتعيين كل حدث إلى حالة جديدة باستخدام معالجات on<EventType>. يضمن switch الشامل على الحالة المغلقة عدم نسيان أي فرع من واجهة المستخدم.
CartBloc — ربط الأحداث بالحالات
import 'package:flutter_bloc/flutter_bloc.dart';
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc() : super(const CartInitial()) {
on<CartItemAdded>(_onItemAdded);
on<CartItemRemoved>(_onItemRemoved);
on<CartItemQuantityChanged>(_onQuantityChanged);
on<CartCleared>(_onCartCleared);
}
void _onItemAdded(CartItemAdded event, Emitter<CartState> emit) {
final current = state;
if (current is! CartLoaded) return;
final existingIndex =
current.items.indexWhere((i) => i.product.id == event.product.id);
List<CartItem> updated;
if (existingIndex >= 0) {
updated = List.of(current.items)
..[existingIndex] = current.items[existingIndex].copyWith(
quantity: current.items[existingIndex].quantity + event.quantity,
);
} else {
updated = [
...current.items,
CartItem(product: event.product, quantity: event.quantity),
];
}
emit(current.copyWith(items: updated));
}
void _onItemRemoved(CartItemRemoved event, Emitter<CartState> emit) {
final current = state;
if (current is! CartLoaded) return;
emit(current.copyWith(
items: current.items
.where((i) => i.product.id != event.productId)
.toList(),
));
}
void _onQuantityChanged(
CartItemQuantityChanged event, Emitter<CartState> emit) {
final current = state;
if (current is! CartLoaded) return;
emit(current.copyWith(
items: current.items
.map((i) => i.product.id == event.productId
? i.copyWith(quantity: event.newQuantity)
: i)
.toList(),
));
}
void _onCartCleared(CartCleared event, Emitter<CartState> emit) {
emit(const CartLoaded(items: []));
}
}
ملخص
الأحداث والحالات المصمَّمة بشكل جيد هي أساس معمارية BLoC القابلة للصيانة:
- استخدم فئة أساسية مغلقة للأحداث والحالات حتى يُطبّق المُترجم المعالجة الشاملة.
- سمِّ الأحداث بصيغة الفعل الماضي (الإجراء صدر بالفعل) والحالات كـ أسماء تصف الوضع الحالي.
- مدِّد Equatable وضع كل حقل ذي معنى في
propsلمنع إعادة بناء الحالات المكررة. - أبقِ الحالات ثابتة — اشتق حالات جديدة بـ
copyWithبدلاً من تغيير الحقول. - تبقى فئة BLoC نفسها رفيعة: تُعيِّن فقط أنواع الأحداث إلى انتقالات الحالة.