Una historia de cómo la eliminación en cascada de Realm ganó el comienzo largo

Todos los usuarios dan por sentado el inicio rápido y la interfaz de usuario receptiva en las aplicaciones móviles. Si la aplicación tarda mucho en iniciarse, el usuario se pone triste y enojado. Puede estropear fácilmente la experiencia del cliente o incluso perder al usuario incluso antes de que comience a utilizar la aplicación.



Una vez descubrimos que la aplicación Dodo Pizza se inicia en un promedio de 3 segundos, y para algunos "afortunados" tarda entre 15 y 20 segundos.



Debajo del corte hay una historia con final feliz: sobre el crecimiento de la base de datos de Realm, pérdidas de memoria, cómo guardamos objetos anidados, y luego nos recuperamos y arreglamos todo.










El autor del artículo: Maxim Kachinkin es un desarrollador de Android en Dodo Pizza.






Tres segundos desde un clic en el icono de la aplicación hasta el onResume () de la primera actividad es infinito. Y para algunos usuarios, el tiempo de lanzamiento alcanzó los 15-20 segundos. Como es esto posible?



Un resumen muy breve para aquellos que no tienen tiempo para leer
Realm. , . . , — 1 . — - -.



Búsqueda y análisis del problema



Hoy en día, cualquier aplicación móvil debe iniciarse rápidamente y responder. Pero no es solo la aplicación móvil. La experiencia del usuario al interactuar con un servicio y una empresa es algo complejo. Por ejemplo, en nuestro caso, la velocidad de entrega es uno de los indicadores clave para un servicio de pizza. Si la entrega es rápida, entonces la pizza estará caliente y el cliente que quiera comer ahora no tendrá que esperar mucho. Para la aplicación, a su vez, es importante crear la sensación de un servicio rápido, porque si la aplicación se inicia en solo 20 segundos, ¿cuánto tardará una pizza?



Al principio, nosotros mismos nos enfrentamos al hecho de que en ocasiones la aplicación se lanza durante un par de segundos, y luego nos empezaron a llegar quejas de otros compañeros de que era “larga”. Pero no logramos repetir esta situación de manera estable.



Cuanto tiempo es De acuerdo aDocumentación de Google , si el inicio en frío de una aplicación tarda menos de 5 segundos, se considera "algo normal". La aplicación de Android Dodo Pizza se lanzó (según la métrica _app_start de Firebase ) con un arranque en frío en un promedio de 3 segundos - "Ni genial, ni terrible", como dicen.



¡Pero luego comenzaron a aparecer quejas de que la aplicación se lanzó durante mucho, mucho, mucho tiempo! Para empezar, decidimos medir lo que es "muy, muy, muy largo". Y usamos el rastreo de inicio de la aplicación Firebase trace para esto .







Esta traza estándar mide el tiempo entre el momento en que el usuario abre la aplicación y el momento en que se ejecuta onResume () de la primera activación. En Firebase Console, esta métrica se llama _app_start. Resultó que:



  • Los usuarios por encima del percentil 95 tienen un tiempo de inicio de casi 20 segundos (algunos tienen más), a pesar de un tiempo medio de inicio en frío de menos de 5 segundos.
  • El tiempo de inicio no es constante, sino que crece con el tiempo. Pero a veces se observan caídas. Encontramos este patrón cuando aumentamos la escala de análisis a 90 días.






Me vinieron a la mente dos pensamientos:



  1. Algo gotea.
  2. Este "algo" se descarta después de la liberación y luego se filtra nuevamente.


“Probablemente algo con la base de datos”, pensamos, y teníamos razón. En primer lugar, usamos la base de datos como caché, la borramos durante la migración. En segundo lugar, la base de datos se carga cuando se inicia la aplicación. Todo encaja.



¿Qué pasa con la base de datos de Realm?



Comenzamos a verificar cómo cambia el contenido de la base de datos durante la vida útil de la aplicación, desde la primera instalación y más adelante en el proceso de uso activo. Puede ver el contenido de la base de datos de Realm a través de Stetho o con más detalle y visualmente abriendo el archivo a través de Realm Studio . Para ver el contenido de la base de datos a través de ADB, copie el archivo de base de datos de Realm:



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


Habiendo examinado el contenido de la base de datos en diferentes momentos, descubrimos que la cantidad de objetos de cierto tipo aumenta constantemente.





La imagen muestra un fragmento de Realm Studio para dos archivos: a la izquierda, la base de datos de la aplicación después de algún tiempo después de la instalación, a la derecha, después del uso activo. Se puede ver que la cantidad de objetos ImageEntityy MoneyTypeha crecido significativamente (la captura de pantalla muestra la cantidad de objetos de cada tipo).



Relación del crecimiento de la base de datos con los tiempos de inicio



