Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Všetci používatelia považujú rýchle spustenie a responzívne používateľské rozhranie v mobilných aplikáciách za samozrejmosť. Ak aplikácia trvá dlho, kým sa spustí, používateľ začne byť smutný a nahnevaný. Môžete ľahko pokaziť zákaznícku skúsenosť alebo úplne stratiť používateľa ešte skôr, ako začne aplikáciu používať.

Raz sme zistili, že spustenie aplikácie Dodo Pizza trvá v priemere 3 sekundy a niektorým „šťastlivcom“ to trvá 15 – 20 sekúnd.

Pod strihom je príbeh so šťastným koncom: o raste databázy Realm, úniku pamäte, ako sme hromadili vnorené objekty a potom sme sa dali dokopy a všetko opravili.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením
Autor článku: Maxim Kačinkin — Android developer v Dodo Pizza.

Tri sekundy od kliknutia na ikonu aplikácie po onResume() prvej aktivity sú nekonečné. A pre niektorých používateľov čas spustenia dosiahol 15-20 sekúnd. Ako je to vôbec možné?

Veľmi krátke zhrnutie pre tých, ktorí nemajú čas čítať
Naša databáza Realm nekonečne rástla. Niektoré vnorené objekty neboli vymazané, ale neustále sa hromadili. Čas spustenia aplikácie sa postupne predlžoval. Potom sme to opravili a čas spustenia prišiel k cieľu - bol kratší ako 1 sekunda a už sa nezvyšoval. Článok obsahuje rozbor situácie a dve riešenia – rýchle a normálne.

Hľadanie a analýza problému

Každá mobilná aplikácia sa dnes musí spúšťať rýchlo a musí reagovať. Nie je to však len o mobilnej aplikácii. Používateľská skúsenosť s interakciou so službou a spoločnosťou je komplexná vec. Napríklad v našom prípade je rýchlosť doručenia jedným z kľúčových ukazovateľov pre pizzeriu. Ak je dodávka rýchla, pizza bude horúca a zákazník, ktorý sa chce najesť teraz, nebude musieť dlho čakať. Pre aplikáciu je zasa dôležité navodiť pocit rýchlej obsluhy, pretože ak spustenie aplikácie trvá len 20 sekúnd, tak ako dlho budete čakať na pizzu?

Najprv sme sa sami stretávali s tým, že niekedy spustenie aplikácie trvalo pár sekúnd, a potom sme začali počuť sťažnosti od ostatných kolegov, ako dlho to trvalo. Túto situáciu sme však nedokázali dôsledne zopakovať.

Aké je to dlhé? Podľa Dokumentácia Google, ak studený štart aplikácie trvá menej ako 5 sekúnd, potom sa to považuje za „ako keby normálne“. Bola spustená aplikácia Dodo Pizza pre Android (podľa metrík Firebase _app_start) pri studený štart v priemere za 3 sekundy - „Nie je to skvelé, nie je to hrozné,“ ako sa hovorí.

Potom sa však začali objavovať sťažnosti, že spustenie aplikácie trvalo veľmi, veľmi, veľmi dlho! Na začiatok sme sa rozhodli zmerať, čo je „veľmi, veľmi, veľmi dlho“. A na to sme použili Firebase trace Sledovanie spustenia aplikácie.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Toto štandardné sledovanie meria čas medzi okamihom, keď používateľ otvorí aplikáciu, a okamihom, keď sa vykoná onResume() prvej aktivity. V konzole Firebase sa táto metrika nazýva _app_start. Ukázalo sa, že:

  • Časy spustenia pre používateľov nad 95. percentilom sú takmer 20 sekúnd (niektorí dokonca dlhšie), napriek tomu, že stredný čas spustenia za studena je menej ako 5 sekúnd.
  • Čas spustenia nie je konštantná hodnota, ale časom rastie. Ale niekedy tam sú kvapky. Tento vzor sme našli, keď sme zvýšili rozsah analýzy na 90 dní.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Napadli ma dve myšlienky:

  1. Niečo vyteká.
  2. Toto „niečo“ sa po uvoľnení resetuje a potom znova vytečie.

