Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Alle gebruikers beschouwen een snelle lancering en een responsieve gebruikersinterface in mobiele applicaties als vanzelfsprekend. Als het lang duurt voordat de applicatie is gestart, begint de gebruiker zich verdrietig en boos te voelen. Je kunt de klantervaring gemakkelijk bederven of de gebruiker volledig kwijtraken nog voordat hij de applicatie gaat gebruiken.

We hebben ooit ontdekt dat de Dodo Pizza-app gemiddeld 3 seconden nodig heeft om te starten, en voor sommige “gelukkigen” duurt het 15-20 seconden.

Onder de snede staat een verhaal met een happy end: over de groei van de Realm-database, een geheugenlek, hoe we geneste objecten verzamelden en onszelf vervolgens bij elkaar brachten en alles repareerden.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won
Artikel auteur: Maxim Kachinkin — Android-ontwikkelaar bij Dodo Pizza.

Drie seconden vanaf het klikken op het applicatiepictogram tot onResume() van de eerste activiteit is oneindig. En voor sommige gebruikers bereikte de opstarttijd 15-20 seconden. Hoe is dit überhaupt mogelijk?

Een zeer korte samenvatting voor degenen die geen tijd hebben om te lezen
Onze Realm-database groeide eindeloos. Sommige geneste objecten zijn niet verwijderd, maar voortdurend verzameld. De opstarttijd van de applicatie nam geleidelijk toe. Vervolgens hebben we het probleem opgelost en de opstarttijd bereikte het doel: deze werd minder dan 1 seconde en nam niet langer toe. Het artikel bevat een analyse van de situatie en twee oplossingen: een snelle en een normale.

Zoeken en analyseren van het probleem

Tegenwoordig moet elke mobiele applicatie snel starten en responsief zijn. Maar het gaat niet alleen om de mobiele app. Gebruikerservaring van interactie met een dienst en een bedrijf is complex. In ons geval is de bezorgsnelheid bijvoorbeeld een van de belangrijkste indicatoren voor pizzaservice. Als de bezorging snel is, is de pizza warm en hoeft de klant die nu wil eten niet lang te wachten. Voor de applicatie is het dan weer belangrijk om een ​​gevoel van snelle service te creëren, want als de applicatie maar 20 seconden nodig heeft om op te starten, hoe lang moet je dan wachten op de pizza?

In eerste instantie werden we zelf geconfronteerd met het feit dat het soms een paar seconden duurde voordat de applicatie werd gestart, en toen begonnen we klachten van andere collega's te horen over hoe lang het duurde. Maar we konden deze situatie niet consequent herhalen.

Hoe lang is het? Volgens Google-documentatieAls een koude start van een applicatie minder dan 5 seconden duurt, dan wordt dit beschouwd als “als normaal”. Dodo Pizza Android-app gelanceerd (volgens Firebase-statistieken _app_start) bij koude start gemiddeld in 3 seconden - "Niet geweldig, niet verschrikkelijk", zoals ze zeggen.

Maar toen begonnen er klachten te verschijnen dat het heel, heel, heel lang duurde voordat de applicatie opstartte! Om te beginnen hebben we besloten te meten wat ‘heel, heel, heel lang’ is. En hiervoor hebben we Firebase-trace gebruikt App-starttracering.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Deze standaard trace meet de tijd tussen het moment dat de gebruiker de applicatie opent en het moment dat de onResume() van de eerste activiteit wordt uitgevoerd. In de Firebase Console wordt deze statistiek _app_start genoemd. Het bleek dat:

  • De opstarttijden voor gebruikers boven het 95e percentiel bedragen bijna 20 seconden (sommige zelfs langer), ondanks dat de gemiddelde koude opstarttijd minder dan 5 seconden bedraagt.
  • De opstarttijd is geen constante waarde, maar groeit in de loop van de tijd. Maar soms zijn er druppels. We ontdekten dit patroon toen we de analyseschaal vergrootten tot 90 dagen.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Er kwamen twee gedachten in me op:

  1. Er lekt iets.
  2. Dit “iets” wordt na het loslaten gereset en lekt vervolgens weer.

