Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Alle brukere tar rask lansering og responsivt brukergrensesnitt i mobilapplikasjoner for gitt. Hvis applikasjonen tar lang tid å starte, begynner brukeren å føle seg trist og sint. Du kan enkelt ødelegge kundeopplevelsen eller helt miste brukeren selv før han begynner å bruke applikasjonen.

Vi oppdaget en gang at Dodo Pizza-appen tar 3 sekunder å starte i gjennomsnitt, og for noen "heldige" tar det 15-20 sekunder.

Under kuttet er en historie med en lykkelig slutt: om veksten av Realm-databasen, en minnelekkasje, hvordan vi samlet nestede objekter, og deretter tok oss sammen og fikset alt.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Historien om hvordan kaskadesletting i Realm vant over en lang lansering
Artikkelforfatter: Maxim Kachinkin — Android-utvikler hos Dodo Pizza.

Tre sekunder fra å klikke på programikonet til onResume() av ​​den første aktiviteten er uendelig. Og for noen brukere nådde oppstartstiden 15-20 sekunder. Hvordan er dette mulig?

En veldig kort oppsummering for de som ikke har tid til å lese
Realm-databasen vår vokste uendelig. Noen nestede objekter ble ikke slettet, men ble stadig akkumulert. Oppstartstiden for applikasjonen økte gradvis. Så fikset vi det, og oppstartstiden kom til målet - den ble mindre enn 1 sekund og økte ikke lenger. Artikkelen inneholder en analyse av situasjonen og to løsninger – en rask og en normal.

Søk og analyse av problemet

I dag må enhver mobilapplikasjon starte raskt og være responsiv. Men det handler ikke bare om mobilappen. Brukeropplevelse av interaksjon med en tjeneste og en bedrift er en kompleks ting. For eksempel, i vårt tilfelle, er leveringshastighet en av nøkkelindikatorene for pizzaservice. Hvis leveringen er rask, blir pizzaen varm, og kunden som vil spise nå slipper å vente lenge. For applikasjonen er det på sin side viktig å skape følelsen av rask service, for hvis applikasjonen bare tar 20 sekunder å starte, hvor lenge må du da vente på pizzaen?

Først ble vi selv møtt med at noen ganger tok applikasjonen et par sekunder å starte, og så begynte vi å høre klager fra andre kolleger om hvor lang tid det tok. Men vi klarte ikke å gjenta denne situasjonen konsekvent.

Hvor lang er den? I følge Google-dokumentasjon, hvis en kaldstart av en applikasjon tar mindre enn 5 sekunder, anses dette som "som normalt". Dodo Pizza Android-appen lansert (i henhold til Firebase-beregninger _app_start) kl kald start i gjennomsnitt på 3 sekunder - "Ikke bra, ikke forferdelig," som de sier.

Men så begynte det å dukke opp klager på at applikasjonen tok veldig, veldig, veldig lang tid å starte! Til å begynne med bestemte vi oss for å måle hva "veldig, veldig, veldig lang" er. Og vi brukte Firebase-sporing for dette Appstartsporing.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Denne standardsporingen måler tiden mellom øyeblikket brukeren åpner applikasjonen og øyeblikket onResume() for den første aktiviteten utføres. I Firebase-konsollen kalles denne beregningen _app_start. Det viste seg at:

  • Oppstartstider for brukere over 95. persentilen er nesten 20 sekunder (noen enda lengre), til tross for at median kaldoppstartstid er mindre enn 5 sekunder.
  • Oppstartstiden er ikke en konstant verdi, men vokser over tid. Men noen ganger er det dråper. Vi fant dette mønsteret da vi økte analyseskalaen til 90 dager.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

To tanker dukket opp:

  1. Noe lekker.
  2. Dette "noe" tilbakestilles etter utgivelse og lekker igjen.

"Sannsynligvis noe med databasen," tenkte vi, og vi hadde rett. For det første bruker vi databasen som en cache; under migrering tømmer vi den. For det andre lastes databasen når applikasjonen starter. Alt passer sammen.

Hva er galt med Realm-databasen

Vi begynte å sjekke hvordan innholdet i databasen endres i løpet av programmets levetid, fra første installasjon og videre under aktiv bruk. Du kan se innholdet i Realm-databasen via steto eller mer detaljert og tydelig ved å åpne filen via Realm Studio. For å se innholdet i databasen via ADB, kopier Realm-databasefilen:

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

Etter å ha sett på innholdet i databasen til forskjellige tider, fant vi ut at antallet objekter av en bestemt type øker stadig.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering
Bildet viser et fragment av Realm Studio for to filer: til venstre - applikasjonsbasen en tid etter installasjon, til høyre - etter aktiv bruk. Det kan sees at antall objekter ImageEntity и MoneyType har vokst betydelig (skjermbildet viser antall objekter av hver type).

Sammenheng mellom databasevekst og oppstartstid