"Pravdepodobne niečo s databázou," pomysleli sme si a mali sme pravdu. V prvom rade používame databázu ako vyrovnávaciu pamäť, pri migrácii ju vymažeme. Po druhé, databáza sa načíta pri spustení aplikácie. Všetko do seba zapadá.

Čo je zlé na databáze Realm

Začali sme kontrolovať, ako sa mení obsah databázy počas životnosti aplikácie, od prvej inštalácie a ďalej počas aktívneho používania. Obsah databázy Realm si môžete pozrieť cez stetho alebo podrobnejšie a prehľadnejšie otvorením súboru cez Realm Studio. Ak chcete zobraziť obsah databázy cez ADB, skopírujte databázový súbor Realm:

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

Pri pohľade na obsah databázy v rôznych časoch sme zistili, že počet objektov určitého typu neustále narastá.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením
Obrázok ukazuje fragment Realm Studio pre dva súbory: vľavo - aplikačná základňa nejaký čas po inštalácii, vpravo - po aktívnom používaní. Je vidieť, že počet objektov ImageEntity и MoneyType výrazne narástol (snímka obrazovky zobrazuje počet objektov každého typu).

Vzťah medzi rastom databázy a časom spustenia

Nekontrolovaný rast databázy je veľmi zlý. Ako to však ovplyvní čas spustenia aplikácie? Je to celkom jednoduché merať to cez ActivityManager. Od Androidu 4.4 logcat zobrazuje protokol s reťazcom Displayed a časom. Tento čas sa rovná intervalu od okamihu spustenia aplikácie do konca vykresľovania aktivity. Počas tejto doby sa vyskytnú tieto udalosti:

  • Spustite proces.
  • Inicializácia objektov.
  • Vytváranie a inicializácia aktivít.
  • Vytvorenie rozloženia.
  • Vykresľovanie aplikácie.

Vyhovuje nám. Ak spustíte ADB s príznakmi -S a -W, môžete získať rozšírený výstup s časom spustenia:

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

Ak to vezmete odtiaľ grep -i WaitTime čas, môžete automatizovať zhromažďovanie tejto metriky a vizuálne sa pozrieť na výsledky. Nižšie uvedený graf ukazuje závislosť času spustenia aplikácie od počtu studených štartov aplikácie.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Zároveň bol rovnaký charakter vzťahu medzi veľkosťou a rastom databázy, ktorá narástla zo 4 MB na 15 MB. Celkovo sa ukazuje, že časom (s rastom studených štartov) sa zväčšil čas spustenia aplikácie aj veľkosť databázy. Máme v rukách hypotézu. Teraz zostávalo už len potvrdiť závislosť. Preto sme sa rozhodli odstrániť „úniky“ a zistiť, či to urýchli spustenie.

Dôvody nekonečného rastu databázy

Pred odstránením „únikov“ stojí za to pochopiť, prečo sa objavili na prvom mieste. Aby sme to dosiahli, spomeňme si, čo je Realm.

Realm je nerelačná databáza. Umožňuje opísať vzťahy medzi objektmi podobným spôsobom, ako je popísaných množstvo relačných databáz ORM v systéme Android. Realm zároveň ukladá objekty priamo do pamäte s najmenším počtom transformácií a mapovaní. To vám umožní čítať dáta z disku veľmi rýchlo, čo je sila Realmu a prečo je tak obľúbený.

(Na účely tohto článku nám postačí tento popis. Viac o Realm si môžete prečítať v pohode dokumentáciu alebo v ich akadémie).

Mnoho vývojárov je zvyknutých viac pracovať s relačnými databázami (napríklad ORM databázy s SQL pod kapotou). A veci ako kaskádové odstraňovanie údajov sa často zdajú byť samozrejmosťou. Ale nie v Ríše.

