پھڑپھڑانے میں "پروڈکشن ریڈی” کا واقعی کیا مطلب ہے۔

میں کئی سالوں سے فلٹر ایپس بنا رہا ہوں، اور مجھے اب بھی یاد ہے کہ میں نے پہلی بار کوئی پروڈکٹ ریلیز کیا تھا جس پر مجھے واقعی فخر تھا۔ کلین UI، ہموار اینیمیشنز، اور سب کچھ بالکل اسی طرح بہہ گیا جیسا میں نے ارادہ کیا تھا۔ میں نے اسے حقیقی صارفین کے حوالے کرنے کے بعد بہتر محسوس کیا۔

ایک ہفتے کے اندر، بگ رپورٹس آنا شروع ہو گئیں۔

اسکرینیں منجمد ہو گئیں، API کالز خاموشی سے ناکام ہو گئیں، صارفین کا فارم ڈیٹا ضائع ہو گیا جو وہ 10 منٹ سے بھر رہے تھے، اور ایک صارف نے اطلاع دی کہ ایپ نے سب وے ٹنل سے گزرنے کے بعد جواب دینا بند کر دیا۔ میں نے کبھی اس کا تجربہ نہیں کیا۔ میں ایسا کیوں کروں گا؟ اس نے میرے کمپیوٹر پر ٹھیک کام کیا۔

اس تجربے نے مجھے کچھ سکھایا کاش کسی نے مجھے پہلے بتایا ہوتا۔ بات یہ ہے کہ کام کرنے والی ایپ اور پروڈکشن کے لیے تیار ایپ کے درمیان حقیقی فرق ہے۔

میں نے اب کئی فلٹر ایپس لانچ کی ہیں اور اس آرٹیکل میں شامل تقریباً ہر دیوار کو ٹکرایا ہے۔ نیٹ ورک کی خرابیاں، میموری لیک، ریاستی انتظام جو پہلے تو سمجھ میں آتا تھا لیکن اسکیل کرنے پر ایک ڈراؤنا خواب بن جاتا ہے، کارکردگی جو ترقی میں ٹھیک محسوس ہوتی تھی لیکن صارفین کے پرانے آلات پر بہت زیادہ بے چین تھی۔

یہ مضمون ہر اس چیز کے بارے میں ہے جو میں نے اس تجربے سے سیکھا ہے۔ یہ حقیقی مسائل کے حقیقی نمونے ہیں، نظریات نہیں۔

انڈیکس

کیوں "یہ میری مشین پر کام کرتا ہے” پھڑپھڑانے میں خطرناک ہے۔

ترقی کا ماحول حسب ذیل ہے: تیز انٹرنیٹ، ایک طاقتور مشین یا ایمولیٹر، ہر گرم ری لوڈ پر کلین ایپ سٹیٹ، ملی سیکنڈ میں جواب دینے والے APIs، اور محتاط ڈویلپرز جو جان بوجھ کر خوشگوار راستے پر چلتے ہیں۔

صارف کیسا لگتا ہے یہ ہے: اسپاٹی موبائل ڈیٹا، پرانا درمیانی رینج کا آلہ، پس منظر میں چلنے والی نصف درجن دیگر ایپس، ان اسکرینوں کے لیے صفر صبر جو بغیر وضاحت کے لوڈ ہونا بند کر دیتی ہے۔

وہ خلا ہے جہاں پروڈکشن بگ رہتا ہے۔

مشکل حصہ یہ ہے کہ Flutter ترقی کو اتنا ہموار بناتا ہے کہ "میرے کمپیوٹر پر کام کرنے” کو "صارفین کے لیے تیار” سمجھنا آسان ہے۔

میں نے وہ غلطی کی۔ میں جانتا ہوں کہ زیادہ تر فلٹر ڈویلپرز نے بھی ایسا کیا ہے۔ ایپ سجیلا لگ رہی ہے۔ حرکت پذیری مکھن ہے۔ جب میں اپنے ساتھیوں کے سامنے اس کا مظاہرہ کرتا ہوں تو سب کچھ ٹھیک ہو جاتا ہے۔ پھر کوئی داغدار موبائل ڈیٹا کے ساتھ کام کرنے کے لیے سفر کرتے ہوئے اسے استعمال کرنے کی کوشش کرتا ہے اور سب کچھ الگ ہو جاتا ہے۔

انجینئرنگ پروڈکشن کے لیے تیار فلٹر اس غیر آرام دہ سچائی کو قبول کرنے کے ساتھ شروع ہوتا ہے کہ چیزیں غلط ہو جائیں گی۔ نیٹ ورک ناکام ہو جاتا ہے۔ آپ کے آلے کی میموری ختم ہو سکتی ہے۔ صارفین بدترین ممکنہ لمحات میں پس منظر میں ایپس چلاتے ہیں۔ سوال یہ نہیں ہے کہ آیا یہ چیزیں ہوتی ہیں، بلکہ یہ ہے کہ کیا آپ کی ایپ ان کے ہونے پر انہیں احسن طریقے سے ہینڈل کرتی ہے۔

ترقی بمقابلہ پیداوار: واقعی کیا بدل رہا ہے۔

میں یہاں مخصوص ہونا چاہتا ہوں کیونکہ "پیداوار مختلف ہے” کہنا آسان ہے، لیکن اس وقت تک اندرونی بنانا مشکل ہے جب تک کہ آپ اس سے بیمار نہ ہوں۔

ڈیولپمنٹ کے دوران، ناکام API کالز کو ٹرمینل میں فوری طور پر دیکھا جا سکتا ہے، منٹوں میں طے کیا جاتا ہے، اور آگے بڑھایا جاتا ہے۔ یہاں تک کہ پیداواری ماحول میں بھی، وہی API کال کی ناکامیاں ہوتی ہیں جب صارفین خالی اسکرین دیکھتے ہیں، نہ جانے کیوں، چند سیکنڈ انتظار کرتے ہیں، اور پھر دوبارہ کوشش کرتے ہیں یا صاف کرتے ہیں۔ تین دن بعد میں نے دیکھا کہ کسی نے 1 ستارہ جائزہ چھوڑا ہے۔

