La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Tutti gli utenti danno per scontati l'avvio rapido e l'interfaccia utente reattiva nelle applicazioni mobili. Se l'avvio dell'applicazione impiega molto tempo, l'utente inizia a sentirsi triste e arrabbiato. Puoi facilmente rovinare l'esperienza del cliente o perdere completamente l'utente ancor prima che inizi a utilizzare l'applicazione.

Una volta abbiamo scoperto che l'app Dodo Pizza impiega in media 3 secondi per avviarsi e per alcuni "fortunati" ci vogliono 15-20 secondi.

Sotto il taglio c'è una storia con un lieto fine: sulla crescita del database Realm, una perdita di memoria, come abbiamo accumulato oggetti annidati, per poi rimetterci in sesto e sistemare tutto.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio
Autore di questo articolo: Maxim Kachinkin — Sviluppatore Android presso Dodo Pizza.

Tre secondi dal clic sull'icona dell'applicazione a onResume() della prima attività sono infiniti. E per alcuni utenti, il tempo di avvio ha raggiunto i 15-20 secondi. Com'è possibile?

Un riassunto molto breve per chi non ha tempo di leggere
Il nostro database Realm è cresciuto all'infinito. Alcuni oggetti nidificati non venivano eliminati, ma venivano costantemente accumulati. Il tempo di avvio dell'applicazione è gradualmente aumentato. Poi abbiamo risolto il problema e il tempo di avvio ha raggiunto il valore previsto: è diventato inferiore a 1 secondo e non è più aumentato. L'articolo contiene un'analisi della situazione e due soluzioni: una rapida e una normale.

Ricerca e analisi del problema

Oggi, qualsiasi applicazione mobile deve essere avviata rapidamente ed essere reattiva. Ma non è solo una questione di app mobile. L'esperienza dell'utente di interazione con un servizio e un'azienda è una cosa complessa. Ad esempio, nel nostro caso, la velocità di consegna è uno degli indicatori chiave per il servizio pizza. Se la consegna è veloce, la pizza sarà calda e il cliente che vuole mangiare adesso non dovrà aspettare molto. Per l'applicazione, a sua volta, è importante creare la sensazione di un servizio veloce, perché se l'applicazione impiega solo 20 secondi per avviarsi, quanto tempo dovrai aspettare per la pizza?

All'inizio, noi stessi ci siamo trovati di fronte al fatto che a volte l'applicazione impiegava un paio di secondi per avviarsi, e poi abbiamo iniziato a sentire lamentele da altri colleghi su quanto tempo impiegava. Ma non siamo stati in grado di ripetere costantemente questa situazione.

Quanto tempo è? Secondo documentazione di Google, se l'avvio a freddo di un'applicazione richiede meno di 5 secondi, ciò viene considerato "come se fosse normale". Lancio dell'app Android Dodo Pizza (secondo le metriche Firebase _app_start) A partenza a freddo in media in 3 secondi - "Non eccezionale, non terribile", come si suol dire.

Ma poi iniziarono ad apparire lamentele sul fatto che l'applicazione impiegava molto, molto, molto tempo per essere lanciata! Per cominciare, abbiamo deciso di misurare cosa significa “molto, molto, molto lungo”. E abbiamo utilizzato la traccia Firebase per questo Traccia di avvio dell'app.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Questa traccia standard misura il tempo che intercorre tra il momento in cui l'utente apre l'applicazione e il momento in cui viene eseguito onResume() della prima attività. Nella console Firebase questa metrica si chiama _app_start. È venuto fuori che:

  • I tempi di avvio per gli utenti al di sopra del 95° percentile sono di quasi 20 secondi (alcuni anche di più), nonostante il tempo medio di avvio a freddo sia inferiore a 5 secondi.
  • Il tempo di avvio non è un valore costante, ma cresce nel tempo. Ma a volte ci sono delle gocce. Abbiamo riscontrato questo modello quando abbiamo aumentato la scala di analisi a 90 giorni.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Mi sono venuti in mente due pensieri:

  1. Qualcosa sta trapelando.
  2. Questo "qualcosa" viene ripristinato dopo il rilascio e poi trapela di nuovo.

