Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Alla användare tar snabb lansering och responsivt användargränssnitt i mobilapplikationer för givet. Om applikationen tar lång tid att starta börjar användaren känna sig ledsen och arg. Du kan enkelt förstöra kundupplevelsen eller helt tappa användaren redan innan han börjar använda applikationen.

Vi upptäckte en gång att Dodo Pizza-appen tar 3 sekunder att starta i genomsnitt, och för vissa "lyckliga" tar det 15-20 sekunder.

Nedanför snittet finns en berättelse med ett lyckligt slut: om tillväxten av Realm-databasen, en minnesläcka, hur vi samlade in kapslade objekt och sedan tog oss samman och fixade allt.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Berättelsen om hur kaskadradering i Realm vann över en lång lansering
Artikelförfattare: Maxim Kachinkin — Android-utvecklare på Dodo Pizza.

Tre sekunder från att klicka på programikonen till onResume() för den första aktiviteten är oändligt. Och för vissa användare nådde starttiden 15-20 sekunder. Hur är detta ens möjligt?

En mycket kort sammanfattning för den som inte har tid att läsa
Vår Realm-databas växte oändligt. Vissa kapslade objekt togs inte bort, utan ackumulerades konstant. Applikationens starttid ökade gradvis. Sedan fixade vi det, och starttiden kom till målet - den blev mindre än 1 sekund och ökade inte längre. Artikeln innehåller en analys av situationen och två lösningar – en snabb och en normal.

Sök och analys av problemet

Idag måste alla mobilapplikationer starta snabbt och vara lyhörda. Men det handlar inte bara om mobilappen. Användarupplevelse av interaktion med en tjänst och ett företag är en komplex sak. Till exempel, i vårt fall är leveranshastighet en av nyckelindikatorerna för pizzaservice. Om leveransen går snabbt blir pizzan varm och den kund som vill äta nu behöver inte vänta länge. För applikationen är det i sin tur viktigt att skapa känslan av snabb service, för om applikationen bara tar 20 sekunder att starta, hur länge ska du då behöva vänta på pizzan?

Till en början ställdes vi själva inför det faktum att applikationen ibland tog ett par sekunder att starta och sedan började vi höra klagomål från andra kollegor om hur lång tid det tog. Men vi kunde inte konsekvent upprepa denna situation.

Hur länge är det? Enligt Googles dokumentation, om en kallstart av en applikation tar mindre än 5 sekunder, anses detta vara "som normalt". Dodo Pizza Android-appen lanseras (enligt Firebase-statistik _app_start) kl kall start i genomsnitt på 3 sekunder - "Inte bra, inte hemskt," som de säger.

Men så började det dyka upp klagomål om att applikationen tog väldigt, väldigt, väldigt lång tid att lansera! Till att börja med bestämde vi oss för att mäta vad "mycket, väldigt, väldigt långt" är. Och vi använde Firebase-spårning för detta Appstartspårning.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Denna standardspårning mäter tiden mellan det ögonblick då användaren öppnar applikationen och det ögonblick då onResume() för den första aktiviteten exekveras. I Firebase-konsolen kallas detta mått _app_start. Det visade sig att:

  • Starttider för användare över 95:e percentilen är nästan 20 sekunder (vissa ännu längre), trots att mediantiden för kallstart är mindre än 5 sekunder.
  • Starttiden är inte ett konstant värde, utan växer över tiden. Men ibland faller det. Vi hittade detta mönster när vi ökade analysskalan till 90 dagar.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Två tankar dök upp:

  1. Något läcker.
  2. Detta "något" återställs efter release och läcker sedan igen.

"Antagligen något med databasen", tänkte vi, och vi hade rätt. För det första använder vi databasen som en cache, under migreringen rensar vi den. För det andra laddas databasen när applikationen startar. Allt passar ihop.

Vad är det för fel på Realm-databasen

Vi började kontrollera hur innehållet i databasen förändras under applikationens livslängd, från den första installationen och vidare under aktiv användning. Du kan se innehållet i Realm-databasen via steto eller mer detaljerat och tydligt genom att öppna filen via Realm Studio. För att se innehållet i databasen via ADB, kopiera Realm-databasfilen:

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

Efter att ha tittat på innehållet i databasen vid olika tidpunkter, upptäckte vi att antalet objekt av en viss typ ständigt ökar.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering
Bilden visar ett fragment av Realm Studio för två filer: till vänster - applikationsbasen en tid efter installationen, till höger - efter aktiv användning. Det kan ses att antalet objekt ImageEntity и MoneyType har vuxit avsevärt (skärmdumpen visar antalet objekt av varje typ).

Samband mellan databastillväxt och uppstartstid

