قصة كيف فاز الحذف المتتالي للعالم بالبداية الطويلة

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



بمجرد أن وجدنا أن تطبيق Dodo Pizza يبدأ في متوسط ​​3 ثوانٍ ، وبالنسبة لبعض "المحظوظين" يستغرق 15-20 ثانية.



تحت القص قصة بنهاية سعيدة: حول نمو قاعدة بيانات Realm ، تسرب الذاكرة ، كيف حفظنا الكائنات المتداخلة ، ثم جمعنا أنفسنا معًا وأصلحنا كل شيء.










مؤلف المقال: Maxim Kachinkin هو مطور Android في Dodo Pizza.






ثلاث ثوانٍ من النقر على أيقونة التطبيق إلى onResume () للنشاط الأول هي ما لا نهاية. وبالنسبة لبعض المستخدمين ، وصل وقت الإطلاق إلى 15-20 ثانية. كيف يكون هذا ممكن حتى؟



ملخص قصير جدًا لمن ليس لديهم وقت للقراءة
Realm. , . . , — 1 . — - -.



بحث وتحليل المشكلة



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



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



كم طوله؟ بالنسبة الىوثائق Google ، إذا كانت البداية الباردة لتطبيق ما تستغرق أقل من 5 ثوانٍ ، فإنها تعتبر "نوعًا طبيعيًا". تم إطلاق تطبيق Dodo Pizza Android (وفقًا لمقياس Firebase _app_start ) في بداية باردة في متوسط ​​3 ثوانٍ - "ليس رائعًا ، وليس فظيعًا" ، كما يقولون.



ولكن بعد ذلك بدأت تظهر الشكاوى بأن التطبيق قد تم إطلاقه لفترة طويلة جدًا جدًا جدًا! بادئ ذي بدء ، قررنا قياس ما هو "طويل جدًا جدًا جدًا". واستخدمنا Firebase trace app start trace لهذا الغرض .







يقيس هذا التتبع القياسي الوقت بين لحظة فتح المستخدم للتطبيق ولحظة تنفيذ onResume () للتنشيط الأول. تستدعي Firebase Console هذا المقياس _app_start. اتضح أن:



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






خطرت في بالي فكرتان:



  1. شيء ما يتسرب.
  2. يتم التخلص من هذا "الشيء" بعد إطلاقه ثم يتسرب مرة أخرى.


اعتقدنا أن هناك شيئًا ما مع قاعدة البيانات ، وكنا على حق. أولاً ، نستخدم قاعدة البيانات كذاكرة تخزين مؤقت ، نقوم بمسحها أثناء الترحيل. ثانيًا ، يتم تحميل قاعدة البيانات عند بدء تشغيل التطبيق. كل شيء يتناسب معا.



ما الخطأ في قاعدة بيانات Realm



بدأنا في التحقق من كيفية تغير محتوى قاعدة البيانات على مدار عمر التطبيق ، من التثبيت الأول وحتى عملية الاستخدام النشط. يمكنك عرض محتويات قاعدة بيانات Realm من خلال Stetho أو بمزيد من التفاصيل وبشكل مرئي عن طريق فتح الملف من خلال Realm Studio . لعرض محتويات قاعدة البيانات عبر ADB ، انسخ ملف قاعدة بيانات Realm:



adb exec-out run-as ${PACKAGE_NAME} cat files/${DB_NAME}


بعد الاطلاع على محتويات قاعدة البيانات في أوقات مختلفة ، اكتشفنا أن عدد الكائنات من نوع معين يتزايد باستمرار.





تُظهر الصورة جزءًا من Realm Studio لملفين: على اليسار - قاعدة بيانات التطبيق بعد بعض الوقت بعد التثبيت ، على اليمين - بعد الاستخدام النشط. ويمكن ملاحظة أن عدد الكائنات ImageEntityو MoneyTypeنمت بشكل ملحوظ (يظهر قطة عدد الكائنات كل نوع).



علاقة نمو قاعدة البيانات بأوقات بدء التشغيل



نمو قاعدة البيانات غير المنضبط سيء للغاية. لكن كيف يؤثر ذلك على وقت إطلاق التطبيق؟ من السهل جدًا قياسه من خلال ActivityManager. بدءًا من Android 4.4 ، يعرض logcat سجلًا يحتوي على السلسلة والوقت المعروضين. هذا الوقت يساوي الفاصل الزمني من لحظة تشغيل التطبيق إلى نهاية عرض النشاط. خلال هذا الوقت ، تحدث الأحداث:



  • بدء العملية.
  • تهيئة الكائن.
  • إنشاء وتهيئة النشاط.
  • إنشاء التخطيط.
  • تقديم التطبيق.


