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

نمط ChangeNotifier

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

ما هو ChangeNotifier؟

ChangeNotifier هو صنف يوفره إطار عمل Flutter ينفّذ واجهة Listenable. يحتفظ بقائمة من المستمعين ويوفر دالة notifyListeners() لإبلاغ جميع المستمعين المسجلين عند تغيّر الحالة. هو الأساس لمعظم أساليب إدارة الحالة في Flutter، من إعدادات InheritedNotifier البسيطة إلى حزمة Provider الشائعة.

هيكل ChangeNotifier الأساسي

class CounterModel extends ChangeNotifier {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners(); // إبلاغ جميع المستمعين بالتغيير
  }

  void decrement() {
    _count--;
    notifyListeners();
  }
}
مفهوم أساسي: ChangeNotifier يتبع نمط المراقب (Observer pattern). الودجات (المراقبون) تسجل اهتمامها في المُبلِّغ (الموضوع). عندما تتغير حالة المُبلِّغ، يتم تحديث جميع المراقبين تلقائيًا. هذا يفصل واجهة المستخدم عن منطق الأعمال.

notifyListeners() بالتفصيل

دالة notifyListeners() تتكرر عبر جميع المستمعين المسجلين وتستدعي كلًا منهم. من الآمن استدعاؤها أثناء البناء، لكن يجب استدعاؤها فقط عندما تتغير الحالة فعلاً لتجنب إعادة البناء غير الضرورية.

الإشعار المشروط

class SettingsModel extends ChangeNotifier {
  String _locale = 'en';
  bool _darkMode = false;

  String get locale => _locale;
  bool get darkMode => _darkMode;

  void setLocale(String newLocale) {
    if (_locale != newLocale) {
      _locale = newLocale;
      notifyListeners(); // إشعار فقط إذا تغيرت القيمة فعلاً
    }
  }

  void toggleDarkMode() {
    _darkMode = !_darkMode;
    notifyListeners();
  }

  // تجميع عدة تغييرات في إشعار واحد
  void applySettings({required String locale, required bool darkMode}) {
    _locale = locale;
    _darkMode = darkMode;
    notifyListeners(); // إشعار واحد لعدة تغييرات
  }
}
نصيحة: عند تحديث عدة خصائص في وقت واحد، عيّن جميع القيم أولاً ثم استدعِ notifyListeners() مرة واحدة في النهاية. هذا يتجنب دورات إعادة بناء متعددة لتغيير منطقي واحد.

addListener و removeListener

خلف الكواليس، الودجات مثل ValueListenableBuilder و AnimatedBuilder تستدعي addListener للاشتراك و removeListener لإلغاء الاشتراك. يمكنك أيضًا استخدام هذه الدوال مباشرة لسيناريوهات مخصصة.

إدارة المستمعين يدويًا

class TimerWidget extends StatefulWidget {
  final CounterModel counter;

  const TimerWidget({super.key, required this.counter});

  @override
  State<TimerWidget> createState() => _TimerWidgetState();
}

class _TimerWidgetState extends State<TimerWidget> {
  int _lastKnownCount = 0;

  @override
  void initState() {
    super.initState();
    _lastKnownCount = widget.counter.count;
    widget.counter.addListener(_onCounterChanged);
  }

  void _onCounterChanged() {
    setState(() {
      _lastKnownCount = widget.counter.count;
    });
  }

  @override
  void dispose() {
    widget.counter.removeListener(_onCounterChanged);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text('العدد: \$_lastKnownCount');
  }
}
تحذير: كل استدعاء لـ addListener يجب أن يقابله استدعاء removeListener. إذا نسيت إزالة مستمع، ستستمر الدالة المرجعية في الاستدعاء حتى بعد التخلص من الودجة، مما قد يسبب أخطاء setState called after dispose وتسريبات ذاكرة.

إنشاء أصناف النموذج مع ChangeNotifier

القوة الحقيقية لـ ChangeNotifier تظهر عند إنشاء أصناف نموذج مخصصة تغلّف كل منطق الأعمال لمجال معين. واجهة المستخدم ببساطة تقرأ الحالة وتستدعي الدوال — لا يعيش أي منطق أعمال في الودجات.

نموذج قائمة المهام