Mimochodom, funkcia odstránenia kaskády bola žiadaná už dlho. Toto revízia и ďalší, s tým spojené, sa aktívne diskutovalo. Bol tu pocit, že to bude čoskoro hotové. Potom sa však všetko pretavilo do zavedenia silných a slabých článkov, ktoré by automaticky vyriešili aj tento problém. Pri tejto úlohe bol dosť živý a aktívny vytiahnuť žiadosť, ktorá je nateraz pre interné ťažkosti pozastavená.

Únik údajov bez kaskádového vymazania

Ako presne dôjde k úniku údajov, ak sa spoliehate na neexistujúce kaskádové vymazanie? Ak ste vnorili objekty Realm, musíte ich odstrániť.
Pozrime sa na (takmer) reálny príklad. Máme 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()

Produkt v košíku má rôzne polia vrátane obrázka ImageEntity, prispôsobené ingrediencie CustomizationEntity. Produkt v košíku môže byť aj kombináciou s vlastnou sadou produktov RealmList (CartProductEntity). Všetky uvedené polia sú objekty Realm. Ak vložíme nový objekt (copyToRealm() / copyToRealmOrUpdate()) s rovnakým ID, potom sa tento objekt úplne prepíše. Ale všetky interné objekty (image, customizationEntity a cartComboProducts) stratia spojenie s rodičom a zostanú v databáze.

Keďže sa spojenie s nimi stratí, už ich nečítame ani nemažeme (pokiaľ k nim výslovne nepristúpime alebo nevymažeme celú „tabuľku“). Nazvali sme to „úniky pamäte“.

Keď pracujeme s Realmom, musíme pred takýmito operáciami vyslovene prejsť všetkými prvkami a vyslovene všetko vymazať. Dá sa to urobiť napríklad takto:

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

Ak to urobíte, všetko bude fungovať tak, ako má. V tomto príklade predpokladáme, že vo vnútri image, customizationEntity a cartComboProducts nie sú žiadne ďalšie vnorené objekty Realm, takže neexistujú žiadne ďalšie vnorené slučky a vymazania.

"Rýchle" riešenie

Prvá vec, ktorú sme sa rozhodli urobiť, bolo vyčistiť najrýchlejšie rastúce objekty a skontrolovať výsledky, či to vyrieši náš pôvodný problém. Najprv bolo urobené najjednoduchšie a najintuitívnejšie riešenie, konkrétne: každý objekt by mal byť zodpovedný za odstránenie svojich detí. Na tento účel sme zaviedli rozhranie, ktoré vrátilo zoznam svojich vnorených objektov Realm:

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

A implementovali sme to do našich objektov 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 vrátime všetky deti ako plochý zoznam. A každý podriadený objekt môže tiež implementovať rozhranie NestedEntityAware, čo naznačuje, že má interné objekty Realm na odstránenie, napr. 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
   )
 }
}

A tak ďalej, hniezdenie predmetov sa môže opakovať.

Potom napíšeme metódu, ktorá rekurzívne vymaže všetky vnorené objekty. Metóda (vyrobená ako rozšírenie) deleteAllNestedEntities získa všetky objekty a metódy najvyššej úrovne deleteNestedRecursively Rekurzívne odstráni všetky vnorené objekty pomocou rozhrania 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()
   }
 }
}

Urobili sme to s najrýchlejšie rastúcimi objektmi a skontrolovali, čo sa stalo.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

V dôsledku toho tie objekty, ktoré sme zakryli týmto riešením, prestali rásť. A celkový rast základne sa spomalil, no nezastavil.

"Normálne" riešenie

Baza síce začala rásť pomalšie, no stále rástla. Začali sme teda hľadať ďalej. Náš projekt veľmi aktívne využíva ukladanie dát do vyrovnávacej pamäte v Realme. Preto je zápis všetkých vnorených objektov pre každý objekt náročný na prácu a zvyšuje sa riziko chýb, pretože pri zmene kódu môžete zabudnúť na špecifikáciu objektov.

Chcel som sa uistiť, že nepoužívam rozhrania, ale že všetko funguje samo.

