Back home

تحسين بدء التشغيل غير المتزامن وتهيئة الظواهر العرضية

لا يستحق الأمر عادةً استبدال 200 مللي ثانية من المكاسب بظروف السباق غير المتكررة وتكاليف استكشاف الأخطاء وإصلاحها.

انخفض مؤشر الشاشة الأول، لكن أحد أكثر الأخطاء المزعجة بدأ يظهر على الإنترنت: كان يظهر من حين لآخر، وكان من الصعب إعادة إنتاجه، وبدا وكأنه ميتافيزيقي.

مكدس التعطل غير مستقر، وتبدو جميع السجلات “طبيعية”، ويمكن أن تعالج نفسها في بعض الأحيان. بالنظر إلى سجلات التغيير، نجد أن الجميع يفعلون الشيء نفسه: تقسيم تهيئة مرحلة بدء التشغيل، وتأخيرها، وجعلها غير متزامنة، وجعلها متزامنة لجعل البدء البارد أسرع.

المشكلة ليست في أن “البطء قد اختفى”، ولكن في أن “التبعيات قد اختفت”، أو بشكل أكثر دقة، أن التبعيات مخفية.

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

خلفية المشكلة: الشاشة الأولى أسرع، وتتعطل النقرة الأولى أحيانًا

وصف الخطأ نموذجي للغاية:

  • يكون التشغيل البارد لنظام Android أسرع، ويتم تقليل وقت الشاشة البيضاء للشاشة الأولى
  • تواجه نسبة صغيرة من مستخدمي الإنترنت أحيانًا أعطالًا أو أخطاء عند “النقرة الأولى بعد الشاشة الأولى”
  • تكون مكدس الأعطال أحيانًا في وحدة الأعمال، وأحيانًا في طبقة الشبكة، وأحيانًا في SDK.
  • يكاد يكون من المستحيل التكاثر في البيئات المحلية والاختبارية، كما أن التكاثر بالتدرج الرمادي غير مستقر أيضًا.

يتم إساءة الحكم على هذا النوع من المشكلات بسهولة على أنها “اختلافات في بيئة الإنترنت” و"توافق النماذج" و"تشنجات SDK لجهة خارجية". ولكن عندما يتعلق الأمر بشكل كبير بتغيير تحسين بدء التشغيل، سأتعامل معه أولاً على أنه شيء أبسط: **ظروف السباق. **

الحكم الأساسي: عدم التزامن ليس طريقة تحسين، فهو يغير دلالات جاهزية النظام.

العديد من البديهيات لتحسين بدء التشغيل هي:

  • تم نقل عناصر الإدخال والإخراج الثقيلة إلى مؤشر ترابط الخلفية
  • الأشياء الثقيلة لوحدة المعالجة المركزية بالتوازي
  • تأخير التهيئة الحرجة للشاشة غير الأولى إلى ما بعد الشاشة الأولى

تكون هذه دائمًا “صالحة” للمقاييس.

لكنهم فعلوا أيضًا شيئًا أكثر خطورة: ** مسح التبعيات المضمنة في الأصل في “التنفيذ المتسلسل”. **

سابقًا في Application#onCreate()، تمت تهيئته بشكل تسلسلي: A -> B -> C. حتى لو لم يكتب أحد المستند، فسيقوم النظام افتراضيًا بهذه الحقيقة:

  • عند انتهاء onCreate()، يتم تشغيل A/B/C على الأقل

وقد تم تقسيمها فيما بعد إلى:

  • التنفيذ فوراً
  • يقوم B بتسليم مهمة غير متزامنة -C تسليم إلى مهمة أخرى غير متزامنة

في هذا الوقت، لم تعد نهاية onCreate() تعني “النظام جاهز”، بل تعني فقط “لقد تخلصت من المهمة”.

غالبًا ما تحدث النقرة الأولى عبر الإنترنت في وقت غير متوقع: اكتمال عرض الشاشة الأول، أو قيام المستخدم بالنقر على الفور، أو يؤدي السلوك التلقائي إلى تشغيل التنقل.

لذلك وقع التفاعل التجاري الأول في نطاق حرج:

  • تمت تهيئة بعض التبعيات
  • البعض لا يزال قيد التشغيل
  • البعض فشل ولكن تم الاحتفاظ به سرا
  • البعض لم يبدأ بعد لأنه تأخر