"Probabilmente qualcosa con il database", abbiamo pensato, e avevamo ragione. Innanzitutto utilizziamo il database come cache e durante la migrazione lo cancelliamo. In secondo luogo, il database viene caricato all'avvio dell'applicazione. Tutto combacia.

Cosa c'è che non va nel database Realm

Abbiamo iniziato a verificare come cambiano i contenuti del database nel corso della vita dell'applicazione, dalla prima installazione e successivamente durante l'utilizzo attivo. È possibile visualizzare il contenuto del database Realm tramite Steto o in modo più dettagliato e chiaro aprendo il file tramite Studio del Regno. Per visualizzare il contenuto del database tramite ADB, copiare il file del database Realm:

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

Dopo aver esaminato il contenuto del database in momenti diversi, abbiamo scoperto che il numero di oggetti di un certo tipo è in costante aumento.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio
L'immagine mostra un frammento di Realm Studio per due file: a sinistra - la base dell'applicazione qualche tempo dopo l'installazione, a destra - dopo l'uso attivo. Si può vedere che il numero di oggetti ImageEntity и MoneyType è cresciuto in modo significativo (lo screenshot mostra il numero di oggetti di ogni tipo).

Relazione tra crescita del database e tempo di avvio

La crescita incontrollata del database è pessima. Ma in che modo ciò influisce sul tempo di avvio dell'applicazione? È abbastanza semplice misurarlo tramite ActivityManager. A partire da Android 4.4, logcat visualizza il registro con la stringa Visualizzato e l'ora. Questo tempo è pari all'intervallo dal momento in cui viene avviata l'applicazione fino alla fine del rendering dell'attività. Durante questo periodo si verificano i seguenti eventi:

  • Avvia il processo.
  • Inizializzazione degli oggetti.
  • Creazione e inizializzazione delle attività.
  • Creazione di un layout.
  • Rendering dell'applicazione.

Ci va bene. Se esegui ADB con i flag -S e -W, puoi ottenere un output esteso con il tempo di avvio:

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

