Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Svi korisnici brzo pokretanje i osjetljivo korisničko sučelje u mobilnim aplikacijama uzimaju zdravo za gotovo. Ako se aplikacija dugo pokreće, korisnik počinje osjećati tugu i ljutnju. Vrlo lako možete pokvariti korisničko iskustvo ili potpuno izgubiti korisnika i prije nego počne koristiti aplikaciju.

Jednom smo otkrili da aplikaciji Dodo Pizza u prosjeku treba 3 sekunde za pokretanje, a nekim “sretnicima” 15-20 sekundi.

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

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje
Autor članka: Maksim Kačinkin — Android programer u Dodo Pizza.

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

Vrlo kratak sažetak za one koji nemaju vremena za čitanje
Naša Realm baza podataka beskrajno je rasla. Neki ugniježđeni objekti nisu izbrisani, ali su se stalno gomilali. Vrijeme pokretanja aplikacije postupno se povećavalo. Zatim smo to popravili i vrijeme pokretanja došlo je 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.

Traženje i analiza problema

Danas se svaka mobilna aplikacija mora pokrenuti brzo i brzo reagirati. Ali ne radi se samo o mobilnoj aplikaciji. Korisničko iskustvo interakcije s uslugom i tvrtkom kompleksna je stvar. Na primjer, u našem slučaju brzina dostave jedan je 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 se i sami suočili s činjenicom da je ponekad aplikaciji trebalo nekoliko sekundi da se pokrene, a onda smo počeli čuti pritužbe drugih kolega na to koliko je to trajalo. Ali nismo uspjeli dosljedno ponavljati ovu situaciju.

Koliko je dugačko? Prema Google dokumentacija, ako hladno pokretanje aplikacije traje manje od 5 sekundi, to se smatra "kao normalnim". Pokrenuta Android aplikacija Dodo Pizza (prema Firebase metrici _app_start) na hladni 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 smo odlučili izmjeriti što je "jako, jako, jako dugo". I za ovo smo koristili Firebase trace Trag pokretanja aplikacije.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

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 se metrika naziva _app_start. Ispostavilo se da:

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

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Dvije su mi misli pale na pamet:

  1. Nešto curi.
  2. Ovo "nešto" se resetira nakon otpuštanja i zatim ponovno curi.

“Vjerojatno nešto s bazom podataka”, pomislili smo i bili smo u pravu. Prvo, bazu podataka koristimo kao predmemoriju; tijekom migracije je brišemo. Drugo, baza podataka se učitava kada se aplikacija pokrene. Sve se slaže.

Što nije u redu s bazom podataka Realma

Počeli smo provjeravati kako se sadržaj baze podataka mijenja tijekom trajanja aplikacije, od prve instalacije pa dalje tijekom aktivnog korištenja. Možete vidjeti sadržaj baze podataka Realm putem Steto ili detaljnije i preglednije otvaranjem datoteke putem Realm Studio. Da biste pregledali sadržaj baze podataka putem ADB-a, kopirajte datoteku baze podataka Realm:

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

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

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje
Slika prikazuje fragment Realm Studio za dvije datoteke: lijevo - baza aplikacije neko vrijeme nakon instalacije, desno - nakon aktivne upotrebe. Vidljivo je da broj objekata ImageEntity и MoneyType je značajno porastao (snimka zaslona prikazuje broj objekata svake vrste).

Odnos između rasta baze podataka i vremena pokretanja

Nekontrolirani rast baze podataka je vrlo loš. Ali kako to utječe na vrijeme pokretanja aplikacije? Vrlo je lako to izmjeriti putem ActivityManagera. Od Androida 4.4, logcat prikazuje dnevnik s prikazanim nizom i vremenom. Ovo vrijeme je jednako intervalu od trenutka pokretanja aplikacije do kraja renderiranja aktivnosti. Tijekom tog vremena događaju se sljedeći događaji:

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