هذه ليست “بطيئة”، إنها حالة غير مكتملة.

عملية العرض التوضيحي: كيف تتقارب المشكلة إلى “شبه التهيئة” خطوة بخطوة؟

لاستكشاف أخطاء مثل هذه المشكلات العرضية وإصلاحها، لن أركز على مكدس الأعطال أولاً. سأفعل ثلاثة أشياء أولاً لتحويل “غير قابل للتكرار” إلى “قابل للتفسير”.

1) ارسم مخطط تبعية بدء التشغيل أولاً، ولا ترسم مخطط الوحدة

يجيب مخطط الوحدة على “من يعتمد على من”، لكن سؤال بدء التشغيل يجيب:

  • ما هي التهيئة التي يجب إكمالها قبل التفاعل الأول
  • ما هي حالات فشل التهيئة التي ستؤثر على دلالات الأعمال
  • أي عملية تهيئة هي مجرد زينة على الكعكة

سأقسم تبعيات بدء التشغيل إلى ثلاث فئات وفقًا لحدود “التفاعل الأول”:

  1. يجب أن يكون جاهزًا (جاهز تمامًا): إذا لم يكن جاهزًا، فلا يمكن السماح له بإدخال المسار الحرج، مثل حالة تسجيل الدخول، ورمز المصادقة، وجدول التوجيه، ونموذج سلسلة المفاتيح (مثل قيود سلسلة المحادثات الرئيسية/جدولة coroutine)، والحد الأدنى لمجموعة تقارير الأعطال.
  2. جاهز تمامًا: يمكنك الدخول إلى العمل إذا لم تكن مستعدًا، ولكن يجب عليك الرجوع إلى الإصدار السابق بطريقة يمكن التحكم فيها، مثل التخزين المؤقت الموصى به، وتجارب AB، وحقول التحسين المدفونة.
  3. مؤجل: يمكن إجراؤه لاحقًا دون التأثير على دلالات التفاعل الأول، مثل الإحماء، وتهيئة وحدة فك ترميز الصورة، وSDK غير المهمة.

قيمة هذه الخطوة هي تغيير الوسيطة من “غير متزامنة أو غير متزامنة” إلى “عند أي حد يجب إكمال هذه التبعية”.

2) امنح كل تبعية “عقد استعداد”، وإلا فإن عدم التزامن يعادل المقامرة

إن ما يسمى بعقد الاستعداد يهدف إلى توضيح أمرين:

  • من سيحكم على جاهزيته
  • كيفية المتابعة عندما لا يكون العمل جاهزًا

عدم التزامن بدون عقد الاستعداد، المظاهر الشائعة هي:

  • يعتقد المتصل أن التهيئة قد اكتملت ويستخدمها مباشرة
  • اعتقد المُهيئ أن المتصل لن يستخدمه مبكرًا -الطرفان على حق، الخطأ على الإنترنت هو في “التوقيت”

أحد أكثر الأعطال شيوعًا التي رأيتها هو تغيير تهيئة المفردة إلى Lazy + async.

الكود الزائف يبدو كالتالي:

object Foo {
 @Volatile private var inited = false

 fun initAsync() {
 GlobalScope.launch(Dispatchers.IO) {
  // 读配置/解密/拉取远端
  inited = true
 }
 }

 fun doWork() {
 check(inited) { "Foo not initialized" }
 // ...
 }
}

سوف يتحسن مؤشر الشاشة الأول، ولكن بمجرد تقديم توقيت الاتصال لـ doWork() قبل نهاية init، سيصبح “عرضيًا”.

والأسوأ من ذلك هو أن العديد من الرموز لن تكون check(inited)، ولكنها ستستمر في التشغيل، مما يؤدي إلى ظهور حالة خطأ، ولن تنفجر حتى وقت لاحق.

3) قياس “نافذة” المنافسة بدلاً من الاعتماد على المشاعر

الشروط اللازمة لعدم التزامن لإحداث المشاكل هي:

  • يحدث التفاعل الأول قبل اكتمال بعض عمليات التهيئة

