Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Svi korisnici brzo pokretanje i odzivno korisničko sučelje u mobilnim aplikacijama uzimaju zdravo za gotovo. Ako aplikacija traje dugo da se pokrene, korisnik se počinje osjećati tužno i ljutito. Možete lako pokvariti korisničko iskustvo ili potpuno izgubiti korisnika čak i prije nego što počne koristiti aplikaciju.

Jednom smo otkrili da aplikaciji Dodo Pizza u prosjeku treba 3 sekunde da se pokrene, a nekim "sretnicima" potrebno je 15-20 sekundi.

Ispod reza je priča sa sretnim završetkom: o rastu Realm baze podataka, curenju memorije, kako smo akumulirali ugniježđene objekte, a onda se sabrali i sve popravili.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma
Autor članka: Maxim Kachinkin — Android programer u Dodo Pizza.

Tri sekunde od klika na ikonu aplikacije do onResume() prve aktivnosti je beskonačno. A za neke korisnike, vrijeme pokretanja dostiglo je 15-20 sekundi. Kako je to uopće moguće?

Vrlo kratak rezime za one koji nemaju vremena za čitanje
Naša baza podataka Realm je beskrajno rasla. Neki ugniježđeni objekti nisu izbrisani, već su se stalno akumulirali. Vrijeme pokretanja aplikacije postepeno se povećavalo. Zatim smo to popravili i vrijeme pokretanja je došlo do cilja - postalo je manje od 1 sekunde i više se nije povećavalo. Članak sadrži analizu situacije i dva rješenja – brzo i normalno.

Pretraga i analiza problema

Danas se svaka mobilna aplikacija mora brzo pokrenuti i biti brza. Ali ne radi se samo o mobilnoj aplikaciji. Korisničko iskustvo interakcije sa servisom i kompanijom je složena stvar. Na primjer, u našem slučaju, brzina isporuke je jedan od ključnih pokazatelja za uslugu pizze. Ako je dostava brza, pizza će biti vruća, a kupac koji sada želi jesti neće morati dugo čekati. Za aplikaciju je, pak, važno stvoriti osjećaj brze usluge, jer ako aplikaciji treba samo 20 sekundi da se pokrene, koliko ćete onda morati čekati na pizzu?

U početku smo i sami bili suočeni s činjenicom da je ponekad aplikaciji trebalo nekoliko sekundi da se pokrene, a onda smo počeli čuti pritužbe drugih kolega koliko je dugo trajalo. Ali nismo bili u mogućnosti da dosljedno ponavljamo ovu situaciju.

Koliko je to dugo? Prema Google dokumentacija, ako hladno pokretanje aplikacije traje manje od 5 sekundi, to se smatra „kao da je normalno“. Pokrenuta Android aplikacija Dodo Pizza (prema Firebase metrikama _app_start) u hladan start u prosjeku za 3 sekunde - "Nije sjajno, nije strašno", kako kažu.

Ali onda su se počele pojavljivati ​​pritužbe da je aplikaciji trebalo jako, jako, jako dugo da se pokrene! Za početak, odlučili smo da izmjerimo šta je "veoma, vrlo, vrlo dugo". Za ovo smo koristili Firebase praćenje Praćenje pokretanja aplikacije.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Ovo standardno praćenje mjeri vrijeme između trenutka kada korisnik otvori aplikaciju i trenutka kada se izvrši onResume() prve aktivnosti. U Firebase konzoli ova metrika se zove _app_start. Ispostavilo se da:

  • Vrijeme pokretanja za korisnike iznad 95. percentila je skoro 20 sekundi (neki čak i duže), uprkos tome što je srednje vrijeme hladnog pokretanja manje od 5 sekundi.
  • Vrijeme pokretanja nije konstantna vrijednost, već vremenom raste. Ali ponekad ima kapi. Našli smo ovaj obrazac kada smo povećali skalu analize na 90 dana.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Pale su mi na pamet dvije misli:

  1. Nešto curi.
  2. Ovo "nešto" se resetuje nakon puštanja i onda ponovo curi.

