Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Усе карыстачы лічаць хуткі запуск і спагадны UI у мабільных прыкладаннях само сабой якія разумеюцца. Калі праграма запускаецца доўга, карыстальнік пачынае сумаваць і злавацца. Проста можна сапсаваць кліенцкі досвед або зусім страціць карыстальніка яшчэ да таго, як ён пачаў карыстацца дадаткам.

Аднойчы мы выявілі, што прыкладанне Дадо Піца запускаецца ў сярэднім 3 секунды, а ў некаторых "шчасліўчыкаў" 15-20 секунд.

Пад катам гісторыя з хэпі эндам: пра рост базы дадзеных Realm, уцечку памяці, тое, як мы назапашвалі ўкладзеныя аб'екты, а пасля ўзялі сябе ў рукі і ўсё паправілі.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло
Аўтар артыкула: Максім Качынкін — Android-распрацоўшчык у Дадо Піцы.

Тры секунды ад кліку на абразок дадатку да onResume() першага актывіці — бясконцасць. А ў некаторых карыстальнікаў час запуску даходзіла да 15-20 секунд. Як такое ўвогуле магчыма?

Вельмі кароткі змест для тых, каму некалі чытаць
У нас бясконца расла база дадзеных Realm. Некаторыя ўкладзеныя аб'екты не выдаляліся, а ўвесь час назапашваліся. Час запуску прыкладання паступова павялічвалася. Потым мы гэта паправілі, і час запуску прыйшоў да мэтавага стала менш за 1 секунды і больш не расце. У артыкуле аналіз сітуацыі і два варыянты рашэння - па-хуткаму і па-нармальнаму.

Пошук і аналіз праблемы

Сёння любое мабільнае прыкладанне павінна запускацца хутка і быць спагадным. Але справа не толькі ў мабільным дадатку. Карыстацкі вопыт узаемадзеяння з сэрвісам і кампаніяй - гэта комплексная рэч. Напрыклад, у нашым выпадку хуткасць дастаўкі - адзін з ключавых паказчыкаў для сэрвісу піцы. Калі дастаўка хуткая, то піца будзе гарачай, і кліент, які хоча есці зараз, не будзе доўга чакаць. Для прыкладання, у сваю чаргу, важна ствараць адчуванне хуткага сэрвісу, бо калі дадатак толькі 20 секунд запускаецца, то колькі давядзецца чакаць піцу?

Спачатку мы самі сутыкаліся з тым, што часам прыкладанне запускаецца пару-тройку секунд, а потым да нас сталі далятаць скаргі іншых калег аб тым, што "доўга". Але стабільна паўтарыць гэтую сітуацыю нам не ўдавалася.

Доўга - гэта колькі? Згодна Google-дакументацыі, Калі халодны старт прыкладання займае менш за 5 секунд, то гэта лічыцца "як бы нармальна". Android-дадатак Дадо Піцы запускалася (згодна з Firebase метрыцы _app_start) пры халодным старце у сярэднім за 3 секунды - "Not great, not terrible", як гаворыцца.

Але потым сталі з'яўляцца скаргі, што дадатак запускаецца вельмі-вельмі-вельмі доўга! Для пачатку мы вырашылі вымераць, што ж такое "вельмі-вельмі-вельмі доўга". І скарысталіся для гэтага Firebase trace App start trace.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Гэты стандартны трэйс вымярае час паміж момантам, калі карыстач адчыняе прыкладанне, і момантам, калі выканаецца onResume() першага актывіці. У Firebase Console гэтая метрыка завецца _app_start. Высветлілася што:

  • Час запуску ў карыстальнікаў вышэй за 95-ы працэнты складае амаль 20 секунд (у некаторых і больш), нягледзячы на ​​тое, што медыяны час халоднага запуску менш за 5 секунд.
  • Час запуску - велічыня не сталая, а якая расце з часам. Але часам назіраюцца падзенні. Гэтую заканамернасць мы знайшлі, калі павялічылі маштаб аналізу да 90 дзён.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

На розум прыйшло дзве думкі:

  1. Нешта выцякае.
  2. Гэтае «нешта» пасля рэлізу скідаецца і потым выцякае ізноў.

«Напэўна, нешта з базай дадзеных», - падумалі мы і мелі рацыю. Па-першае, база дадзеных выкарыстоўваецца ў нас як кэш, пры міграцыі мы яе чысцім. Па-другое, база дадзеных загружаецца пры старце дадатку. Усё сыходзіцца.

Што не так з базай дадзеных Realm

Мы сталі правяраць, як мяняецца змесціва базы з часам жыцця прыкладання, ад першай ўстаноўкі і далей у працэсе актыўнага выкарыстання. Паглядзець змесціва базы дадзеных Realm можна праз Стэто ці больш падрабязна і наглядна, адкрыўшы файл праз Realm Studio. Каб паглядзець змесціва базы праз ADB, які капіюецца файл базы Realm:

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