Ukontrollert databasevekst er veldig dårlig. Men hvordan påvirker dette programmets oppstartstid? Det er ganske enkelt å måle dette gjennom ActivityManager. Siden Android 4.4 viser logcat loggen med strengen vist og klokkeslettet. Denne tiden er lik intervallet fra det øyeblikket applikasjonen startes til slutten av aktivitetsgjengivelsen. I løpet av denne tiden skjer følgende hendelser:

  • Start prosessen.
  • Initialisering av objekter.
  • Oppretting og initialisering av aktiviteter.
  • Opprette en layout.
  • Søknadsgjengivelse.

Passer oss. Hvis du kjører ADB med flaggene -S og -W, kan du få utvidet utgang med oppstartstiden:

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

Hvis du tar den derfra grep -i WaitTime tid, kan du automatisere innsamlingen av denne beregningen og visuelt se på resultatene. Grafen nedenfor viser avhengigheten av applikasjonens oppstartstid på antall kaldstarter av applikasjonen.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Samtidig var det samme karakter av forholdet mellom størrelsen og veksten av databasen, som vokste fra 4 MB til 15 MB. Totalt viser det seg at over tid (med veksten av kaldstarter) økte både applikasjonsstarttiden og størrelsen på databasen. Vi har en hypotese på hånden. Nå gjensto det bare å bekrefte avhengigheten. Derfor bestemte vi oss for å fjerne "lekkasjene" og se om dette ville fremskynde lanseringen.

Grunner til uendelig databasevekst

Før du fjerner "lekkasjer", er det verdt å forstå hvorfor de dukket opp i utgangspunktet. For å gjøre dette, la oss huske hva Realm er.

Realm er en ikke-relasjonell database. Den lar deg beskrive relasjoner mellom objekter på en lignende måte som hvor mange ORM-relasjonsdatabaser på Android som er beskrevet. Samtidig lagrer Realm objekter direkte i minnet med minst mulig transformasjoner og tilordninger. Dette lar deg lese data fra disken veldig raskt, som er Realms styrke og hvorfor den er elsket.

(For formålet med denne artikkelen vil denne beskrivelsen være nok for oss. Du kan lese mer om Realm in the cool dokumentasjon eller i deres akademi).

Mange utviklere er vant til å jobbe mer med relasjonsdatabaser (for eksempel ORM-databaser med SQL under panseret). Og ting som overlappende sletting av data virker ofte som en selvfølge. Men ikke i riket.

Kaskadeslettingsfunksjonen har forresten blitt spurt i lang tid. Dette revisjon и en annen, knyttet til det, ble aktivt diskutert. Det var en følelse av at det snart ville bli gjort. Men så ble alt oversatt til introduksjonen av sterke og svake lenker, som også automatisk ville løse dette problemet. Var ganske livlig og aktiv på denne oppgaven pull forespørsel, som er satt på pause for nå på grunn av interne vanskeligheter.

Datalekkasje uten gjennomgripende sletting

Hvordan lekker data nøyaktig hvis du stoler på en ikke-eksisterende kaskadesletting? Hvis du har nestede Realm-objekter, må de slettes.
La oss se på et (nesten) ekte eksempel. Vi har en gjenstand 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()

Produktet i handlekurven har forskjellige felt, inkludert et bilde ImageEntity, tilpassede ingredienser CustomizationEntity. Produktet i handlekurven kan også være en kombinasjon med sitt eget sett med produkter RealmList (CartProductEntity). Alle oppførte felt er Realm-objekter. Hvis vi setter inn et nytt objekt (copyToRealm() / copyToRealmOrUpdate()) med samme id, vil dette objektet bli fullstendig overskrevet. Men alle interne objekter (image, customizationEntity og cartComboProducts) vil miste forbindelsen med overordnet og forbli i databasen.

Siden forbindelsen med dem er tapt, leser vi dem ikke lenger eller sletter dem (med mindre vi eksplisitt har tilgang til dem eller sletter hele "tabellen"). Vi kalte dette "minnelekkasjer".

Når vi jobber med Realm, må vi eksplisitt gå gjennom alle elementene og eksplisitt slette alt før slike operasjoner. Dette kan for eksempel gjøres slik:

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

Hvis du gjør dette, vil alt fungere som det skal. I dette eksemplet antar vi at det ikke er andre nestede Realm-objekter inne i image, customizationEntity og cartComboProducts, så det er ingen andre nestede løkker og slettinger.

"Rask" løsning

Det første vi bestemte oss for å gjøre var å rydde opp i de raskest voksende objektene og sjekke resultatene for å se om dette ville løse vårt opprinnelige problem. Først ble den enkleste og mest intuitive løsningen laget, nemlig: hvert objekt skal være ansvarlig for å fjerne barna sine. For å gjøre dette introduserte vi et grensesnitt som returnerte en liste over de nestede Realm-objektene:

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

Og vi implementerte det i Realm-objektene våre:

@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 vi returnerer alle barn som en flat liste. Og hvert underordnede objekt kan også implementere NestedEntityAware-grensesnittet, noe som indikerer at det har interne Realm-objekter å slette, for eksempel 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
   )
 }
}

