Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Kõik kasutajad peavad mobiilirakenduste kiiret käivitamist ja reageerivat kasutajaliidest enesestmõistetavaks. Kui rakenduse käivitamine võtab kaua aega, hakkab kasutaja kurbust ja vihast tundma. Saate hõlpsasti kliendikogemuse rikkuda või kasutaja täielikult kaotada juba enne, kui ta rakendust kasutama hakkab.

Kunagi avastasime, et Dodo Pizza rakenduse käivitamine võtab keskmiselt 3 sekundit ja mõnel "õnnelikul" 15-20 sekundit.

Lõike all on õnneliku lõpuga lugu: Realmi andmebaasi kasvust, mälulekkest, sellest, kuidas me pesastatud objekte kogusime ning seejärel end kokku võtsime ja kõik parandasime.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise
Artikli autor: Maksim Katšinkin - Androidi arendaja Dodo Pizzas.

Kolm sekundit rakenduse ikoonil klõpsamisest esimese tegevuse onResume()-ni on lõpmatus. Ja mõne kasutaja jaoks ulatus käivitusaeg 15-20 sekundini. Kuidas see üldse võimalik on?

Väga lühike kokkuvõte neile, kel pole aega lugeda
Meie Realmi andmebaas kasvas lõputult. Mõningaid pesastatud objekte ei kustutatud, vaid neid kogunes pidevalt. Rakenduse käivitusaeg pikenes järk-järgult. Seejärel parandasime selle ja käivitusaeg jõudis sihtmärgini - see jäi alla 1 sekundi ja enam ei suurenenud. Artikkel sisaldab olukorra analüüsi ja kahte lahendust – kiiret ja tavalist.

Probleemi otsimine ja analüüs

Tänapäeval peab iga mobiilirakendus kiiresti käivituma ja reageerima. Kuid see ei puuduta ainult mobiilirakendust. Kasutajakogemus teenuse ja ettevõttega suhtlemisel on keeruline asi. Näiteks meie puhul on kohaletoimetamise kiirus üks pitsateenuse võtmenäitajaid. Kui kohaletoimetamine on kiire, on pitsa kuum ja klient, kes soovib kohe süüa, ei pea kaua ootama. Rakenduse jaoks on omakorda oluline luua kiire teeninduse tunne, sest kui rakenduse käivitamiseks kulub vaid 20 sekundit, siis kui kaua peate pitsat ootama?

Algul seisime ise silmitsi tõsiasjaga, et mõnikord võttis rakenduse käivitamine paar sekundit ja siis hakkasime kuulma teiste kolleegide kaebusi, kui kaua see aega võttis. Kuid me ei suutnud seda olukorda järjekindlalt korrata.

Kui kaua see on? Vastavalt Google'i dokumentatsioon, kui rakenduse külmkäivitamine võtab aega vähem kui 5 sekundit, peetakse seda "nagu normaalseks". Dodo Pizza Androidi rakendus käivitati (vastavalt Firebase'i mõõdikutele _app_start) kell külmkäivitus keskmiselt 3 sekundiga - "Pole suurepärane, mitte kohutav," nagu öeldakse.

Kuid siis hakkasid ilmuma kaebused, et rakenduse käivitamine võttis väga-väga-väga kaua aega! Alustuseks otsustasime mõõta, mis on "väga, väga, väga pikk". Ja me kasutasime selleks Firebase'i jälge Rakenduse käivitusjälg.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

See standardjälg mõõdab aega, mis kulub hetkest, mil kasutaja rakenduse avab, kuni hetkeni, mil käivitatakse esimese toimingu onResume(). Firebase'i konsoolis nimetatakse seda mõõdikut _app_start. Selgus, et:

  • 95. protsentiili ületavate kasutajate käivitusajad on peaaegu 20 sekundit (mõned isegi pikemad), hoolimata sellest, et külmkäivituse keskmine aeg on alla 5 sekundi.
  • Käivitusaeg ei ole püsiv väärtus, vaid aja jooksul kasvab. Kuid mõnikord on tilgad. Leidsime selle mustri, kui suurendasime analüüsi skaala 90 päevani.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Kaks mõtet tulid pähe:

  1. Midagi lekib.
  2. See "midagi" lähtestatakse pärast vabastamist ja seejärel lekib uuesti.