“Waarschijnlijk iets met de database”, dachten we, en we hadden gelijk. Ten eerste gebruiken we de database als cache; tijdens de migratie wissen we deze. Ten tweede wordt de database geladen wanneer de applicatie start. Alles past bij elkaar.

Wat is er mis met de Realm-database

We begonnen te controleren hoe de inhoud van de database verandert gedurende de levensduur van de applicatie, vanaf de eerste installatie en verder tijdens actief gebruik. U kunt de inhoud van de Realm-database bekijken via stethoscoop of gedetailleerder en duidelijker door het bestand te openen via Real Studio. Om de inhoud van de database via ADB te bekijken, kopieert u het Realm-databasebestand:

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

Nadat we de inhoud van de database op verschillende tijdstippen hadden bekeken, kwamen we erachter dat het aantal objecten van een bepaald type voortdurend toeneemt.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won
De afbeelding toont een fragment van Realm Studio voor twee bestanden: links - de applicatiebasis enige tijd na installatie, rechts - na actief gebruik. Het is te zien dat het aantal objecten ImageEntity и MoneyType is aanzienlijk gegroeid (de schermafbeelding toont het aantal objecten van elk type).

Relatie tussen databasegroei en opstarttijd

Ongecontroleerde databasegroei is erg slecht. Maar welke invloed heeft dit op de opstarttijd van de applicatie? Via de ActivityManager is dit vrij eenvoudig te meten. Sinds Android 4.4 geeft logcat het logboek weer met de tekenreeks Weergegeven en de tijd. Deze tijd is gelijk aan het interval vanaf het moment dat de applicatie wordt gestart tot het einde van de weergave van de activiteit. Gedurende deze tijd vinden de volgende gebeurtenissen plaats:

  • Start het proces.
  • Initialisatie van objecten.
  • Creëren en initialiseren van activiteiten.
  • Een lay-out maken.
  • Toepassingsweergave.

Past bij ons. Als u ADB uitvoert met de vlaggen -S en -W, kunt u uitgebreide uitvoer krijgen met de opstarttijd:

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

Als je het vanaf daar pakt grep -i WaitTime In de loop van de tijd kunt u het verzamelen van deze statistieken automatiseren en de resultaten visueel bekijken. Onderstaande grafiek toont de afhankelijkheid van de opstarttijd van de applicatie van het aantal koude starts van de applicatie.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Tegelijkertijd was er dezelfde aard van de relatie tussen de omvang en de groei van de database, die groeide van 4 MB naar 15 MB. In totaal blijkt dat in de loop van de tijd (met de groei van het aantal koude starts) zowel de opstarttijd van de applicatie als de omvang van de database zijn toegenomen. We hebben een hypothese in handen. Nu hoefde alleen nog maar de afhankelijkheid te worden bevestigd. Daarom besloten we de “lekken” te verwijderen en te kijken of dit de lancering zou versnellen.

Redenen voor eindeloze databasegroei

Voordat u de "lekken" verwijdert, is het de moeite waard om te begrijpen waarom ze überhaupt verschenen. Laten we, om dit te doen, onthouden wat Realm is.

Realm is een niet-relationele database. Hiermee kunt u relaties tussen objecten beschrijven op een vergelijkbare manier als hoeveel relationele ORM-databases op Android worden beschreven. Tegelijkertijd slaat Realm objecten rechtstreeks in het geheugen op met zo min mogelijk transformaties en toewijzingen. Hierdoor kun je zeer snel gegevens van de schijf lezen, wat de kracht van Realm is en waarom het zo geliefd is.

(Voor de doeleinden van dit artikel is deze beschrijving voldoende voor ons. U kunt meer lezen over Realm in the cool documentatie of in hun academie).

Veel ontwikkelaars zijn eraan gewend om meer met relationele databases te werken (bijvoorbeeld ORM-databases met SQL onder de motorkap). En zaken als trapsgewijze gegevensverwijdering lijken vaak een gegeven. Maar niet in Realm.