لذلك سأضيف نوعين من السجلات (لاحظ أنها نقاط زمنية قابلة للمحاذاة):

  • t0: بدء العملية/بدء Application.onCreate
  • t1: الشاشة الأولى تفاعلية (قابلة للنقر عليها بالفعل)
  • t_ready(X): النقطة الزمنية التي يكون فيها كل تبعية مفتاح جاهزة

ثم ألق نظرة على التوزيع:

  • ما هي نسبة t1 < t_ready(Auth)
  • ما هي نسبة t1 < t_ready(Router)
  • وما إذا كانت مرتبطة بالنموذج والشبكة والتمهيد الساخن والبارد وإصدار النظام

وبمجرد التمكن من قياس هذه النافذة، ستصبح العديد من “الأحداث” فجأة غير غامضة: إنها مجرد حدث احتمالي.

حالات سوء الفهم والفشل: كلما كتبت أكثر، زادت احتمالية خلق مشكلات يصعب استكشافها وإصلاحها.

بعد بدء عملية عدم التزامن، سيكون الفريق حذرًا بطبيعة الحال:

  • إذا لم تكن التبعية جاهزة، استخدم القيمة الافتراضية
  • إذا لم يتم سحب التكوين، انتقل إلى ذاكرة التخزين المؤقت الأخيرة
  • AB سقط للسيطرة قبل الحصول عليه.

كل من هذه الأمور منطقية من تلقاء نفسها، ولكن لها أثران جانبيان.

سوء الفهم 1: تحويل “الافتقار إلى التبعيات” إلى “انجراف دلالي”

من السهل بالفعل استكشاف أخطاء التعطل وإصلاحها، ولكن حالات الخطأ هي الأكثر صعوبة في استكشاف الأخطاء وإصلاحها.

على سبيل المثال، إذا كانت حالة تسجيل الدخول غير جاهزة، فسوف تصبح “لم يتم تسجيل الدخول”. سينقل هذا المستخدم إلى صفحة خطأ عندما تؤدي النقرة الأولى إلى الانتقال. لاحقًا، عندما تصبح حالة تسجيل الدخول الحقيقية جاهزة، تتم إعادة تعيين حالة الصفحة مرة أخرى، لذلك تظهر “الفلاش” و"القفز للخلف" و"تسجيل الخروج أحيانًا".

سترى مجموعة من الفروع “العادية” في السجل: كلها مغطاة بالتصميم. لكن تجربة المستخدم سيئة، ومن الصعب ربطها بتحسين بدء التشغيل.

سوء الفهم 2: يؤدي إخفاء أسرار بعضنا البعض إلى سلسلة أدلة مكسورة لاستكشاف الأخطاء وإصلاحها

التبعية (أ) ليست جاهزة، لذا فهي تسلك طريقًا غادرًا.

في الوقت نفسه، التبعية B ليست جاهزة وقد مرت أيضًا بجميع أنواع الحيل.

في النهاية، يتصرف العمل مثل مشكلة “ب”، ولكن السبب الجذري هو “أ”.

الأمر الأكثر واقعية هو: من أجل “عدم التعطل”، يتم ابتلاع الاستثناء وتسجيل الفشل على أنه debug، مما يترك “نتيجة خاطئة” واحدة فقط عبر الإنترنت.

هذا هو أحد مصادر “عدم التكرار”: محو إشارة فشل المفتاح.

كيفية الإصلاح: تغيير “عدم التزامن” إلى “حدود الاستعداد التي يمكن التحقق منها”

لحل هذه المشكلة، عادة ما يتم تشديد دلالات بدء تشغيل النظام مرة أخرى.

سأفعل ثلاث خطوات من الأقل إلى الأعلى تكلفة.

1) تحديد بوابة جاهزة قابلة للتنفيذ

الاعتماد على هارد ريدي يعطي بوابة موحدة:

  • يجب عليك اجتياز البوابة قبل التفاعل لأول مرة
  • إذا لم يتمكن من المرور، قم بحظر العمليات الرئيسية أو قم بتوفير مسار واضح للرجوع إلى إصدار سابق.

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

  • استمر عندما تصبح جاهزًا
  • عرض التحميل إذا لم يكن جاهزًا، أو قائمة الانتظار أولاً

مفتاح هذه الخطوة هو تغيير “التبعية غير جاهزة” من حالة السباق الضمنية إلى حالة صريحة.