"Tõenäoliselt on midagi andmebaasiga," mõtlesime ja meil oli õigus. Esiteks kasutame andmebaasi vahemäluna, migratsiooni ajal tühjendame selle. Teiseks laaditakse andmebaas siis, kui rakendus käivitub. Kõik sobib kokku.

Mis Realmi andmebaasil viga on?

Hakkasime kontrollima, kuidas andmebaasi sisu muutub rakenduse eluea jooksul, alates esimesest installimisest ja edasi aktiivse kasutamise ajal. Realmi andmebaasi sisu saate vaadata kaudu stetho või täpsemalt ja selgemalt avades faili kaudu Realmi stuudio. Andmebaasi sisu vaatamiseks ADB kaudu kopeerige Realmi andmebaasifail:

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

Vaadates andmebaasi sisu erinevatel aegadel, saime teada, et teatud tüüpi objektide arv kasvab pidevalt.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise
Pildil on Realm Studio fragment kahe faili jaoks: vasakul - rakenduse alus mõni aeg pärast installimist, paremal - pärast aktiivset kasutamist. On näha, et objektide arv ImageEntity и MoneyType on oluliselt kasvanud (kuvatõmmis näitab igat tüüpi objektide arvu).

Andmebaasi kasvu ja käivitusaja vaheline seos

Andmebaasi kontrollimatu kasv on väga halb. Aga kuidas see mõjutab rakenduse käivitusaega? Seda on ActivityManageri kaudu üsna lihtne mõõta. Alates versioonist Android 4.4 kuvab logcat logi koos stringiga Kuvatud ja kellaajaga. See aeg võrdub intervalliga rakenduse käivitamise hetkest kuni tegevuse renderdamise lõpuni. Selle aja jooksul toimuvad järgmised sündmused:

  • Alustage protsessi.
  • Objektide initsialiseerimine.
  • Tegevuste loomine ja initsialiseerimine.
  • Paigutuse loomine.
  • Rakenduse renderdamine.

Meile sobib. Kui käitate ADB-d lippudega -S ja -W, saate käivitusajaga pikendatud väljundi:

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

Kui sa selle sealt haarad grep -i WaitTime aja jooksul saate selle mõõdiku kogumise automatiseerida ja tulemusi visuaalselt vaadata. Allolev graafik näitab rakenduse käivitusaja sõltuvust rakenduse külmkäivituste arvust.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Samal ajal oli samalaadne seos andmebaasi suuruse ja kasvu vahel, mis kasvas 4 MB-lt 15 MB-ni. Kokkuvõttes selgub, et aja jooksul (külmkäivituste kasvuga) suurenes nii rakenduse käivitamise aeg kui ka andmebaasi maht. Meie kätes on hüpotees. Nüüd jäi üle vaid sõltuvust kinnitada. Seetõttu otsustasime "lekked" eemaldada ja vaadata, kas see kiirendaks käivitamist.

Andmebaasi lõputu kasvu põhjused

Enne "lekete" eemaldamist tasub mõista, miks need üldse ilmusid. Selleks meenutagem, mis on Realm.

Realm on mitterelatsiooniline andmebaas. See võimaldab teil kirjeldada objektide vahelisi suhteid sarnaselt sellega, kui palju Androidi ORM-i relatsiooniandmebaase kirjeldatakse. Samal ajal salvestab Realm objekte otse mällu, kus on kõige vähem teisendusi ja vastendusi. See võimaldab teil kettalt andmeid väga kiiresti lugeda, mis on Realmi tugevus ja miks seda armastatakse.

(Selle artikli jaoks piisab meile sellest kirjeldusest. Realmi kohta saate lähemalt lugeda jahedast dokumentatsioon või nendes akadeemia).

Paljud arendajad on harjunud rohkem töötama relatsiooniandmebaasidega (näiteks ORM-andmebaasid, mille katte all on SQL). Ja sellised asjad nagu andmete kaskaadkustutamine tunduvad sageli iseenesestmõistetavad. Aga mitte Realmis.

