قبل كم سنة، كان في فريقنا شاب صغير، خلينا نسميه “سالم”، متحمس وشاطر، لكن حماسه كان أحيانًا يسبق خبرته. كنا شغالين على تطبيق خدماتي، وكان سالم مسؤول عن شاشة عرض تفاصيل طلب الخدمة. بعد أيام من الشغل، إجاني فرحان وهو يحكي: “يا بشمهندس أبو عمر، لقيت طريقة عبقرية نخفف فيها الكود ونحسّن الأداء! بدل كل تعقيدات الـ Provider اللي بنستخدمها، عملت Service بسيط بـ Singleton Pattern، وبوصله من أي مكان في الشاشة مباشرة. شوف ما أسرعه وأبسطه!”.
فتحت الكود، وفعلاً، كان الكود أبسط ظاهريًا. لا `context.watch` ولا `Consumer`، مجرد استدعاء مباشر لـ `OrderDetailsService.instance.order`. مبدئيًا، التطبيق اشتغل تمام. ضغطنا على طلب الخدمة، وفتحت الشاشة وعرضت البيانات. سالم كان مبسوط على حاله، وأنا ابتسمت ابتسامة هادية وقلتله: “شغل نظيف يا سالم، لكن تعال نعمل تجربة صغيرة”. طلبت منه يفتح طلب خدمة رقم #101، وبعدها من داخل نفس الشاشة، يضغط على رابط “الخدمة السابقة المرتبطة” اللي بتوديه على طلب رقم #100. فتحت الشاشة الثانية وعرضت بيانات الطلب #100 بشكل صحيح. “شايف يا بشمهندس؟ زي الحلاوة!”. قلتله: “تمام، هسّا اكبس زر الرجوع (Back)”.
وهون كانت الصدمة. لما رجعنا لشاشة الطلب #101، كانت الشاشة لسا عنوانها “تفاصيل الطلب #101″، لكن كل البيانات اللي جواها – اسم العميل، السعر، التاريخ – كانت بيانات الطلب #100! سالم طلع فيي وعيونه كلها حيرة، وسألني: “شو القصة؟ كيف هيك صار؟”.
هذه القصة يا أصدقائي هي المدخل المثالي لأحد أهم الفخاخ في عالم هندسة البرمجيات، فخ الـ Singleton، والسؤال اللي بيكشفلك مين “السينيور” الحقيقي في أي مقابلة فلاتر.
السؤال المخادع في مقابلة فلاتر
تخيل حالك في مقابلة عمل، والمحاور شخص خبيث شوي (مثلي 😉)، وبيسألك السؤال التالي ببرود:
“يا بشمهندس، فريقنا يعاني من كثرة الـ Boilerplate code في المشروع. لماذا لا نستخدم ببساطة Global Singletons أو حتى Static Classes لإدارة حالة الشاشات (Screen State)؟ أليس هذا أسرع في التنفيذ وأوفر للذاكرة من تعقيدات الـ `Context` وأدوات مثل Provider أو Riverpod؟”
هنا، المحاور نصب لك فخًا. هو لا يريد أن يسمع الإجابة المحفوظة من الكتب، بل يريد أن يرى كيف تفكر معماريًا وكيف تتنبأ بالمشاكل التي لا تظهر إلا في سيناريوهات الاستخدام الحقيقي.
الإجابة النموذجية (إجابة المطور المتوسط)
المطور المتوسط، اللي قرأ كتب الـ Clean Code وحضر كم كورس، راح يجاوب بثقة:
“لا، استخدام الـ Singleton لهذا الغرض يعتبر Anti-pattern. أولاً، يجعل اختبار الوحدات (Unit Testing) كابوسًا حقيقيًا، لأنه يتسبب في تسرب الحالة (State Leakage) بين الاختبارات. ثانيًا، هو يكسر مبادئ SOLID، خصوصًا مبدأ الحقن بالاعتماديات (Dependency Inversion)، ويجعل الكود مرتبطًا بشكل وثيق (Tightly Coupled) وصعب التتبع والصيانة.”
هل هذه الإجابة خاطئة؟ لا أبدًا، هي صحيحة 100% نظريًا وأكاديميًا. لكنها إجابة “محفوظة”. هي تصف الأعراض الجانبية التي تؤثر على المطور، لكنها تتجاهل “الكارثة” الحقيقية التي ستنفجر في وجه المستخدم النهائي.
الإجابة الإبداعية (إجابة المطور السينيور)
هنا، المطور السينيور يبتسم. هو يعرف أن الحديث عن الـ Testing و SOLID هو رفاهية مقارنة بالمشكلة الأساسية. فيأخذ المحاور في رحلة عملية داخل التطبيق:
“يا صديقي، كلامك عن الـ Testing صحيح، لكنها مشكلة داخلية إلنا كمطورين. المصيبة الأكبر بتصير عند المستخدم، وتحديدًا في آلية التنقل (Navigation). خليني أرسم لك سيناريو بسيط راح يخلي تطبيقنا ينهار منطقيًا.”
“تخيل تطبيق متجر إلكتروني. عندنا Singleton لإدارة حالة صفحة المنتج، خلينا نسميه ProductService:
// هذا هو الفخ بعينه!
class ProductService {
// The global static instance
static final ProductService instance = ProductService._internal();
factory ProductService() => instance;
ProductService._internal();
// The state that will be shared globally and disastrously
Product? currentProduct;
Future<void> fetchProduct(String productId) async {
// Simulate a network call
print('Fetching data for product: $productId');
currentProduct = await Api.getProductById(productId);
// Anyone listening? No, this is the problem. UI won't rebuild automatically.
// Even if we use a listener, the data itself is overwritten.
}
}
والآن، لنرى السيناريو الكارثي خطوة بخطوة:
- المستخدم يفتح صفحة “منتج أ” (مثلاً، لابتوب). شاشة المنتج تستدعي
ProductService.instance.fetchProduct('laptop-dell'). الآن الـ Singleton يحمل بيانات اللابتوب. - في أسفل الصفحة، يرى المستخدم قسم “منتجات مشابهة” ويضغط على “منتج ب” (مثلاً، ماوس). هذا يفتح شاشة منتج جديدة فوق الشاشة الحالية في الـ Navigation Stack.
- الشاشة الجديدة، بدورها، تستدعي نفس الـ Singleton:
ProductService.instance.fetchProduct('mouse-logitech'). هنا المصيبة: تم استبدال بيانات اللابتوب في الذاكرة ببيانات الماوس. الـ Singleton لا يعرف أنه توجد شاشتان، هو مجرد متغير عام واحد. - الكارثة الكبرى: المستخدم يقرر أنه لا يريد الماوس ويضغط على زر “الرجوع” (Back) ليعود إلى صفحة “منتج أ” (اللابتوب).
ماذا سيحدث؟ سيجد المستخدم أمامه صفحة اللابتوب، ولكن كل البيانات المعروضة (السعر، الصورة، الوصف) هي بيانات الماوس! ليش؟ لأن الواجهة الرسومية (UI) لهذه الصفحة لا تزال تقرأ من نفس الـ Singleton الذي تم تعديل بياناته.”
الخلاصة المعمارية: ربط “عمر البيانات” بـ “عمر الواجهة”
وهنا تأتي الضربة القاضية في إجابة السينيور:
“المشكلة يا صديقي ليست في الـ Singleton بحد ذاته، بل في عدم تطابق “دورة حياة البيانات” (Data Lifecycle) مع “دورة حياة الواجهة” (Widget Lifecycle). الـ Singleton يعيش طوال فترة حياة التطبيق، بينما بيانات صفحة المنتج يجب أن تعيش وتموت مع صفحة المنتج نفسها.”
“لهذا السبب نستخدم أدوات مثل Provider، Bloc، أو Riverpod. هذه الأدوات ليست مجرد تعقيد زائد، بل هي حل عبقري لإنشاء “نطاق” (Scope) للحالة. عندما نستخدم ChangeNotifierProvider مثلاً، نحن ننشئ نسخة (Instance) جديدة من الـ ViewModel أو الـ Notifier لكل صفحة يتم دفعها في الـ Navigation Stack.”
// الطريقة الصحيحة: ربط الحالة بالـ Route
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider(
// ننشئ نسخة جديدة من الـ ViewModel مع كل صفحة جديدة
create: (ctx) => ProductViewModel(productId: 'laptop-dell'),
child: ProductPage(),
),
),
);
بهذه الطريقة، عندما يفتح المستخدم صفحة “منتج أ”، يتم إنشاء `ProductViewModel` خاص بها. وعندما يفتح صفحة “منتج ب”، يتم إنشاء `ProductViewModel` آخر، منفصل تمامًا. عندما يعود المستخدم للخلف، يتم تدمير (dispose) الـ `ViewModel` الخاص بـ “منتج ب”، وتبقى نسخة “منتج أ” سليمة كما تركها. هذا هو مفهوم عزل البيانات (Data Isolation) الذي يمنع وقوع الكارثة التي وصفناها.
لماذا هذه الإجابة قوية جدًا؟
- لمس الواقع: ابتعدت عن التنظير وضربت مثالًا حيًا يصف Bug حقيقي سيواجهه المستخدم والعميل.
- فهم عميق للمنصة: أظهرت فهمًا لكيفية عمل الـ Navigator Stack في فلاتر وكيفية إدارة الـ Routes وحالاتها.
- تبرير الحلول المعقدة: أثبتت أن “التعقيد” الظاهري في أدوات إدارة الحالة هو ضرورة حتمية، وليس مجرد “فلسفة زائدة” من المطورين.
نصائح عملية من أخوكم أبو عمر
- متى يكون الـ Singleton مقبولاً؟ نعم، له استخداماته! استخدمه للخدمات التي ليس لها حالة مرتبطة بواجهة معينة (Stateless Services). أمثلة: خدمة تسجيل الأخطاء (Logger)، خدمة الاتصال بالـ API (ApiClient) التي لا تخزن استجابات، أو خدمة إدارة الثيمات (ThemeService). القاعدة هي: إذا كانت الخدمة لا تحمل بيانات تتغير من شاشة لأخرى، فالـ Singleton قد يكون خيارًا جيدًا.
- فكّر دائمًا بدورة الحياة: قبل كتابة أي كود لإدارة الحالة، اسأل نفسك: “كم من الوقت أحتاج هذه البيانات لتبقى في الذاكرة؟”. إذا كانت الإجابة “طالما هذه الشاشة مفتوحة”، فأنت تحتاج لحل مربوط بالـ Widget Tree (مثل Provider/Riverpod). إذا كانت الإجابة “طوال فترة عمل التطبيق”، فقد يكون الـ Singleton مناسبًا.
- تجربة المستخدم هي الحكم: أفضل طريقة لاكتشاف العيوب المعمارية هي محاكاة أسوأ سيناريوهات التنقل للمستخدم. افتح شاشات متداخلة، ارجع للخلف، اترك التطبيق في الخلفية ثم ارجع له. هذه السيناريوهات تكشف ما لا تكشفه الاختبارات البسيطة.
الخلاصة 💡
يا جماعة الخير، الفرق بين المطور المتوسط والسينيور ليس في كمية الأكواد التي يكتبها، بل في كمية المشاكل التي يمنع حدوثها. السؤال عن الـ Singleton ليس سؤالاً عن نمط تصميم، بل هو سؤال عن فهمك لأساسيات بناء تطبيقات مستقرة وقابلة للتوسع.
في المرة القادمة التي تفكر فيها باستخدام “حل سريع” مثل الـ Singleton لإدارة حالة شاشة، تذكر قصة سالم والبيانات المختلطة، واسأل نفسك: هل أنا أحل مشكلة اليوم لأخلق كارثة الغد؟
أتمنى لكم تجربة برمجية خالية من الكوارث المنطقية! 😄