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

أفضل ممارسات إدارة الحالة

45 دقيقة الدرس 12 من 14

لماذا أفضل الممارسات مهمة

مع نمو تطبيق Flutter الخاص بك، تصبح الحالة المدارة بشكل سيء المصدر الأول للأخطاء ومشاكل الأداء وإحباط المطورين. اتباع أفضل الممارسات المثبتة من البداية يوفر عليك إعادة هيكلة مؤلمة لاحقاً. في هذا الدرس سنغطي المبادئ الأساسية التي تنطبق بغض النظر عن حل إدارة الحالة الذي تختاره -- سواء كان setState أو Provider أو Bloc أو Riverpod أو أي شيء آخر.

حقيقة عالمية: هذه المبادئ تأتي من عقود من خبرة هندسة البرمجيات (Flux و Redux و MVI و MVVM). ليست خاصة بـ Flutter -- إنها مجربة في كل إطار عمل واجهة مستخدم. أتقنها مرة واحدة وستكتب كوداً أفضل في أي إطار عمل.

1. مصدر واحد للحقيقة

كل جزء من الحالة في تطبيقك يجب أن يعيش في مكان واحد بالضبط. إذا كانت بيانات المستخدم تعيش في كائنين مختلفين فسيخرجان عن التزامن في النهاية. كائن يعرض الاسم القديم بينما آخر يعرض الاسم الجديد -- ولديك خطأ يصعب تتبعه بشكل لا يصدق.

سيء: حالة مكررة

// نمط مضاد: نفس البيانات في مكانين
class ProfileScreen extends StatefulWidget {
  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  String userName = '';  // نسخة محلية من اسم المستخدم

  @override
  void initState() {
    super.initState();
    userName = UserService.currentUser.name;  // منسوخ من الخدمة
  }

  void _updateName(String newName) {
    setState(() {
      userName = newName;  // محدث محلياً...
    });
    // لكن UserService.currentUser.name الآن قديم!
    // الشاشات الأخرى لا تزال تعرض الاسم القديم
  }
}

// في هذه الأثناء في SettingsScreen...
class _SettingsScreenState extends State<SettingsScreen> {
  // هذا لا يزال يعرض الاسم القديم لأنه يقرأ
  // من UserService التي لم يتم تحديثها أبداً
  String get displayName => UserService.currentUser.name;
}

جيد: مصدر واحد للحقيقة

// نموذج المستخدم يعيش في مكان واحد: UserNotifier
class UserNotifier extends ChangeNotifier {
  User _user;

  UserNotifier(this._user);

  User get user => _user;

  void updateName(String newName) {
    _user = _user.copyWith(name: newName);
    notifyListeners();  // جميع المستمعين يحصلون على التحديث
  }
}

// ProfileScreen يقرأ من المصدر الواحد
class ProfileScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final user = context.watch<UserNotifier>().user;
    return Text(user.name);  // دائماً محدث
  }
}

// SettingsScreen يقرأ من نفس المصدر الواحد
class SettingsScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final user = context.watch<UserNotifier>().user;
    return Text(user.name);  // دائماً يطابق ProfileScreen
  }
}
قاعدة عامة: إذا وجدت نفسك تنسخ الحالة من مكان لآخر توقف. بدلاً من ذلك اجعل كلا المكانين يقرآن من نفس المصدر. الاستثناء الوحيد هو حالة واجهة المستخدم المحلية حقاً (مثل ما إذا كان حقل النص مركزاً عليه) والتي لا تحتاج للمشاركة.

2. الحالة غير القابلة للتغيير

لا تعدل كائنات الحالة مباشرة أبداً. بدلاً من ذلك أنشئ نسخاً جديدة مع تطبيق التغييرات. هذا يجعل كودك قابلاً للتنبؤ وقابلاً للتصحيح وآمناً من الآثار الجانبية العرضية. عندما تكون الحالة غير قابلة للتغيير يمكنك دائماً مقارنة القديم بالجديد لرؤية ما تغير بالضبط.

نمط الحالة غير القابلة للتغيير مع copyWith

// فئة حالة غير قابلة للتغيير باستخدام copyWith
class AppState {
  final List<Todo> todos;
  final FilterType filter;
  final bool isLoading;
  final String? errorMessage;