مناسب لنا. إذا قمت بتشغيل ADB باستخدام العلامتين -S و -W ، فيمكنك الحصول على إخراج ممتد مع وقت البدء:



adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN


إذا قمت بكشط grep -i WaitTimeالوقت من هناك ، فيمكنك أتمتة جمع هذا المقياس ومشاهدة النتائج بيانياً. يوضح الرسم البياني أدناه اعتماد وقت تشغيل التطبيق على عدد مرات بدء التطبيق الباردة.







في الوقت نفسه ، كان الاعتماد على حجم ونمو القاعدة هو نفسه ، حيث نما من 4 ميغا بايت إلى 15 ميغا بايت. نتيجة لذلك ، اتضح أنه بمرور الوقت (مع نمو البدايات الباردة) ، زاد أيضًا وقت تشغيل التطبيق وحجم قاعدة البيانات. لدينا فرضية في أيدينا. الآن بقي لتأكيد الاعتماد. لذلك ، قررنا إزالة "التسريبات" ومعرفة ما إذا كانت ستسرع عملية الإطلاق.



أسباب النمو اللانهائي لقاعدة البيانات



قبل إزالة "التسريبات" ، يجدر بنا أن نفهم سبب ظهورها على الإطلاق. للقيام بذلك ، دعونا نتذكر ما هو عالم.



Realm هي قاعدة بيانات غير علائقية. يتيح لك وصف العلاقات بين الكائنات بطريقة مماثلة تصفها العديد من قواعد البيانات العلائقية ORM على Android. في الوقت نفسه ، يحفظ Realm الكائنات مباشرة في الذاكرة بأقل عدد من التحويلات والتعيينات. يسمح لك هذا بقراءة البيانات من القرص بسرعة كبيرة ، وهو ما يمثل قوة من قوة عالم ومحبوب.



(لأغراض هذه المقالة ، سيكون هذا الوصف كافياً بالنسبة لنا. يمكنك قراءة المزيد عن Realm في الوثائق الرائعة أو في أكاديميتها ).



كثير من المطورين معتادون على العمل أكثر مع قواعد البيانات العلائقية (على سبيل المثال ، قواعد بيانات ORM مع SQL تحت الغطاء). وغالبًا ما تبدو أشياء مثل حذف البيانات المتتالية وكأنها مسألة طبيعية. لكن ليس في المملكة.



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



تسرب البيانات بدون حذف متتالي



كيف تتسرب البيانات بالضبط إذا كنت تأمل في حذف متتالي غير موجود؟ إذا كان لديك كائنات Realm متداخلة ، فيجب حذفها.

لنلقِ نظرة على مثال (تقريبًا) من العالم الحقيقي. لدينا كائن CartItemEntity:



@RealmClass
class CartItemEntity(
 @PrimaryKey
 override var id: String? = null,
 ...
 var name: String = "",
 var description: String = "",
 var image: ImageEntity? = null,
 var category: String = MENU_CATEGORY_UNKNOWN_ID,
 var customizationEntity: CustomizationEntity? = null,
 var cartComboProducts: RealmList<CartProductEntity> = RealmList(),
 ...
) : RealmObject()


يحتوي المنتج الموجود في العربة على حقول مختلفة ، بما في ذلك صورة ImageEntityومكونات مخصصة CustomizationEntity. أيضًا ، يمكن أن يكون المنتج الموجود في السلة مزيجًا مع مجموعة المنتجات الخاصة به RealmList (CartProductEntity). جميع الحقول المدرجة هي كائنات Realm. إذا أدخلنا كائنًا جديدًا (copyToRealm () / copyToRealmOrUpdate ()) بنفس المعرف ، فسيتم استبدال هذا الكائن بالكامل. ولكن ستفقد جميع الكائنات الداخلية (الصورة ، والتخصيص ، والكارت كومبو برودوكتس) الاتصال بالأصل وستبقى في قاعدة البيانات.



نظرًا لفقد الاتصال بهم ، لم نعد نقرأها أو نحذفها (ما لم نشير إليها صراحةً أو مسح "الجدول" بالكامل). أطلقنا على هذا "تسرب الذاكرة".



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