Okontrollerad databastillväxt är mycket dålig. Men hur påverkar detta programmets starttid? Det är ganska enkelt att mäta detta genom ActivityManager. Sedan Android 4.4 visar logcat loggen med strängen Visad och tiden. Denna tid är lika med intervallet från det att applikationen startas till slutet av aktivitetsrenderingen. Under denna tid inträffar följande händelser:

  • Starta processen.
  • Initialisering av objekt.
  • Skapande och initiering av aktiviteter.
  • Skapa en layout.
  • Applikationsrendering.

Passar oss. Om du kör ADB med flaggorna -S och -W kan du få utökad utdata med starttiden:

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

Om du tar det därifrån grep -i WaitTime tid, kan du automatisera insamlingen av detta mått och visuellt titta på resultaten. Grafen nedan visar beroendet av applikationens starttid på antalet kallstarter av applikationen.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Samtidigt fanns det samma karaktär av förhållandet mellan storleken och tillväxten av databasen, som växte från 4 MB till 15 MB. Totalt visar det sig att med tiden (med ökningen av kallstarter) ökade både applikationsstarttiden och storleken på databasen. Vi har en hypotes på våra händer. Nu återstod bara att bekräfta beroendet. Därför bestämde vi oss för att ta bort "läckorna" och se om detta skulle påskynda lanseringen.

Skäl till oändlig databastillväxt

Innan du tar bort "läckor" är det värt att förstå varför de dök upp i första hand. För att göra detta, låt oss komma ihåg vad Realm är.

Realm är en icke-relationell databas. Det låter dig beskriva relationer mellan objekt på ett liknande sätt som hur många ORM-relationsdatabaser på Android som beskrivs. Samtidigt lagrar Realm objekt direkt i minnet med minsta möjliga mängd transformationer och mappningar. Detta gör att du kan läsa data från disken mycket snabbt, vilket är Realms styrka och varför den är älskad.

(För syftet med denna artikel kommer denna beskrivning att räcka för oss. Du kan läsa mer om Realm in the cool dokumentation eller i deras akademi).

Många utvecklare är vana vid att arbeta mer med relationsdatabaser (till exempel ORM-databaser med SQL under huven). Och saker som kaskadradering av data verkar ofta som en självklarhet. Men inte i riket.

Förresten, funktionen för kaskadradering har efterfrågats länge. Detta revision и annan, i samband med det, diskuterades aktivt. Det fanns en känsla av att det snart skulle vara klart. Men sedan översattes allt till införandet av starka och svaga länkar, vilket också automatiskt skulle lösa detta problem. Var ganska livlig och aktiv i denna uppgift pull begäran, som har pausats för nu på grund av interna svårigheter.

Dataläcka utan kaskadradering

Hur exakt läcker data om du litar på en icke-existerande kaskadradering? Om du har kapslade Realm-objekt måste de tas bort.
Låt oss titta på ett (nästan) verkligt exempel. Vi har ett 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()

Produkten i varukorgen har olika fält, inklusive en bild ImageEntity, anpassade ingredienser CustomizationEntity. Dessutom kan produkten i varukorgen vara en kombination med sin egen uppsättning produkter RealmList (CartProductEntity). Alla listade fält är Realm-objekt. Om vi ​​infogar ett nytt objekt (copyToRealm() / copyToRealmOrUpdate()) med samma id, så kommer detta objekt att skrivas över helt. Men alla interna objekt (image, customizationEntity och cartComboProducts) kommer att förlora anslutningen till föräldern och förbli i databasen.

Eftersom anslutningen till dem försvinner läser vi dem inte längre eller tar bort dem (såvida vi inte uttryckligen kommer åt dem eller rensar hela "tabellen"). Vi kallade detta "minnesläckor".

När vi arbetar med Realm måste vi explicit gå igenom alla element och explicit radera allt innan sådana operationer. Detta kan till exempel göras så här:

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

Om du gör detta kommer allt att fungera som det ska. I det här exemplet antar vi att det inte finns några andra kapslade Realm-objekt i image, customizationEntity och cartComboProducts, så det finns inga andra kapslade loopar och borttagningar.

"Snabb" lösning

Det första vi bestämde oss för att göra var att rensa upp de snabbast växande föremålen och kontrollera resultaten för att se om detta skulle lösa vårt ursprungliga problem. Först gjordes den enklaste och mest intuitiva lösningen, nämligen: varje objekt ska ansvara för att ta bort sina barn. För att göra detta introducerade vi ett gränssnitt som returnerade en lista över dess kapslade Realm-objekt:

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

Och vi implementerade det i våra Realm-objekt:

@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 lämnar tillbaka alla barn som en platt lista. Och varje underordnat objekt kan också implementera NestedEntityAware-gränssnittet, vilket indikerar att det har interna Realm-objekt att ta bort, till exempel 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
   )
 }
}