  const AppState({
    this.todos = const [],
    this.filter = FilterType.all,
    this.isLoading = false,
    this.errorMessage,
  });

  // copyWith ينشئ نسخة جديدة -- لا يغير أبداً
  AppState copyWith({
    List<Todo>? todos,
    FilterType? filter,
    bool? isLoading,
    String? errorMessage,
  }) {
    return AppState(
      todos: todos ?? this.todos,
      filter: filter ?? this.filter,
      isLoading: isLoading ?? this.isLoading,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }

  // حالة مشتقة -- محسوبة من الحالة الموجودة ولا تُخزن بشكل منفصل أبداً
  int get completedCount => todos.where((t) => t.isCompleted).length;
  int get activeCount => todos.length - completedCount;

  List<Todo> get filteredTodos {
    switch (filter) {
      case FilterType.active:
        return todos.where((t) => !t.isCompleted).toList();
      case FilterType.completed:
        return todos.where((t) => t.isCompleted).toList();
      case FilterType.all:
        return todos;
    }
  }
}

// Todo غير قابل للتغيير مع copyWith
class Todo {
  final String id;
  final String title;
  final bool isCompleted;
  final DateTime createdAt;

  const Todo({
    required this.id,
    required this.title,
    this.isCompleted = false,
    required this.createdAt,
  });

  Todo copyWith({String? title, bool? isCompleted}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      isCompleted: isCompleted ?? this.isCompleted,
      createdAt: createdAt,
    );
  }
}

// الاستخدام في notifier -- دائماً استبدل ولا تغير
class TodoNotifier extends ChangeNotifier {
  AppState _state = const AppState();

  AppState get state => _state;

  void addTodo(String title) {
    final newTodo = Todo(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title,
      createdAt: DateTime.now(),
    );
    // أنشئ قائمة جديدة ولا تضف إلى الموجودة
    _state = _state.copyWith(todos: [..._state.todos, newTodo]);
    notifyListeners();
  }

  void toggleTodo(String id) {
    final updatedTodos = _state.todos.map((todo) {
      return todo.id == id
          ? todo.copyWith(isCompleted: !todo.isCompleted)
          : todo;
    }).toList();
    _state = _state.copyWith(todos: updatedTodos);
    notifyListeners();
  }
}
خطأ شائع: لا تفعل _state.todos.add(newTodo) -- هذا يغير القائمة الموجودة في مكانها. الودجات التي خزنت مرجعاً للقائمة القديمة لن ترى التغيير. دائماً أنشئ قائمة جديدة: [..._state.todos, newTodo].

3. تدفق البيانات أحادي الاتجاه

يجب أن تتدفق البيانات في اتجاه واحد: واجهة المستخدم ترسل الإجراءات → الحالة تتحدث → واجهة المستخدم تعيد البناء. واجهة المستخدم لا تعدل الحالة مباشرة أبداً. هذا يسهل تتبع مصدر أي تغيير وسببه.

التدفق أحادي الاتجاه في التطبيق

// التدفق: حدث واجهة مستخدم -> إجراء -> تحديث حالة -> إعادة بناء واجهة مستخدم
//
//  [المستخدم يضغط الزر]
//        |
//        v
//  [واجهة المستخدم تستدعي notifier.addTodo('اشتري حليب')]
//        |
//        v
//  [TodoNotifier ينشئ حالة جديدة مع المهمة المضافة]
//        |
//        v
//  [notifyListeners() ينطلق]
//        |
//        v
//  [جميع الودجات المراقبة تعيد البناء بالحالة الجديدة]

class TodoListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // الخطوة 4: واجهة المستخدم تقرأ الحالة وتعيد البناء
    final state = context.watch<TodoNotifier>().state;

    return Scaffold(
      body: ListView.builder(
        itemCount: state.filteredTodos.length,
        itemBuilder: (context, index) {
          final todo = state.filteredTodos[index];
          return CheckboxListTile(
            title: Text(todo.title),
            value: todo.isCompleted,
            // الخطوة 1: واجهة المستخدم ترسل الإجراء (حدث المستخدم)
            onChanged: (_) {
              // الخطوة 2: المُعلم يعالج الإجراء
              context.read<TodoNotifier>().toggleTodo(todo.id);
              // الخطوة 3: الحالة تُحدث و notifyListeners يُستدعى
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context),
        child: const Icon(Icons.add),
      ),
    );
  }
}

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