val entity = realm.where(CartItemEntity::class.java).equalTo("id", id).findFirst()
if (first != null) {
 deleteFromRealm(first.image)
 deleteFromRealm(first.customizationEntity)
 for(cartProductEntity in first.cartComboProducts) {
   deleteFromRealm(cartProductEntity)
 }
 first.deleteFromRealm()
}
//    


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



حل سريع



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



interface NestedEntityAware {
 fun getNestedEntities(): Collection<RealmObject?>
}


وقمنا بتطبيقه في كائنات عالمنا:



@RealmClass
class DataPizzeriaEntity(
 @PrimaryKey
 var id: String? = null,
 var name: String? = null,
 var coordinates: CoordinatesEntity? = null,
 var deliverySchedule: ScheduleEntity? = null,
 var restaurantSchedule: ScheduleEntity? = null,
 ...
) : RealmObject(), NestedEntityAware {

 override fun getNestedEntities(): Collection<RealmObject?> {
   return listOf(
       coordinates,
       deliverySchedule,
       restaurantSchedule
   )
 }
}


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



@RealmClass
class ScheduleEntity(
 var monday: DayOfWeekEntity? = null,
 var tuesday: DayOfWeekEntity? = null,
 var wednesday: DayOfWeekEntity? = null,
 var thursday: DayOfWeekEntity? = null,
 var friday: DayOfWeekEntity? = null,
 var saturday: DayOfWeekEntity? = null,
 var sunday: DayOfWeekEntity? = null
) : RealmObject(), NestedEntityAware {

 override fun getNestedEntities(): Collection<RealmObject?> {
   return listOf(
       monday, tuesday, wednesday, thursday, friday, saturday, sunday
   )
 }
}


وهكذا ، يمكن تكرار تداخل الكائنات.



ثم نكتب طريقة تزيل بشكل متكرر كل الكائنات المتداخلة. الطريقة (التي تم إنشاؤها في شكل ملحق) deleteAllNestedEntitiesتحصل على جميع كائنات المستوى الأعلى deleteNestedRecursivelyوتزيل بشكل متكرر جميع الكائنات المتداخلة باستخدام واجهة NestedEntityAware:



fun <T> Realm.deleteAllNestedEntities(entities: Collection<T>,
 entityClass: Class<out RealmObject>,
 idMapper: (T) -> String,
 idFieldName : String = "id"
 ) {

 val existedObjects = where(entityClass)
     .`in`(idFieldName, entities.map(idMapper).toTypedArray())
     .findAll()

 deleteNestedRecursively(existedObjects)
}

private fun Realm.deleteNestedRecursively(entities: Collection<RealmObject?>) {
 for(entity in entities) {
   entity?.let { realmObject ->
     if (realmObject is NestedEntityAware) {
       deleteNestedRecursively((realmObject as NestedEntityAware).getNestedEntities())
     }
     realmObject.deleteFromRealm()
   }
 }
}


لقد فعلنا ذلك باستخدام الكائنات الأسرع نموًا وفحصنا ما حدث.







نتيجة لذلك ، توقفت الكائنات التي غطيناها بهذا الحل عن النمو. وتباطأ النمو الإجمالي للقاعدة ، لكنه لم يتوقف.



الحل "الطبيعي"



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



أردت التأكد من عدم استخدام الواجهات ، ولكن جعل كل شيء يعمل من تلقاء نفسه.



عندما نريد أن يعمل شيء ما من تلقاء نفسه ، علينا استخدام التفكير. للقيام بذلك ، يمكننا استعراض كل حقل في الفصل والتحقق مما إذا كان كائنًا من Realm أم قائمة كائنات:



RealmModel::class.java.isAssignableFrom(field.type)

RealmList::class.java.isAssignableFrom(field.type)


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



fun <T : Any> Realm.cascadeDelete(entities: Collection<T?>) {
 if(entities.isEmpty()) {
   return
 }

 entities.filterNotNull().let { notNullEntities ->
   notNullEntities
       .filterRealmObject()
       .flatMap { realmObject -> getNestedRealmObjects(realmObject) }
       .also { realmObjects -> cascadeDelete(realmObjects) }

   notNullEntities
       .forEach { entity ->
         if((entity is RealmObject) && entity.isValid) {
           entity.deleteFromRealm()
         }
       }
 }
}


