A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Todos os usuarios dan por feito o lanzamento rápido e a interface de usuario sensible nas aplicacións móbiles. Se a aplicación tarda moito en iniciarse, o usuario comeza a sentirse triste e enfadado. Pode facilmente estragar a experiencia do cliente ou perder completamente o usuario mesmo antes de que comece a usar a aplicación.

Unha vez descubrimos que a aplicación Dodo Pizza tarda 3 segundos en lanzarse de media, e para algúns "afortunados" tarda entre 15 e 20 segundos.

Debaixo do corte hai unha historia cun final feliz: sobre o crecemento da base de datos do Reino, unha fuga de memoria, como acumulamos obxectos aniñados e, a continuación, xuntámonos e arranxamos todo.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento
Autor do artigo: Maxim Kachinkin — Desenvolvedor de Android en Dodo Pizza.

Tres segundos desde facer clic na icona da aplicación ata onResume() da primeira actividade son infinitos. E para algúns usuarios, o tempo de inicio alcanzou os 15-20 segundos. Como é posible isto?

Un resumo moi breve para quen non teña tempo de ler
A nosa base de datos do Reino creceu sen parar. Algúns obxectos aniñados non se eliminaron, senón que acumuláronse constantemente. O tempo de inicio da aplicación aumentou gradualmente. Despois arranxámolo e o tempo de inicio chegou ao obxectivo: pasou a ser menos de 1 segundo e xa non aumentou. O artigo contén unha análise da situación e dúas solucións: unha rápida e outra normal.

Busca e análise do problema

Hoxe, calquera aplicación móbil debe iniciarse rapidamente e ser sensible. Pero non se trata só da aplicación móbil. A experiencia do usuario de interacción cun servizo e unha empresa é algo complexo. Por exemplo, no noso caso, a velocidade de entrega é un dos indicadores clave para o servizo de pizza. Se a entrega é rápida, a pizza estará quente e o cliente que queira comer agora non terá que esperar moito. Para a aplicación, á súa vez, é importante crear a sensación de servizo rápido, porque se a aplicación só tarda 20 segundos en lanzarse, canto tempo terás que esperar pola pizza?

Nun primeiro momento, nós mesmos enfrontámonos ao feito de que ás veces a aplicación tardaba un par de segundos en lanzarse, e despois comezamos a escoitar queixas doutros compañeiros sobre o tempo que tardaba. Pero non puidemos repetir constantemente esta situación.

Canto tempo é? Dacordo con Documentación de Google, se o inicio en frío dunha aplicación leva menos de 5 segundos, entón considérase "como se fose normal". Lanzamento da aplicación Dodo Pizza para Android (segundo as métricas de Firebase _iniciar_aplicación) ás arranque en frío de media en 3 segundos: "Non xenial, nin terrible", como din.

Pero entón comezaron a aparecer queixas de que a aplicación tardou moito, moi, moito tempo en lanzarse! Para comezar, decidimos medir o que é "moi, moi, moi longo". E usamos o rastrexo de Firebase para iso Rastreo de inicio da aplicación.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Este trazo estándar mide o tempo entre o momento en que o usuario abre a aplicación e o momento en que se executa onResume() da primeira actividade. Na consola de Firebase, esta métrica chámase _app_start. Resultou que:

  • Os tempos de inicio dos usuarios por riba do percentil 95 son de case 20 segundos (algúns incluso máis), a pesar de que o tempo medio de inicio en frío é inferior a 5 segundos.
  • O tempo de inicio non é un valor constante, senón que crece co paso do tempo. Pero ás veces hai pingas. Atopamos este patrón cando aumentamos a escala de análise a 90 días.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Dous pensamentos viñeron á cabeza:

  1. Algo está filtrando.
  2. Este "algo" restablece despois do lanzamento e volve a filtrarse.

"Probablemente algo coa base de datos", pensamos, e tiñamos razón. En primeiro lugar, usamos a base de datos como caché; durante a migración borrámola. En segundo lugar, a base de datos cárgase cando se inicia a aplicación. Todo encaixa.

Que hai de malo na base de datos Realm

Comezamos a comprobar como cambia o contido da base de datos ao longo da vida útil da aplicación, desde a primeira instalación e máis adiante durante o uso activo. Podes ver o contido da base de datos Realm a través de Esteto ou con máis detalle e claramente abrindo o ficheiro via Realm Studio. Para ver o contido da base de datos a través de ADB, copie o ficheiro de base de datos de Realm:

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

Despois de mirar o contido da base de datos en diferentes momentos, descubrimos que o número de obxectos dun determinado tipo está en constante aumento.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento
A imaxe mostra un fragmento de Realm Studio para dous ficheiros: á esquerda - a base da aplicación algún tempo despois da instalación, á dereita - despois do uso activo. Pódese ver que o número de obxectos ImageEntity и MoneyType creceu significativamente (a captura de pantalla mostra o número de obxectos de cada tipo).