Odgovara nam. Ako pokrenete ADB sa zastavicama -S i -W, 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 odande zgrabite grep -i WaitTime vremena, možete automatizirati prikupljanje ove metrike i vizualno pogledati rezultate. Donji grafikon prikazuje ovisnost vremena pokretanja aplikacije o broju hladnih pokretanja aplikacije.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Istodobno je postojala ista priroda odnosa između veličine i rasta baze podataka, koja je narasla s 4 MB na 15 MB. Ukupno se pokazalo da se s vremenom (s porastom hladnih pokretanja) povećavalo i vrijeme pokretanja aplikacije i veličina baze podataka. Imamo hipotezu u rukama. Sada je preostalo samo potvrditi ovisnost. Stoga smo odlučili ukloniti "curenja" i vidjeti hoće li to ubrzati lansiranje.

Razlozi za beskrajni rast baze podataka

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

Realm je nerelacijska baza podataka. Omogućuje vam da opišete odnose između objekata na sličan način na koji su opisane mnoge ORM relacijske baze podataka na Androidu. U isto vrijeme, Realm pohranjuje objekte izravno u memoriju s najmanjom količinom transformacija i preslikavanja. To vam omogućuje vrlo brzo čitanje podataka s diska, što je snaga Realma i zašto ga se voli.

(Za potrebe ovog članka, ovaj opis će nam biti dovoljan. Više o Realmu možete pročitati u cool dokumentacija ili u njihovoj akademije).

Mnogi programeri navikli su raditi više s relacijskim bazama podataka (na primjer, ORM baze podataka sa SQL-om ispod haube). A stvari poput kaskadnog brisanja podataka često se čine kao dane. Ali ne u Kraljevstvu.

Usput, značajka kaskadnog brisanja tražena je dugo vremena. Ovaj revizija и još, povezano s njim, aktivno se raspravljalo. Postojao je osjećaj da će to uskoro biti učinjeno. Ali onda se sve pretočilo u uvođenje jakih i slabih karika, što bi također automatski riješilo ovaj problem. Bio je dosta živahan i aktivan na ovom zadatku zahtjev za povlačenjem, koji je za sada pauziran zbog internih poteškoća.

Curenje podataka bez kaskadnog brisanja

Kako točno podaci cure ako se oslanjate na nepostojeće kaskadno brisanje? Ako imate ugniježđene Realm objekte, oni se moraju izbrisati.
Pogledajmo (gotovo) pravi primjer. Imamo 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()

Proizvod u košarici ima različita polja, uključujući i sliku ImageEntity, prilagođeni sastojci CustomizationEntity. Također, proizvod u košarici može biti kombinacija s vlastitim setom proizvoda RealmList (CartProductEntity). Sva navedena polja su Realm objekti. Ako umetnemo novi objekt (copyToRealm() / copyToRealmOrUpdate()) s istim ID-om, tada će ovaj objekt biti potpuno prebrisan. Ali svi interni objekti (image, customizationEntity i cartComboProducts) izgubit će vezu s roditeljem i ostati u bazi podataka.

Budući da je veza s njima izgubljena, više ih ne čitamo niti brišemo (osim ako im eksplicitno pristupimo ili očistimo cijelu “tablicu”). To smo nazvali "curenje memorije".

Kada radimo s Realmom, moramo eksplicitno proći kroz sve elemente i eksplicitno obrisati sve 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 učinite, sve će 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" rješenje

Prvo što smo odlučili učiniti je očistiti najbrže rastuće objekte i provjeriti rezultate da vidimo hoće li to riješiti naš izvorni problem. Prvo je napravljeno najjednostavnije i najintuitivnije rješenje, naime: svaki objekt treba biti odgovoran za uklanjanje svojih potomaka. Da bismo to učinili, uveli smo sučelje koje vraća popis svojih ugniježđenih Realm objekata:

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