Паглядзеўшы на змесціва базы ў розны час, мы высветлілі, што колькасць аб'ектаў вызначанага тыпу ўвесь час павялічваецца.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло
На малюнку паказаны фрагмент Realm Studio для двух файлаў: злева - база прыкладання праз некаторы час пасля ўстаноўкі, справа - пасля актыўнага выкарыстання. Відаць, што колькасць аб'ектаў ImageEntity и MoneyType моцна вырасла (на скрыншоце паказана колькасць аб'ектаў кожнага тыпу).

Сувязь росту базы дадзеных з часам запуску

Некантралюемы рост базы дадзеных - гэта вельмі дрэнна. Але як гэта ўплывае на час запуску дадатку? Памераць гэта дастаткова проста праз ActivityManager. Пачынальна з Android 4.4, logcat адлюстроўвае лог са радком Displayed і часам. Гэты час роўна прамежку з моманту запуску прыкладання да канца адмалёўкі актывiт. За гэты час адбываюцца падзеі:

  • Запуск працэсу.
  • Ініцыялізацыя аб'ектаў.
  • Стварэнне і ініцыялізацыя актывісты.
  • Стварэнне лейаўта.
  • Адмалёўка прыкладання.

Нам падыходзіць. Калі запусціць 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 Час, можна аўтаматызаваць збор гэтай метрыкі і паглядзець навочна на вынікі. На графіцы ніжэй прыведзена залежнасць часу запуску дадатку ад колькасці халодных запускаў прыкладання.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Пры гэтым быў такі ж характар ​​??залежнасці памеру і росту базы, якая вырасла з 4 МБ да 15 МБ. Разам атрымліваецца, што з часам (з ростам лядоўняў запускаў) расло і час запуску прыкладання і памер базы. У нас на руках зьявілася гіпотэза. Цяпер заставалася пацвердзіць залежнасць. Таму мы вырашылі прыбраць "уцечкі" і праверыць, ці паскорыць гэта запуск.

Прычыны бясконцага росту базы даных

Перш чым прыбіраць "уцечкі", варта разабрацца, чаму яны ўвогуле з'явіліся. Для гэтага ўспомнім, што такое Realm.

Realm - гэта нерэляцыйная база дадзеных. Яна дазваляе апісваць сувязі паміж аб'ектамі падобным спосабам, якім апісваюць многія ORM рэляцыйныя базы даных на Android. Пры гэтым Realm захоўвае напроста аб'екты ў памяці з найменшай колькасцю пераўтварэнняў і мапінгаў. Гэта дазваляе чытаць дадзеныя з дыска вельмі хутка, што з'яўляецца моцным бокам Realm, за якую яго кахаюць.

(У рамках дадзенага артыкула гэтага апісання нам будзе дастаткова. Больш падрабязна пра Realm можна прачытаць у круты дакументацыі ці ў іх акадэміі).

Многія распрацоўшчыкі прывыклі працаваць у большай ступені з рэляцыйнымі базамі дадзеных (напрыклад, ORM-базамі c SQL пад капотам). І такія рэчы як каскаднае выдаленне дадзеных часта здаюцца само сабой якая разумеецца справай. Але не ў Realm.

Дарэчы кажучы, фічу каскаднага выдалення просяць зрабіць ужо даўно. Гэтую дапрацоўку и іншую, звязаную з ёй, актыўна абмяркоўвалі. Было адчуванне, што яна хутка будзе зроблена. Але потым усё перавялося ва ўвядзенне моцных і слабых спасылак, якія таксама аўтаматам вырашылі б гэтую праблему. Па гэтай задачы быў даволі жывы і актыўны пул-рэквест, які пакуль з-за ўнутраных складанасцяў паставілі на паўзу

Уцечка дадзеных без каскаднага выдалення

Як менавіта выцякаюць дадзеныя, калі спадзявацца на неіснуючае каскаднае выдаленне? Калі ў вас ёсць укладзеныя 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()) з такім жа id, то гэты аб'ект цалкам перазапішацца. Але ўсе ўнутраныя аб'екты (image, customizationEntity і cartComboProducts) страцяць сувязь з бацькоўскім і застануцца ў базе.

Так як сувязь з імі страчана, мы іх больш не чытаем і не выдаляем (толькі калі не звяртацца да іх відавочна ці не чысціць усю "табліцу"). Мы гэта назвалі "уцечкамі памяці".

Калі мы працуем з 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()
}
// и потом уже сохраняем

Калі зрабіць так, дык усё будзе працаваць як трэба. У дадзеным прыкладзе мы мяркуем, што ўсярэдзіне image, customizationEntity і cartComboProducts няма іншых укладзеных Realm-аб'ектаў, таму няма іншых укладзеных цыклаў і выдаленняў.