Keď chceme, aby niečo fungovalo samo, musíme použiť reflexiu. Aby sme to dosiahli, môžeme prejsť cez každé pole triedy a skontrolovať, či ide o objekt Realm alebo zoznam objektov:

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

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

Ak je pole RealmModel alebo RealmList, potom pridajte objekt tohto poľa do zoznamu vnorených objektov. Všetko je úplne rovnaké, ako sme to urobili vyššie, len tu sa to urobí samo. Samotná metóda kaskádového odstránenia je veľmi jednoduchá a vyzerá takto:

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

Rozšírenie filterRealmObject filtruje a prepúšťa iba objekty Realm. Metóda getNestedRealmObjects prostredníctvom odrazu nájde všetky vnorené objekty Realm a umiestni ich do lineárneho zoznamu. Potom robíme to isté rekurzívne. Pri odstraňovaní je potrebné skontrolovať platnosť objektu isValid, pretože sa môže stať, že rôzne rodičovské objekty môžu mať vnorené rovnaké. Je lepšie sa tomu vyhnúť a pri vytváraní nových objektov jednoducho použiť automatické generovanie id.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Plná implementácia metódy 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)
}

V dôsledku toho v našom klientskom kóde používame „kaskádové mazanie“ pre každú operáciu úpravy údajov. Napríklad operácia vloženia vyzerá takto:

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

Najprv metóda getManagedEntities prijíma všetky pridané objekty a potom metódu cascadeDelete Rekurzívne vymaže všetky zhromaždené objekty pred napísaním nových. Tento prístup nakoniec používame počas celej aplikácie. Úniky pamäte v Realme sú úplne preč. Po vykonaní rovnakého merania závislosti času spustenia od počtu studených štartov aplikácie vidíme výsledok.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Zelená čiara zobrazuje závislosť času spustenia aplikácie od počtu studených štartov pri automatickom kaskádovom odstraňovaní vnorených objektov.

Výsledky a závery

Neustále rastúca databáza Realm spôsobovala, že sa aplikácia spúšťala veľmi pomaly. Vydali sme aktualizáciu s vlastným „kaskádovým odstránením“ vnorených objektov. A teraz monitorujeme a vyhodnocujeme, ako naše rozhodnutie ovplyvnilo čas spustenia aplikácie prostredníctvom metriky _app_start.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Na analýzu vezmeme časové obdobie 90 dní a vidíme: čas spustenia aplikácie, medián aj čas, ktorý pripadá na 95. percentil používateľov, sa začal znižovať a už viac nerastie.

Príbeh o tom, ako kaskádové vymazanie v Realme zvíťazilo nad dlhým spustením

Ak sa pozriete na sedemdňový graf, metrika _app_start vyzerá úplne adekvátne a je kratšia ako 1 sekunda.

Je tiež potrebné dodať, že Firebase štandardne odosiela upozornenia, ak stredná hodnota _app_start presiahne 5 sekúnd. Ako však vidíme, nemali by ste sa na to spoliehať, ale radšej ísť do toho a výslovne si to skontrolovať.

Špeciálna vec na databáze Realm je, že je to nerelačná databáza. Napriek jednoduchosti použitia, podobnosti s riešeniami ORM a prepájaniu objektov nemá kaskádové mazanie.

Ak sa to neberie do úvahy, potom sa vnorené objekty hromadia a „unikajú“. Databáza bude neustále rásť, čo následne ovplyvní spomalenie alebo štart aplikácie.

Podelil som sa o naše skúsenosti, ako rýchlo urobiť kaskádové mazanie objektov v Realme, ktoré ešte nie je vybalené, ale hovorí sa o ňom už dlho povedať и povedať. V našom prípade to výrazne urýchlilo čas spustenia aplikácie.

Napriek diskusii o bezprostrednom výskyte tejto funkcie je absencia kaskádového odstránenia v Realme vykonaná zámerne. Ak navrhujete novú aplikáciu, berte to do úvahy. A ak už používate Realm, skontrolujte, či nemáte takéto problémy.

Zdroj: hab.com

Pridať komentár