Se lo prendi da lì grep -i WaitTime volta, puoi automatizzare la raccolta di questa metrica e osservare visivamente i risultati. Il grafico seguente mostra la dipendenza del tempo di avvio dell'applicazione dal numero di avvii a freddo dell'applicazione.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Allo stesso tempo, è rimasta la stessa natura del rapporto tra dimensione e crescita del database, che è passato da 4 MB a 15 MB. In totale, risulta che nel tempo (con l'aumento degli avvii a freddo), sono aumentati sia il tempo di avvio dell'applicazione che la dimensione del database. Abbiamo un'ipotesi tra le mani. Ora non restava che confermare la dipendenza. Pertanto, abbiamo deciso di rimuovere le “fughe di notizie” e vedere se questo avrebbe accelerato il lancio.

Ragioni per la crescita infinita del database

Prima di rimuovere le “fughe di notizie”, vale la pena capire perché sono apparse. Per fare questo, ricordiamo cos’è Realm.

Realm è un database non relazionale. Permette di descrivere le relazioni tra oggetti in modo simile a come vengono descritti molti database relazionali ORM su Android. Allo stesso tempo, Realm archivia gli oggetti direttamente in memoria con il minor numero di trasformazioni e mappature. Ciò ti consente di leggere i dati dal disco molto rapidamente, che è il punto di forza di Realm e il motivo per cui è amato.

(Ai fini di questo articolo, questa descrizione sarà sufficiente per noi. Puoi leggere di più su Realm in the cool documentazione o nel loro l'accademia).

Molti sviluppatori sono abituati a lavorare maggiormente con database relazionali (ad esempio, database ORM con SQL sotto il cofano). E cose come la cancellazione a cascata dei dati spesso sembrano scontate. Ma non nel Regno.

A proposito, la funzione di eliminazione a cascata è stata richiesta da molto tempo. Questo revisione и un altro, ad esso associato, è stato discusso attivamente. C'era la sensazione che presto sarebbe stato fatto. Ma poi tutto si è tradotto nell’introduzione di anelli forti e anelli deboli, che avrebbero risolto automaticamente anche questo problema. Era piuttosto vivace e attivo in questo compito richiesta pull, che per ora è stata sospesa a causa di difficoltà interne.

Perdita di dati senza eliminazione a cascata

Come avviene esattamente la perdita di dati se si fa affidamento su un'eliminazione a cascata inesistente? Se hai nidificato oggetti Realm, devono essere eliminati.
Facciamo un esempio (quasi) reale. Abbiamo un oggetto 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()

Il prodotto nel carrello ha diversi campi, inclusa un'immagine ImageEntity, ingredienti personalizzati CustomizationEntity. Inoltre, il prodotto nel carrello può essere combinato con il proprio set di prodotti RealmList (CartProductEntity). Tutti i campi elencati sono oggetti Realm. Se inseriamo un nuovo oggetto (copyToRealm() / copyToRealmOrUpdate()) con lo stesso id, questo oggetto verrà completamente sovrascritto. Ma tutti gli oggetti interni (immagine, customizzazioneEntity e cartComboProducts) perderanno la connessione con il genitore e rimarranno nel database.

Poiché la connessione con essi viene persa, non li leggiamo né li cancelliamo più (a meno che non accediamo esplicitamente ad essi o cancelliamo l'intera “tabella”). Abbiamo chiamato questo "perdite di memoria".

Quando lavoriamo con Realm, dobbiamo esaminare esplicitamente tutti gli elementi ed eliminare esplicitamente tutto prima di tali operazioni. Questo può essere fatto, ad esempio, in questo modo:

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

Se lo fai, tutto funzionerà come dovrebbe. In questo esempio, presupponiamo che non ci siano altri oggetti Realm nidificati all'interno di image, optimizationEntity e cartComboProducts, quindi non ci sono altri cicli ed eliminazioni nidificati.

Soluzione "rapida".

La prima cosa che abbiamo deciso di fare è stata ripulire gli oggetti a crescita più rapida e controllare i risultati per vedere se questo avrebbe risolto il nostro problema originale. Innanzitutto è stata adottata la soluzione più semplice e intuitiva, ovvero: ogni oggetto dovrebbe essere responsabile della rimozione dei propri figli. Per fare ciò, abbiamo introdotto un'interfaccia che restituiva un elenco dei suoi oggetti Realm nidificati:

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

E lo abbiamo implementato nei nostri oggetti 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 restituiamo tutti i bambini come un elenco semplice. E ogni oggetto figlio può anche implementare l'interfaccia NestedEntityAware, indicando che ha oggetti Realm interni da eliminare, ad esempio 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
   )
 }
}

E così via, l'annidamento degli oggetti può essere ripetuto.

Quindi scriviamo un metodo che elimina ricorsivamente tutti gli oggetti nidificati. Metodo (realizzato come estensione) deleteAllNestedEntities ottiene tutti gli oggetti e i metodi di livello superiore deleteNestedRecursively Rimuove ricorsivamente tutti gli oggetti nidificati utilizzando l'interfaccia 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()
   }
 }
}

Lo abbiamo fatto con gli oggetti a crescita più rapida e abbiamo verificato cosa è successo.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Di conseguenza, gli oggetti che abbiamo coperto con questa soluzione hanno smesso di crescere. E la crescita complessiva della base è rallentata, ma non si è fermata.

La soluzione "normale".

Sebbene la base abbia iniziato a crescere più lentamente, è comunque cresciuta. Quindi abbiamo iniziato a guardare oltre. Il nostro progetto fa un uso molto attivo della memorizzazione nella cache dei dati in Realm. Pertanto, scrivere tutti gli oggetti nidificati per ciascun oggetto richiede molto lavoro, inoltre aumenta il rischio di errori, poiché è possibile dimenticare di specificare gli oggetti quando si modifica il codice.

Volevo assicurarmi di non utilizzare interfacce, ma che tutto funzionasse da solo.