Overigens wordt er al heel lang naar de functie voor trapsgewijze verwijdering gevraagd. Dit herziening и een ander, die daarmee verband hield, werd actief besproken. Er was een gevoel dat het snel klaar zou zijn. Maar alles vertaalde zich vervolgens in de introductie van sterke en zwakke schakels, wat ook dit probleem automatisch zou oplossen. Was behoorlijk levendig en actief met deze taak trek verzoek, die voorlopig is opgeschort vanwege interne problemen.

Datalek zonder trapsgewijze verwijdering

Hoe lekken gegevens precies als u vertrouwt op een niet-bestaande trapsgewijze verwijdering? Als u Realm-objecten hebt genest, moeten deze worden verwijderd.
Laten we eens kijken naar een (bijna) reëel voorbeeld. We hebben een voorwerp 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()

Het product in de winkelwagen heeft verschillende velden, inclusief een afbeelding ImageEntity, aangepaste ingrediënten CustomizationEntity. Het product in de winkelwagen kan ook een combinatie zijn met een eigen set producten RealmList (CartProductEntity). Alle vermelde velden zijn Realm-objecten. Als we een nieuw object invoegen (copyToRealm() / copyToRealmOrUpdate()) met hetzelfde id, dan wordt dit object volledig overschreven. Maar alle interne objecten (image, maatwerkEntity en cartComboProducts) verliezen de verbinding met het bovenliggende object en blijven in de database.

Omdat de verbinding ermee verloren is, lezen of verwijderen we ze niet meer (tenzij we ze expliciet openen of de hele “tabel” leegmaken). We noemden dit ‘geheugenlekken’.

Wanneer we met Realm werken, moeten we vóór dergelijke bewerkingen expliciet alle elementen doorlopen en alles expliciet verwijderen. Dit kan bijvoorbeeld als volgt:

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

Als u dit doet, werkt alles zoals het hoort. In dit voorbeeld gaan we ervan uit dat er geen andere geneste Realm-objecten in image, customisationEntity en cartComboProducts voorkomen, dus er zijn geen andere geneste lussen en verwijderingen.

"Snelle" oplossing

Het eerste wat we besloten te doen was de snelst groeiende objecten opruimen en de resultaten controleren om te zien of dit ons oorspronkelijke probleem zou oplossen. Eerst werd de eenvoudigste en meest intuïtieve oplossing bedacht, namelijk: elk object zou verantwoordelijk moeten zijn voor het verwijderen van zijn kinderen. Om dit te doen, hebben we een interface geïntroduceerd die een lijst met geneste Realm-objecten retourneerde:

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

En we hebben het geïmplementeerd in onze Realm-objecten:

@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 we retourneren alle kinderen als een platte lijst. En elk onderliggend object kan ook de NestedEntityAware-interface implementeren, wat aangeeft dat het bijvoorbeeld interne Realm-objecten heeft om te verwijderen 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
   )
 }
}

En zo kan het nesten van objecten worden herhaald.

Vervolgens schrijven we een methode die recursief alle geneste objecten verwijdert. Werkwijze (gemaakt als verlengstuk) deleteAllNestedEntities krijgt alle objecten en methoden op het hoogste niveau deleteNestedRecursively Verwijdert recursief alle geneste objecten met behulp van de NestedEntityAware-interface:

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

Dit deden we met de snelst groeiende objecten en controleerden wat er gebeurde.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Als gevolg hiervan stopten de objecten die we met deze oplossing bedekten met groeien. En de algehele groei van de basis vertraagde, maar stopte niet.

De ‘normale’ oplossing

Hoewel de basis langzamer begon te groeien, groeide deze nog steeds. Dus zijn we verder gaan zoeken. Ons project maakt zeer actief gebruik van datacaching in Realm. Daarom is het schrijven van alle geneste objecten voor elk object arbeidsintensief en neemt de kans op fouten toe, omdat u bij het wijzigen van de code kunt vergeten objecten op te geven.

Ik wilde er zeker van zijn dat ik geen interfaces gebruikte, maar dat alles op zichzelf werkte.

Als we willen dat iets op zichzelf werkt, moeten we reflectie gebruiken. Om dit te doen, kunnen we elk klassenveld doorlopen en controleren of het een Realm-object of een lijst met objecten is:

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

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