ترقی کے دوران غیر ضروری طور پر دوبارہ تعمیر کیے جانے والے وجیٹس میں چند ملی سیکنڈ لاگت آتی ہے جسے صارفین کبھی نہیں دیکھیں گے۔ پیداواری ماحول میں، پس منظر میں چلنے والی متعدد ایپس کے ساتھ پرانے یا کم طاقت والے آلات پر، وہی غیر ضروری تعمیر نو فریموں کو 16ms بجٹ سے آگے بڑھا دے گی، جس کی وجہ سے ہنگامہ آرائی ہو گی جسے صارفین محسوس کریں گے۔

ترقی کے دوران، ایک میموری لیک جو 10 منٹ میں 5 MB استعمال کرتا ہے قابل توجہ نہیں ہے۔ ہمارے پاس ایک بار چیٹ کی خصوصیت میں ایک لیک ہوا تھا، ایک غیر پروسیس شدہ اسٹریم سبسکرپشن جس کا ہم جانچ کے دوران بالکل بھی پتہ نہیں لگا سکے۔ پیداوار میں، کم میموری والے ڈیوائس پر ایک گھنٹے کے استعمال کے بعد، OS نے ایپ کے وسط سیشن کو ختم کرنا شروع کر دیا۔ صارفین کا خیال تھا کہ حادثے تصادفی طور پر ہوئے ہیں۔ اس کا پتہ لگانے میں شرمناک حد تک لمبا وقت لگا۔

پیٹرن ہمیشہ ایک جیسا ہوتا ہے۔ جو مسائل ترقیاتی پیمانے پر نظر نہیں آتے ہیں وہ پروڈکشن پیمانے پر سنگین ہو جاتے ہیں، اور جو مسائل ڈویلپمنٹ ہارڈویئر پر معمولی ہوتے ہیں وہ اصل صارفین کی ملکیت والے ہارڈ ویئر پر سنگین ہو جاتے ہیں۔

نیٹ ورک کا استحکام اور دفاعی درخواست کو سنبھالنا

اگر مجھے کیڑے کا صرف ایک زمرہ چننا پڑا جس نے مجھے تمام ایپس میں سب سے زیادہ پریشان کیا، تو یہ یہی ہوگا۔ موبائل نیٹ ورک صحیح معنوں میں قابل اعتماد نہیں ہیں، اور Flutter ایپس کو اکثر ایسے لکھا جاتا ہے جیسے وہ نہیں ہیں۔

نیٹ ورکنگ کے سب سے عام نمونے جو میں نے دیکھے ہیں (اور اپنے بارے میں اس سے زیادہ سالوں سے لکھا ہے جو میں تسلیم کرنا چاہتا ہوں) یہ ہیں:

final response = await dio.get('/user');

setState(() {
  user = response.data;
});

یہ ترقی کے دوران بالکل کام کرتا ہے۔ تاہم، پیداوار میں ناکام ہونے کے چار طریقے ہیں:

  1. نیٹ ورک کی خرابی درخواست کے ناکام ہونے کا سبب بنتی ہے اور غیر ہینڈل پروپیگیشن کی رعایت۔

  2. جواب آنے سے پہلے صارف کہیں اور چلا جاتا ہے۔ setState منسوخ شدہ ویجیٹ پر کال کی گئی۔

  3. API غیر متوقع ڈیٹا واپس کرتا ہے اور رن ٹائم پر کاسٹ کا سبب بنتا ہے۔

  4. درخواست غیر معینہ مدت تک لٹک جاتی ہے اور صارف ہمیشہ کے لیے اسپنر کو گھورتا رہتا ہے۔

میں نے چاروں کو درست سمجھا۔ یہاں ایک ورژن ہے جو اسے سنبھالتا ہے:

Future loadUser(String userId) async {
  setState(() {
    isLoading = true;
    error = null;
  });

  try {
    final response = await dio.get('/user/$userId');

    // mounted checks whether this widget is still in the widget tree.
    // If the user navigated away while the request was running,
    // mounted is false. Calling setState on a disposed widget throws
    // an error — this one line prevents that entire class of crash.
    if (!mounted) return;

    setState(() {
      user = User.fromJson(response.data as Map);
      isLoading = false;
    });
  } on DioException catch (e) {
    if (!mounted) return;

    setState(() {
      // Give the user a message that is actually useful.
      // "Something went wrong" is not helpful. Knowing whether
      // they have no internet vs the server failed lets them
      // decide whether to move or wait.
      error = e.type == DioExceptionType.connectionError
          ? 'No internet connection. Please try again.'
          : 'Failed to load profile. Please try again.';
      isLoading = false;
    });
  }
}

تین ریاستوں میں ہر اسکرین کی ضرورت ہوتی ہے۔

میں کامیابی کی کہانیوں کے لیے اسکرینیں ڈیزائن کروں گا اور بعد میں لوڈنگ اور غلطیوں پر غور کروں گا۔ یہ ایک غلطی تھی۔ کوئی بھی اسکرین جو ریموٹ ڈیٹا کو کھینچتی ہے ان تینوں کی ضرورت ہوتی ہے:

@override
Widget build(BuildContext context) {
  // Loading: never leave users staring at a blank screen.
  // A spinner tells them something is happening.
  if (isLoading) {
    return const Center(child: CircularProgressIndicator());
  }

  // Error: show what went wrong and how to recover.
  // A dead end with no retry button is one of the most
  // frustrating things a user can experience.
  if (error != null) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(error!, style: const TextStyle(color: Colors.red)),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () => loadUser(widget.userId),
            child: const Text('Try again'),
          ),
        ],
      ),
    );
  }

  // Success: show the data.
  return UserProfileView(user: user!);
}

