Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Vsi uporabniki jemljejo hiter zagon in odziven uporabniški vmesnik v mobilnih aplikacijah za samoumevna. Če se aplikacija zažene dolgo, se uporabnik začne počutiti žalostno in jezno. Z lahkoto lahko pokvarite uporabniško izkušnjo ali popolnoma izgubite uporabnika, še preden začne uporabljati aplikacijo.

Nekoč smo ugotovili, da se aplikacija Dodo Pizza v povprečju zažene v 3 sekundah, nekaterim “srečnežem” pa 15-20 sekund.

Pod rezom je zgodba s srečnim koncem: o rasti baze podatkov Realm, puščanju pomnilnika, kako smo kopičili ugnezdene objekte, nato pa se zbrali in vse popravili.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom
avtor članka: Maksim Kačinkin — Android razvijalec pri Dodo Pizza.

Tri sekunde od klika na ikono aplikacije do onResume() prve dejavnosti so neskončnost. Pri nekaterih uporabnikih je čas zagona dosegel 15-20 sekund. Kako je to sploh mogoče?

Zelo kratek povzetek za tiste, ki nimajo časa za branje
Naša baza podatkov Realm je neskončno rasla. Nekateri ugnezdeni objekti niso bili izbrisani, ampak so se nenehno kopičili. Čas zagona aplikacije se je postopoma povečeval. Potem smo to popravili in čas zagona je prišel do cilja - postal je manj kot 1 sekunda in se ni več povečal. Članek vsebuje analizo stanja in dve rešitvi - hitro in normalno.

Iskanje in analiza problema

Danes se mora vsaka mobilna aplikacija zagnati hitro in biti odzivna. A ne gre samo za mobilno aplikacijo. Uporabniška izkušnja interakcije s storitvijo in podjetjem je kompleksna stvar. Na primer, v našem primeru je hitrost dostave eden ključnih kazalnikov za storitev pizze. Če je dostava hitra, bo pica vroča in stranki, ki želi jesti zdaj, ne bo treba dolgo čakati. Za aplikacijo pa je pomembno ustvariti občutek hitre storitve, kajti če se aplikacija zažene le v 20 sekundah, kako dolgo boste morali čakati na pico?

Sprva smo se sami soočali s tem, da je aplikacija včasih trajala nekaj sekund, da se je zagnala, nato pa smo začeli poslušati pritožbe drugih sodelavcev, kako dolgo je trajalo. Vendar te situacije nismo mogli dosledno ponoviti.

Kako dolgo je? Po navedbah Googlova dokumentacija, če hladni zagon aplikacije traja manj kot 5 sekund, se to šteje za »kot normalno«. Predstavljena aplikacija Dodo Pizza za Android (glede na meritve Firebase _app_start) pri hladen zagon v povprečju v 3 sekundah - "Ni super, ni grozno", kot pravijo.

Potem pa so se začele pojavljati pritožbe, da se je aplikacija zagnala zelo, zelo, zelo dolgo! Za začetek smo se odločili izmeriti, kaj je "zelo, zelo, zelo dolgo". In za to smo uporabili sled Firebase Začetna sled aplikacije.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

To standardno sledenje meri čas med trenutkom, ko uporabnik odpre aplikacijo, in trenutkom, ko se izvede onResume() prve dejavnosti. V konzoli Firebase se ta metrika imenuje _app_start. Izkazalo se je, da:

  • Zagonski časi za uporabnike nad 95. percentilom so skoraj 20 sekund (nekateri celo dlje), kljub temu, da je srednji čas hladnega zagona krajši od 5 sekund.
  • Čas zagona ni stalna vrednost, ampak sčasoma raste. Ampak včasih pride do padcev. Ta vzorec smo ugotovili, ko smo obseg analize povečali na 90 dni.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Dve misli sta mi prišli na misel:

  1. Nekaj ​​pušča.
  2. To »nekaj« se po sprostitvi ponastavi in ​​nato spet pušča.