Als het veld een RealmModel of RealmList is, voeg dan het object van dit veld toe aan een lijst met geneste objecten. Alles is precies hetzelfde als we hierboven deden, alleen hier zal het vanzelf gebeuren. De cascade-verwijderingsmethode zelf is heel eenvoudig en ziet er als volgt uit:

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

Verlenging filterRealmObject filtert alleen Realm-objecten uit en geeft deze door. Methode getNestedRealmObjects door middel van reflectie vindt het alle geneste Realm-objecten en plaatst deze in een lineaire lijst. Dan doen we hetzelfde recursief. Bij het verwijderen moet u het object controleren op geldigheid isValid, omdat het kan zijn dat verschillende ouderobjecten identieke geneste objecten kunnen hebben. Het is beter om dit te vermijden en eenvoudigweg het automatisch genereren van ID's te gebruiken bij het maken van nieuwe objecten.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Volledige implementatie van de getNestedRealmObjects-methode

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

Als gevolg hiervan gebruiken we in onze klantcode ‘cascading delete’ voor elke gegevenswijziging. Voor een invoegbewerking ziet het er bijvoorbeeld als volgt uit:

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

Methode eerst getManagedEntities ontvangt alle toegevoegde objecten en vervolgens de methode cascadeDelete Verwijdert recursief alle verzamelde objecten voordat nieuwe worden geschreven. Uiteindelijk gebruiken we deze aanpak gedurende de hele applicatie. Geheugenlekken in Realm zijn volledig verdwenen. Nadat we dezelfde meting hebben uitgevoerd van de afhankelijkheid van de opstarttijd van het aantal koude starts van de applicatie, zien we het resultaat.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

De groene lijn toont de afhankelijkheid van de opstarttijd van de applicatie van het aantal koude starts tijdens het automatisch trapsgewijs verwijderen van geneste objecten.

Resultaten en conclusies

De steeds groter wordende Realm-database zorgde ervoor dat de applicatie erg langzaam opstartte. We hebben een update uitgebracht met onze eigen "trapsgewijze verwijdering" van geneste objecten. En nu monitoren en evalueren we hoe onze beslissing de opstarttijd van de applicatie beïnvloedde via de _app_start-statistiek.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Voor analyse nemen we een periode van 90 dagen en zien we: de opstarttijd van de applicatie, zowel de mediaan als die op het 95e percentiel van de gebruikers, begon af te nemen en niet langer te stijgen.

Het verhaal over hoe cascade-verwijdering in Realm een ​​lange lancering won

Als je naar de zevendaagse grafiek kijkt, ziet de _app_start-statistiek er volkomen adequaat uit en is deze minder dan 1 seconde.

Het is ook de moeite waard om toe te voegen dat Firebase standaard meldingen verzendt als de mediaanwaarde van _app_start groter is dan 5 seconden. Zoals we echter kunnen zien, moet u hier niet op vertrouwen, maar er eerder naar toe gaan en dit expliciet controleren.

Het bijzondere aan de Realm database is dat het een niet-relationele database is. Ondanks het gebruiksgemak, de gelijkenis met ORM-oplossingen en het koppelen van objecten, is er geen sprake van cascadeverwijdering.

Als hier geen rekening mee wordt gehouden, zullen geneste objecten zich ophopen en “weglekken”. De database zal voortdurend groeien, wat op zijn beurt de vertraging of het opstarten van de applicatie zal beïnvloeden.

Ik deelde onze ervaring over hoe je snel een cascade-verwijdering van objecten in Realm kunt uitvoeren, die nog niet uit de doos is, maar waar al lang over wordt gesproken zeggen и zeggen. In ons geval versnelde dit de opstarttijd van de applicatie aanzienlijk.

Ondanks de discussie over de aanstaande verschijning van deze functie, is de afwezigheid van cascade-verwijdering in Realm een ​​ontwerp. Als u een nieuwe applicatie ontwerpt, houd hier dan rekening mee. En als u Realm al gebruikt, controleer dan of u dergelijke problemen ondervindt.

Bron: www.habr.com

Voeg een reactie