La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Tots els usuaris donen per fet un llançament ràpid i una interfície d'usuari sensible a les aplicacions mòbils. Si l'aplicació triga molt a llançar-se, l'usuari comença a sentir-se trist i enfadat. Podeu fer malbé l'experiència del client o perdre-lo completament fins i tot abans que comenci a utilitzar l'aplicació.

Una vegada vam descobrir que l'aplicació Dodo Pizza triga 3 segons a llançar-se de mitjana, i per a alguns "afortunats" triga entre 15 i 20 segons.

A sota del tall hi ha una història amb un final feliç: sobre el creixement de la base de dades del Regne, una fuga de memòria, com vam acumular objectes imbricats i després ens vam ajuntar i ho vam arreglar tot.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

La història de com va guanyar la supressió en cascada al llarg llançament de Realm
Autor de l'article: Maxim Kachinkin — Desenvolupador d'Android a Dodo Pizza.

Tres segons des de fer clic a la icona de l'aplicació fins a onResume() de la primera activitat és infinit. I per a alguns usuaris, el temps d'inici va arribar als 15-20 segons. Com és això possible?

Un resum molt breu per als que no tenen temps de llegir
La nostra base de dades del regne va créixer sense parar. Alguns objectes imbricats no es van suprimir, sinó que s'acumulen constantment. El temps d'inici de l'aplicació va augmentar gradualment. Llavors ho vam arreglar i el temps d'inici va arribar a l'objectiu: va passar a menys d'1 segon i ja no va augmentar. L'article conté una anàlisi de la situació i dues solucions: una ràpida i una de normal.

Recerca i anàlisi del problema

Avui dia, qualsevol aplicació mòbil s'ha d'iniciar ràpidament i respondre. Però no es tracta només de l'aplicació mòbil. L'experiència d'usuari d'interacció amb un servei i una empresa és una cosa complexa. Per exemple, en el nostre cas, la velocitat de lliurament és un dels indicadors clau del servei de pizza. Si el lliurament és ràpid, la pizza estarà calenta i el client que vulgui menjar ara no haurà d'esperar gaire. Per a l'aplicació, al seu torn, és important crear la sensació de servei ràpid, perquè si l'aplicació només triga 20 segons a llançar-se, quant de temps hauràs d'esperar a la pizza?

Al principi, nosaltres mateixos ens vam trobar amb el fet que de vegades l'aplicació trigava un parell de segons a llançar-se, i després vam començar a escoltar queixes d'altres companys sobre quant de temps trigava. Però no vam poder repetir constantment aquesta situació.

Quant de temps és? D'acord amb Documentació de Google, si l'inici en fred d'una aplicació triga menys de 5 segons, es considera "com si fos normal". S'ha llançat l'aplicació per a Android Dodo Pizza (segons les mètriques de Firebase _inici_aplicació) a les arrencada en fred de mitjana en 3 segons: "No genial, no terrible", com diuen.

Però llavors van començar a aparèixer queixes que l'aplicació va trigar molt, molt, molt a llançar-se! Per començar, vam decidir mesurar què és "molt, molt, molt llarg". I hem utilitzat la traça de Firebase per a això Traça d'inici de l'aplicació.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Aquest rastre estàndard mesura el temps entre el moment en què l'usuari obre l'aplicació i el moment en què s'executa l'onResume() de la primera activitat. A la consola de Firebase, aquesta mètrica s'anomena _app_start. Va resultar que:

  • Els temps d'inici per als usuaris per sobre del percentil 95 són gairebé 20 segons (alguns fins i tot més llargs), tot i que el temps mitjà d'inici en fred és inferior a 5 segons.
  • El temps d'inici no és un valor constant, sinó que creix amb el temps. Però de vegades hi ha gotes. Vam trobar aquest patró quan vam augmentar l'escala d'anàlisi a 90 dies.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Em van venir al cap dues reflexions:

  1. Alguna cosa s'està filtrant.
  2. Aquest "alguna cosa" es restableix després del llançament i després es torna a filtrar.

"Probablement alguna cosa amb la base de dades", vam pensar, i teníem raó. En primer lloc, fem servir la base de dades com a memòria cau; durant la migració l'esborrem. En segon lloc, la base de dades es carrega quan s'inicia l'aplicació. Tot encaixa.

Què passa amb la base de dades Realm?

Vam començar a comprovar com canvia el contingut de la base de dades al llarg de la vida de l'aplicació, des de la primera instal·lació i durant l'ús actiu. Podeu veure el contingut de la base de dades Realm mitjançant Esteto o amb més detall i clarament obrint el fitxer via Realm Studio. Per veure el contingut de la base de dades mitjançant ADB, copieu el fitxer de base de dades Realm:

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