I implementirali smo to 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 ravnu listu. Svaki podređeni objekt također može implementirati sučelje NestedEntityAware, pokazujući 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žđivanje objekata može se ponavljati.

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 pomoću sučelja 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()
   }
 }
}

Učinili smo to s najbrže rastućim objektima i provjerili što se dogodilo.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Kao rezultat toga, oni objekti koje smo prekrili ovom otopinom prestali su rasti. I ukupni rast baze je usporen, ali nije prestao.

"Normalno" rješenje

Iako je baza počela sporije rasti, ipak je rasla. Pa smo počeli tražiti dalje. Naš projekt vrlo aktivno koristi predmemoriju podataka u Realmu. Stoga je pisanje svih ugniježđenih objekata za svaki objekt zahtjevno, plus rizik od pogrešaka se povećava, jer možete zaboraviti navesti objekte prilikom mijenjanja koda.

Htio sam biti siguran da ne koristim sučelja, već da sve radi samo od sebe.

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

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

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

Ako je polje RealmModel ili RealmList, dodajte objekt ovog polja na popis ugniježđenih objekata. Sve je potpuno isto kao što smo radili gore, samo će ovdje biti učinjeno samo od sebe. Sama metoda kaskadnog brisanja vrlo je 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()
         }
       }
 }
}

Proširenje filterRealmObject filtrira i propušta samo Realm objekte. metoda getNestedRealmObjects kroz refleksiju, pronalazi sve ugniježđene Realm objekte i stavlja ih na linearni popis. Zatim radimo istu stvar rekurzivno. Prilikom brisanja morate provjeriti valjanost objekta isValid, jer može biti da različiti nadređeni objekti mogu imati ugniježđene identične. Bolje je to izbjegavati i jednostavno koristiti automatsko generiranje id-a prilikom stvaranja novih objekata.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Puna 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 kodu našeg klijenta koristimo "kaskadno brisanje" za svaku operaciju izmjene 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 metodu cascadeDelete Rekurzivno briše sve prikupljene objekte prije pisanja novih. Na kraju koristimo ovaj pristup kroz cijelu aplikaciju. Curenja memorije u Realmu su potpuno nestala. Provodeći isto mjerenje ovisnosti vremena pokretanja o broju hladnih pokretanja aplikacije, vidimo rezultat.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Zelena linija prikazuje ovisnost vremena pokretanja aplikacije o broju hladnih pokretanja tijekom automatskog kaskadnog brisanja ugniježđenih objekata.

Rezultati i zaključci

Stalno rastuća baza podataka Realm uzrokovala je vrlo sporo pokretanje aplikacije. Izdali smo ažuriranje s vlastitim "kaskadnim brisanjem" ugniježđenih objekata. Sada pratimo i procjenjujemo kako je naša odluka utjecala na vrijeme pokretanja aplikacije putem metrike _app_start.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

Za analizu uzimamo vremensko razdoblje od 90 dana i vidimo: vrijeme pokretanja aplikacije, i srednje i ono koje pada na 95. percentil korisnika, počelo se smanjivati ​​i više ne raste.

Priča o tome kako je kaskadno brisanje u Realmu pobijedilo dugotrajno pokretanje

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

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

Posebna stvar kod Realm baze podataka je da je to nerelacijska baza podataka. Unatoč jednostavnosti korištenja, sličnosti s ORM rješenjima i povezivanju objekata, nema kaskadnog brisanja.

Ako se to ne uzme u obzir, tada će se ugniježđeni objekti nakupljati i "iscuriti". Baza će stalno rasti, što će pak utjecati na usporavanje ili pokretanje aplikacije.

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

Unatoč raspravama o skorom pojavljivanju ove značajke, nepostojanje kaskadnog brisanja u Realmu napravljeno je po namjeri. Ako dizajnirate novu aplikaciju, uzmite to u obzir. A ako već koristite Realm, provjerite imate li takvih problema.

Izvor: www.habr.com

Dodajte komentar