دوبارہ کوشش کرنے والے بٹن کے ساتھ غلطی کی حالت اچھا خیال نہیں ہے۔ نیٹ ورک کی بندش سے ٹھیک ہونے والے صارف اور یہ سوچنے والے صارف کے درمیان فرق ہے کہ ان کی ایپ سے سمجھوتہ کیا گیا ہے۔

دوبارہ کوشش کریں منطق اور پیداوار کی درخواست لائف سائیکل

موبائل نیٹ ورک ہر وقت عارضی طور پر ناکام رہتے ہیں۔ درخواستوں کے دوران صارف اندھے مقامات سے گزرتے ہیں، لفٹ میں داخل ہوتے ہیں، یا وائی فائی سے موبائل ڈیٹا پر سوئچ کرتے ہیں۔ درخواست ناکام ہو جاتی ہے، لیکن 2 سیکنڈ کے بعد دوبارہ کوشش کامیاب ہو جاتی ہے۔

دوبارہ کوشش کی منطق کے بغیر، نیٹ ورک کی کوئی بھی عارضی غلطی صارف کے نقطہ نظر سے ایک مستقل غلطی ہے۔ یہ ایک بری ڈیل ہے۔

Future withRetry(
  Future Function() request, {
  int maxAttempts = 3,
  Duration delay = const Duration(seconds: 1),
}) async {
  for (int i = 0; i < maxAttempts; i++) {
    try {
      return await request();
    } catch (e) {
      // On the final attempt, stop retrying and let the
      // error propagate to the caller.
      if (i == maxAttempts - 1) rethrow;

      // Wait before trying again. This gives temporary network
      // issues time to resolve and avoids hammering a server
      // that might already be struggling.
      await Future.delayed(delay);
    }
  }

  throw Exception('Retry failed');
}

یہ استعمال کرنا آسان ہے۔

final user = await withRetry(
  () => dio.get('/user/$userId'),
  maxAttempts: 3,
  delay: const Duration(seconds: 2),
);

ہائی ٹریفک پروڈکشن ایپس کے لیے، دیکھیں: dio_smart_retry. یہ ایکسپونینشل بیک آف کو لاگو کرتا ہے اور ہر دوبارہ کوشش کے درمیان تاخیر کو دوگنا کرتا ہے، جس سے اصل بندش کی صورت میں سرور کا بوجھ بہت زیادہ مدنظر رکھا جاتا ہے۔

آف لائن سپورٹ اور مقامی استقامت

شرمناک سپورٹ ٹکٹ حاصل کرنے کے بعد، میں نے آف لائن سپورٹ کو سنجیدگی سے لینا سیکھا۔ صارف نے ایک لمبا آن بورڈنگ فارم (15 فیلڈز) پُر کیا جس میں کئی منٹ لگے اور کنکشن غیر مستحکم ہونے پر جمع کروایا۔ آپ کی درخواست ناکام ہوگئی۔ فارم صاف ہو گیا ہے۔ تمام ڈیٹا غائب ہے۔ وہ ناراض تھے، اور واضح طور پر، انہیں ہونے کا پورا حق تھا.

آف لائن سپورٹ کا مقصد انٹرنیٹ کے بغیر تمام فعالیت کو نقل کرنا نہیں ہے۔ یہ اس بات کو یقینی بنانے کے لیے ہے کہ صارفین ترقی سے محروم نہ ہوں اور ڈیڈ اینڈز کو نہ ماریں۔

ریموٹ ڈیٹا کیشنگ

یہاں کی حکمت عملی سادہ ہے۔ جب بھی نیٹ ورک کی درخواست کامیاب ہوتی ہے، ہم نتائج کو مقامی طور پر اسٹور کرتے ہیں۔ پھر، اگر اگلی درخواست ناکام ہو جاتی ہے، تو یہ آپ کو وہ چیز دیتی ہے جو آپ نے پچھلی بار ایک ایرر اسکرین دکھانے کے بجائے محفوظ کی تھی۔

class UserRepository {
  final Dio _dio;
  final Box _cache; // Hive box

  UserRepository(this._dio, this._cache);

  Future getUser(String userId) async {
    try {
      final response = await _dio.get('/user/$userId');
      final user = User.fromJson(response.data as Map);

      // Save fresh data to the cache every time a request succeeds.
      // This means the next request can fall back to this
      // if the network is unavailable.
      await _cache.put('user_$userId', user.toJson());

      return user;
    } catch (e) {
      // Network failed. See if we have something cached.
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        // Stale data is better than an error screen.
        // The user sees something useful even without internet.
        return User.fromJson(Map.from(cached));
      }

      // Nothing cached. We have no choice but to surface the error.
      rethrow;
    }
  }
}

صارف کے ان پٹ کو محفوظ کریں۔

آن بورڈنگ ٹکٹ کی اصلاحات یہ ہیں جن کا میں نے ذکر کیا ہے:

// Save whatever the user has typed whenever the field changes.
_contentController.addListener(() async {
  await _cache.put('draft_post', _contentController.text);
});

// When the screen opens, restore any saved draft.
@override
void initState() {
  super.initState();
  final draft = _cache.get('draft_post') as String?;
  if (draft != null && draft.isNotEmpty) {
    _contentController.text = draft;
  }
}

// Clear the draft once the user successfully submits.
Future _submit() async {
  await _repository.createPost(_contentController.text);
  await _cache.delete('draft_post');
}

کوڈ کی تین لائنیں جو صارفین کو اپنا کام کھونے سے بچاتی ہیں۔ کوئی بھی فارم جسے پُر کرنے میں ایک منٹ سے زیادہ وقت لگتا ہے کرنے کے قابل ہے۔

