La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

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 comienza a sentirse triste y enojado. Puede estropear fácilmente la experiencia del cliente o perderlo por completo incluso antes de que empiece a utilizar la aplicación.

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

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

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado
Autor del artículo: Maxim Kachinkin — Desarrollador de Android en Dodo Pizza.

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

Un resumen muy breve para aquellos que no tienen tiempo para leer.
Nuestra base de datos de Realm creció sin cesar. Algunos objetos anidados no se eliminaron, sino que se acumularon constantemente. El tiempo de inicio de la aplicación aumentó gradualmente. Luego lo arreglamos y el tiempo de inicio llegó al objetivo: pasó a ser menos de 1 segundo y ya no aumentó. El artículo contiene un análisis de la situación y dos soluciones: una rápida y otra normal.

Búsqueda y análisis del problema.

Hoy en día, cualquier aplicación móvil debe iniciarse rápidamente y tener capacidad de respuesta. Pero no se trata sólo de la aplicación móvil. La experiencia del usuario en la interacción con un servicio y una empresa es algo complejo. Por ejemplo, en nuestro caso, la velocidad de entrega es uno de los indicadores clave del servicio de pizza. Si la entrega es rápida, 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 servicio rápido, porque si la aplicación sólo tarda 20 segundos en iniciarse, ¿cuánto tiempo tendrás que esperar por la pizza?

Al principio, nosotros mismos nos enfrentamos al hecho de que a veces la aplicación tardaba un par de segundos en iniciarse, y luego empezamos a escuchar quejas de otros compañeros sobre cuánto tiempo tardaba. Pero no pudimos repetir esta situación de manera consistente.

¿Cuánto dura? De acuerdo a documentación de google, si el inicio en frío de una aplicación tarda menos de 5 segundos, se considera "como si fuera normal". Lanzamiento de la aplicación Dodo Pizza para Android (según las métricas de Firebase) _inicio_aplicación) a inicio fresco en promedio en 3 segundos: "Ni genial, ni terrible", como dicen.

¡Pero luego comenzaron a aparecer quejas de que la aplicación tardó muchísimo en iniciarse! Para empezar, decidimos medir qué es “muy, muy, muy largo”. Y usamos el seguimiento de Firebase para esto. Seguimiento de inicio de la aplicación.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

Este seguimiento 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 actividad. En Firebase Console, esta métrica se llama _app_start. Resultó que:

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

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

Me vinieron a la mente dos pensamientos:

  1. Algo está goteando.
  2. Este “algo” se restablece después de su liberación y luego se filtra nuevamente.

“Probablemente algo relacionado con la base de datos”, pensamos, y teníamos razón. En primer lugar, utilizamos la base de datos como caché; durante la migración la borramos. 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 comprobar cómo cambian los contenidos de la base de datos a lo largo de la vida útil de la aplicación, desde la primera instalación y durante el uso activo. Puede ver el contenido de la base de datos de Realm a través de Stetho o de forma más detallada y clara abriendo el archivo a través de Estudio de reino. Para ver el contenido de la base de datos a través de ADB, copie el archivo de la base de datos de Realm:

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

Después de examinar el contenido de la base de datos en diferentes momentos, descubrimos que la cantidad de objetos de un determinado tipo aumenta constantemente.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado
La imagen muestra un fragmento de Realm Studio para dos archivos: a la izquierda, la base de la aplicación algún tiempo después de la instalación, a la derecha, después del uso activo. Se puede observar que el número de objetos ImageEntity и MoneyType ha crecido significativamente (la captura de pantalla muestra la cantidad de objetos de cada tipo).

Relación entre el crecimiento de la base de datos y el tiempo de inicio

El crecimiento descontrolado de la base de datos es muy malo. Pero, ¿cómo afecta esto al tiempo de inicio de la aplicación? Es bastante fácil medir esto a través del ActivityManager. Desde Android 4.4, logcat muestra el registro con la cadena Mostrado y la hora. Este tiempo es igual al intervalo desde el momento en que se inicia la aplicación hasta el final de la representación de la actividad. Durante este tiempo ocurren los siguientes eventos:

  • Inicie el proceso.
  • Inicialización de objetos.
  • Creación e inicialización de actividades.
  • Creando un diseño.
  • Representación de aplicaciones.