»Verjetno nekaj z bazo podatkov,« smo pomislili in imeli smo prav. Najprej bazo uporabljamo kot predpomnilnik, med selitvijo jo počistimo. Drugič, zbirka podatkov se naloži, ko se aplikacija zažene. Vse se ujema.

Kaj je narobe z bazo podatkov Realm

Začeli smo preverjati, kako se vsebina baze spreminja skozi življenjsko dobo aplikacije, od prve namestitve in naprej med aktivno uporabo. Vsebino baze podatkov Realm si lahko ogledate prek Steto ali podrobneje in pregledneje z odpiranjem datoteke prek Realm Studio. Če si želite ogledati vsebino baze podatkov prek ADB, kopirajte datoteko baze podatkov Realm:

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

Ob različnih časovnih pregledih vsebine podatkovne baze smo ugotovili, da število predmetov določene vrste nenehno narašča.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom
Slika prikazuje fragment Realm Studio za dve datoteki: na levi - osnova aplikacije nekaj časa po namestitvi, na desni - po aktivni uporabi. Vidi se, da je število predmetov ImageEntity и MoneyType se je znatno povečalo (posnetek zaslona prikazuje število predmetov vsake vrste).

Razmerje med rastjo baze podatkov in časom zagona

Nenadzorovana rast baze podatkov je zelo slaba. Toda kako to vpliva na čas zagona aplikacije? To je precej enostavno izmeriti prek ActivityManagerja. Od Androida 4.4 logcat prikazuje dnevnik z nizom Displayed in časom. Ta čas je enak intervalu od trenutka zagona aplikacije do konca upodabljanja aktivnosti. V tem času se zgodijo naslednji dogodki:

  • Zaženite postopek.
  • Inicializacija objektov.
  • Ustvarjanje in inicializacija dejavnosti.
  • Ustvarjanje postavitve.
  • Upodabljanje aplikacij.

Ustreza nam. Če zaženete ADB z zastavicama -S in -W, lahko dobite razširjen izhod z zagonskim časom:

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

Če ga zgrabite od tam grep -i WaitTime časa, lahko avtomatizirate zbiranje te metrike in si vizualno ogledate rezultate. Spodnji graf prikazuje odvisnost časa zagona aplikacije od števila hladnih zagonov aplikacije.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Hkrati je bila enaka narava razmerja med velikostjo in rastjo baze podatkov, ki je narasla s 4 MB na 15 MB. Skupaj se izkaže, da sta se sčasoma (z rastjo hladnih zagonov) povečala tako čas zagona aplikacije kot velikost baze podatkov. V rokah imamo hipotezo. Zdaj je ostalo le še potrditi odvisnost. Zato smo se odločili odstraniti "puščanje" in preveriti, ali bo to pospešilo zagon.

Razlogi za neskončno rast baze podatkov

Preden odstranite "puščanje", je vredno razumeti, zakaj so se sploh pojavili. Če želite to narediti, se spomnimo, kaj je Realm.

Realm je nerelacijska zbirka podatkov. Omogoča vam, da opišete odnose med objekti na podoben način, kot je opisanih veliko relacijskih baz podatkov ORM v sistemu Android. Hkrati Realm shranjuje predmete neposredno v pomnilnik z najmanjšo količino transformacij in preslikav. To vam omogoča zelo hitro branje podatkov z diska, kar je prednost Realma in zakaj je tako priljubljen.

(Za namene tega članka nam bo ta opis zadostoval. Več o Realmu lahko preberete v cool dokumentacijo ali v njihovem akademija).

Mnogi razvijalci so navajeni več delati z relacijskimi bazami podatkov (na primer baze podatkov ORM s SQL pod pokrovom). In stvari, kot je kaskadno brisanje podatkov, se pogosto zdijo samoumevne. Ampak ne v Realmu.