Muide, kaskaadi kustutamise funktsiooni on juba pikka aega küsitud. See läbivaatamine и teine, sellega seotud, arutati aktiivselt. Tekkis tunne, et see saab varsti tehtud. Siis aga muutus kõik tugevate ja nõrkade lülide kasutuselevõtuks, mis ka selle probleemi automaatselt lahendaks. Oli selle ülesandega üsna elav ja aktiivne tõmba taotlus, mis on praeguseks sisemiste raskuste tõttu peatatud.

Andmeleke ilma kaskaad kustutamiseta

Kuidas täpselt andmed lekivad, kui toetuda olematule kaskaadkustutamisele? Kui teil on pesastatud Realmi objekte, tuleb need kustutada.
Vaatame (peaaegu) reaalset näidet. Meil on objekt 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()

Ostukorvis olev toode on erinevate väljadega, sh pilt ImageEntity, kohandatud koostisained CustomizationEntity. Samuti võib ostukorvis olev toode olla kombineeritud oma tootekomplektiga RealmList (CartProductEntity). Kõik loetletud väljad on Realmi objektid. Kui sisestame sama id-ga uue objekti (copyToRealm() / copyToRealmOrUpdate()), siis see objekt kirjutatakse täielikult üle. Kuid kõik sisemised objektid (pilt, customizationEntity ja cartComboProducts) kaotavad ühenduse vanemaga ja jäävad andmebaasi.

Kuna ühendus nendega katkeb, ei loe me neid enam ega kustuta neid (välja arvatud juhul, kui me neile selgesõnaliselt juurde pääseme või kogu "tabelit" ei tühjenda). Me nimetasime seda "mälu lekkeks".

Realmiga töötades peame enne selliseid toiminguid selgesõnaliselt kõik elemendid läbi käima ja kõik selgesõnaliselt kustutama. Seda saab teha näiteks järgmiselt:

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

Kui teete seda, töötab kõik nii, nagu peab. Selles näites eeldame, et pildi, customizationEntity ja cartComboProductsi sees pole muid pesastatud Realmi objekte, seega pole muid pesastatud silmuseid ja kustutamisi.

"Kiire" lahendus

Esimese asjana otsustasime puhastada kõige kiiremini kasvavad objektid ja kontrollida tulemusi, et näha, kas see lahendab meie algse probleemi. Esiteks tehti kõige lihtsam ja intuitiivsem lahendus, nimelt: iga objekt peaks vastutama oma laste eemaldamise eest. Selleks tutvustasime liidest, mis tagastas oma pesastatud Realmi objektide loendi:

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

Ja me rakendasime selle oma Realmi objektides:

@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 tagastame kõik lapsed kindla nimekirjana. Ja iga alamobjekt saab rakendada ka NestedEntityAware'i liidest, mis näitab, et sellel on näiteks sisemised Realmi objektid, mida kustutada. 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
   )
 }
}

Ja nii edasi, objektide pesastumine võib korduda.

Seejärel kirjutame meetodi, mis kustutab rekursiivselt kõik pesastatud objektid. Meetod (tehtud laiendusena) deleteAllNestedEntities saab kõik tipptaseme objektid ja meetodi deleteNestedRecursively Eemaldab rekursiivselt kõik pesastatud objektid, kasutades NestedEntityAware liidest:

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()
   }
 }
}

Tegime seda kõige kiiremini kasvavate objektidega ja kontrollisime, mis juhtus.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Selle tulemusel lakkasid need objektid, mis selle lahendusega katsime, kasvama. Ja baasi üldine kasv aeglustus, kuid ei peatunud.

"Tavaline" lahendus

Kuigi alus hakkas aeglasemalt kasvama, kasvas see siiski. Hakkasime siis edasi otsima. Meie projekt kasutab Realmis väga aktiivselt andmete vahemällu. Seetõttu on kõigi pesastatud objektide kirjutamine iga objekti jaoks töömahukas, lisaks suureneb vigade oht, kuna koodi muutmisel võite unustada objekte täpsustada.

Tahtsin veenduda, et ma ei kasuta liideseid, vaid et kõik toimis iseenesest.