„Vjerovatno nešto sa bazom podataka“, pomislili smo i bili smo u pravu. Prvo koristimo bazu podataka kao keš memoriju; tokom migracije je brišemo. Drugo, baza podataka se učitava kada se aplikacija pokrene. Sve se uklapa.

Šta nije u redu sa bazom podataka Realm

Počeli smo da proveravamo kako se sadržaj baze podataka menja tokom životnog veka aplikacije, od prve instalacije pa dalje tokom aktivnog korišćenja. Možete pogledati sadržaj baze podataka Realm putem stetho ili detaljnije i jasnije otvaranjem datoteke putem Realm Studio. Da vidite sadržaj baze podataka putem ADB-a, kopirajte datoteku baze podataka Realm:

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

Promatrajući sadržaj baze podataka u različito vrijeme, ustanovili smo da se broj objekata određene vrste stalno povećava.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma
Na slici je prikazan fragment Realm Studio-a za dva fajla: sa leve strane - baza aplikacije neko vreme nakon instalacije, sa desne strane - nakon aktivne upotrebe. Vidi se da je broj objekata ImageEntity и MoneyType je značajno porastao (snimak ekrana pokazuje broj objekata svake vrste).

Odnos između rasta baze podataka i vremena pokretanja

Nekontrolisani rast baze podataka je veoma loš. Ali kako to utiče na vrijeme pokretanja aplikacije? To je prilično lako izmjeriti kroz ActivityManager. Od Androida 4.4, logcat prikazuje dnevnik sa stringom Displayed i vremenom. Ovo vrijeme je jednako intervalu od trenutka pokretanja aplikacije do kraja prikazivanja aktivnosti. Tokom ovog perioda dešavaju se sledeći događaji:

  • Započnite proces.
  • Inicijalizacija objekata.
  • Kreiranje i inicijalizacija aktivnosti.
  • Kreiranje izgleda.
  • Rendering aplikacije.

Odgovara nam. Ako pokrenete ADB sa -S i -W zastavicama, možete dobiti prošireni izlaz s vremenom pokretanja:

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

Ako ga zgrabiš odatle grep -i WaitTime vrijeme, možete automatizirati prikupljanje ove metrike i vizualno pogledati rezultate. Grafikon ispod prikazuje zavisnost vremena pokretanja aplikacije od broja hladnih pokretanja aplikacije.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Istovremeno, postojala je ista priroda odnosa između veličine i rasta baze podataka, koja je porasla sa 4 MB na 15 MB. Ukupno se ispostavilo da su se vremenom (s porastom hladnih pokretanja) povećali i vrijeme pokretanja aplikacije i veličina baze podataka. Imamo hipotezu. Sada je preostalo samo da se potvrdi zavisnost. Stoga smo odlučili da uklonimo „curenja“ i vidimo hoće li to ubrzati lansiranje.

Razlozi za beskrajni rast baze podataka

Prije uklanjanja "curenja", vrijedi razumjeti zašto su se uopće pojavili. Da bismo to učinili, prisjetimo se šta je Realm.

Realm je nerelaciona baza podataka. Omogućava vam da opišete odnose između objekata na sličan način kao što je opisano koliko je ORM relacijskih baza podataka na Androidu. U isto vrijeme, Realm pohranjuje objekte direktno u memoriju uz najmanju količinu transformacija i mapiranja. Ovo vam omogućava da vrlo brzo čitate podatke sa diska, što je snaga Realma i zašto ga vole.

(Za potrebe ovog članka, ovaj opis će nam biti dovoljan. Više o Realmu možete pročitati na hladnom dokumentaciju ili u njihovom akademija).

Mnogi programeri su navikli više raditi s relacijskim bazama podataka (na primjer, ORM baze podataka sa SQL-om ispod haube). A stvari kao što je kaskadno brisanje podataka često izgledaju kao date. Ali ne u Carstvu.