Mimogrede, funkcija kaskadnega brisanja je bila zahtevana že dolgo časa. to revizija и drugo, povezano z njim, se je aktivno razpravljalo. Bil je občutek, da bo kmalu storjeno. Potem pa se je vse skupaj prevedlo v uvedbo močnih in šibkih povezav, ki bi samodejno rešile tudi ta problem. Pri tej nalogi je bil kar živahen in aktiven povlecite zahtevo, ki je zaradi notranjih težav zaenkrat zaustavljena.

Uhajanje podatkov brez kaskadnega brisanja

Kako natančno pride do uhajanja podatkov, če se zanašate na neobstoječe kaskadno brisanje? Če imate ugnezdene predmete Realm, jih je treba izbrisati.
Poglejmo si (skoraj) resničen primer. Imamo predmet 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()

Izdelek v košarici ima različna polja, vključno s sliko ImageEntity, prilagojene sestavine CustomizationEntity. Prav tako je izdelek v košarici lahko kombinacija s svojim naborom izdelkov RealmList (CartProductEntity). Vsa navedena polja so predmeti Realm. Če vstavimo nov objekt (copyToRealm() / copyToRealmOrUpdate()) z istim ID-jem, bo ta objekt popolnoma prepisan. Toda vsi notranji objekti (image, customizationEntity in cartComboProducts) bodo izgubili povezavo z nadrejenim elementom in ostali v bazi podatkov.

Ker je povezava z njimi izgubljena, jih ne beremo več ali brišemo (razen če izrecno dostopamo do njih ali počistimo celotno “tabelo”). Temu smo rekli "puščanje spomina".

Ko delamo z Realmom, moramo pred takšnimi operacijami eksplicitno iti skozi vse elemente in vse eksplicitno izbrisati. To je mogoče storiti na primer takole:

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

Če to storite, potem bo vse delovalo, kot bi moralo. V tem primeru predpostavljamo, da znotraj image, customizationEntity in cartComboProducts ni drugih ugnezdenih predmetov Realm, zato ni drugih ugnezdenih zank in izbrisov.

"Hitra" rešitev

Prva stvar, ki smo se jo odločili narediti, je bila čiščenje najhitreje rastočih predmetov in preverjanje rezultatov, da bi ugotovili, ali bo to rešilo naš prvotni problem. Najprej je bila narejena najenostavnejša in najbolj intuitivna rešitev, in sicer: vsak objekt naj bo odgovoren za odstranitev svojih podrejenih elementov. Da bi to naredili, smo predstavili vmesnik, ki je vrnil seznam ugnezdenih predmetov Realm:

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

Implementirali smo ga v naše objekte 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 vrnemo vse otroke kot ploščat seznam. In vsak podrejeni objekt lahko izvaja tudi vmesnik NestedEntityAware, kar nakazuje, da ima notranje objekte Realm za brisanje, na primer 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
   )
 }
}

In tako naprej, gnezdenje predmetov se lahko ponavlja.

Nato napišemo metodo, ki rekurzivno izbriše vse ugnezdene objekte. Metoda (izdelana kot razširitev) deleteAllNestedEntities pridobi vse objekte in metode najvišje ravni deleteNestedRecursively Rekurzivno odstrani vse ugnezdene objekte z uporabo vmesnika 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()
   }
 }
}

To smo storili z najhitreje rastočimi objekti in preverili, kaj se je zgodilo.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Posledično so tisti predmeti, ki smo jih prekrili s to raztopino, prenehali rasti. In splošna rast baze se je upočasnila, vendar se ni ustavila.

"Normalna" rešitev

Čeprav je osnova začela rasti počasneje, je vseeno rasla. Tako smo začeli iskati naprej. Naš projekt zelo aktivno uporablja predpomnjenje podatkov v Realmu. Zato je pisanje vseh ugnezdenih objektov za vsak objekt delovno intenzivno, poleg tega pa se poveča tveganje za napake, ker lahko ob spreminjanju kode pozabite določiti objekte.

Želel sem se prepričati, da ne uporabljam vmesnikov, ampak da vse deluje samo po sebi.