يجب أن تكون الودجات بسيطة -- تعرض البيانات وتوجه أحداث المستخدم. كل منطق الأعمال (التحقق والحسابات واستدعاءات API وتحويلات البيانات) يعيش في فئات منفصلة. هذا يجعل كودك قابلاً للاختبار وقابلاً لإعادة الاستخدام وقابلاً للصيانة.

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

// سيء: منطق الأعمال مختلط في الودجت
class _BadLoginScreenState extends State<BadLoginScreen> {
  bool _isLoading = false;
  String? _error;

  Future<void> _login() async {
    // منطق التحقق في الودجت -- خطأ
    if (_emailController.text.isEmpty) {
      setState(() => _error = 'البريد الإلكتروني مطلوب');
      return;
    }
    if (!_emailController.text.contains('@')) {
      setState(() => _error = 'بريد إلكتروني غير صالح');
      return;
    }
    setState(() => _isLoading = true);
    try {
      // استدعاء API في الودجت -- خطأ
      final response = await http.post(
        Uri.parse('https://api.example.com/login'),
        body: {'email': _emailController.text},
      );
      // تحليل JSON في الودجت -- خطأ
      final data = jsonDecode(response.body);
      // منطق التنقل في الودجت -- فوضوي
      Navigator.pushReplacement(context, /*...*/);
    } catch (e) {
      setState(() => _error = e.toString());
    }
    setState(() => _isLoading = false);
  }
}

// جيد: منطق الأعمال في مُعلم منفصل
class AuthNotifier extends ChangeNotifier {
  final AuthRepository _repo;

  AuthNotifier(this._repo);

  AuthState _state = const AuthState();
  AuthState get state => _state;

  String? validateEmail(String email) {
    if (email.isEmpty) return 'البريد الإلكتروني مطلوب';
    if (!email.contains('@')) return 'صيغة بريد إلكتروني غير صالحة';
    return null;  // null يعني صالح
  }

  Future<bool> login(String email, String password) async {
    final emailError = validateEmail(email);
    if (emailError != null) {
      _state = _state.copyWith(errorMessage: emailError);
      notifyListeners();
      return false;
    }

    _state = _state.copyWith(isLoading: true, errorMessage: null);
    notifyListeners();

    try {
      final user = await _repo.login(email, password);
      _state = _state.copyWith(user: user, isLoading: false);
      notifyListeners();
      return true;
    } catch (e) {
      _state = _state.copyWith(
        isLoading: false,
        errorMessage: 'فشل تسجيل الدخول: \${e.toString()}',
      );
      notifyListeners();
      return false;
    }
  }
}

// جيد: الودجت رقيقة -- فقط تعرض الحالة وتوجه الأحداث
class LoginScreen extends StatelessWidget {
  final _emailCtrl = TextEditingController();
  final _passCtrl = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthNotifier>();

    return Column(
      children: [
        TextField(controller: _emailCtrl),
        TextField(controller: _passCtrl, obscureText: true),
        if (auth.state.errorMessage != null)
          Text(auth.state.errorMessage!, style: TextStyle(color: Colors.red)),
        ElevatedButton(
          onPressed: auth.state.isLoading
              ? null
              : () async {
                  final success = await auth.login(
                    _emailCtrl.text,
                    _passCtrl.text,
                  );
                  if (success && context.mounted) {
                    Navigator.pushReplacementNamed(context, '/home');
                  }
                },
          child: auth.state.isLoading
              ? const CircularProgressIndicator()
              : const Text('تسجيل الدخول'),
        ),
      ],
    );
  }
}

5. تجنب إعادة البناء غير الضرورية

في كل مرة ينطلق notifyListeners() جميع الودجات التي تستخدم context.watch على ذلك المُعلم ستعيد البناء. إذا كانت ودجت تحتاج فقط جزءاً صغيراً من البيانات استخدم Selector أو context.select لإعادة البناء فقط عندما يتغير ذلك الجزء المحدد.

تحسين إعادة البناء باستخدام Selector