مقامی استقامت کے لیے استعمال ہونے والے پیکیجز:

  1. چھتہ سادہ کلیدی قدر اسٹوریج کے لیے

  2. ایثار جب آپ کو زیادہ طاقتور سوالات کی ضرورت ہو۔

  3. مربع فلائٹ متعلقہ ڈیٹا کے لیے

  4. مشترکہ_ترجیحات یہ صرف صارف کی ترتیبات پر لاگو ہوتا ہے، اہم مواد پر نہیں۔

بڑے پیمانے پر ریاستی انتظام

setState تم ٹھیک ہو؟ میں یہ واضح کرنا چاہتا ہوں کیونکہ فلٹر کمیونٹی میں اس کے ساتھ ایسا سلوک کرنے کا رجحان ہے جیسے یہ ہمیشہ غلط ہے۔ مقامی، سادہ UI ریاستوں کے لیے (بٹن ٹوگلز، فارم فیلڈز جو توثیق دکھا رہے ہیں) setState یہ بالکل صحیح ٹول ہے۔

مسئلہ اس وقت شروع ہوتا ہے جب آپ اسے اس حالت کے لیے استعمال کرتے ہیں کہ متعدد ویجٹس پر انحصار کرتے ہیں، غیر مطابقت پذیر آپریشنز، یا کسی بھی ایسی چیز کو جو نیویگیشن کو زندہ رہنے کی ضرورت ہے۔ میں نے یہ سب کیا ہے۔ مسئلہ یہ ہے:

// This setState call lives high in the widget tree.
// Every widget below it rebuilds — including expensive ones
// that have nothing to do with this state change.
setState(() {
  currentUser = updatedUser;
});

ایپ کے بڑھنے کے ساتھ ہی صورتحال مزید خراب ہوتی جاتی ہے۔ پھیلاؤ کو دوبارہ منظم کریں۔ ضمنی اثرات غیر متوقع ترتیب میں ہوتے ہیں۔ آپ فعالیت کو بنانے کے بجائے ڈیبگنگ میں زیادہ وقت گزارنا شروع کر دیتے ہیں۔

ریور فورڈ پر جائیں۔

اپنی دوسری ایپ کے ساتھ اس دیوار کو ٹکرانے کے بعد، میں نے ریور پوڈ پر سوئچ کیا اور کبھی پیچھے مڑ کر نہیں دیکھا۔ بنیادی خیال سادہ ہے۔ ریاست ویجیٹ کے باہر موجود ہے، اور ویجیٹ بالکل اسی ریاست کو سبسکرائب کرتا ہے جس کی اسے ضرورت ہے۔

@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  AsyncValue build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future _load() async {
    state = const AsyncValue.loading();

    // AsyncValue.guard runs the future and wraps the result
    // in AsyncValue.data on success or AsyncValue.error on failure.
    // It saves you from writing try/catch every single time.
    state = await AsyncValue.guard(
      () => ref.read(userRepositoryProvider).getUser(userId),
    );
  }

  Future refresh() => _load();
}

ویجیٹ میں:

@override
Widget build(BuildContext context) {
  // ref.watch subscribes this widget to the notifier.
  // It rebuilds only when userAsync changes — not when
  // unrelated state elsewhere in the app changes.
  final userAsync = ref.watch(userNotifierProvider(widget.userId));

  return userAsync.when(
    // when() forces you to handle loading, error, and data.
    // Miss one and it's a compile error, not a runtime surprise.
    loading: () => const CircularProgressIndicator(),
    error: (e, _) => Text('Error: $e'),
    data: (user) => UserProfileView(user: user),
  );
}

جس کے لیے میں سب سے زیادہ شکر گزار ہوں: when() لوڈ یا خرابی کی شرائط کو بھولنے کے نتیجے میں تالیف کی غلطیاں ہوں گی۔ کمپائلر کسی ایسی چیز کو نافذ کرتا ہے جسے میں بھول جاتا تھا۔

ناقابل تغیر حالت

لائیو چیٹ کی خصوصیت کے بارے میں مجھے پریشان کرنے والی چیزوں میں سے ایک ایپ کے مختلف حصوں کے درمیان شیئر کی جانے والی تبدیلی کی فہرست تھی۔

List messages = [];

// Later, in different places:
messages.add(newMessage);       // socket handler
messages.removeAt(0);          // pagination
messages.insert(0, pinned);    // push notification handler

یہ ایک حقیقی تکلیف تھی کہ اس بات کا سراغ لگانا کہ کس تبدیلی کی وجہ سے یہ پیغام آیا جب یہ دو بار ظاہر ہو گا یا تصادفی طور پر غائب ہو جائے گا۔ اس کا حل یہ ہے کہ اسے کبھی تبدیل نہ کریں اور ہمیشہ ایک نئی فہرست بنائیں۔

// The old list is unchanged. The new state is a new list.
// Every change is explicit and traceable.
state = [...state, newMessage];

یہ ایک چھوٹے سے کام کی طرح محسوس ہوتا ہے جب تک کہ آپ میوٹیشن بگ کو ڈیبگ کرنے میں دو گھنٹے نہ گزاریں۔ تب یہ بہت اہم محسوس ہوتا ہے۔

پھڑپھڑانا تیز ہے۔ لیکن غیر ضروری دوبارہ تعمیرات کا ڈھیر لگ جاتا ہے، اور کم درجے کے آلات پر، تعمیر نمایاں ہوتی ہے۔

Const ویجٹ مکمل طور پر دوبارہ تعمیر کو چھوڑ دیتے ہیں۔

کہ const کلیدی لفظ ڈارٹ کو بتاتا ہے کہ یہ ویجیٹ مرتب وقت پر بنایا گیا ہے اور اسے غیر معینہ مدت تک دوبارہ استعمال کیا جا سکتا ہے۔ کوئی بھی ویجیٹ جس کا مواد کبھی تبدیل نہیں ہوتا وہ امیدوار ہے۔

// Without const: a new Text instance is created on every
// rebuild of the parent, even though the content never changes.
Text('Welcome to the app')

// With const: Flutter reuses the same instance.
// No rebuild work, no allocation.
const Text('Welcome to the app')