Og så videre, hekking av gjenstander kan gjentas.

Deretter skriver vi en metode som rekursivt sletter alle nestede objekter. Metode (laget som en utvidelse) deleteAllNestedEntities henter alle objekter og metode på toppnivå deleteNestedRecursively Fjerner rekursivt alle nestede objekter ved hjelp av NestedEntityAware-grensesnittet:

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

Vi gjorde dette med de raskest voksende gjenstandene og sjekket hva som skjedde.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Som et resultat sluttet de gjenstandene som vi dekket med denne løsningen å vokse. Og den generelle veksten av basen avtok, men stoppet ikke.

Den "normale" løsningen

Selv om basen begynte å vokse saktere, vokste den fortsatt. Så vi begynte å lete videre. Prosjektet vårt bruker svært aktivt databufring i Realm. Derfor er det arbeidskrevende å skrive alle nestede objekter for hvert objekt, pluss at risikoen for feil øker, fordi du kan glemme å spesifisere objekter når du endrer koden.

Jeg ville være sikker på at jeg ikke brukte grensesnitt, men at alt fungerte av seg selv.

Når vi vil at noe skal fungere av seg selv, må vi bruke refleksjon. For å gjøre dette kan vi gå gjennom hvert klassefelt og sjekke om det er et Realm-objekt eller en liste over objekter:

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

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

Hvis feltet er en RealmModel eller RealmList, legger du til objektet i dette feltet til en liste over nestede objekter. Alt er akkurat det samme som vi gjorde ovenfor, bare her vil det gjøres av seg selv. Selve kaskadeslettingsmetoden er veldig enkel og ser slik ut:

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

Utvidelse filterRealmObject filtrerer ut og sender kun Realm-objekter. Metode getNestedRealmObjects gjennom refleksjon finner den alle nestede Realm-objekter og plasserer dem i en lineær liste. Så gjør vi det samme rekursivt. Når du sletter, må du sjekke objektet for gyldighet isValid, fordi det kan være at forskjellige overordnede objekter kan ha nestede identiske. Det er bedre å unngå dette og ganske enkelt bruke automatisk generering av id når du oppretter nye objekter.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Full implementering av getNestedRealmObjects-metoden

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

Som et resultat bruker vi i klientkoden vår "cascading delete" for hver dataendringsoperasjon. For en innsettingsoperasjon ser det for eksempel slik ut:

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

Metoden først getManagedEntities mottar alle lagt til objekter, og deretter metoden cascadeDelete Sletter rekursivt alle innsamlede objekter før du skriver nye. Vi ender opp med å bruke denne tilnærmingen gjennom hele søknaden. Minnelekkasjer i Realm er helt borte. Etter å ha utført den samme måling av avhengigheten av oppstartstid på antall kaldstarter av applikasjonen, ser vi resultatet.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Den grønne linjen viser avhengigheten av applikasjonens oppstartstid av antall kaldstarter under automatisk kaskadesletting av nestede objekter.

Resultater og konklusjoner

Den stadig voksende Realm-databasen førte til at applikasjonen startet veldig sakte. Vi ga ut en oppdatering med vår egen "cascading delete" av nestede objekter. Og nå overvåker og evaluerer vi hvordan avgjørelsen vår påvirket programmets oppstartstid gjennom _app_start-beregningen.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

For analyse tar vi en tidsperiode på 90 dager og ser: applikasjonsstarttiden, både medianen og den som faller på 95. persentilen av brukere, begynte å avta og ikke lenger stiger.

Historien om hvordan kaskadesletting i Realm vant over en lang lansering

Hvis du ser på syv-dagers diagrammet, ser _app_start-beregningen helt tilstrekkelig ut og er mindre enn 1 sekund.

Det er også verdt å legge til at Firebase som standard sender varsler hvis medianverdien for _app_start overstiger 5 sekunder. Men, som vi kan se, bør du ikke stole på dette, men heller gå inn og sjekke det eksplisitt.

Det spesielle med Realm-databasen er at det er en ikke-relasjonell database. Til tross for dens brukervennlighet, likhet med ORM-løsninger og objektkobling, har den ikke kaskadesletting.

Hvis dette ikke tas med i betraktningen, vil nestede objekter samle seg og "lekke bort." Databasen vil vokse konstant, noe som igjen vil påvirke nedbremsingen eller oppstarten av applikasjonen.

Jeg delte vår erfaring om hvordan du raskt kan gjøre en kaskadesletting av objekter i Realm, som ennå ikke er ute av boksen, men som har blitt snakket om i lang tid si и si. I vårt tilfelle raskere dette oppstartstiden betraktelig.

Til tross for diskusjonen om det nært forestående utseendet til denne funksjonen, er fraværet av kaskadesletting i Realm gjort av design. Hvis du designer en ny applikasjon, så ta hensyn til dette. Og hvis du allerede bruker Realm, sjekk om du har slike problemer.

Kilde: www.habr.com

Legg til en kommentar