El crecimiento incontrolado de la base de datos es muy malo. Pero, ¿cómo afecta esto al tiempo de lanzamiento de la aplicación? Es bastante fácil medirlo a través del ActivityManager. A partir de Android 4.4, logcat muestra un registro con la cadena y la hora mostradas. Este tiempo es igual al intervalo desde el momento en que se lanzó la aplicación hasta el final de la prestación de la actividad. Durante este tiempo, ocurren eventos:



  • Iniciando el proceso.
  • Inicialización de objeto.
  • Creación e inicialización de actividad.
  • Creación de maquetación.
  • Representación de aplicaciones.


Adecuado para nosotros. Si ejecuta ADB con los indicadores -S y -W, puede obtener una salida extendida con la hora de inicio:



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


Si reúne grep -i WaitTimetiempo a partir de ahí , puede automatizar la recopilación de esta métrica y ver los resultados gráficamente. El siguiente gráfico muestra la dependencia del tiempo de inicio de la aplicación en el número de inicios en frío de la aplicación.







Al mismo tiempo, la dependencia del tamaño y el crecimiento de la base fue la misma, que pasó de 4 MB a 15 MB. En total, resulta que con el tiempo (con el aumento de los inicios en frío), tanto el tiempo de lanzamiento de la aplicación como el tamaño de la base de datos crecieron. Tenemos una hipótesis en nuestras manos. Ahora quedaba confirmar la dependencia. Por lo tanto, decidimos eliminar las "filtraciones" y ver si acelera el lanzamiento.



Razones para el crecimiento infinito de la base de datos



Antes de eliminar las "fugas", vale la pena comprender por qué aparecieron. Para hacer esto, recordemos qué es Realm.



Realm es una base de datos no relacional. Le permite describir las relaciones entre objetos de una manera similar a la que describen muchas bases de datos relacionales ORM en Android. Al mismo tiempo, Realm guarda directamente los objetos en la memoria con el menor número de transformaciones y asignaciones. Esto le permite leer datos del disco muy rápidamente, lo cual es una fortaleza de Realm y es muy querido.



(Para los propósitos de este artículo, esta descripción será suficiente para nosotros. Puedes leer más sobre Realm en la documentación interesante o en su academia ).



Muchos desarrolladores están acostumbrados a trabajar más con bases de datos relacionales (por ejemplo, bases de datos ORM con SQL bajo el capó). Y cosas como la eliminación de datos en cascada a menudo parecen una cuestión de rutina. Pero no en Realm.



Por cierto, la función de eliminación en cascada se ha solicitado durante mucho tiempo. Esta revisión y otra relacionada con ella fueron discutidas activamente. Tenía la sensación de que pronto se haría. Pero luego todo se convirtió en la introducción de vínculos fuertes y débiles, que también resolverían automáticamente este problema. Para esta tarea, hubo una solicitud de extracción bastante animada y activa , que se detuvo por ahora debido a dificultades internas.



Fuga de datos sin eliminación en cascada



¿Cómo se filtran exactamente los datos si espera una eliminación en cascada inexistente? Si ha anidado objetos Realm, debe eliminarlos.

Veamos un ejemplo (casi) del mundo real. Tenemos un objeto 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 producto en el carrito tiene diferentes campos, incluida una imagen ImageEntity, ingredientes personalizados CustomizationEntity. Además, el producto en la canasta puede ser un combo con su propio conjunto de productos RealmList (CartProductEntity). Todos los campos enumerados son objetos de Reino. Si insertamos un nuevo objeto (copyToRealm () / copyToRealmOrUpdate ()) con el mismo id, entonces este objeto se sobrescribirá por completo. Pero todos los objetos internos (imagen, personalizaciónEntity y cartComboProducts) perderán su conexión con el padre y permanecerán en la base de datos.



Dado que se pierde la conexión con ellos, ya no los leemos ni los eliminamos (a menos que nos refiramos explícitamente a ellos o borremos toda la "tabla"). A esto lo llamamos "pérdidas de memoria".



Cuando trabajamos con Realm, debemos revisar explícitamente todos los elementos y eliminar explícitamente todo antes de tales operaciones. Esto se puede hacer, por ejemplo, así:



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 hace esto, todo funcionará como debería. En este ejemplo, asumimos que no hay otros objetos de Realm anidados dentro de la imagen, personalizaciónEntity y cartComboProducts, por lo que no hay otros bucles y eliminaciones anidados.



Solucion rapida



En primer lugar, decidimos limpiar los objetos de más rápido crecimiento y verificar los resultados, si esto resolverá nuestro problema original. Primero, se hizo la solución más simple e intuitiva, a saber: cada objeto debería ser responsable de eliminar a sus hijos después de sí mismo. Para hacer esto, presentamos la siguiente interfaz, que devolvió una lista de sus objetos Realm anidados:



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


Y lo implementamos en nuestros objetos 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
   )
 }
}