Och så vidare, kapslingen av föremål kan upprepas.

Sedan skriver vi en metod som rekursivt tar bort alla kapslade objekt. Metod (gjord som en förlängning) deleteAllNestedEntities hämtar alla objekt och metoder på toppnivå deleteNestedRecursively Tar rekursivt bort alla kapslade objekt med NestedEntityAware-gränssnittet:

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 detta med de snabbast växande föremålen och kollade vad som hände.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Som ett resultat slutade de objekt som vi täckte med den här lösningen att växa. Och den övergripande tillväxten av basen avtog, men slutade inte.

Den "normala" lösningen

Även om basen började växa långsammare, växte den fortfarande. Så vi började leta vidare. Vårt projekt använder mycket aktivt datacachning i Realm. Därför är det arbetskrävande att skriva alla kapslade objekt för varje objekt, plus att risken för fel ökar, eftersom du kan glömma att ange objekt när du ändrar koden.

Jag ville vara säker på att jag inte använde gränssnitt, men att allt fungerade på egen hand.

När vi vill att något ska fungera av sig själv måste vi använda reflektion. För att göra detta kan vi gå igenom varje klassfält och kontrollera om det är ett Realm-objekt eller en lista med objekt:

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

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

Om fältet är en RealmModel eller RealmList, lägg sedan till objektet i detta fält till en lista med kapslade objekt. Allt är precis som vi gjorde ovan, bara här kommer det att göras av sig självt. Själva metoden för kaskadradering är väldigt enkel och ser ut så här:

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

Förlängning filterRealmObject filtrerar bort och skickar endast Realm-objekt. Metod getNestedRealmObjects genom reflektion hittar den alla kapslade Realm-objekt och placerar dem i en linjär lista. Sedan gör vi samma sak rekursivt. När du raderar måste du kontrollera objektets giltighet isValid, eftersom det kan vara så att olika överordnade objekt kan ha kapslade identiska. Det är bättre att undvika detta och helt enkelt använda automatisk generering av id när du skapar nya objekt.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Fullständig implementering av metoden 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)
}

Som ett resultat använder vi i vår klientkod "cascading delete" för varje datamodifieringsoperation. Till exempel, för en infogningsoperation ser det ut så här:

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

Metod först getManagedEntities tar emot alla tillagda objekt och sedan metoden cascadeDelete Tar rekursivt bort alla insamlade objekt innan du skriver nya. Det slutar med att vi använder detta tillvägagångssätt under hela applikationen. Minnesläckor i Realm är helt borta. Efter att ha utfört samma mätning av starttidens beroende av antalet kallstarter av applikationen ser vi resultatet.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Den gröna linjen visar beroendet av programmets starttid på antalet kallstarter under automatisk kaskadradering av kapslade objekt.

Resultat och slutsatser

Den ständigt växande Realm-databasen fick applikationen att starta väldigt långsamt. Vi släppte en uppdatering med vår egen "cascading delete" av kapslade objekt. Och nu övervakar och utvärderar vi hur vårt beslut påverkade applikationens starttid genom måttet _app_start.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

För analys tar vi en tidsperiod på 90 dagar och ser: applikationsstarttiden, både medianen och den som faller på 95:e percentilen av användare, började minska och stiger inte längre.

Berättelsen om hur kaskadradering i Realm vann över en lång lansering

Om du tittar på sjudagarsdiagrammet ser mätvärdet _app_start helt adekvat ut och är mindre än 1 sekund.

Det är också värt att tillägga att Firebase som standard skickar meddelanden om medianvärdet för _app_start överstiger 5 sekunder. Men som vi kan se bör du inte lita på detta, utan hellre gå in och kontrollera det uttryckligen.

Det speciella med Realm-databasen är att det är en icke-relationell databas. Trots sin lätthet att använda, likhet med ORM-lösningar och objektlänkning har den inte kaskadradering.

Om detta inte tas med i beräkningen kommer kapslade objekt att samlas och "läcka bort". Databasen kommer att växa konstant, vilket i sin tur kommer att påverka nedgången eller uppstarten av applikationen.

Jag delade med mig av vår erfarenhet av hur man snabbt gör en kaskadradering av objekt i Realm, som ännu inte är klar, men det har pratats om länge de säger и de säger. I vårt fall snabbade detta upp applikationens starttid avsevärt.

Trots diskussionen om det nära förestående utseendet på denna funktion, är frånvaron av kaskadradering i Realm gjord av design. Om du designar en ny applikation, ta då hänsyn till detta. Och om du redan använder Realm, kontrollera om du har sådana problem.

Källa: will.com

Lägg en kommentar