Usput, funkcija kaskadnog brisanja je tražena već duže vrijeme. Ovo revizija и drugi, vezano za to, aktivno se raspravljalo. Postojao je osjećaj da će to uskoro biti učinjeno. No, onda se sve pretočilo u uvođenje jakih i slabih karika, što bi također automatski riješilo ovaj problem. Bio je prilično živ i aktivan na ovom zadatku pull request, koji je za sada pauziran zbog unutrašnjih poteškoća.

Curenje podataka bez kaskadnog brisanja

Kako tačno dolazi do curenja podataka ako se oslanjate na nepostojeće kaskadno brisanje? Ako imate ugniježđene objekte Realm, onda ih morate izbrisati.
Pogledajmo (skoro) pravi primjer. Imamo objekat 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()

Proizvod u korpi ima različita polja, uključujući i sliku ImageEntity, prilagođeni sastojci CustomizationEntity. Takođe, proizvod u korpi može biti kombinacija sa sopstvenim setom proizvoda RealmList (CartProductEntity). Sva navedena polja su objekti Realm. Ako ubacimo novi objekat (copyToRealm() / copyToRealmOrUpdate()) sa istim ID-om, onda će ovaj objekat biti potpuno prepisan. Ali svi interni objekti (image, customizationEntity i cartComboProducts) će izgubiti vezu s roditeljem i ostati u bazi podataka.

Pošto je veza s njima izgubljena, više ih ne čitamo niti brišemo (osim ako im eksplicitno ne pristupimo ili obrišemo cijelu „tabelu“). To smo nazvali "curenjem memorije".

Kada radimo sa Realm-om, moramo eksplicitno proći kroz sve elemente i eksplicitno sve izbrisati prije takvih operacija. To se može učiniti, na primjer, ovako:

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

Ako to uradite, onda će sve raditi kako treba. U ovom primjeru pretpostavljamo da nema drugih ugniježđenih Realm objekata unutar image, customizationEntity i cartComboProducts, tako da nema drugih ugniježđenih petlji i brisanja.

"Brzo" rešenje

Prva stvar koju smo odlučili je da očistimo najbrže rastuće objekte i provjerimo rezultate da vidimo hoće li to riješiti naš prvobitni problem. Prvo je napravljeno najjednostavnije i najintuitivnije rješenje, naime: svaki objekt treba biti odgovoran za uklanjanje svoje djece. Da bismo to učinili, uveli smo sučelje koje je vraćalo listu svojih ugniježđenih Realm objekata:

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

I implementirali smo ga u naše Realm objekte:

@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 vraćamo svu djecu kao paušalnu listu. Svaki podređeni objekt također može implementirati NestedEntityAware sučelje, što ukazuje da ima interne Realm objekte za brisanje, na primjer 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
   )
 }
}

I tako dalje, ugniježđenje objekata se može ponoviti.

Zatim pišemo metodu koja rekurzivno briše sve ugniježđene objekte. Metoda (napravljena kao proširenje) deleteAllNestedEntities dobiva sve objekte i metode najviše razine deleteNestedRecursively Rekurzivno uklanja sve ugniježđene objekte koristeći NestedEntityAware sučelje:

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

To smo uradili sa najbrže rastućim objektima i proverili šta se dogodilo.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Kao rezultat toga, oni objekti koje smo prekrili ovim rješenjem prestali su rasti. I ukupni rast baze je usporen, ali nije stao.

"normalno" rešenje

Iako je baza počela sporije rasti, ipak je rasla. Tako da smo počeli tražiti dalje. Naš projekat veoma aktivno koristi keširanje podataka u Realmu. Stoga je pisanje svih ugniježđenih objekata za svaki objekt naporno, plus rizik od grešaka se povećava, jer možete zaboraviti navesti objekte prilikom promjene koda.

Hteo sam da budem siguran da ne koristim interfejse, već da sve radi samo od sebe.

Kada želimo da nešto radi samo od sebe, moramo koristiti refleksiju. Da bismo to učinili, možemo proći kroz svako polje klase i provjeriti je li to Realm objekt ili lista objekata:

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

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