یہ ایک چھوٹی سی بات لگتی ہے۔ بہت سے جامد عناصر کے ساتھ بڑے ویجیٹ درختوں میں، مجموعی اثر واقعی کام میں آتا ہے۔ اسے عادت بنائیں۔

تعمیر نو کا دائرہ چھوٹا رکھیں

جب setState یہ ویجیٹ کے درخت کے اوپری حصے میں بیٹھتا ہے اور اس کے نیچے تمام ویجٹ دوبارہ بنائے جاتے ہیں۔ یہ ان وجیٹس پر بھی لاگو ہوتا ہے جو تبدیل شدہ حالت سے متعلق نہیں ہیں۔ حل یہ ہے کہ ریاست کو جہاں تک ممکن ہو درخت سے نیچے دھکیل دیا جائے، مثالی طور پر خود سے نکالے گئے ویجٹ میں۔

// The problem: counter lives in the parent, so every
// setState call rebuilds the entire subtree — including
// ExpensiveListWidget, which has nothing to do with the counter.
class _BadExampleState extends State {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $_counter'),
        ElevatedButton(
          onPressed: () => setState(() => _counter++),
          child: const Text('Increment'),
        ),
        const ExpensiveListWidget(), // rebuilds for no reason
      ],
    );
  }
}

اب، اگر گنتی تبدیل ہوتی ہے، تو صرف وہی ویجیٹ دوبارہ بنایا جائے گا۔ ExpensiveListWidget یہ اچھوتا تھا۔

نامعلوم لمبائی کی اشیاء کے لیے ListView.builder

کوئی راستہ نہیں Column میپڈ لسٹ کے ساتھ، آپ تمام آئٹمز کو پہلے سے بناتے ہیں، قطع نظر اس سے کہ وہ نظر آرہی ہیں یا نہیں۔ 200 آئٹمز کی فہرست میں 200 ویجٹ ہوں گے اس سے پہلے کہ صارف اسکرول کرے۔

// This builds every single item widget upfront.
// With 200 items, 200 widgets are created on first render,
// most of which are immediately off-screen.
Column(
  children: items.map((item) => ItemCard(item: item)).toList(),
)

// This builds only what is visible, plus a small buffer.
// Scrolling through 10,000 items uses the same memory as 10.
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemCard(items[index]);
  },
)

ListView.builder یہ بڑی فہرستوں کے لیے موزوں نہیں ہے۔ یہ نامعلوم یا متغیر سائز کی فہرستوں کے لیے درست ڈیفالٹ ہے۔ میں استعمال کرتا ہوں Column میپ شدہ فہرستیں صرف اس صورت میں استعمال کریں جب آپ کو یقین ہو کہ فہرست ہمیشہ چھوٹی رہے گی۔

یہ ان کیڑوں میں سے ایک ہے جو ترقی کے دوران مکمل طور پر کسی کا دھیان نہیں جاتا ہے اور پیداوار میں ظاہر ہوتا رہتا ہے۔

منظر نامہ: ایک غیر مطابقت پذیر آپریشن شروع ہوتا ہے، صارف اس کے مکمل ہونے سے پہلے ہی چلا جاتا ہے، آپریشن مکمل ہوتا ہے، اور کال کرنے کی کوشش کی جاتی ہے۔ setState ایک ویجیٹ سے جو اب موجود نہیں ہے۔

Future _loadData() async {
  final data = await repository.fetchData();

  // If the user navigated away during the await above,
  // this widget is gone. setState throws:
  // "setState() called after dispose()"
  setState(() => this.data = data );
}

حل ایک لائن ہے:

Future _loadData() async {
  final data = await repository.fetchData();

  // mounted is true while the widget is in the tree,
  // false after dispose() has been called.
  if (!mounted) return;

  setState(() => this.data = data);
}

اب ہم خود بخود یہ چیک ہر گھنٹے لکھتے ہیں۔ await یہ مندرجہ ذیل کی طرف جاتا ہے۔ setState. یہ جلد ہی پٹھوں کی یادداشت بن جاتی ہے۔

اپنی تعمیر کے اندر فیوچر نہ بنائیں۔

یہ ایک ایسا مسئلہ ہے جسے نظر انداز کرنا آسان ہے۔ اگر آپ براہ راست اندر سے مستقبل بناتے ہیں۔ build ہر بار جب آپ دوبارہ تعمیر کرتے ہیں تو یہ طریقہ ایک نیا مستقبل تخلیق کرتا ہے۔ دوسرے الفاظ میں FutureBuilder ہر بار اسے ایک نئے کام کے طور پر سمجھا جاتا ہے اور غیر ضروری طور پر لوڈ شدہ حالت پر دوبارہ ترتیب دیا جاتا ہے۔

// Bad: a new Future is created on every rebuild.
// FutureBuilder sees a different Future each time
// and resets to loading state unnecessarily.
@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: repository.fetchUser(userId), // new Future every build
    builder: (context, snapshot) { ... },
  );
}
// Good: create the Future once in initState.
// FutureBuilder holds the same reference across rebuilds.
late final Future _userFuture;

@override
void initState() {
  super.initState();
  _userFuture = repository.fetchUser(widget.userId);
}

@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: _userFuture,
    builder: (context, snapshot) { ... },
  );
}

بھاری کام کو UI تھریڈ سے ہٹا دیں۔

ڈارٹ UI کو ڈیفالٹ تنہائی میں پیش کرتا ہے۔ سی پی یو کی کوئی بھی چیز جو اس کو روکتی ہے وہ فریموں کو گرائے گی۔

// Parsing a large API response synchronously on the main isolate
// can block rendering for 50-200ms on slower devices.
final users = (response.data as List)
    .map((json) => User.fromJson(json))
    .toList();
// compute() runs the function in a separate isolate.
// The main isolate stays free to render frames.
// Note: the function must be top-level or static —
// closures that capture local state cannot be sent to another isolate.
final users = await compute(parseUsers, response.data);