Ko želimo, da nekaj deluje samo od sebe, moramo uporabiti refleksijo. To naredimo tako, da gremo skozi vsako polje razreda in preverimo, ali gre za objekt Realm ali seznam objektov:

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

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

Če je polje RealmModel ali RealmList, dodajte predmet tega polja na seznam ugnezdenih objektov. Vse je popolnoma enako, kot smo naredili zgoraj, le tukaj bo storjeno samo. Sama metoda kaskadnega brisanja je zelo preprosta in izgleda takole:

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

Razširitev filterRealmObject filtrira in posreduje samo objekte Realm. Metoda getNestedRealmObjects z refleksijo najde vse ugnezdene objekte Realm in jih postavi na linearni seznam. Nato naredimo isto stvar rekurzivno. Pri brisanju morate preveriti veljavnost predmeta isValid, ker se lahko zgodi, da imajo lahko različni nadrejeni objekti ugnezdene enake. Temu se je bolje izogniti in pri ustvarjanju novih predmetov preprosto uporabiti samodejno generiranje id-ja.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

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

Posledično v naši kodi odjemalca uporabljamo »kaskadno brisanje« za vsako operacijo spreminjanja podatkov. Na primer, za operacijo vstavljanja je videti takole:

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 prejme vse dodane objekte in nato metodo cascadeDelete Rekurzivno izbriše vse zbrane objekte, preden zapiše nove. Na koncu uporabimo ta pristop skozi celotno aplikacijo. Puščanja pomnilnika v Realmu so popolnoma izginila. Po enaki meritvi odvisnosti časa zagona od števila hladnih zagonov aplikacije vidimo rezultat.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Zelena črta prikazuje odvisnost časa zagona aplikacije od števila hladnih zagonov med samodejnim kaskadnim brisanjem ugnezdenih objektov.

Rezultati in zaključki

Nenehno rastoča baza podatkov Realm je povzročala zelo počasen zagon aplikacije. Izdali smo posodobitev z lastnim "kaskadnim brisanjem" ugnezdenih predmetov. In zdaj spremljamo in ocenjujemo, kako je naša odločitev vplivala na čas zagona aplikacije prek metrike _app_start.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Za analizo vzamemo časovno obdobje 90 dni in vidimo: čas zagona aplikacije, tako mediana kot tisti, ki pade na 95. percentil uporabnikov, se je začel zmanjševati in ne narašča več.

Zgodba o tem, kako je kaskadno brisanje v Realmu zmagalo nad dolgo zagonom

Če pogledate sedemdnevni grafikon, je metrika _app_start videti popolnoma ustrezna in je manjša od 1 sekunde.

Prav tako je vredno dodati, da Firebase privzeto pošilja obvestila, če srednja vrednost _app_start preseže 5 sekund. Vendar, kot vidimo, se na to ne bi smeli zanašati, ampak raje pojdite in izrecno preverite.

Posebnost baze podatkov Realm je, da je nerelacijska zbirka podatkov. Kljub enostavni uporabi, podobnosti z rešitvami ORM in povezovanju objektov nima kaskadnega brisanja.

Če tega ne upoštevamo, se bodo ugnezdeni predmeti kopičili in »odhajali«. Baza podatkov bo nenehno rasla, kar bo vplivalo na upočasnitev ali zagon aplikacije.

Delil sem našo izkušnjo o tem, kako hitro narediti kaskadno brisanje objektov v Realmu, kar še ni iz škatlice, a se o tem že dolgo govori pravijo и pravijo. V našem primeru je to zelo pospešilo zagon aplikacije.

Kljub razpravam o skorajšnjem pojavu te funkcije je odsotnost kaskadnega brisanja v Realmu narejena načrtno. Če načrtujete novo aplikacijo, potem to upoštevajte. In če že uporabljate Realm, preverite, ali imate takšne težave.

Vir: www.habr.com

Dodaj komentar