class Todo {
  final String id;
  final String title;
  bool isCompleted;
  final DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    this.isCompleted = false,
    DateTime? createdAt,
  }) : createdAt = createdAt ?? DateTime.now();
}

class TodoListModel extends ChangeNotifier {
  final List<Todo> _todos = [];

  List<Todo> get todos => List.unmodifiable(_todos);
  List<Todo> get completedTodos =>
      _todos.where((t) => t.isCompleted).toList();
  List<Todo> get pendingTodos =>
      _todos.where((t) => !t.isCompleted).toList();
  int get totalCount => _todos.length;
  int get completedCount => completedTodos.length;

  void addTodo(String title) {
    _todos.add(Todo(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title,
    ));
    notifyListeners();
  }

  void toggleTodo(String id) {
    final index = _todos.indexWhere((t) => t.id == id);
    if (index >= 0) {
      _todos[index].isCompleted = !_todos[index].isCompleted;
      notifyListeners();
    }
  }

  void removeTodo(String id) {
    _todos.removeWhere((t) => t.id == id);
    notifyListeners();
  }

  void clearCompleted() {
    _todos.removeWhere((t) => t.isCompleted);
    notifyListeners();
  }
}

فصل منطق الأعمال عن واجهة المستخدم

مع نماذج ChangeNotifier، تصبح ودجاتك طبقات عرض رقيقة. تقرأ البيانات من النموذج وتستدعي دوال النموذج استجابةً لإجراءات المستخدم. هذا الفصل يجعل الكود أسهل في الاختبار والصيانة وإعادة الاستخدام.

نموذج حالة المصادقة

enum AuthStatus { unauthenticated, loading, authenticated, error }

class AuthModel extends ChangeNotifier {
  AuthStatus _status = AuthStatus.unauthenticated;
  String? _userId;
  String? _email;
  String? _errorMessage;

  AuthStatus get status => _status;
  String? get userId => _userId;
  String? get email => _email;
  String? get errorMessage => _errorMessage;
  bool get isAuthenticated => _status == AuthStatus.authenticated;

  Future<void> login(String email, String password) async {
    _status = AuthStatus.loading;
    _errorMessage = null;
    notifyListeners();

    try {
      // محاكاة استدعاء API
      await Future.delayed(const Duration(seconds: 2));

      if (email.isNotEmpty && password.length >= 6) {
        _userId = 'user_123';
        _email = email;
        _status = AuthStatus.authenticated;
      } else {
        throw Exception('بيانات اعتماد غير صالحة');
      }
    } catch (e) {
      _status = AuthStatus.error;
      _errorMessage = e.toString();
    }

    notifyListeners();
  }

  void logout() {
    _userId = null;
    _email = null;
    _status = AuthStatus.unauthenticated;
    _errorMessage = null;
    notifyListeners();
  }
}

// الودجة هي واجهة مستخدم صرفة — بلا منطق أعمال:
class LoginScreen extends StatelessWidget {
  final AuthModel auth;

  const LoginScreen({super.key, required this.auth});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: auth,
      builder: (context, _) {
        if (auth.status == AuthStatus.loading) {
          return const Center(child: CircularProgressIndicator());
        }
        if (auth.isAuthenticated) {
          return Center(child: Text('مرحبًا، \${auth.email}!'));
        }
        return Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              if (auth.errorMessage != null)
                Text(auth.errorMessage!, style: const TextStyle(color: Colors.red)),
              ElevatedButton(
                onPressed: () => auth.login('user@example.com', 'password123'),
                child: const Text('تسجيل الدخول'),
              ),
            ],
          ),
        );
      },
    );
  }
}

عدة مستمعين

ChangeNotifier واحد يمكن أن يكون له العديد من المستمعين. هذا مفيد عندما تحتاج عدة ودجات غير مرتبطة للاستجابة لنفس تغيير الحالة. كل ودجة تسجل مستمعها الخاص بشكل مستقل.

نموذج سلة التسوق مع عدة مستمعين

class CartModel extends ChangeNotifier {
  final Map<String, int> _items = {};

  Map<String, int> get items => Map.unmodifiable(_items);
  int get totalItems => _items.values.fold(0, (sum, qty) => sum + qty);
  bool get isEmpty => _items.isEmpty;

  void addItem(String productId) {
    _items[productId] = (_items[productId] ?? 0) + 1;
    notifyListeners();
  }