Рашэнне «па-хуткаму»

Перш за ўсё мы вырашылі падчысціць самыя хуткарослыя аб'екты і праверыць вынікі - ці вырашыць гэта нашу першапачатковую праблему. Спачатку было зроблена найболей простае і інтуітыўна-зразумелае рашэнне, а менавіта: кожны аб'ект павінен быць адказным за выдаленне за сабой сваіх дзяцей. Для гэтага ўвялі такі інтэрфейс, які вяртаў спіс сваіх укладзеных Realm-аб'ектаў:

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

І рэалізавалі яго ў нашых Realm-аб'ектах:

@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 доўгі запуск перамагло

У выніку тыя аб'екты, якія мы пакрылі гэтым рашэньнем, перасталі расці. А агульны рост базы замарудзіўся, але не спыніўся.

Рашэнне "па-нармальнаму"

База хоць і стала расці павольней, але ўсё роўна расла. Таму мы пачалі шукаць далей. У нашым праекце вельмі актыўна выкарыстоўваецца кэшаванне дадзеных у Realm. Таму пісаць для кожнага аб'екта ўсе ўкладзеныя аб'екты працаёмка, плюс павялічваецца рызыка памылкі, бо можна забыцца паказаць аб'екты пры змене кода.

Жадалася зрабіць так, каб не выкарыстоўваць інтэрфейсы, а каб усё працавала само.

Калі мы хочам, каб нешта працавала само, даводзіцца выкарыстоўваць рэфлексію. Для гэтага мы можам прайсціся па кожным полі класа і праверыць, ці з'яўляецца ён 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 праз рэфлексію знаходзіць усе укладзеныя Realm-аб'екты і складае іх у лінейны спіс. Далей рэкурсіўна які робіцца ўсё тое ж самае. Пры выдаленні трэба праверыць аб'ект на валіднасць isValid, таму што можа быць такое, што розныя бацькоўскія аб'екты могуць мець укладзеныя аднолькавыя. Гэтага лепш не дапушчаць і проста выкарыстоўваць автогенерацию id пры стварэнні новых аб'ектаў.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Поўная рэалізацыя метаду 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 цалкам зніклі. Правёўшы той жа замер залежнасці часу запуску ад колькасці лядоўняў запускаў прыкладання, мы бачым вынік.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Зялёная лінія паказвае залежнасць часу запуску прыкладання ад колькасці лядоўняў стартаў пры аўтаматычным каскадным выдаленні ўкладзеных аб'ектаў.

Вынікі і высновы

Пастаянна расце база дадзеных Realm моцна запавольвала запуск прыкладання. Мы выпусцілі абнаўленне з уласным "каскадным выдаленнем" укладзеных аб'ектаў. І зараз адсочваем і ацэньваем, як наша рашэнне паўплывала на час запуску прыкладання праз метрыку _app_start.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Для аналізу бярэм прамежак часу 90 дзён і бачым: час запуску прыкладання, як медыяннае, так і тое, што прыпадае на 95 працэнтаў карыстальнікаў, пачало памяншацца і больш не падымаецца.

Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло

Калі паглядзець на сямідзённы графік, то метрыка _app_start цалкам выглядае адэкватнай і складае менш за 1 секунду.

Асобна варта дадаць, што па змаўчанні Firebase шле апавяшчэнні, калі медыянае значэнне _app_start перавышае 5 секунд. Аднак, як мы бачым, на гэта не варта спадзявацца, а лепей зайсці і праверыць яго відавочна.

Асаблівасць базы даных Realm заключаецца ў тым, што гэта нерэляцыйная база даных. Нягледзячы на ​​простае выкарыстанне, падабенства працы з ORM-рашэннямі і звязванне аб'ектаў, у яе няма каскаднага выдалення.

Калі гэта не ўлічваць, то ўкладзеныя аб'екты будуць назапашвацца, "выцякаць". База дадзеных будзе расці ўвесь час, што ў сваю чаргу адаб'ецца на запаволенні працы або запуску прыкладання.

Я падзяліўся нашым досведам, як хутка зрабіць каскаднае выдаленне аб'ектаў у Realm, якога пакуль няма са скрынкі, але пра які даўно кажуць и кажуць. У нашым выпадку гэта моцна паскорыла час запуску дадатку.

Нягледзячы на ​​абмеркаванне хуткага з'яўлення гэтай фічы, адсутнасць каскаднага выдалення ў Realm зроблена by design. Калі вы праектуеце новае прыкладанне, то улічвайце гэта. А калі ўжо выкарыстоўваеце Realm – праверце, ці няма ў вас такіх праблем.

Крыніца: habr.com

Дадаць каментар