Després d'haver mirat el contingut de la base de dades en diferents moments, vam descobrir que el nombre d'objectes d'un determinat tipus augmenta constantment.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm
La imatge mostra un fragment de Realm Studio per a dos fitxers: a l'esquerra - la base de l'aplicació un temps després de la instal·lació, a la dreta - després de l'ús actiu. Es pot veure que el nombre d'objectes ImageEntity и MoneyType ha crescut significativament (la captura de pantalla mostra el nombre d'objectes de cada tipus).

Relació entre el creixement de la base de dades i el temps d'inici

El creixement descontrolat de la base de dades és molt dolent. Però, com afecta això el temps d'inici de l'aplicació? És bastant fàcil mesurar-ho mitjançant l'ActivityManager. Des d'Android 4.4, logcat mostra el registre amb la cadena Mostrada i l'hora. Aquest temps és igual a l'interval des del moment en què s'inicia l'aplicació fins al final de la representació de l'activitat. Durant aquest temps es produeixen els següents esdeveniments:

  • Inicieu el procés.
  • Inicialització d'objectes.
  • Creació i inicialització d'activitats.
  • Creació d'un disseny.
  • Renderització d'aplicacions.

Ens convé. Si executeu ADB amb els senyaladors -S i -W, podeu obtenir una sortida ampliada amb el temps d'inici:

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

Si l'agafes des d'allà grep -i WaitTime temps, podeu automatitzar la recollida d'aquesta mètrica i mirar visualment els resultats. El gràfic següent mostra la dependència del temps d'inici de l'aplicació del nombre d'inicis en fred de l'aplicació.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Al mateix temps, hi havia la mateixa naturalesa de la relació entre la mida i el creixement de la base de dades, que va passar de 4 MB a 15 MB. En total, resulta que amb el temps (amb el creixement dels arrencades en fred), tant el temps d'inici de l'aplicació com la mida de la base de dades van augmentar. Tenim una hipòtesi a les nostres mans. Ara només quedava confirmar la dependència. Per tant, vam decidir eliminar les "fuites" i veure si això acceleraria el llançament.

Raons per al creixement infinit de bases de dades

Abans d'eliminar les "fuites", val la pena entendre per què van aparèixer en primer lloc. Per fer-ho, recordem què és el Regne.

Realm és una base de dades no relacional. Us permet descriure les relacions entre objectes de manera similar a quantes bases de dades relacionals ORM es descriuen a Android. Al mateix temps, Realm emmagatzema objectes directament a la memòria amb la menor quantitat de transformacions i mapes. Això us permet llegir les dades del disc molt ràpidament, que és la força del Regne i per què s'estima.

(Per als propòsits d'aquest article, aquesta descripció ens serà suficient. Podeu llegir més sobre Realm a la fresca documentació o en els seus acadèmia).

Molts desenvolupadors estan acostumats a treballar més amb bases de dades relacionals (per exemple, bases de dades ORM amb SQL sota el capó). I coses com la supressió de dades en cascada sovint semblen un fet. Però no al regne.

Per cert, la funció d'eliminació en cascada s'ha demanat durant molt de temps. Això revisió и un altre, associat amb ell, es va discutir activament. Hi havia la sensació que aviat es faria. Però després tot es va traduir en la introducció de vincles forts i febles, que també resoldrien automàticament aquest problema. Va ser força animat i actiu en aquesta tasca petició d'extracció, que de moment s'ha aturat per dificultats internes.

Fuga de dades sense supressió en cascada

Com es filtren les dades exactament si confieu en una supressió en cascada inexistent? Si teniu objectes del regne imbricats, s'han de suprimir.
Vegem un exemple (gairebé) real. Tenim un objecte 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()

El producte del carretó té diferents camps, inclosa una imatge ImageEntity, ingredients personalitzats CustomizationEntity. A més, el producte del carretó pot ser una combinació amb el seu propi conjunt de productes RealmList (CartProductEntity). Tots els camps llistats són objectes del regne. Si inserim un objecte nou (copyToRealm() / copyToRealmOrUpdate()) amb el mateix identificador, aquest objecte se sobreescriurà completament. Però tots els objectes interns (imatge, customizationEntity i cartComboProducts) perdran la connexió amb el pare i romandran a la base de dades.

Com que es perd la connexió amb ells, ja no els llegim ni els suprimim (tret que hi accedim explícitament o esborrem tota la "taula"). Vam anomenar això "fuites de memòria".

Quan treballem amb Realm, hem de revisar explícitament tots els elements i suprimir-ho tot abans d'aquestes operacions. Això es pot fer, per exemple, així:

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

Si feu això, tot funcionarà com cal. En aquest exemple, suposem que no hi ha altres objectes de Realm imbricats dins d'image, customizationEntity i cartComboProducts, de manera que no hi ha altres bucles ni supressions imbricats.

Solució "ràpida".

El primer que vam decidir fer va ser netejar els objectes de creixement més ràpid i comprovar els resultats per veure si això resoldria el nostre problema original. En primer lloc, es va fer la solució més senzilla i intuïtiva, és a dir: cada objecte s'ha d'encarregar d'eliminar els seus fills. Per fer-ho, vam introduir una interfície que retornava una llista dels seus objectes de regne imbricats:

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

I ho vam implementar als nostres objectes de regne:

@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 tornem tots els nens com una llista plana. I cada objecte fill també pot implementar la interfície NestedEntityAware, cosa que indica que té objectes interns de Realm per eliminar, per exemple 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
   )
 }
}