List parseUsers(dynamic data) {
  return (data as List)
      .map((json) => User.fromJson(json as Map))
      .toList();
}

میں پہنچتا ہوں compute جب بھی آپ JSON کے بڑے جوابات کو پارس کر رہے ہوں، امیج پروسیسنگ کر رہے ہوں، یا کوئی اور چیز جو تیز پروفائل پر سست محسوس ہو۔ میرے سر میں حد تقریباً 16ms ہے۔ اگر کسی کام میں اس سے زیادہ وقت لگ سکتا ہے، تو اسے ریاستی قرنطینہ میں نہیں ہونا چاہیے۔

میموری لیک اور لائف سائیکل مینجمنٹ

ان تمام ایپس میں سے جو میں نے جاری کی ہیں، اس میں سب سے زیادہ ڈیبگنگ کا وقت لگا۔ پھڑپھڑانے میں میموری کا لیک ہونا فوری طور پر بند نہیں ہوتا ہے۔ ایپ بھاری ہونا شروع ہو جاتی ہے، OS اسے پس منظر میں مارنا شروع کر دیتا ہے، اور یہ آہستہ آہستہ فی سیشن چند میگا بائٹس کے حساب سے بنتا ہے جب تک کہ صارف "بے ترتیب کریشز” کے بارے میں بگ رپورٹ درج نہیں کر دیتے۔

بنیادی وجہ تقریباً ہمیشہ ایک ہی ہوتی ہے۔ ویجیٹ کے اندر بنائے گئے آئٹمز ویجیٹ کے غائب ہونے کے بعد بھی چلتے رہیں گے۔

ایک کنٹرولر جسے کبھی ضائع نہیں کیا جائے گا۔

میموری لیک کی سب سے عام وجہ جو میں نے دیکھی ہے، بشمول میرے اپنے کوڈ میں، وہ کنٹرولرز ہیں جن سے: initState اور کبھی رہا نہیں ہوا۔ پھڑپھڑانا خود بخود ان کو صاف نہیں کرتا ہے۔

class _ProfileScreenState extends State {
  late final TextEditingController _nameController;
  late final AnimationController _fadeController;
  late final ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController();
    _fadeController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    // Every controller created in initState needs to be
    // disposed here. This is not optional — it releases
    // native resources and removes listeners that would
    // otherwise keep this widget's memory alive indefinitely.
    _nameController.dispose();
    _fadeController.dispose();
    _scrollController.dispose();
    super.dispose(); // always last
  }
}

کا تصرف نہیں AnimationController یہ خاص طور پر برا ہے۔ اس میں ایک ٹکر ہے جو ہر فریم کو چلاتا ہے، اس لیے یہ اسکرین غائب ہونے کے بعد بھی CPU استعمال کرتا رہتا ہے۔ میں نے دیکھا ہے کہ اس کی وجہ یادداشت کے مسائل کے علاوہ نمایاں بیٹری ڈرین ہوتی ہے۔

سلسلہ کو سبسکرائب کریں۔

class _ChatScreenState extends State {
  StreamSubscription? _messageSubscription;

  @override
  void initState() {
    super.initState();
    _messageSubscription = messageStream.listen((message) {
      // Without cancellation, this callback keeps firing
      // even after the screen is removed from the tree.
      // It will call setState on a disposed widget and
      // hold message objects in memory that should be freed.
      if (mounted) setState(() => messages.add(message));
    });
  }

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

ٹائمر

@override
void dispose() {
  // A timer that fires after dispose will try to run
  // a callback on a widget that no longer exists.
  _dismissTimer?.cancel();
  super.dispose();
}

قواعد جو میں بغیر کسی استثناء کے پیروی کرتا ہوں: ہر چیز جس میں بنائی گئی ہے۔ initState کہ dispose, cancelیا close طریقہ اس کی کال ہو جاتا ہے. dispose. کوئی استثنا نہیں، بس "میں اسے بعد میں شامل کروں گا۔”

مشاہدہ اور تنازعات کی رپورٹنگ

اس سے پہلے کہ ہم اپنی پہلی پروڈکشن ایپ میں کریش رپورٹنگ کو ضم کر دیں، ڈیبگنگ ایک حقیقی تکلیف تھی۔ صارفین کریش کی اطلاع دے رہے ہیں۔ میں ان سے پوچھوں گا کہ وہ کیا کر رہے ہیں۔ "ہم نے ابھی اسے کھولا ہے،” وہ کہیں گے۔ میں اس کوڈ کو دیکھوں گا کہ اس کی وجہ کیا ہو سکتی ہے۔ آدھا وقت مجھے سمجھ نہیں آیا۔

کریش رپورٹنگ کے ساتھ، چیزیں بالکل مختلف ہیں۔

پری لانچ کی ترتیبات

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Catch Flutter framework errors — widget build errors,
  // rendering errors, etc.
  FlutterError.onError =
      FirebaseCrashlytics.instance.recordFlutterFatalError;

  // Catch errors in async code that Flutter does not catch —
  // errors in event handlers, timers, isolates.
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}

ناکامی کو کبھی خاموش نہ ہونے دیں۔

// This is how I used to write it. If submitOrder throws,
// nothing happens. The user has no idea. I have no idea.
await api.submitOrder(order);
// This is how I write it now.
try {
  await api.submitOrder(order);
  setState(() => orderStatus = OrderStatus.confirmed);
} catch (e, stackTrace) {
  // recordError sends the full exception and stack trace
  // to Crashlytics, with device info and the user's
  // recent session activity attached automatically.
  FirebaseCrashlytics.instance.recordError(e, stackTrace);
  setState(() => orderStatus = OrderStatus.failed);
}

تحریک کا راستہ

خام کریش لاگز آپ کو بتائیں گے کہ مسئلہ کیا ہے۔ بریڈ کرمبس آپ کو بتاتے ہیں کہ جب مسئلہ پیش آیا تو صارف کیا کر رہا تھا۔ یہ ایک ہی چیز نہیں ہیں۔