Relación entre o crecemento da base de datos e o tempo de inicio

O crecemento descontrolado da base de datos é moi malo. Pero como afecta isto ao tempo de inicio da aplicación? É bastante sinxelo medir isto a través do ActivityManager. Desde Android 4.4, logcat mostra o rexistro coa cadea Mostrada e a hora. Este tempo é igual ao intervalo desde o momento en que se inicia a aplicación ata o final da representación da actividade. Durante este tempo ocorren os seguintes eventos:

  • Inicia o proceso.
  • Inicialización de obxectos.
  • Creación e inicialización de actividades.
  • Creando un esquema.
  • Representación de aplicacións.

Convénnos. Se executas ADB coas marcas -S e -W, podes obter unha saída ampliada co tempo de inicio:

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

Se o colles de alí grep -i WaitTime tempo, pode automatizar a recollida desta métrica e mirar visualmente os resultados. O seguinte gráfico mostra a dependencia do tempo de inicio da aplicación co número de arranques en frío da aplicación.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Ao mesmo tempo, houbo a mesma natureza da relación entre o tamaño e o crecemento da base de datos, que pasou de 4 MB a 15 MB. En total, resulta que co paso do tempo (co crecemento dos arranques en frío), tanto o tempo de lanzamento da aplicación como o tamaño da base de datos aumentaron. Temos unha hipótese nas nosas mans. Agora só quedaba confirmar a dependencia. Polo tanto, decidimos eliminar as "fugas" e ver se isto aceleraría o lanzamento.

Razóns para o crecemento infinito da base de datos

Antes de eliminar as "fugas", vale a pena entender por que apareceron en primeiro lugar. Para iso, lembremos o que é Realm.

Realm é unha base de datos non relacional. Permítelle describir as relacións entre obxectos dun xeito similar ao de cantas bases de datos relacionais ORM se describen en Android. Ao mesmo tempo, Realm almacena obxectos directamente na memoria coa menor cantidade de transformacións e asignacións. Isto permítelle ler os datos do disco moi rapidamente, que é o punto forte de Realm e por que é amado.

(Para os efectos deste artigo, esta descrición será suficiente para nós. Podes ler máis sobre Realm no fresco documentación ou nas súas academia).

Moitos desenvolvedores están afeitos a traballar máis con bases de datos relacionais (por exemplo, bases de datos ORM con SQL baixo o capó). E cousas como a eliminación de datos en cascada adoitan parecer un feito. Pero non no Reino.

Por certo, a función de eliminación en cascada foi solicitada durante moito tempo. Isto revisión и outra, asociado a ela, foi discutido activamente. Había a sensación de que pronto se faría. Pero despois todo traduciuse na introdución de ligazóns fortes e débiles, que tamén resolverían automaticamente este problema. Foi bastante animado e activo nesta tarefa solicitude de extracción, que por agora estivo en pausa por dificultades internas.

Fuga de datos sen eliminación en cascada

Como se filtran os datos exactamente se confías nunha eliminación en cascada inexistente? Se tes obxectos de reino aniñados, deben eliminarse.
Vexamos un exemplo (case) real. Temos un obxecto 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()

O produto no carro ten diferentes campos, incluíndo unha imaxe ImageEntity, ingredientes personalizados CustomizationEntity. Ademais, o produto do carro pode ser un combo co seu propio conxunto de produtos RealmList (CartProductEntity). Todos os campos da lista son obxectos do reino. Se inserimos un novo obxecto (copyToRealm() / copyToRealmOrUpdate()) co mesmo id, entón este obxecto será completamente sobrescrito. Pero todos os obxectos internos (imaxe, customizationEntity e cartComboProducts) perderán a conexión co pai e permanecerán na base de datos.

Dado que se perde a conexión con eles, xa non os lemos nin os eliminamos (a non ser que accedamos a eles de forma explícita ou borremos toda a "táboa"). Chamámoslle "fugas de memoria".

Cando traballamos con Realm, debemos revisar explícitamente todos os elementos e eliminar explícitamente todo antes de tales operacións. Isto pódese facer, por exemplo, 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()
}
// и потом уже сохраняем

Se fai isto, todo funcionará como debería. Neste exemplo, asumimos que non hai outros obxectos de Realm aniñados dentro de imaxe, customizationEntity e cartComboProducts, polo que non hai outros bucles e eliminacións aniñados.

Solución "rápida".

O primeiro que decidimos facer foi limpar os obxectos de máis rápido crecemento e comprobar os resultados para ver se isto resolvería o noso problema orixinal. En primeiro lugar, realizouse a solución máis sinxela e intuitiva, a saber: cada obxecto debería ser responsable de eliminar os seus fillos. Para iso, introducimos unha interface que devolveu unha lista dos seus obxectos de Realm anidados:

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

E implementámolo nos nosos obxectos do Reino:

@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 todos os nenos como unha lista plana. E cada obxecto fillo tamén pode implementar a interface NestedEntityAware, o que indica que ten obxectos de Realm internos para eliminar, por exemplo 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 así por diante, a anidación de obxectos pódese repetir.

Despois escribimos un método que elimina de forma recursiva todos os obxectos aniñados. Método (realizado como extensión) deleteAllNestedEntities obtén todos os obxectos e métodos de nivel superior deleteNestedRecursively Elimina de forma recursiva todos os obxectos aniñados mediante a interface 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()
   }
 }
}

Fixémolo cos obxectos de máis rápido crecemento e comprobamos o que pasou.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Como resultado, aqueles obxectos que cubrimos con esta solución deixaron de crecer. E o crecemento xeral da base diminuíu, pero non parou.

A solución "normal".

Aínda que a base comezou a crecer máis lentamente, aínda creceu. Así que comezamos a buscar máis aló. O noso proxecto fai un uso moi activo da caché de datos en Realm. Polo tanto, escribir todos os obxectos aniñados para cada obxecto é un traballo intensivo, ademais de aumentar o risco de erros, porque pode esquecerse de especificar obxectos ao cambiar o código.

Quería asegurarme de que non utilizaba interfaces, pero que todo funcionaba por si só.

Cando queremos que algo funcione por si só, temos que usar a reflexión. Para iso, podemos pasar por cada campo de clase e comprobar se se trata dun obxecto Realm ou dunha lista de obxectos:

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

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

Se o campo é un RealmModel ou RealmList, engade o obxecto deste campo a unha lista de obxectos aniñados. Todo é exactamente o mesmo que fixemos anteriormente, só que aquí farase por si só. O propio método de eliminación en cascada é moi sinxelo e ten o seguinte 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 e pasa só obxectos do Reino. Método getNestedRealmObjects mediante a reflexión, atopa todos os obxectos do Reino aniñados e colócaos nunha lista lineal. Despois facemos o mesmo de forma recursiva. Ao eliminar, cómpre comprobar a validez do obxecto isValid, porque pode ser que diferentes obxectos pai poidan ter outros idénticos aniñados. É mellor evitar isto e simplemente usar a xeración automática de id ao crear novos obxectos.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Implementación completa do 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, no noso código de cliente usamos "eliminación en cascada" para cada operación de modificación de datos. Por exemplo, para unha operación de inserción ten o seguinte aspecto:

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 primeiro getManagedEntities recibe todos os obxectos engadidos e despois o método cascadeDelete Elimina de forma recursiva todos os obxectos recollidos antes de escribir outros novos. Acabamos usando este enfoque durante toda a aplicación. As fugas de memoria en Realm desapareceron por completo. Unha vez realizada a mesma medición da dependencia do tempo de inicio do número de arranques en frío da aplicación, vemos o resultado.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

A liña verde mostra a dependencia do tempo de inicio da aplicación do número de arranques en frío durante a eliminación automática en cascada de obxectos aniñados.

Resultados e conclusións

A base de datos de Realm en constante crecemento estaba facendo que a aplicación se lanzase moi lentamente. Lanzamos unha actualización coa nosa propia "eliminación en cascada" de obxectos aniñados. E agora supervisamos e avaliamos como a nosa decisión afectou o tempo de inicio da aplicación mediante a métrica _app_start.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Para a análise, tomamos un período de tempo de 90 días e vemos: o tempo de lanzamento da aplicación, tanto a mediana como a que cae no percentil 95 de usuarios, comezou a diminuír e xa non aumenta.

A historia de como a eliminación en cascada en Realm gañou un longo lanzamento

Se observas o gráfico de sete días, a métrica _app_start parece completamente adecuada e dura menos de 1 segundo.

Tamén paga a pena engadir que, por defecto, Firebase envía notificacións se o valor medio de _app_start supera os 5 segundos. Non obstante, como podemos ver, non debes confiar niso, senón entrar e comprobalo de forma explícita.

O especial da base de datos Realm é que é unha base de datos non relacional. A pesar da súa facilidade de uso, semellanza coas solucións ORM e a ligazón de obxectos, non ten eliminación en cascada.

Se isto non se ten en conta, os obxectos aniñados acumularanse e "fuxiranse". A base de datos crecerá constantemente, o que á súa vez afectará á ralentización ou inicio da aplicación.

Compartín a nosa experiencia sobre como eliminar rapidamente obxectos en cascada en Realm, que aínda non está fóra da caixa, pero que se fala dende hai moito tempo. din eles и din eles. No noso caso, isto acelerou moito o tempo de inicio da aplicación.

A pesar da discusión sobre a aparición inminente desta función, a ausencia de eliminación en cascada en Realm faise por deseño. Se estás a deseñar unha nova aplicación, téñao en conta. E se xa estás usando Realm, comproba se tes tales problemas.

Fonte: www.habr.com

Engadir un comentario