I així successivament, la nidificació d'objectes es pot repetir.

A continuació, escrivim un mètode que elimina de forma recursiva tots els objectes imbricats. Mètode (realitzat com a extensió) deleteAllNestedEntities obté tots els objectes i mètodes de primer nivell deleteNestedRecursively Elimina de manera recursiva tots els objectes imbricats mitjançant la interfície 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()
   }
 }
}

Ho vam fer amb els objectes de creixement més ràpid i vam comprovar què passava.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Com a resultat, aquells objectes que vam cobrir amb aquesta solució van deixar de créixer. I el creixement global de la base es va alentir, però no es va aturar.

La solució "normal".

Tot i que la base va començar a créixer més lentament, encara va créixer. Així que vam començar a buscar més enllà. El nostre projecte fa un ús molt actiu de la memòria cau de dades a Realm. Per tant, escriure tots els objectes imbricats per a cada objecte requereix molta mà d'obra, i augmenta el risc d'errors, ja que podeu oblidar-vos d'especificar objectes quan canvieu el codi.

Volia assegurar-me que no feia servir interfícies, sinó que tot funcionava per si sol.

Quan volem que alguna cosa funcioni per si sola, hem d'utilitzar la reflexió. Per fer-ho, podem recórrer cada camp de classe i comprovar si es tracta d'un objecte Realm o d'una llista d'objectes:

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

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

Si el camp és un RealmModel o RealmList, afegiu l'objecte d'aquest camp a una llista d'objectes imbricats. Tot és exactament igual que hem fet anteriorment, només que aquí es farà tot sol. El mètode d'eliminació en cascada en si és molt senzill i té aquest aspecte:

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

Extensió filterRealmObject filtra i passa només objectes del regne. Mètode getNestedRealmObjects mitjançant la reflexió, troba tots els objectes del Regne imbricats i els col·loca en una llista lineal. Aleshores fem el mateix de forma recursiva. Quan suprimiu, heu de comprovar la validesa de l'objecte isValid, perquè pot ser que diferents objectes pares puguin tenir-ne d'idèntics imbricats. És millor evitar-ho i simplement utilitzar la generació automàtica d'identificador quan es creen objectes nous.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Implementació completa del mètode 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)
}

Com a resultat, al nostre codi de client fem servir la "supressió en cascada" per a cada operació de modificació de dades. Per exemple, per a una operació d'inserció té aquest aspecte:

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

Primer mètode getManagedEntities rep tots els objectes afegits i després el mètode cascadeDelete Suprimeix de manera recursiva tots els objectes recollits abans d'escriure'n de nous. Acabem utilitzant aquest enfocament durant tota l'aplicació. Les fuites de memòria al regne han desaparegut completament. Després d'haver realitzat la mateixa mesura de la dependència del temps d'arrencada del nombre d'arrencada en fred de l'aplicació, veiem el resultat.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

La línia verda mostra la dependència del temps d'inici de l'aplicació del nombre d'inicis en fred durant la supressió automàtica en cascada d'objectes imbricats.

Resultats i conclusions

La creixent base de dades de Realm feia que l'aplicació s'iniciés molt lentament. Vam publicar una actualització amb la nostra pròpia "eliminació en cascada" d'objectes imbricats. I ara monitoritzem i avaluem com la nostra decisió va afectar el temps d'inici de l'aplicació mitjançant la mètrica _app_start.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Per a l'anàlisi, prenem un període de temps de 90 dies i veiem: el temps d'inici de l'aplicació, tant la mitjana com el que recau en el percentil 95 d'usuaris, va començar a disminuir i ja no augmenta.

La història de com va guanyar la supressió en cascada al llarg llançament de Realm

Si mireu el gràfic de set dies, la mètrica _app_start sembla completament adequada i dura menys d'1 segon.

També val la pena afegir que, de manera predeterminada, Firebase envia notificacions si el valor mitjà de _app_start supera els 5 segons. Tanmateix, com podem veure, no us hauríeu de confiar en això, sinó d'entrar i comprovar-ho explícitament.

L'especial de la base de dades Realm és que és una base de dades no relacional. Malgrat la seva facilitat d'ús, la seva similitud amb les solucions ORM i l'enllaç d'objectes, no té supressió en cascada.

Si això no es té en compte, els objectes imbricats s'acumularan i "fuiran". La base de dades creixerà constantment, cosa que al seu torn afectarà la desacceleració o l'inici de l'aplicació.

Vaig compartir la nostra experiència sobre com fer ràpidament una supressió en cascada d'objectes a Realm, que encara no està fora de la caixa, però se'n parla des de fa molt de temps. dir и dir. En el nostre cas, això va accelerar molt el temps d'inici de l'aplicació.

Malgrat la discussió sobre l'aparició imminent d'aquesta característica, l'absència de supressió en cascada a Realm es fa per disseny. Si esteu dissenyant una aplicació nova, tingueu-ho en compte. I si ja esteu utilitzant Realm, comproveu si teniu aquests problemes.

Font: www.habr.com

Afegeix comentari