FirebaseCrashlytics.instance.log('User opened checkout');
FirebaseCrashlytics.instance.log('Payment sheet presented');
FirebaseCrashlytics.instance.log('User submitted payment');
// crash here — now I know the exact sequence

اپنی پروڈکشن فلٹر ایپ کی جانچ کریں۔

سچ پوچھیں تو، میں نے اپنی پہلی ایپ کو کم جانچا۔ میں تیزی سے آگے بڑھ رہا تھا، خصوصیات کام کر رہی تھیں، اور تحریری ٹیسٹ سست محسوس ہو رہے تھے۔ اس کے بعد ہم نے قیمتوں کے حساب کتاب کو ری فیکٹر کیا اور ایک ایسا بگ متعارف کرایا جو فوری طور پر واضح نہیں تھا اور اسے جاری کر دیا۔ ایک اور صارف نے یہ مجھ سے پہلے پایا۔

اب ہم زیادہ احتیاط سے جانچتے ہیں۔ یہ سب کچھ نہیں ہے، یہ اہم چیز ہے۔

یونٹ ٹیسٹ بزنس منطق

test('discount applies percentage correctly', () {
  final result = calculateDiscountedPrice(
    price: 100.0,
    discountPercent: 10,
  );

  // 10% off 100.00 should be 90.00
  expect(result, equals(90.0));
});

test('discount throws for negative percentage', () {
  expect(
    () => calculateDiscountedPrice(price: 100, discountPercent: -5),
    throwsA(isA()),
  );
});

کاروباری منطق (قیمتوں کا تعین، توثیق، منظوری) باقاعدہ ڈارٹ فنکشنز میں ہونا چاہیے جس میں کوئی فلٹر انحصار نہیں ہے، اس لیے ان کی جانچ ملی سیکنڈ میں اور بنیادی ڈھانچے کی جانچ کیے بغیر کی جا سکتی ہے۔

ویجیٹ ٹیسٹ UI اسٹیٹس

فلٹر کی ویجیٹ ٹیسٹنگ اس کی بہترین خصوصیات میں سے ایک ہے۔ آپ ڈیوائس یا ایمولیٹر کے بغیر لوڈ کی حیثیت، خرابی کی صورتحال، اور صارف کے تعامل کی جانچ کر سکتے ہیں۔

testWidgets('shows error state with retry button on load failure',
    (tester) async {
  final mockRepo = MockUserRepository();
  when(mockRepo.getUser(any)).thenThrow(Exception('Network error'));

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userRepositoryProvider.overrideWithValue(mockRepo),
      ],
      child: const MaterialApp(home: ProfileScreen(userId: 'test')),
    ),
  );

  // pumpAndSettle waits for all animations and async
  // operations to complete before asserting.
  await tester.pumpAndSettle();

  expect(find.text('Failed to load profile. Please try again.'), findsOneWidget);
  expect(find.text('Try again'), findsOneWidget);
});

جانچ کے لیے ترجیحات بنیادی کاروباری منطق، غلطیاں اور لوڈنگ کی حالتیں، رقم یا ڈیٹا پر مشتمل کوئی بہاؤ جو صارف کے ذریعے بازیافت نہیں کیا جا سکتا، اور ایپ اور بیک اینڈ کے درمیان انضمام پوائنٹس ہیں۔ جامد UI ویجٹس جن میں کوئی منطق نہیں ہوتی عام طور پر بے نقاب رہ جاتے ہیں۔

فن تعمیر اور طویل مدتی دیکھ بھال

میں نے جو پہلی ایپ جاری کی تھی اس کا کوئی حقیقی فن تعمیر نہیں تھا۔ یہ سب ویجٹ میں تھا۔ کاروباری منطق UI کوڈ کے ساتھ بیٹھی تھی۔ ریاست بکھر گئی۔

اس نے 6 ماہ تک ٹھیک کام کیا۔ پھر ہمیں ایک سے زیادہ موجودہ اسکرینوں میں ٹچ فنکشنلٹی کو شامل کرنا پڑا، اور جو ایک دن لگنا چاہیے تھا اس میں ایک ہفتہ لگا کیونکہ ہم کسی اور چیز کو توڑے بغیر کچھ تبدیل نہیں کر سکتے تھے۔

یہ دوسری ایپ ہے جس پر میں نے زیادہ غور سے غور کیا۔ یہ خصوصیت اس کے اپنے فولڈر میں ہے۔ وجیٹس سے الگ اسٹوریج۔ ریاست کا انتظام UI پرت سے باہر ہوتا ہے۔ جیسا کہ تقاضے بدل گئے (وہ ہمیشہ کرتے ہیں)، تبدیلیاں شامل کی گئیں۔

پرت کی حدود کے بارے میں الگ الگ خدشات

lib/
  features/
    profile/
      data/
        profile_repository.dart     # network + cache logic
      domain/
        user.dart                   # clean domain model
      presentation/
        profile_screen.dart         # widget
        profile_notifier.dart       # state

وجیٹس کو نیٹ ورک کال نہیں کرنی چاہیے۔ ریپوزٹری کو فلٹر درآمد نہیں کرنا چاہئے۔ آپ میں سے کسی کو بھی دوسرے کے اندر کے بارے میں کچھ نہیں جاننا چاہیے۔

اگر آپ کو ڈیٹا ماخذ کو تبدیل کرنے کی ضرورت ہے، ایک مُک کے ساتھ ایک اطلاع دہندہ کی جانچ کرنا ہے، یا کاروباری منطق کو چھوئے بغیر UI کو تبدیل کرنے کی ضرورت ہے، تو یہ علیحدگی اسے ممکن بناتی ہے۔

تکنیکی قرض توقع سے زیادہ تیزی سے جمع ہوتا ہے۔

