دورة حياة الـ Widget في Flutter: initState و dispose والمؤقتات
كتبت dispose أكثر من مرة لتحرير متحكمات النصوص، وربما تساءلت: من يستدعي هذه الدالة؟ ومتى؟ وهل يوجد عكسها، أي دالة تُستدعى عند بداية الشاشة؟ كل StatefulWidget يمر بمراحل محددة من ولادته إلى إزالته، وهذه المراحل تسمى دورة الحياة (Lifecycle). فهمها يحل لك مسائل كثيرة: أين أحمّل البيانات؟ أين أبدأ المؤقت؟ ولماذا يتسرب من تطبيقي جزء من الذاكرة؟
المراحل بالترتيب
Section titled “المراحل بالترتيب”تخيّل الشاشة مثل محل تجاري: يُفتح صباحاً، ويعمل طوال اليوم، ويُغلق مساءً.
createState ← يُنشأ كائن الحالة ↓initState ← مرة واحدة: فتح المحل وتجهيزه ↓build ← رسم الواجهة (يتكرر كثيراً) ↓ ↑setState ← تغيّرت البيانات، فأعد البناء ↓dispose ← مرة واحدة: إغلاق المحل وإطفاء الأنوار| الدالة | كم مرة | ماذا تضع فيها |
|---|---|---|
initState |
مرة واحدة عند الإنشاء | تجهيز القيم الأولى، وبدء المؤقتات، وطلب البيانات |
build |
مرات كثيرة | وصف الواجهة فقط |
didUpdateWidget |
كلما أرسل الأب خصائص جديدة | التفاعل مع تغيّر الخصائص |
dispose |
مرة واحدة عند الإزالة | إيقاف المؤقتات، وتحرير المتحكمات |
initState: البداية
Section titled “initState: البداية”class _ClockState extends State<StatefulWidget> { late String _greeting;
@override void initState() { super.initState(); final hour = DateTime.now().hour; _greeting = hour < 12 ? 'صباح الخير' : 'مساء الخير'; }
@override Widget build(BuildContext context) { return Text(_greeting); }}initStateتُستدعى مرة واحدة قبل أولbuild.- السطر
super.initState()يجب أن يكون أول سطر فيها. - الكلمة
lateتعني: «هذا المتغير ستُعطى قيمته لاحقاً قبل استخدامه»، وهي مناسبة للقيم التي تُحسب فيinitState.
لماذا لا نحسب التحية داخل build؟ لأن build تُستدعى مرات كثيرة جداً، فيُعاد الحساب في كل مرة بلا فائدة. والأسوأ لو كان الحساب طلباً من الإنترنت: سترسل الطلب عشرات المرات.
dispose: النهاية
Section titled “dispose: النهاية”كل شيء بدأته أنت ويستمر في العمل وحده يجب أن توقفه أنت: المؤقتات، ومتحكمات النصوص والتمرير والحركة، والاشتراكات في مصادر البيانات.
@overridevoid dispose() { _timer?.cancel(); _controller.dispose(); super.dispose();}هنا الترتيب معكوس: super.dispose() يكون آخر سطر. والقاعدة سهلة الحفظ: في البداية الأب أولاً، وفي النهاية الأب أخيراً.
إذا نسيت dispose يبقى المؤقت يعمل بعد إغلاق الشاشة، ويحاول تحديث شاشة لم تعد موجودة، ويحجز ذاكرة لا تتحرر. هذا هو تسرب الذاكرة (Memory Leak).
Timer: المؤقتات
Section titled “Timer: المؤقتات”Timer.periodic(const Duration(seconds: 1), (timer) { debugPrint('مرت ثانية');});Timer.periodic تستدعي الدالة كل فترة محددة، إلى أن تستدعي cancel(). ويوجد أيضاً Timer(duration, callback) لتنفيذ شيء مرة واحدة بعد تأخير. وكلاهما يحتاج الاستيراد:
import 'dart:async';mounted: هل الشاشة ما زالت موجودة؟
Section titled “mounted: هل الشاشة ما زالت موجودة؟”تذكّر هذا السطر من درس النوافذ: if (!mounted) return;. الآن تفهم معناه كاملاً: الخاصية mounted تكون true من بعد initState إلى قبل dispose. وبعد إزالة الشاشة تصبح false.
Future<void> _load() async { await Future.delayed(const Duration(seconds: 2)); if (!mounted) return; setState(() { _loaded = true; });}إذا أغلق المستخدم الشاشة خلال الثانيتين، ثم استُدعيت setState، يظهر الخطأ «setState() called after dispose()». فحص mounted يمنع ذلك.
didUpdateWidget: تغيّرت خصائصي
Section titled “didUpdateWidget: تغيّرت خصائصي”كلاس الحالة يعيش طويلاً، أما الـ Widget نفسه فيُستبدل بنسخة جديدة كلما أعاد الأب البناء. إذا أرسل الأب قيمة مختلفة لإحدى الخصائص، تُستدعى didUpdateWidget، وتعطيك النسخة القديمة لتقارن:
@overridevoid didUpdateWidget(covariant UserCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.userId != widget.userId) { debugPrint('تغيّر المستخدم، حمّل بياناته من جديد'); }}لن تحتاجها كثيراً في البداية، لكن تذكّرها عندما تلاحظ أن شاشة تعرض بيانات قديمة مع أن الأب أرسل لها قيمة جديدة.
حالة التطبيق نفسه
Section titled “حالة التطبيق نفسه”للشاشة دورة حياة، وللتطبيق كله دورة حياة أيضاً: يكون في الواجهة، ثم ينتقل إلى الخلفية عندما يضغط المستخدم زر الرئيسية، ثم يعود. تستطيع متابعة ذلك بـ AppLifecycleListener:
late final AppLifecycleListener _listener;
@overridevoid initState() { super.initState(); _listener = AppLifecycleListener( onPause: () => debugPrint('التطبيق في الخلفية'), onResume: () => debugPrint('عاد التطبيق للواجهة'), );}
@overridevoid dispose() { _listener.dispose(); super.dispose();}استخدامات شائعة: إيقاف الفيديو أو اللعبة عند الخروج، وحفظ المسودة، وتحديث البيانات عند العودة.
المشروع: ساعة إيقاف
Section titled “المشروع: ساعة إيقاف”import 'dart:async';
import 'package:flutter/material.dart';
class StopwatchScreen extends StatefulWidget { const StopwatchScreen({super.key});
@override State<StopwatchScreen> createState() => _StopwatchScreenState();}
class _StopwatchScreenState extends State<StopwatchScreen> { Timer? _timer; int _seconds = 0;
bool get _running => _timer != null;
void _start() { _timer = Timer.periodic(const Duration(seconds: 1), (timer) { setState(() { _seconds++; }); }); setState(() {}); }
void _stop() { _timer?.cancel(); setState(() { _timer = null; }); }
void _reset() { _timer?.cancel(); setState(() { _timer = null; _seconds = 0; }); }
@override void dispose() { _timer?.cancel(); super.dispose(); }
@override Widget build(BuildContext context) { final minutes = (_seconds ~/ 60).toString().padLeft(2, '0'); final seconds = (_seconds % 60).toString().padLeft(2, '0');
return Scaffold( appBar: AppBar(title: const Text('ساعة إيقاف')), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('$minutes:$seconds', style: const TextStyle(fontSize: 64)), const SizedBox(height: 24), Wrap( spacing: 12, children: [ FilledButton( onPressed: _running ? _stop : _start, child: Text(_running ? 'إيقاف' : 'ابدأ'), ), OutlinedButton( onPressed: _reset, child: const Text('تصفير'), ), ], ), ], ), ), ); }}شرح أهم الأجزاء:
Timer? _timer: يكونnullعندما تكون الساعة متوقفة. ومنه نعرف الحالة:_runningتُرجعtrueإذا وُجد مؤقت._start: تنشئ مؤقتاً يزيد_secondsكل ثانية ويعيد البناء._stop: تلغي المؤقت وتجعلهnull، فيتحول نص الزر إلى «ابدأ».dispose: تلغي المؤقت إذا أُغلقت الشاشة والساعة تعمل. هذا أهم سطر في المشروع.~/قسمة صحيحة بلا كسور، و%باقي القسمة، وpadLeft(2, '0')تضيف صفراً في البداية، فيظهر الوقت بالشكل02:05.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل ساعة الإيقاف وجرّب الأزرار الثلاثة.
- احذف سطر الإلغاء من
disposeمؤقتاً، وشغّل الساعة، ثم ارجع من الشاشة وراقب الأخطاء في الطرفية. ثم أعد السطر. - اجعل المؤقت يعمل كل 100 ملّي ثانية ليعرض أجزاء الثانية.
- تحدٍّ: أضف زر «لفّة» يحفظ الوقت الحالي في قائمة تظهر تحت الأزرار.
أخطاء شائعة
Section titled “أخطاء شائعة”- بدء مؤقت أو طلب بيانات داخل build: يتكرر مع كل إعادة بناء، فتحصل على عشرات المؤقتات. مكانه
initState. - نسيان إلغاء المؤقت في dispose: تسرب ذاكرة وخطأ «setState() called after dispose()».
- نسيان super.initState أو super.dispose: يحذرك المحلل، وقد يتعطل سلوك الـ Widget.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
أين المكان الصحيح لبدء مؤقت يعمل من لحظة فتح الشاشة؟
initState تُستدعى مرة واحدة عند إنشاء الحالة، أما build فتتكرر كثيراً فتنشئ مؤقتات عديدة.
سؤال
ماذا يحدث إذا لم تلغِ Timer.periodic في dispose؟
المؤقت لا يعرف أن الشاشة أُغلقت، فيستمر ويحاول استدعاء setState على حالة أُزيلت.
الخلاصة
Section titled “الخلاصة”- دورة الحياة:
initStateمرة واحدة، ثمbuildمرات كثيرة، ثمdisposeمرة واحدة. initStateللتجهيز وبدء العمليات، وbuildللرسم فقط.- كل ما بدأته أوقفه في
dispose: مؤقتات ومتحكمات واشتراكات. - افحص
mountedقبلsetStateبعد أيawait. AppLifecycleListenerيخبرك بانتقال التطبيق إلى الخلفية وعودته.