Como getNestedEntitiesles devolvemos a todos los niños una lista plana. Y cada objeto hijo también puede implementar la interfaz NestedEntityAware, informando que tiene objetos internos de Realm para eliminar, por ejemplo 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
   )
 }
}


Y así sucesivamente, se puede repetir el anidamiento de objetos.



Luego escribimos un método que elimina de forma recursiva todos los objetos anidados. El método (creado en forma de extensión) deleteAllNestedEntitiesobtiene todos los objetos de nivel superior y deleteNestedRecursivelyelimina de forma recursiva todo lo anidado mediante la interfaz 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()
   }
 }
}


Hicimos esto con los objetos de más rápido crecimiento y verificamos lo que sucedió.







Como resultado, los objetos que cubrimos con esta solución dejaron de crecer. Y el crecimiento general de la base se desaceleró, pero no se detuvo.



La solución "normal"



La base, aunque empezó a crecer más lentamente, seguía creciendo. Así que empezamos a buscar más. En nuestro proyecto, el almacenamiento en caché de datos en Realm se utiliza de forma muy activa. Por lo tanto, escribir todos los objetos anidados para cada objeto es laborioso, además de que aumenta el riesgo de un error, porque puede olvidarse de especificar los objetos al cambiar el código.



Quería asegurarme de no usar interfaces, sino de hacer que todo funcione por sí solo.



Cuando queremos que algo funcione por sí solo, tenemos que utilizar la reflexión. Para ello, podemos recorrer cada campo de la clase y comprobar si es un objeto Realm o una lista de objetos:



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

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


Si el campo es RealmModel o RealmList, agregue el objeto de este campo a la lista de objetos anidados. Todo es exactamente igual que lo hicimos anteriormente, solo que aquí se hará solo. El método de eliminación en cascada en sí es muy simple y se ve así:



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


La extensión filterRealmObjectfiltra y pasa solo objetos Realm. El método getNestedRealmObjectsbusca todos los objetos Realm anidados mediante la reflexión y los agrega a una lista lineal. Luego hacemos lo mismo de forma recursiva. Al eliminar, debe verificar la validez del objeto isValid, porque es posible que diferentes objetos principales tengan los mismos objetos anidados. Es mejor evitar esto y usar la autogeneración de id al crear nuevos objetos.





Implementación completa del método 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)
}




Como resultado, en nuestro código de cliente, utilizamos una "eliminación en cascada" para cada operación de cambio de datos. Por ejemplo, para una operación de inserción, se ve así:



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


Primero, el método getManagedEntitiesobtiene todos los objetos agregados y luego el método cascadeDeleteelimina de forma recursiva todos los objetos recopilados antes de escribir otros nuevos. Terminamos utilizando este enfoque en toda la aplicación. Las pérdidas de memoria en Realm han desaparecido por completo. Habiendo realizado la misma medición de la dependencia del tiempo de lanzamiento del número de arranques en frío de la aplicación, vemos el resultado.







La línea verde muestra la dependencia del tiempo de inicio de la aplicación del número de inicios en frío durante la eliminación automática en cascada de objetos anidados.



Resultados y conclusiones



La base de datos de Realm en constante crecimiento ralentizó enormemente el inicio de la aplicación. Hemos lanzado una actualización con nuestra propia "eliminación en cascada" de objetos anidados. Y ahora hacemos un seguimiento y evaluamos cómo nuestra decisión afectó el tiempo de lanzamiento de la aplicación a través de la métrica _app_start.







Para el análisis tomamos un intervalo de tiempo de 90 días y vemos: el tiempo de lanzamiento de la aplicación, tanto la mediana como la que cae en el percentil 95 de usuarios, comenzó a disminuir y no sube más.







Si observa el gráfico de siete días, la métrica _app_start parece completamente adecuada y es de menos de 1 segundo.



También debemos agregar que, por defecto, Firebase envía notificaciones si el valor medio de _app_start excede los 5 segundos. Sin embargo, como podemos ver, no debe confiar en esto, sino entrar y verificarlo explícitamente.



La peculiaridad de la base de datos Realm es que es una base de datos no relacional. A pesar de su uso simple, la similitud de trabajar con soluciones ORM y vincular objetos, no tiene una eliminación en cascada.



Si esto no se tiene en cuenta, los objetos anidados se acumularán, se "filtrarán". La base de datos crecerá constantemente, lo que a su vez afectará la ralentización o el lanzamiento de la aplicación.



Compartí nuestra experiencia, qué tan rápido se eliminan objetos en cascada en el Reino, que no está listo para usar, pero que tuvo una larga charla y charla . En nuestro caso, esto aceleró enormemente el tiempo de lanzamiento de la aplicación.



A pesar de la discusión sobre la aparición inminente de esta característica, la falta de eliminación en cascada en Realm se realiza por diseño. Considere esto si está diseñando una nueva aplicación. Y si ya está usando Realm, verifique si tiene tales problemas.



All Articles