Kui tahame, et miski töötaks iseenesest, peame kasutama peegeldust. Selleks saame läbida iga klassivälja ja kontrollida, kas tegemist on Realmi objektiga või objektide loendiga:

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

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

Kui väli on RealmModel või RealmList, lisage selle välja objekt pesastatud objektide loendisse. Kõik on täpselt sama, nagu eespool, ainult siin tehakse seda iseenesest. Kaskaadi kustutamise meetod ise on väga lihtne ja näeb välja järgmine:

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()
         }
       }
 }
}

Laiendus filterRealmObject filtreerib välja ja edastab ainult Realmi objekte. meetod getNestedRealmObjects peegelduse kaudu leiab see kõik pesastatud Realmi objektid ja paneb need lineaarsesse loendisse. Seejärel teeme sama asja rekursiivselt. Kustutamisel peate kontrollima objekti kehtivust isValid, sest võib juhtuda, et erinevatel põhiobjektidel võivad olla pesastatud identsed objektid. Parem on seda vältida ja kasutada uute objektide loomisel lihtsalt ID automaatset genereerimist.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

GetNestedRealmObjectsi meetodi täielik rakendamine

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)
}

Selle tulemusena kasutame oma kliendikoodis iga andmete muutmise toimingu jaoks kaskaadkustutamist. Näiteks sisestustoimingu puhul näeb see välja järgmine:

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 }
 ))
}

Meetod kõigepealt getManagedEntities võtab vastu kõik lisatud objektid ja seejärel meetodi cascadeDelete Kustutab rekursiivselt kõik kogutud objektid enne uute kirjutamist. Lõppkokkuvõttes kasutame seda lähenemisviisi kogu rakenduse vältel. Mälulekked Realmis on täielikult kadunud. Pärast sama mõõtmist käivitusaja sõltuvuse kohta rakenduse külmkäivituste arvust, näeme tulemust.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Roheline joon näitab rakenduse käivitusaja sõltuvust külmkäivituste arvust pesastatud objektide automaatse kaskaadi kustutamise ajal.

Tulemused ja järeldused

Pidevalt kasvav Realmi andmebaas põhjustas rakenduse väga aeglase käivitumise. Andsime välja värskenduse, mis sisaldab pesastatud objektide "kaskaadkustutamist". Nüüd jälgime ja hindame mõõdiku _app_start kaudu, kuidas meie otsus rakenduse käivitusaega mõjutas.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Analüüsiks võtame 90-päevase ajavahemiku ja näeme: rakenduse käivitamise aeg, nii mediaan kui ka see, mis langeb kasutajate 95. protsentiilile, hakkas vähenema ega tõuse enam.

Lugu sellest, kuidas kaskaadkustutamine Realmis võitis pika käivitamise

Kui vaatate seitsmepäevast diagrammi, näib _app_start mõõdik täiesti piisav ja on alla 1 sekundi.

Samuti tasub lisada, et vaikimisi saadab Firebase märguandeid, kui _app_start mediaanväärtus ületab 5 sekundit. Kuid nagu näeme, ei tohiks te sellele lootma jääda, vaid pigem minge sisse ja kontrollige seda selgesõnaliselt.

Realmi andmebaasi eripära on see, et see on mitterelatsiooniline andmebaas. Vaatamata kasutuslihtsusele, sarnasusele ORM-lahendustega ja objektide linkimisele ei ole sellel kaskaadkustutust.

Kui seda ei võeta arvesse, kogunevad pesastatud objektid ja need lekivad. Andmebaas kasvab pidevalt, mis omakorda mõjutab rakenduse aeglustumist või käivitamist.

Jagasin meie kogemust, kuidas kiiresti teha Realmis objektide kaskaadkustutamist, mis pole küll veel karbist väljas, kuid millest on juba ammu räägitud ütlema и ütlema. Meie puhul kiirendas see oluliselt rakenduse käivitusaega.

Vaatamata arutelule selle funktsiooni peatse ilmumise üle, on Realmis kaskaadkustutuse puudumine tehtud disainiga. Kui kujundate uut rakendust, siis arvestage sellega. Ja kui te juba kasutate Realmi, kontrollige, kas teil on selliseid probleeme.

Allikas: www.habr.com

Lisa kommentaar