Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Alle brugere tager hurtig lancering og responsiv UI i mobilapplikationer for givet. Hvis applikationen tager lang tid at starte, begynder brugeren at føle sig trist og vred. Du kan nemt spolere kundeoplevelsen eller helt miste brugeren, allerede inden han begynder at bruge applikationen.

Vi opdagede engang, at Dodo Pizza-appen tager 3 sekunder at starte i gennemsnit, og for nogle "heldige" tager det 15-20 sekunder.

Under snittet er en historie med en lykkelig slutning: om væksten af ​​Realm-databasen, en hukommelseslækage, hvordan vi akkumulerede indlejrede objekter og derefter tog os sammen og fiksede alting.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering
Artiklens forfatter: Maxim Kachinkin — Android-udvikler hos Dodo Pizza.

Tre sekunder fra at klikke på applikationsikonet til onResume() af den første aktivitet er uendeligt. Og for nogle brugere nåede opstartstiden 15-20 sekunder. Hvordan er dette overhovedet muligt?

Et meget kort resumé for dem, der ikke har tid til at læse
Vores Realm-database voksede uendeligt. Nogle indlejrede objekter blev ikke slettet, men blev konstant akkumuleret. Applikationens starttid steg gradvist. Så fik vi det rettet, og starttiden nåede målet - den blev mindre end 1 sekund og steg ikke længere. Artiklen indeholder en analyse af situationen og to løsninger - en hurtig og en normal.

Søgning og analyse af problemet

I dag skal enhver mobilapplikation starte hurtigt og være lydhør. Men det handler ikke kun om mobilappen. Brugeroplevelse af interaktion med en service og en virksomhed er en kompleks ting. For eksempel er leveringshastighed i vores tilfælde en af ​​nøgleindikatorerne for pizzaservice. Hvis leveringen er hurtig, vil pizzaen være varm, og den kunde, der vil spise nu, skal ikke vente længe. For applikationen er det til gengæld vigtigt at skabe følelsen af ​​hurtig service, for hvis applikationen kun tager 20 sekunder at starte, hvor længe skal du så vente på pizzaen?

Først stod vi selv over for, at nogle gange tog applikationen et par sekunder at starte, og så begyndte vi at høre klager fra andre kolleger over, hvor lang tid det tog. Men vi var ikke i stand til konsekvent at gentage denne situation.

Hvor lang er den? Ifølge Google dokumentation, hvis en koldstart af en applikation tager mindre end 5 sekunder, betragtes dette som "som normalt". Dodo Pizza Android-app lanceret (ifølge Firebase-metrics _app_start) kl kold start i gennemsnit på 3 sekunder - "Ikke fantastisk, ikke forfærdeligt," som de siger.

Men så begyndte der at dukke klager over, at applikationen tog meget, meget, meget lang tid at starte! Til at begynde med besluttede vi at måle, hvad "meget, meget, meget langt" er. Og vi brugte Firebase-sporing til dette App start sporing.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Denne standardsporing måler tiden mellem det øjeblik, brugeren åbner applikationen, og det øjeblik onResume() af den første aktivitet udføres. I Firebase-konsollen hedder denne metric _app_start. Det viste sig at:

  • Opstartstider for brugere over 95. percentilen er næsten 20 sekunder (nogle endnu længere), på trods af at den gennemsnitlige koldstartstid er mindre end 5 sekunder.
  • Opstartstiden er ikke en konstant værdi, men vokser over tid. Men nogle gange er der fald. Vi fandt dette mønster, da vi øgede analyseskalaen til 90 dage.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

To tanker dukkede op:

  1. Noget lækker.
  2. Dette "noget" nulstilles efter frigivelse og lækker derefter igen.

"Sikkert noget med databasen," tænkte vi, og vi havde ret. For det første bruger vi databasen som en cache; under migreringen rydder vi den. For det andet indlæses databasen, når applikationen starter. Alt passer sammen.

Hvad er der galt med Realm-databasen

Vi begyndte at kontrollere, hvordan indholdet af databasen ændrer sig i løbet af programmets levetid, fra den første installation og videre under aktiv brug. Du kan se indholdet af Realm-databasen via steto eller mere detaljeret og tydeligt ved at åbne filen via Realm Studio. For at se indholdet af databasen via ADB skal du kopiere Realm-databasefilen:

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

Efter at have set på indholdet af databasen på forskellige tidspunkter, fandt vi ud af, at antallet af objekter af en bestemt type er konstant stigende.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering
Billedet viser et fragment af Realm Studio for to filer: til venstre - applikationsbasen et stykke tid efter installationen, til højre - efter aktiv brug. Det kan ses, at antallet af objekter ImageEntity и MoneyType er vokset betydeligt (skærmbilledet viser antallet af objekter af hver type).

Sammenhæng mellem databasevækst og opstartstid