// سيء: يعيد بناء الودجت بالكامل عندما يتغير أي جزء من الحالة
class TodoCounter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // هذا يعيد البناء عندما تتغير المهام أو الفلتر أو التحميل...
    final state = context.watch<TodoNotifier>().state;
    return Text('النشطة: \${state.activeCount}');
  }
}

// جيد: يعيد البناء فقط عندما يتغير activeCount فعلاً
class TodoCounter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final activeCount = context.select<TodoNotifier, int>(
      (notifier) => notifier.state.activeCount,
    );
    return Text('النشطة: \$activeCount');
  }
}

// جيد: استخدام ودجت Selector لنفس التأثير
class TodoCounter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Selector<TodoNotifier, int>(
      selector: (_, notifier) => notifier.state.activeCount,
      builder: (context, activeCount, child) {
        return Text('النشطة: \$activeCount');
      },
    );
  }
}

// جيد: استخدام Consumer فقط حيث يُحتاج
class TodoListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('مهامي'),  // ثابت -- لا يعيد البناء أبداً
        actions: [
          // فقط هذا الجزء الصغير يعيد البناء عند تغير العدد
          Consumer<TodoNotifier>(
            builder: (context, notifier, child) {
              return Badge(
                label: Text('\${notifier.state.activeCount}'),
                child: child,
              );
            },
            child: const Icon(Icons.list),  // ابن ثابت -- مخزن مؤقتاً
          ),
        ],
      ),
      body: Consumer<TodoNotifier>(
        builder: (context, notifier, _) {
          return ListView.builder(
            itemCount: notifier.state.filteredTodos.length,
            itemBuilder: (context, index) {
              return TodoTile(todo: notifier.state.filteredTodos[index]);
            },
          );
        },
      ),
    );
  }
}

6. الحالة المحلية مقابل الحالة العامة

ليست كل الحالة تحتاج أن تكون عامة. استخدم النطاق الصحيح لكل جزء من الحالة:

دليل نطاق الحالة:

حالة محلية (setState على مستوى الودجت): قيم إدخال النماذج ومتحكمات الرسوم المتحركة وموضع التمرير وفتح/إغلاق الصفحة السفلية وتركيز حقل النص واختيار علامات التبويب. القاعدة: إذا كانت ودجت واحدة فقط تهتم بها أبقها محلية.

حالة محددة النطاق (Provider على مستوى الشجرة الفرعية): سلة التسوق في شاشات التجارة الإلكترونية وحالة معالج النماذج متعدد الخطوات وإعدادات خاصة بالميزة. القاعدة: إذا كانت مجموعة من الشاشات المرتبطة تشاركها حددها لشجرتهم الفرعية.

حالة عامة (Provider على مستوى التطبيق): المصادقة/جلسة المستخدم وتفضيلات المظهر/اللغة والإشعارات على مستوى التطبيق وأعلام الميزات. القاعدة: إذا كان التطبيق بأكمله يحتاجها اجعلها عامة.

تحديد نطاق الحالة لشجرة فرعية

// حالة عامة -- مقدمة في جذر التطبيق
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthNotifier()),
        ChangeNotifierProvider(create: (_) => ThemeNotifier()),
      ],
      child: const MyApp(),
    ),
  );
}

// حالة محددة النطاق -- تتواجد فقط عندما تكون شاشات التسوق نشطة
class ShopNavigator extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      // CartNotifier يُنشأ عندما يدخل المستخدم قسم المتجر
      // ويُتلف عندما يغادره
      create: (_) => CartNotifier(),
      child: Navigator(
        onGenerateRoute: (settings) {
          // جميع مسارات المتجر تشارك نفس CartNotifier
          return MaterialPageRoute(
            builder: (_) => ShopScreen(),
          );
        },
      ),
    );
  }
}

// حالة محلية -- فقط هذه الودجت تهتم بها
class ExpandableCard extends StatefulWidget {
  final String title;
  final String content;

  const ExpandableCard({required this.title, required this.content});

  @override
  State<ExpandableCard> createState() => _ExpandableCardState();
}

class _ExpandableCardState extends State<ExpandableCard> {
  bool _isExpanded = false;  // حالة محلية بحتة

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        children: [
          ListTile(
            title: Text(widget.title),
            trailing: Icon(_isExpanded ? Icons.expand_less : Icons.expand_more),
            onTap: () => setState(() => _isExpanded = !_isExpanded),
          ),
          if (_isExpanded) Padding(
            padding: const EdgeInsets.all(16),
            child: Text(widget.content),
          ),
        ],
      ),
    );
  }
}

