تخطَّ إلى المحتوى

دورة حياة الـ Widget في Flutter: initState و dispose والمؤقتات

الدرس 26 من 33 · Flutter

كتبت dispose أكثر من مرة لتحرير متحكمات النصوص، وربما تساءلت: من يستدعي هذه الدالة؟ ومتى؟ وهل يوجد عكسها، أي دالة تُستدعى عند بداية الشاشة؟ كل StatefulWidget يمر بمراحل محددة من ولادته إلى إزالته، وهذه المراحل تسمى دورة الحياة (Lifecycle). فهمها يحل لك مسائل كثيرة: أين أحمّل البيانات؟ أين أبدأ المؤقت؟ ولماذا يتسرب من تطبيقي جزء من الذاكرة؟

تخيّل الشاشة مثل محل تجاري: يُفتح صباحاً، ويعمل طوال اليوم، ويُغلق مساءً.

createState ← يُنشأ كائن الحالة
↓
initState ← مرة واحدة: فتح المحل وتجهيزه
↓
build ← رسم الواجهة (يتكرر كثيراً)
↓ ↑
setState ← تغيّرت البيانات، فأعد البناء
↓
dispose ← مرة واحدة: إغلاق المحل وإطفاء الأنوار
الدالة كم مرة ماذا تضع فيها
initState مرة واحدة عند الإنشاء تجهيز القيم الأولى، وبدء المؤقتات، وطلب البيانات
build مرات كثيرة وصف الواجهة فقط
didUpdateWidget كلما أرسل الأب خصائص جديدة التفاعل مع تغيّر الخصائص
dispose مرة واحدة عند الإزالة إيقاف المؤقتات، وتحرير المتحكمات
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 تُستدعى مرات كثيرة جداً، فيُعاد الحساب في كل مرة بلا فائدة. والأسوأ لو كان الحساب طلباً من الإنترنت: سترسل الطلب عشرات المرات.

كل شيء بدأته أنت ويستمر في العمل وحده يجب أن توقفه أنت: المؤقتات، ومتحكمات النصوص والتمرير والحركة، والاشتراكات في مصادر البيانات.

@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}

هنا الترتيب معكوس: super.dispose() يكون آخر سطر. والقاعدة سهلة الحفظ: في البداية الأب أولاً، وفي النهاية الأب أخيراً.

إذا نسيت dispose يبقى المؤقت يعمل بعد إغلاق الشاشة، ويحاول تحديث شاشة لم تعد موجودة، ويحجز ذاكرة لا تتحرر. هذا هو تسرب الذاكرة (Memory Leak).

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، وتعطيك النسخة القديمة لتقارن:

@override
void didUpdateWidget(covariant UserCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
debugPrint('تغيّر المستخدم، حمّل بياناته من جديد');
}
}

لن تحتاجها كثيراً في البداية، لكن تذكّرها عندما تلاحظ أن شاشة تعرض بيانات قديمة مع أن الأب أرسل لها قيمة جديدة.

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

late final AppLifecycleListener _listener;
@override
void initState() {
super.initState();
_listener = AppLifecycleListener(
onPause: () => debugPrint('التطبيق في الخلفية'),
onResume: () => debugPrint('عاد التطبيق للواجهة'),
);
}
@override
void dispose() {
_listener.dispose();
super.dispose();
}

استخدامات شائعة: إيقاف الفيديو أو اللعبة عند الخروج، وحفظ المسودة، وتحديث البيانات عند العودة.

lib/stopwatch_screen.dart
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.
  1. شغّل ساعة الإيقاف وجرّب الأزرار الثلاثة.
  2. احذف سطر الإلغاء من dispose مؤقتاً، وشغّل الساعة، ثم ارجع من الشاشة وراقب الأخطاء في الطرفية. ثم أعد السطر.
  3. اجعل المؤقت يعمل كل 100 ملّي ثانية ليعرض أجزاء الثانية.
  4. تحدٍّ: أضف زر «لفّة» يحفظ الوقت الحالي في قائمة تظهر تحت الأزرار.
  • بدء مؤقت أو طلب بيانات داخل build: يتكرر مع كل إعادة بناء، فتحصل على عشرات المؤقتات. مكانه initState.
  • نسيان إلغاء المؤقت في dispose: تسرب ذاكرة وخطأ «setState() called after dispose()».
  • نسيان super.initState أو super.dispose: يحذرك المحلل، وقد يتعطل سلوك الـ Widget.

سؤال

أين المكان الصحيح لبدء مؤقت يعمل من لحظة فتح الشاشة؟

سؤال

ماذا يحدث إذا لم تلغِ Timer.periodic في dispose؟

  • دورة الحياة: initState مرة واحدة، ثم build مرات كثيرة، ثم dispose مرة واحدة.
  • initState للتجهيز وبدء العمليات، و build للرسم فقط.
  • كل ما بدأته أوقفه في dispose: مؤقتات ومتحكمات واشتراكات.
  • افحص mounted قبل setState بعد أي await.
  • AppLifecycleListener يخبرك بانتقال التطبيق إلى الخلفية وعودته.