Ukontrolleret databasevækst er meget dårlig. Men hvordan påvirker dette applikationens starttid? Det er ret nemt at måle dette gennem ActivityManager. Siden Android 4.4 viser logcat loggen med strengen vist og klokkeslættet. Denne tid er lig med intervallet fra det øjeblik, applikationen startes, til slutningen af ​​aktivitetsgengivelsen. I løbet af denne tid opstår følgende begivenheder:

  • Start processen.
  • Initialisering af objekter.
  • Oprettelse og initialisering af aktiviteter.
  • Oprettelse af et layout.
  • Applikationsgengivelse.

Passer til os. Hvis du kører ADB med -S og -W flagene, kan du få udvidet output med starttiden:

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

Hvis du tager den derfra grep -i WaitTime tid, kan du automatisere indsamlingen af ​​denne metrik og visuelt se på resultaterne. Grafen nedenfor viser afhængigheden af ​​applikationens opstartstid af antallet af koldstarter af applikationen.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Samtidig var der den samme karakter af forholdet mellem størrelsen og væksten af ​​databasen, som voksede fra 4 MB til 15 MB. I alt viser det sig, at over tid (med væksten af ​​koldstarter) steg både applikationsstarttiden og størrelsen af ​​databasen. Vi har en hypotese på hånden. Nu var der kun tilbage at bekræfte afhængigheden. Derfor besluttede vi at fjerne "lækagen" og se, om dette ville fremskynde lanceringen.

Årsager til endeløs databasevækst

Før du fjerner "lækager", er det værd at forstå, hvorfor de dukkede op i første omgang. For at gøre dette, lad os huske, hvad Realm er.

Realm er en ikke-relationel database. Det giver dig mulighed for at beskrive relationer mellem objekter på samme måde som hvor mange ORM-relationsdatabaser på Android, der er beskrevet. Samtidig gemmer Realm objekter direkte i hukommelsen med den mindste mængde transformationer og kortlægninger. Dette giver dig mulighed for at læse data fra disken meget hurtigt, hvilket er Realms styrke, og hvorfor det er elsket.

(Til denne artikels formål vil denne beskrivelse være nok for os. Du kan læse mere om Realm in the cool dokumentation eller i deres akademi).

Mange udviklere er vant til at arbejde mere med relationelle databaser (for eksempel ORM-databaser med SQL under motorhjelmen). Og ting som kaskadesletning af data virker ofte som givet. Men ikke i riget.

I øvrigt har kaskadesletningsfunktionen været spurgt i lang tid. Det her revision и en anden, forbundet med det, blev aktivt diskuteret. Der var en følelse af, at det snart ville blive gjort. Men så blev alt oversat til introduktionen af ​​stærke og svage led, som også automatisk ville løse dette problem. Var ret livlig og aktiv på denne opgave pull anmodning, som er sat på pause indtil videre på grund af interne vanskeligheder.

Datalæk uden kaskadende sletning

Hvordan lækker data præcist, hvis du stoler på en ikke-eksisterende kaskadende sletning? Hvis du har indlejrede Realm-objekter, skal de slettes.
Lad os se på et (næsten) rigtigt eksempel. Vi har en genstand 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 kurven har forskellige felter, inklusive et billede ImageEntity, tilpassede ingredienser CustomizationEntity. Produktet i kurven kan også være en kombination med sit eget sæt produkter RealmList (CartProductEntity). Alle anførte felter er Realm-objekter. Hvis vi indsætter et nyt objekt (copyToRealm() / copyToRealmOrUpdate()) med samme id, så vil dette objekt blive fuldstændigt overskrevet. Men alle interne objekter (image, customizationEntity og cartComboProducts) vil miste forbindelsen til forælderen og forblive i databasen.

Da forbindelsen med dem er mistet, læser vi dem ikke længere eller sletter dem (medmindre vi eksplicit får adgang til dem eller rydder hele "tabellen"). Vi kaldte dette "hukommelseslækager".

Når vi arbejder med Realm, skal vi eksplicit gennemgå alle elementerne og eksplicit slette alt før sådanne operationer. Dette kan for eksempel gøres sådan:

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 gør dette, vil alt fungere som det skal. I dette eksempel antager vi, at der ikke er andre indlejrede Realm-objekter inde i image, customizationEntity og cartComboProducts, så der er ingen andre indlejrede sløjfer og sletninger.

"Hurtig" løsning

Den første ting, vi besluttede at gøre, var at rydde op i de hurtigst voksende objekter og kontrollere resultaterne for at se, om dette ville løse vores oprindelige problem. Først blev den enkleste og mest intuitive løsning lavet, nemlig: hvert objekt skulle være ansvarligt for at fjerne sine børn. For at gøre dette introducerede vi en grænseflade, der returnerede en liste over dets indlejrede Realm-objekter:

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

Og vi implementerede det i vores Realm-objekter:

@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 børn som en flad liste. Og hvert underordnede objekt kan også implementere NestedEntityAware-grænsefladen, hvilket indikerer, at det har interne Realm-objekter, der skal slettes, f.eks. 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, indlejring af objekter kan gentages.

Derefter skriver vi en metode, der rekursivt sletter alle indlejrede objekter. Metode (lavet som en udvidelse) deleteAllNestedEntities får alle objekter og metoder på øverste niveau deleteNestedRecursively Fjerner rekursivt alle indlejrede objekter ved hjælp af NestedEntityAware-grænsefladen:

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 hurtigst voksende objekter og tjekkede, hvad der skete.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Som et resultat stoppede de genstande, som vi dækkede med denne løsning, med at vokse. Og den samlede vækst af basen aftog, men stoppede ikke.

Den "normale" løsning

Selvom basen begyndte at vokse langsommere, voksede den stadig. Så vi begyndte at kigge videre. Vores projekt gør meget aktivt brug af data caching i Realm. Derfor er det arbejdskrævende at skrive alle indlejrede objekter for hvert objekt, plus at risikoen for fejl øges, fordi du kan glemme at angive objekter, når du ændrer koden.

Jeg ville sikre mig, at jeg ikke brugte grænseflader, men at alt fungerede af sig selv.

Når vi vil have noget til at fungere af sig selv, er vi nødt til at bruge refleksion. For at gøre dette kan vi gå gennem hvert klassefelt og kontrollere, 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, skal du tilføje objektet i dette felt til en liste over indlejrede objekter. Alt er nøjagtigt det samme som vi gjorde ovenfor, kun her vil det blive gjort af sig selv. Selve kaskadesletningsmetoden er meget enkel og ser sådan ud:

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

Udvidelse filterRealmObject filtrerer og sender kun Realm-objekter. Metode getNestedRealmObjects gennem refleksion finder den alle indlejrede Realm-objekter og placerer dem i en lineær liste. Så gør vi det samme rekursivt. Når du sletter, skal du kontrollere objektet for gyldighed isValid, fordi det kan være, at forskellige overordnede objekter kan have indlejrede identiske. Det er bedre at undgå dette og blot bruge automatisk generering af id, når du opretter nye objekter.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Fuld implementering af 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 bruger vi i vores klientkode "cascading delete" for hver dataændringsoperation. For eksempel, for en indsættelsesoperation ser det sådan ud:

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 modtager alle tilføjede objekter og derefter metoden cascadeDelete Sletter rekursivt alle indsamlede objekter, før du skriver nye. Vi ender med at bruge denne tilgang gennem hele ansøgningen. Hukommelseslækager i Realm er fuldstændig væk. Efter at have udført den samme måling af opstartstidens afhængighed af antallet af koldstarter af applikationen, ser vi resultatet.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Den grønne linje viser afhængigheden af ​​applikationens opstartstid af antallet af koldstarter under automatisk kaskadesletning af indlejrede objekter.

Resultater og konklusioner

Den stadigt voksende Realm-database fik applikationen til at starte meget langsomt. Vi udgav en opdatering med vores egen "cascading delete" af indlejrede objekter. Og nu overvåger og evaluerer vi, hvordan vores beslutning påvirkede applikationens opstartstid gennem metrikken _app_start.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Til analyse tager vi en tidsperiode på 90 dage og ser: applikationens starttid, både medianen og den, der falder på 95. percentilen af ​​brugere, begyndte at falde og stiger ikke længere.

Fortællingen om, hvordan kaskadesletning i Realm vandt over en lang lancering

Hvis du ser på syv-dages diagrammet, ser _app_start-metrikken fuldstændig passende ud og er mindre end 1 sekund.

Det er også værd at tilføje, at Firebase som standard sender meddelelser, hvis medianværdien af ​​_app_start overstiger 5 sekunder. Men som vi kan se, skal du ikke stole på dette, men hellere gå ind og tjekke det eksplicit.

Det særlige ved Realm-databasen er, at det er en ikke-relationel database. På trods af dens brugervenlighed, lighed med ORM-løsninger og objektlinkning, har den ikke kaskadesletning.

Hvis dette ikke tages i betragtning, vil indlejrede objekter samle sig og "lække væk". Databasen vil vokse konstant, hvilket igen vil påvirke opbremsningen eller opstarten af ​​applikationen.

Jeg delte vores erfaring med, hvordan man hurtigt laver en kaskadesletning af objekter i Realm, som endnu ikke er ude af boksen, men der er blevet talt om i lang tid de siger и de siger. I vores tilfælde fremskyndede dette applikationens starttid betydeligt.

På trods af diskussionen om det nært forestående udseende af denne funktion, er fraværet af kaskadesletning i Realm lavet af design. Hvis du designer en ny applikation, så tag dette i betragtning. Og hvis du allerede bruger Realm, så tjek om du har sådanne problemer.

Kilde: www.habr.com

Tilføj en kommentar