Quando vogliamo che qualcosa funzioni da solo, dobbiamo usare la riflessione. Per fare ciò, possiamo esaminare ciascun campo della classe e verificare se si tratta di un oggetto Realm o di un elenco di oggetti:

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

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

Se il campo è un RealmModel o RealmList, aggiungi l'oggetto di questo campo a un elenco di oggetti nidificati. Tutto è esattamente come abbiamo fatto sopra, solo che qui verrà fatto da solo. Il metodo di eliminazione a cascata in sé è molto semplice e si presenta così:

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

Estensione filterRealmObject filtra e trasmette solo oggetti Realm. Metodo getNestedRealmObjects attraverso la riflessione, trova tutti gli oggetti Realm nidificati e li inserisce in un elenco lineare. Quindi facciamo la stessa cosa in modo ricorsivo. Durante l'eliminazione è necessario verificare la validità dell'oggetto isValid, perché è possibile che diversi oggetti genitore possano averne annidati identici. È meglio evitarlo e utilizzare semplicemente la generazione automatica dell'ID quando si creano nuovi oggetti.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Implementazione completa del metodo 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)
}

Di conseguenza, nel nostro codice client utilizziamo la "eliminazione a cascata" per ogni operazione di modifica dei dati. Ad esempio, per un'operazione di inserimento assomiglia a questa:

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

Innanzitutto il metodo getManagedEntities riceve tutti gli oggetti aggiunti e quindi il metodo cascadeDelete Elimina in modo ricorsivo tutti gli oggetti raccolti prima di scriverne di nuovi. Finiamo per utilizzare questo approccio in tutta l'applicazione. Le perdite di memoria in Realm sono completamente scomparse. Avendo effettuato la stessa misurazione della dipendenza del tempo di avvio dal numero di avviamenti a freddo dell'applicazione, vediamo il risultato.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

La linea verde mostra la dipendenza del tempo di avvio dell'applicazione dal numero di avvii a freddo durante l'eliminazione automatica a cascata degli oggetti nidificati.

Risultati e conclusioni

Il database Realm in continua crescita causava l'avvio dell'applicazione molto lentamente. Abbiamo rilasciato un aggiornamento con la nostra "eliminazione a cascata" di oggetti nidificati. E ora monitoriamo e valutiamo in che modo la nostra decisione ha influenzato il tempo di avvio dell'applicazione attraverso la metrica _app_start.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Per l'analisi prendiamo un periodo di 90 giorni e vediamo: il tempo di lancio dell'applicazione, sia quello mediano che quello che rientra nel 95° percentile degli utenti, ha iniziato a diminuire e non aumenta più.

La storia di come l'eliminazione a cascata in Realm ha avuto successo dopo un lungo lancio

Se guardi il grafico di sette giorni, la metrica _app_start sembra del tutto adeguata ed è inferiore a 1 secondo.

Vale anche la pena aggiungere che, per impostazione predefinita, Firebase invia notifiche se il valore medio di _app_start supera i 5 secondi. Tuttavia, come possiamo vedere, non dovresti fare affidamento su questo, ma piuttosto andare a controllarlo esplicitamente.

La particolarità del database Realm è che si tratta di un database non relazionale. Nonostante la sua facilità d'uso, la somiglianza con le soluzioni ORM e il collegamento di oggetti, non prevede la cancellazione a cascata.

Se questo non viene preso in considerazione, gli oggetti annidati si accumuleranno e “fugheranno via”. Il database crescerà costantemente, il che a sua volta influenzerà il rallentamento o l'avvio dell'applicazione.

Ho condiviso la nostra esperienza su come eseguire rapidamente un'eliminazione a cascata di oggetti in Realm, che non è ancora pronta, ma di cui si parla da molto tempo говорят и говорят. Nel nostro caso, ciò ha notevolmente accelerato i tempi di avvio dell'applicazione.

Nonostante la discussione sull'imminente comparsa di questa funzionalità, l'assenza di eliminazioni a cascata in Realm è prevista dalla progettazione. Se stai progettando una nuova applicazione, tienilo in considerazione. E se stai già utilizzando Realm, controlla se riscontri problemi di questo tipo.

Fonte: habr.com

Aggiungi un commento