2) اجعل التهيئة “مهمة ذات حالة” بدلاً من “إطلاق النار والنسيان”.

يتم طرح العديد من عمليات التهيئة مباشرة باستخدام GlobalScope.launch أو تجمع مؤشرات الترابط، وإذا فشلت، فإنها تفشل.

سيكون النهج الأكثر قابلية للتحكم هو:

  • كل عملية تهيئة لها الحالة: NotStarted / Running / Ready / Failed
  • يحصل المتصل على مقبض يمكن انتظاره (حتى لو لم ينتظر في النهاية)

الكود الزائف:

sealed class InitState {
 data object NotStarted : InitState()
 data object Running : InitState()
 data object Ready : InitState()
 data class Failed(val error: Throwable) : InitState()
}

class Initializer {
 @Volatile private var state: InitState = InitState.NotStarted
 private val deferred = CompletableDeferred<Unit>()

 fun start() {
 if (state != InitState.NotStarted) return
 state = InitState.Running
 scope.launch(Dispatchers.IO) {
  runCatching {
  // do init
  }.onSuccess {
  state = InitState.Ready
  deferred.complete(Unit)
  }.onFailure {
  state = InitState.Failed(it)
  deferred.completeExceptionally(it)
  }
 }
 }

 suspend fun awaitReady() = deferred.await()
}

وهذا يجعل أمرين صحيحين:

  • يمكنك اختيار مكان الانتظار
  • لا مزيد من “التفكير في أنه أفضل”

3) تعيين الحدود ومفاتيح التراجع لتأخير التهيئة

التهيئة البطيئة ليست مستحيلة، ولكنها تتطلب شروطًا حدودية:

  • أي من المستخدمين/السيناريوهات يمكن تأخيره (مثل بدء التشغيل البارد فقط، أو تأخير بدء التشغيل الساخن أيضًا)
  • ما يجب فعله عند حدوث الفشل (إعادة المحاولة، التعطيل، التراجع)
  • كيفية ملاحظة التدرج الرمادي (توزيع النوافذ الجاهزة، معدل الفشل، نسبة التدهور)

أفضل أن أجعل “بدء المزامنة” مفتاحًا لسياسة التراجع بدلاً من تغيير الكود لمرة واحدة.

لأنه بمجرد اكتشاف مشكلة عرضية عبر الإنترنت، فإن أسرع طريقة لوقف النزيف هي عادةً “التراجع عن عدم التزامن”.

الحدود المطبقة: متى يكون عدم التزامن مربحًا ومتى يكون خسارة؟

الفرضية القائلة بأن عدم التزامن مربح هو:

  • التبعية ناعمة جاهزة أو مؤجلة
  • عقد الاستعداد واضح، وهناك سلسلة أدلة على الفشل
  • النافذة الجاهزة صغيرة ومستقرة ولا تمتد إلى التفاعل الأول

السيناريوهات النموذجية التي يكون فيها عدم التزامن خسارة:

  • التبعية جاهزة تمامًا، ولكن تم نقلها من أجل المقاييس
  • تغطية الفشل بالتستر مما يؤدي إلى الانحراف الدلالي
  • لا توجد بوابة جاهزة، وبالتالي تصبح حالة السباق حدثاً احتمالياً

لتلخيص ذلك في جملة واحدة: هل يمكنك توضيح متى لا يكون جاهزًا، وإذا كان بإمكان الشركة الحفاظ على دلالات متسقة عندما لا يكون جاهزًا، فإن عدم التزامن يعتبر محسّنًا. **

ملخص

يتم توجيه تحسين البداية الباردة بسهولة بواسطة مؤشرات الأداء الرئيسية (KPI) إلى مشكلة ذات هدف واحد: جعل الشاشة الأولى أسرع.

ولكن ما يجب وضعه في الاعتبار حقًا أثناء مرحلة بدء التشغيل هو “متى سيتم اعتبار النظام متاحًا؟” كلما تم تقسيم التهيئة إلى أجزاء، زادت الحاجة إلى كتابة دلالات الاستعداد في الكود وفي الملاحظات بشكل أكثر وضوحًا.

وإلا فإن ما سنفعله هو استبدال البطء الحتمي بالأخطاء الاحتمالية.

FAQ

What to read next

Related

Continue reading