Ako je polje RealmModel ili RealmList, dodajte objekt ovog polja na listu ugniježđenih objekata. Sve je potpuno isto kao što smo uradili gore, samo što će se ovde to uraditi samo od sebe. Sama metoda kaskadnog brisanja je vrlo jednostavna i izgleda ovako:

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

Produžetak filterRealmObject filtrira i prosljeđuje samo Realm objekte. Metoda getNestedRealmObjects kroz refleksiju, pronalazi sve ugniježđene objekte Realm i stavlja ih u linearnu listu. Zatim radimo istu stvar rekurzivno. Prilikom brisanja potrebno je provjeriti valjanost objekta isValid, jer može biti da različiti roditeljski objekti mogu imati ugniježđene identične. Bolje je to izbjeći i jednostavno koristiti automatsko generiranje id-a prilikom kreiranja novih objekata.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Potpuna implementacija metode 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)
}

Kao rezultat toga, u našem klijentskom kodu koristimo “kaskadno brisanje” za svaku operaciju modifikacije podataka. Na primjer, za operaciju umetanja to izgleda ovako:

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

Metoda prva getManagedEntities prima sve dodane objekte, a zatim i metodu cascadeDelete Rekurzivno briše sve prikupljene objekte prije pisanja novih. Ovaj pristup koristimo u cijeloj aplikaciji. Curenje memorije u Realmu je potpuno nestalo. Nakon što smo izvršili isto mjerenje ovisnosti vremena pokretanja od broja hladnih pokretanja aplikacije, vidimo rezultat.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Zelena linija pokazuje zavisnost vremena pokretanja aplikacije od broja hladnih pokretanja tokom automatskog kaskadnog brisanja ugniježđenih objekata.

Rezultati i zaključci

Sve veća baza podataka Realm-a je uzrokovala da se aplikacija vrlo sporo pokreće. Izdali smo ažuriranje s našim vlastitim "kaskadnim brisanjem" ugniježđenih objekata. A sada pratimo i procjenjujemo kako je naša odluka utjecala na vrijeme pokretanja aplikacije putem metrike _app_start.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Za analizu uzimamo vremenski period od 90 dana i vidimo: vrijeme pokretanja aplikacije, i srednje i ono koje pada na 95. percentil korisnika, počelo je da se smanjuje i više ne raste.

Priča o tome kako je pobijedilo kaskadno brisanje u dugom lansiranju Realma

Ako pogledate sedmodnevni grafikon, metrika _app_start izgleda potpuno adekvatna i manja je od 1 sekunde.

Također je vrijedno dodati da Firebase prema zadanim postavkama šalje obavještenja ako srednja vrijednost _app_start prelazi 5 sekundi. Međutim, kao što vidimo, ne biste se trebali oslanjati na ovo, već radije uđite i eksplicitno provjerite.

Posebna stvar u vezi sa bazom podataka Realm je to što je to nerelaciona baza podataka. Uprkos jednostavnosti upotrebe, sličnosti sa ORM rešenjima i povezivanju objekata, nema kaskadno brisanje.

Ako se to ne uzme u obzir, tada će se ugniježđeni objekti akumulirati i „iscuriti“. Baza podataka će stalno rasti, što će zauzvrat utjecati na usporavanje ili pokretanje aplikacije.

Podijelio sam naše iskustvo o tome kako brzo napraviti kaskadno brisanje objekata u Realmu, što još nije izašlo iz kutije, ali se o tome već dugo priča kažu и kažu. U našem slučaju, ovo je uvelike ubrzalo vrijeme pokretanja aplikacije.

Uprkos raspravi o skoroj pojavi ove karakteristike, odsustvo kaskadnog brisanja u Realm-u je napravljeno dizajnom. Ako dizajnirate novu aplikaciju, uzmite to u obzir. A ako već koristite Realm, provjerite imate li takvih problema.

izvor: www.habr.com

Dodajte komentar