يقوم الامتداد filterRealmObjectبتصفية وتمرير كائنات Realm فقط. getNestedRealmObjectsيعثر هذا الأسلوب على جميع كائنات العالم المتداخلة من خلال الانعكاس ويضيفها إلى قائمة خطية. ثم نفعل الشيء نفسه بشكل متكرر. عند الحذف ، تحتاج إلى التحقق من صحة الكائن isValid، لأنه قد يكون للكائنات الرئيسية المختلفة نفس الكائنات المتداخلة. من الأفضل تجنب ذلك واستخدام الإنشاء التلقائي للمعرف عند إنشاء كائنات جديدة.





التنفيذ الكامل لطريقة getNestedRealmObjects
private fun getNestedRealmObjects(realmObject: RealmObject) : List<RealmObject> {
 val nestedObjects = mutableListOf<RealmObject>()
 val fields = realmObject.javaClass.superclass.declaredFields

//   ,     RealmModel   RealmList
 fields.forEach { field ->
   when {
     RealmModel::class.java.isAssignableFrom(field.type) -> {
       try {
         val child = getChildObjectByField(realmObject, field)
         child?.let {
           if (isInstanceOfRealmObject(it)) {
             nestedObjects.add(child as RealmObject)
           }
         }
       } catch (e: Exception) { ... }
     }

     RealmList::class.java.isAssignableFrom(field.type) -> {
       try {
         val childList = getChildObjectByField(realmObject, field)
         childList?.let { list ->
           (list as RealmList<*>).forEach {
             if (isInstanceOfRealmObject(it)) {
               nestedObjects.add(it as RealmObject)
             }
           }
         }
       } catch (e: Exception) { ... }
     }
   }
 }

 return nestedObjects
}

private fun getChildObjectByField(realmObject: RealmObject, field: Field): Any? {
 val methodName = "get${field.name.capitalize()}"
 val method = realmObject.javaClass.getMethod(methodName)
 return method.invoke(realmObject)
}




نتيجة لذلك ، في رمز العميل الخاص بنا ، نستخدم "حذف متتالي" لكل عملية تغيير بيانات. على سبيل المثال ، بالنسبة لعملية الإدراج ، يبدو الأمر كما يلي:



override fun <T : Entity> insert(
 entityInformation: EntityInformation,
 entities: Collection<T>): Collection<T> = entities.apply {
 realmInstance.cascadeDelete(getManagedEntities(entityInformation, this))
 realmInstance.copyFromRealm(
     realmInstance
         .copyToRealmOrUpdate(this.map { entity -> entity as RealmModel }
 ))
}


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







يوضح الخط الأخضر اعتماد وقت تشغيل التطبيق على عدد مرات البدء الباردة أثناء الحذف التلقائي المتتالي للكائنات المتداخلة.



النتائج والاستنتاجات



أدت قاعدة بيانات Realm المتنامية باستمرار إلى إبطاء إطلاق التطبيق بشكل كبير. لقد أصدرنا تحديثًا باستخدام "الحذف المتتالي" الخاص بنا للكائنات المتداخلة. والآن نقوم بتتبع وتقييم مدى تأثير قرارنا على وقت تشغيل التطبيق من خلال مقياس _app_start.







للتحليل ، نأخذ فترة زمنية مدتها 90 يومًا ونرى: بدأ وقت تشغيل التطبيق ، سواء الوسيط أو الذي يقع على 95 بالمائة من المستخدمين ، في الانخفاض ولم يعد يرتفع.







إذا نظرت إلى الرسم البياني لسبعة أيام ، فإن مقياس _app_start يبدو مناسبًا تمامًا وأقل من ثانية واحدة.



يجب أن نضيف أيضًا أنه افتراضيًا يرسل Firebase إشعارات إذا تجاوز متوسط ​​قيمة _app_start 5 ثوانٍ. ومع ذلك ، كما نرى ، لا يجب الاعتماد على هذا ، بل عليك الدخول والتحقق منه بشكل صريح.



خصوصية قاعدة بيانات Realm هي أنها قاعدة بيانات غير علائقية. على الرغم من استخدامه البسيط ، والتشابه في العمل مع حلول ORM وربط الكائنات ، فإنه لا يحتوي على حذف متتالي.



إذا لم يتم أخذ ذلك في الاعتبار ، فسوف تتراكم الكائنات المتداخلة ، "تسرب". ستنمو قاعدة البيانات باستمرار ، وهذا بدوره سيؤثر على تباطؤ أو إطلاق التطبيق.



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



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



All Articles