شارٹ کٹ جو آج 30 منٹ بچاتے ہیں اب سے مہینے میں کئی گھنٹے لگتے ہیں۔ فلٹر میں سب سے تیز کمپوزنگ شارٹ کٹس:

  • ویجیٹ کے اندر کاروباری منطق (آزمائشی نہیں، دوبارہ قابل استعمال نہیں)

  • dynamic ٹائپ شدہ ماڈل کے بجائے (مرتب وقت کی غلطی کی بجائے رن ٹائم غلطی)

  • توثیق کی منطق کو کاپی اور پیسٹ کریں (اسے ایک جگہ تبدیل کریں اور اسے کہیں اور بھول جائیں)

  • واضح ملکیت کے بغیر تغیر پذیر عالمی ریاست

ان میں سے کوئی بھی روز اول کی آفات نہیں ہے۔ وہ سب اگلی تبدیلی کو اس سے زیادہ مشکل بناتے ہیں، اور اس کے بعد کی تبدیلیاں اس سے بھی زیادہ مشکل۔

اینڈ ٹو اینڈ مثال: پروڈکشن گریڈ پروفائل کی فعالیت

اس مضمون میں ہر چیز کو ایک خصوصیت میں ملایا گیا ہے۔ کیشنگ اور دوبارہ کوششوں کے ساتھ ریپوزٹریز، پرامید اپ ڈیٹس کے ساتھ ریور پوڈ اطلاعات، تینوں ریاستوں کو سنبھالنے والے ویجٹ، اور مجموعی طور پر لائف سائیکل کا مناسب انتظام۔

ذخیرہ

class ProfileRepository {
  final Dio _dio;
  final Box _cache;

  ProfileRepository(this._dio, this._cache);

  Future getUser(String userId) async {
    try {
      final response = await withRetry(
        () => _dio.get('/users/$userId'),
      );

      final user = User.fromJson(
        response.data as Map,
      );

      // Cache successful responses for offline fallback.
      await _cache.put('user_$userId', user.toJson());

      return user;
    } on DioException catch (e) {
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        return User.fromJson(Map.from(cached));
      }

      if (e.type == DioExceptionType.connectionError) {
        throw NoInternetException();
      }

      throw ServerException(e.response?.statusCode ?? 0);
    }
  }

  Future updateDisplayName(String userId, String name) async {
    await withRetry(
      () => _dio.patch('/users/$userId', data: {'displayName': name}),
    );

    // Invalidate cache so the next read fetches fresh data.
    await _cache.delete('user_$userId');
  }
}

یاد دہانی

@riverpod
class ProfileNotifier extends _$ProfileNotifier {
  @override
  AsyncValue build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future _load() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(
      () => ref.read(profileRepositoryProvider).getUser(userId),
    );
  }

  Future refresh() => _load();

  Future updateName(String newName) async {
    final current = state.valueOrNull;
    if (current == null) return;

    try {
      await ref
          .read(profileRepositoryProvider)
          .updateDisplayName(userId, newName);

      // Update the UI immediately without waiting for a reload.
      state = AsyncValue.data(current.copyWith(displayName: newName));
    } catch (e, st) {
      FirebaseCrashlytics.instance.recordError(e, st);
      // Restore the previous state if the update fails.
      state = AsyncValue.data(current);
      rethrow;
    }
  }
}

ویجیٹ

class ProfileScreen extends ConsumerWidget {
  final String userId;
  const ProfileScreen({required this.userId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final profileAsync = ref.watch(profileNotifierProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: profileAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => _ErrorView(
          message: e is NoInternetException
              ? 'No internet connection.'
              : 'Failed to load profile.',
          onRetry: () => ref
              .read(profileNotifierProvider(userId).notifier)
              .refresh(),
        ),
        data: (user) => _ProfileView(user: user, userId: userId),
      ),
    );
  }
}

class _ProfileView extends ConsumerStatefulWidget {
  final User user;
  final String userId;
  const _ProfileView({required this.user, required this.userId});

  @override
  ConsumerState<_ProfileView> createState() => _ProfileViewState();
}

class _ProfileViewState extends ConsumerState<_ProfileView> {
  late final TextEditingController _nameController;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController(text: widget.user.displayName);
  }

  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }

  Future _saveName() async {
    try {
      await ref
          .read(profileNotifierProvider(widget.userId).notifier)
          .updateName(_nameController.text);

      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Name updated.')),
      );
    } catch (_) {
      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Failed to update name.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        TextField(
          controller: _nameController,
          decoration: const InputDecoration(labelText: 'Display name'),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: _saveName,
          child: const Text('Save'),
        ),
      ],
    );
  }
}

حتمی خیالات

اس میں سے کوئی بھی خاص طور پر ترقی یافتہ نہیں ہے۔ یہ زیادہ تر عادت ہے۔ checking mounted, disposing controllers, handling the error state, caching for offline. ہر عادت آپ کو پیداوار کی ناکامی کے ایک مخصوص زمرے سے بچنے میں مدد دیتی ہے اور ایک ایسی ایپ بناتی ہے جس پر صارفین بھروسہ کر سکتے ہیں۔

کاش میں نے اپنی پہلی ایپ اس طرح لکھی ہوتی۔ میں نہیں جانتا تھا۔ کیونکہ میں وہ نہیں جانتا تھا جو میں پہلے سے نہیں جانتا تھا۔ یہ عام بات ہے۔

لیکن اگر آپ اپنی پہلی پروڈکشن ایپ کو ریلیز کرنے سے پہلے اسے پڑھ رہے ہیں، تو اب آپ کو متعدد ریلیز کردہ ایپس اور بہت سے مایوس کن صارف کے تاثرات حاصل کرنے کا فائدہ حاصل ہوگا۔

ان پیٹرنز کو شامل کرنے کا بہترین وقت وہ ہے جب فیچر لانچ کیا جائے۔ دوسرا بہترین وقت اب ہے۔

اوپر تک سکرول کریں۔