Nos sirve. Si ejecuta ADB con los indicadores -S y -W, puede obtener resultados extendidos con el tiempo de inicio:

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

Si lo agarras desde allí grep -i WaitTime Al mismo tiempo, puede automatizar la recopilación de esta métrica y observar visualmente los resultados. El siguiente gráfico muestra la dependencia del tiempo de inicio de la aplicación del número de inicios en frío de la aplicación.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

Al mismo tiempo, se mantuvo la misma naturaleza de la relación entre el tamaño y el crecimiento de la base de datos, que pasó de 4 MB a 15 MB. En total, resulta que con el tiempo (con el aumento de los arranques en frío), aumentaron tanto el tiempo de inicio de la aplicación como el tamaño de la base de datos. Tenemos una hipótesis entre manos. Ahora sólo quedaba confirmar la dependencia. Por lo tanto, decidimos eliminar las “filtraciones” y ver si esto aceleraría el lanzamiento.

Razones del crecimiento interminable de la base de datos

Antes de eliminar las “fugas”, conviene entender por qué aparecieron en primer lugar. Para ello, recordemos qué es Realm.

Realm es una base de datos no relacional. Le permite describir las relaciones entre objetos de manera similar a como se describen muchas bases de datos relacionales ORM en Android. Al mismo tiempo, Realm almacena objetos directamente en la memoria con la menor cantidad de transformaciones y asignaciones. Esto le permite leer datos del disco muy rápidamente, que es el punto fuerte de Realm y por qué es tan querido.

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

Muchos desarrolladores están acostumbrados a trabajar más con bases de datos relacionales (por ejemplo, bases de datos ORM con SQL integrado). Y cosas como la eliminación de datos en cascada a menudo parecen un hecho. Pero no en Reino.

Por cierto, la función de eliminación en cascada se ha solicitado durante mucho tiempo. Este revisión и otro, asociado con él, fue discutido activamente. Había la sensación de que pronto se haría. Pero luego todo se tradujo en la introducción de vínculos fuertes y débiles, que también resolverían automáticamente este problema. Fue bastante animado y activo en esta tarea. solicitud de extracción, que por ahora se encuentra en pausa debido a dificultades internas.

Fuga de datos sin eliminación en cascada

¿Cómo se filtran exactamente los datos si depende de una eliminación en cascada inexistente? Si tiene objetos Realm anidados, debe eliminarlos.
Veamos un ejemplo (casi) 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 del carrito tiene diferentes campos, incluida una imagen. ImageEntity, ingredientes personalizados CustomizationEntity. Además, el producto del carrito puede ser un combo con su propio conjunto de productos. RealmList (CartProductEntity). Todos los campos enumerados son objetos Realm. Si insertamos un nuevo objeto (copyToRealm() / copyToRealmOrUpdate()) con la misma identificación, este objeto se sobrescribirá por completo. Pero todos los objetos internos (imagen, personalizaciónEntity y cartComboProducts) perderán la 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 accedamos a ellos explícitamente o borremos toda la “tabla”). A esto lo llamamos "fugas de memoria".

Cuando trabajamos con Realm, debemos revisar explícitamente todos los elementos y eliminar todo explícitamente antes de realizar dichas 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 haces esto, todo funcionará como debería. En este ejemplo, asumimos que no hay otros objetos Realm anidados dentro de la imagen, la personalizaciónEntity y cartComboProducts, por lo que no hay otros bucles ni eliminaciones anidados.

Solución "rápida"

Lo primero que decidimos hacer fue limpiar los objetos de más rápido crecimiento y verificar los resultados para ver si esto resolvería nuestro problema original. Primero, se tomó la solución más simple e intuitiva, a saber: cada objeto debería ser responsable de eliminar a sus hijos. Para hacer esto, introdujimos una interfaz que devolvía 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
   )
 }
}