7. اختبار المنطق ذي الحالة

لأن منطق الأعمال يعيش في فئات منفصلة (ليس في الودجات) يمكنك اختباره باختبارات وحدة بسيطة -- لا حاجة لاختبار الودجات للتحقق من المنطق.

اختبار منطق الحالة

// test/todo_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  late TodoNotifier notifier;

  setUp(() {
    notifier = TodoNotifier();
  });

  group('TodoNotifier', () {
    test('يبدأ بحالة فارغة', () {
      expect(notifier.state.todos, isEmpty);
      expect(notifier.state.activeCount, 0);
      expect(notifier.state.completedCount, 0);
    });

    test('addTodo يزيد العدد', () {
      notifier.addTodo('اشتري حليب');
      expect(notifier.state.todos.length, 1);
      expect(notifier.state.activeCount, 1);
      expect(notifier.state.todos.first.title, 'اشتري حليب');
    });

    test('toggleTodo يُعلّم كمكتمل', () {
      notifier.addTodo('اشتري حليب');
      final todoId = notifier.state.todos.first.id;

      notifier.toggleTodo(todoId);

      expect(notifier.state.todos.first.isCompleted, true);
      expect(notifier.state.completedCount, 1);
      expect(notifier.state.activeCount, 0);
    });

    test('الفلتر يعرض المهام المطابقة فقط', () {
      notifier.addTodo('مهمة 1');
      notifier.addTodo('مهمة 2');
      notifier.toggleTodo(notifier.state.todos.first.id);

      notifier.setFilter(FilterType.active);
      expect(notifier.state.filteredTodos.length, 1);
      expect(notifier.state.filteredTodos.first.title, 'مهمة 2');

      notifier.setFilter(FilterType.completed);
      expect(notifier.state.filteredTodos.length, 1);
      expect(notifier.state.filteredTodos.first.title, 'مهمة 1');
    });

    test('يُعلم المستمعين عند تغير الحالة', () {
      int callCount = 0;
      notifier.addListener(() => callCount++);

      notifier.addTodo('مهمة');
      expect(callCount, 1);

      notifier.toggleTodo(notifier.state.todos.first.id);
      expect(callCount, 2);
    });
  });
}

8. استعادة الحالة

عندما يقتل Android تطبيقك في الخلفية أو يدور المستخدم الجهاز تضيع الحالة المحلية. RestorationMixin في Flutter يتيح لك حفظ واستعادة حالة واجهة المستخدم المؤقتة تلقائياً.

ما تستعيده مقابل ما تعيد جلبه: استعد حالة واجهة المستخدم مثل موضع التمرير وتقدم النماذج وعلامات التبويب المحددة. أعد جلب حالة البيانات من API أو قاعدة البيانات المحلية. احفظ الحالة الحرجة (جلسة المستخدم والإعدادات) في SharedPreferences أو قاعدة بيانات -- لا تعتمد على الاستعادة للبيانات التي يجب أن تبقى بعد إلغاء تثبيت التطبيق.

الملخص: قائمة مراجعة أفضل الممارسات

مرجع سريع:
1. مصدر واحد للحقيقة -- كل جزء من الحالة يعيش في مكان واحد بالضبط
2. حالة غير قابلة للتغيير -- دائماً أنشئ نسخاً جديدة ولا تغير أبداً
3. تدفق أحادي الاتجاه -- واجهة مستخدم → إجراء → تحديث حالة → إعادة بناء واجهة مستخدم
4. فصل الاهتمامات -- ودجات رقيقة ومعلمون/كتل سمينة
5. تقليل إعادة البناء -- استخدم Selector/select لإعادة بناء مستهدفة
6. النطاق الصحيح -- محلي أو محدد النطاق أو عام حسب الحاجة
7. اختبر المنطق بشكل منفصل -- اختبارات وحدة للمعلمين بدون اختبارات ودجات
8. استعد واحفظ -- احفظ حالة واجهة المستخدم مع RestorationMixin واحفظ البيانات في التخزين