  void removeItem(String productId) {
    _items.remove(productId);
    notifyListeners();
  }

  void updateQuantity(String productId, int quantity) {
    if (quantity <= 0) {
      _items.remove(productId);
    } else {
      _items[productId] = quantity;
    }
    notifyListeners();
  }

  void clear() {
    _items.clear();
    notifyListeners();
  }
}

// عدة ودجات تستمع لنفس CartModel:

// 1. شارة شريط التطبيق تعرض عدد العناصر
class CartBadge extends StatelessWidget {
  final CartModel cart;
  const CartBadge({super.key, required this.cart});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: cart,
      builder: (context, _) {
        return Badge(
          label: Text('\${cart.totalItems}'),
          isLabelVisible: !cart.isEmpty,
          child: const Icon(Icons.shopping_cart),
        );
      },
    );
  }
}

// 2. زر الدفع يُفعّل/يُعطّل بناءً على حالة السلة
class CheckoutButton extends StatelessWidget {
  final CartModel cart;
  const CheckoutButton({super.key, required this.cart});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: cart,
      builder: (context, _) {
        return ElevatedButton(
          onPressed: cart.isEmpty ? null : () => _checkout(),
          child: Text('الدفع (\${cart.totalItems} عناصر)'),
        );
      },
    );
  }

  void _checkout() { /* الانتقال إلى صفحة الدفع */ }
}

التخلص بشكل صحيح

صنف ChangeNotifier لديه دالة dispose التي تحرر جميع المستمعين وتُعلّم المُبلِّغ كمُتخلَّص منه. بعد التخلص، استدعاء notifyListeners() أو addListener سيُطلق خطأ. مالك المُبلِّغ هو المسؤول عن استدعاء dispose.

أنماط التخلص الصحيحة

// النمط 1: StatefulWidget يملك المُبلِّغ
class MyPage extends StatefulWidget {
  const MyPage({super.key});

  @override
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  late final TodoListModel _todoModel;
  late final AuthModel _authModel;

  @override
  void initState() {
    super.initState();
    _todoModel = TodoListModel();
    _authModel = AuthModel();
  }

  @override
  void dispose() {
    _todoModel.dispose();
    _authModel.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

// النمط 2: التحقق من التخلص قبل الإشعار (دفاعي)
class SafeModel extends ChangeNotifier {
  bool _isDisposed = false;

  @override
  void dispose() {
    _isDisposed = true;
    super.dispose();
  }

  @override
  void notifyListeners() {
    if (!_isDisposed) {
      super.notifyListeners();
    }
  }
}
تحذير: لا تستدعِ أبدًا notifyListeners() في ChangeNotifier تم التخلص منه. هذا يُطلق FlutterError. إذا كان نموذجك يُجري عمليات غير متزامنة (مثل استدعاءات API)، فقد يصل الرد بعد التخلص. استخدم علامة حماية أو تحقق من hasListeners قبل الإشعار.
نصيحة: اجعل نماذج ChangeNotifier مركزة على مجال واحد. TodoListModel يجب أن يدير المهام فقط. AuthModel يجب أن يدير حالة المصادقة فقط. إذا وجدت نموذجًا ينمو كثيرًا، قسّمه إلى نماذج أصغر ومركزة.

الملخص

  • ChangeNotifier ينفّذ نمط المراقب مع addListener و removeListener و notifyListeners().
  • استدعِ notifyListeners() فقط عندما تتغير الحالة فعلاً لتجنب إعادة البناء غير الضرورية.
  • اجمع عدة تغييرات في الخصائص في استدعاء واحد لـ notifyListeners() للكفاءة.
  • كل addListener يجب أن يقابله removeListener لمنع تسريبات الذاكرة.
  • أنشئ أصناف نموذج مخصصة تغلّف منطق الأعمال، مع إبقاء الودجات كطبقات واجهة مستخدم رقيقة.
  • مُبلِّغ واحد يمكن أن يكون له عدة مستمعين — عدة ودجات يمكنها الاستجابة لنفس تغيير الحالة.
  • احرص دائمًا على التخلص من المُبلِّغين في دالة dispose الخاصة بالودجة المالكة، واحمِ من الإشعارات بعد التخلص في السيناريوهات غير المتزامنة.