В getNestedEntities devolvemos a todos los niños como una lista plana. Y cada objeto secundario también puede implementar la interfaz NestedEntityAware, lo que indica que tiene objetos Realm internos 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 recursivamente todos los objetos anidados. Método (hecho como una extensión) deleteAllNestedEntities obtiene todos los objetos y métodos de nivel superior deleteNestedRecursively Elimina recursivamente todos los objetos anidados 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 comprobamos qué sucedió.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

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"

Aunque la base empezó a crecer más lentamente, siguió creciendo. Entonces comenzamos a mirar más allá. Nuestro proyecto hace un uso muy activo del almacenamiento en caché de datos en Realm. Por lo tanto, escribir todos los objetos anidados para cada objeto requiere mucha mano de obra y además aumenta el riesgo de errores, porque puede olvidarse de especificar los objetos al cambiar el código.

Quería asegurarme de no utilizar interfaces, sino de que todo funcionara por sí solo.

Cuando queremos que algo funcione por sí solo, tenemos que utilizar la reflexión. Para hacer esto, podemos revisar cada campo de clase y verificar 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 una lista de objetos anidados. Todo es exactamente igual que hicimos anteriormente, solo que aquí se hará solo. El método de eliminación en cascada en sí es muy simple y tiene este aspecto:

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ón filterRealmObject filtra y pasa solo objetos Realm. Método getNestedRealmObjects a través de la reflexión, encuentra todos los objetos Realm anidados y los coloca en una lista lineal. Luego hacemos lo mismo de forma recursiva. Al eliminar, debe verificar la validez del objeto. isValid, porque puede ser que diferentes objetos principales tengan objetos idénticos anidados. Es mejor evitar esto y simplemente utilizar la generación automática de identificación al crear nuevos objetos.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

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 "eliminación en cascada" para cada operación de modificación 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 }
 ))
}

Método primero getManagedEntities recibe todos los objetos agregados, y luego el método cascadeDelete Elimina recursivamente todos los objetos recopilados antes de escribir otros nuevos. Terminamos usando 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 inicio del número de inicios en frío de la aplicación, vemos el resultado.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

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 hacía que la aplicación se iniciara muy lentamente. Lanzamos una actualización con nuestra propia "eliminación en cascada" de objetos anidados. Y ahora monitoreamos y evaluamos cómo nuestra decisión afectó el tiempo de inicio de la aplicación a través de la métrica _app_start.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

Para el análisis tomamos un periodo de tiempo de 90 días y vemos: el tiempo de lanzamiento de aplicaciones, tanto el mediano como el que cae en el percentil 95 de usuarios, comenzó a disminuir y ya no aumenta.

La historia de cómo la eliminación en cascada en Realm superó un lanzamiento prolongado

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

También vale la pena agregar que, de forma predeterminada, Firebase envía notificaciones si el valor medio de _app_start supera los 5 segundos. Sin embargo, como podemos ver, no debes confiar en esto, sino entrar y comprobarlo explícitamente.

Lo especial de la base de datos Realm es que es una base de datos no relacional. A pesar de su facilidad de uso, similitud con las soluciones ORM y vinculación de objetos, no tiene eliminación en cascada.

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

Compartí nuestra experiencia sobre cómo realizar rápidamente una eliminación en cascada de objetos en Realm, algo que aún no está listo para usar, pero se ha hablado de ello durante mucho tiempo. ellos dicen и ellos dicen. En nuestro caso, esto aceleró enormemente el tiempo de inicio de la aplicación.

A pesar de la discusión sobre la inminente aparición de esta característica, la ausencia de eliminación en cascada en Realm se debe al diseño. Si está diseñando una nueva aplicación, tenga esto en cuenta. Y si ya estás usando Realm, comprueba si tienes este tipo de problemas.

Fuente: habr.com

Añadir un comentario