A história de como a exclusão em cascata no Realm venceu um longo lançamento

Todos os usuários consideram o lançamento rápido e a interface de usuário responsiva em aplicativos móveis garantidos. Se o aplicativo demorar para iniciar, o usuário começa a ficar triste e com raiva. Você pode facilmente estragar a experiência do cliente ou perdê-lo completamente antes mesmo de ele começar a usar o aplicativo.

Certa vez, descobrimos que o aplicativo Dodo Pizza leva em média 3 segundos para iniciar e, para alguns “sortudos”, leva de 15 a 20 segundos.

Abaixo do corte está uma história com final feliz: sobre o crescimento do banco de dados do Realm, um vazamento de memória, como acumulamos objetos aninhados e depois nos recompusemos e consertamos tudo.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

A história de como a exclusão em cascata no Realm venceu um longo lançamento
Autor do artigo: Máximo Kachinkin — Desenvolvedor Android na Dodo Pizza.

Três segundos desde o clique no ícone do aplicativo até onResume() da primeira atividade são infinitos. E para alguns usuários, o tempo de inicialização atingiu 15 a 20 segundos. Como isso é possível?

Um breve resumo para quem não tem tempo de ler
Nosso banco de dados Realm cresceu incessantemente. Alguns objetos aninhados não foram excluídos, mas foram acumulados constantemente. O tempo de inicialização do aplicativo aumentou gradualmente. Então consertamos e o tempo de inicialização atingiu a meta - ficou menos de 1 segundo e não aumentou mais. O artigo contém uma análise da situação e duas soluções - uma rápida e outra normal.

Pesquisa e análise do problema

Hoje, qualquer aplicativo móvel deve ser lançado rapidamente e ser responsivo. Mas não se trata apenas do aplicativo móvel. A experiência do usuário de interação com um serviço e uma empresa é algo complexo. Por exemplo, no nosso caso, a velocidade de entrega é um dos principais indicadores do serviço de pizza. Se a entrega for rápida, a pizza estará quente e o cliente que quiser comer agora não precisará esperar muito. Para o aplicativo, por sua vez, é importante criar a sensação de rapidez no atendimento, pois se o aplicativo demorar apenas 20 segundos para iniciar, quanto tempo você terá que esperar pela pizza?

No início, nós próprios nos deparamos com o fato de que às vezes o aplicativo demorava alguns segundos para iniciar, e então começamos a ouvir reclamações de outros colegas sobre quanto tempo demorava. Mas não conseguimos repetir esta situação de forma consistente.

Quanto tempo dura? De acordo com Documentação do Google, se a inicialização a frio de um aplicativo demorar menos de 5 segundos, isso será considerado “como se fosse normal”. Lançamento do aplicativo Dodo Pizza para Android (de acordo com métricas do Firebase _app_start) no partida a frio em média em 3 segundos - “Nem ótimo, nem terrível”, como dizem.

Mas então começaram a aparecer reclamações de que o aplicativo demorava muito, muito, muito tempo para ser lançado! Para começar, decidimos medir o que é “muito, muito, muito longo”. E usamos o rastreamento do Firebase para isso Rastreamento de início do aplicativo.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Este rastreamento padrão mede o tempo entre o momento em que o usuário abre a aplicação e o momento em que onResume() da primeira atividade é executado. No Firebase Console, essa métrica é chamada _app_start. Acontece que:

  • Os tempos de inicialização para usuários acima do percentil 95 são de quase 20 segundos (alguns até mais longos), apesar do tempo médio de inicialização a frio ser inferior a 5 segundos.
  • O tempo de inicialização não é um valor constante, mas aumenta com o tempo. Mas às vezes há quedas. Encontramos esse padrão quando aumentamos a escala de análise para 90 dias.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Dois pensamentos vieram à mente:

  1. Algo está vazando.
  2. Esse “algo” é redefinido após o lançamento e vaza novamente.

“Provavelmente algo com o banco de dados”, pensamos, e estávamos certos. Primeiramente, usamos o banco de dados como cache; durante a migração, nós o limpamos. Em segundo lugar, o banco de dados é carregado quando o aplicativo é iniciado. Tudo se encaixa.

O que há de errado com o banco de dados Realm

Começamos a verificar como o conteúdo do banco de dados muda ao longo da vida do aplicativo, desde a primeira instalação e durante o uso ativo. Você pode visualizar o conteúdo do banco de dados Realm via Esteto ou com mais detalhes e clareza abrindo o arquivo via Estúdio de reino. Para visualizar o conteúdo do banco de dados via ADB, copie o arquivo de banco de dados Realm:

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

Observando o conteúdo do banco de dados em diferentes momentos, descobrimos que o número de objetos de um determinado tipo está aumentando constantemente.

A história de como a exclusão em cascata no Realm venceu um longo lançamento
A imagem mostra um fragmento do Realm Studio para dois arquivos: à esquerda - a base do aplicativo algum tempo após a instalação, à direita - após uso ativo. Pode-se ver que o número de objetos ImageEntity и MoneyType cresceu significativamente (a captura de tela mostra o número de objetos de cada tipo).

Relação entre crescimento do banco de dados e tempo de inicialização

O crescimento descontrolado do banco de dados é muito ruim. Mas como isso afeta o tempo de inicialização do aplicativo? É muito fácil medir isso através do ActivityManager. Desde o Android 4.4, o logcat exibe o log com a string Displayed e a hora. Este tempo é igual ao intervalo desde o momento em que a aplicação é iniciada até o final da renderização da atividade. Durante esse período ocorrem os seguintes eventos:

  • Inicie o processo.
  • Inicialização de objetos.
  • Criação e inicialização de atividades.
  • Criando um layout.
  • Renderização de aplicativos.

Nos serve. Se você executar o ADB com os sinalizadores -S e -W, poderá obter uma saída estendida com o tempo de inicialização:

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

Se você pegar daí grep -i WaitTime Ao mesmo tempo, você pode automatizar a coleta dessa métrica e observar visualmente os resultados. O gráfico abaixo mostra a dependência do tempo de inicialização do aplicativo no número de inicializações a frio do aplicativo.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Ao mesmo tempo, houve a mesma natureza da relação entre o tamanho e o crescimento da base de dados, que passou de 4 MB para 15 MB. No total, com o tempo (com o crescimento das inicializações a frio), tanto o tempo de inicialização do aplicativo quanto o tamanho do banco de dados aumentaram. Temos uma hipótese em mãos. Agora só faltava confirmar a dependência. Portanto, decidimos remover os “vazamentos” e ver se isso agilizaria o lançamento.

Razões para o crescimento infinito do banco de dados

Antes de remover “vazamentos”, vale a pena entender por que eles apareceram. Para fazer isso, vamos lembrar o que é Realm.

Realm é um banco de dados não relacional. Ele permite descrever relacionamentos entre objetos de maneira semelhante a quantos bancos de dados relacionais ORM no Android são descritos. Ao mesmo tempo, o Realm armazena objetos diretamente na memória com o mínimo de transformações e mapeamentos. Isso permite que você leia os dados do disco muito rapidamente, que é o ponto forte do Realm e por que ele é amado.

(Para os propósitos deste artigo, esta descrição será suficiente para nós. Você pode ler mais sobre o Realm no site legal documentação ou em seus a academia).

Muitos desenvolvedores estão acostumados a trabalhar mais com bancos de dados relacionais (por exemplo, bancos de dados ORM com SQL subjacente). E coisas como exclusão em cascata de dados muitas vezes parecem óbvias. Mas não no Reino.

A propósito, o recurso de exclusão em cascata já é solicitado há muito tempo. Esse revisão и outro, associado a ele, foi ativamente discutido. Havia uma sensação de que isso seria feito em breve. Mas depois tudo se traduziu na introdução de elos fortes e fracos, o que também resolveria automaticamente este problema. Estava bastante animado e ativo nesta tarefa solicitação pull, que por enquanto está pausado por dificuldades internas.

Vazamento de dados sem exclusão em cascata

Como exatamente os dados vazam se você depende de uma exclusão em cascata inexistente? Se você tiver objetos Realm aninhados, eles deverão ser excluídos.
Vejamos um exemplo (quase) real. Temos um 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()

O produto no carrinho possui campos diferentes, incluindo uma foto ImageEntity, ingredientes personalizados CustomizationEntity. Além disso, o produto no carrinho pode ser uma combinação com seu próprio conjunto de produtos RealmList (CartProductEntity). Todos os campos listados são objetos Realm. Se inserirmos um novo objeto (copyToRealm() / copyToRealmOrUpdate()) com o mesmo id, então este objeto será completamente sobrescrito. Mas todos os objetos internos (imagem, customizaçãoEntity e cartComboProducts) perderão a conexão com o pai e permanecerão no banco de dados.

Como a conexão com eles é perdida, não os lemos nem os excluímos (a menos que os acessemos explicitamente ou limpemos toda a “tabela”). Chamamos isso de “vazamentos de memória”.

Quando trabalhamos com Realm, devemos passar explicitamente por todos os elementos e deletar tudo explicitamente antes de tais operações. Isso pode ser feito, por exemplo, assim:

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 você fizer isso, tudo funcionará como deveria. Neste exemplo, presumimos que não há outros objetos Realm aninhados dentro de image,customizationEntity e cartComboProducts, portanto, não há outros loops e exclusões aninhados.

Solução "rápida"

A primeira coisa que decidimos fazer foi limpar os objetos de crescimento mais rápido e verificar os resultados para ver se isso resolveria o nosso problema original. Primeiramente foi feita a solução mais simples e intuitiva, a saber: cada objeto deveria ser responsável por remover seus filhos. Para fazer isso, introduzimos uma interface que retornou uma lista de seus objetos Realm aninhados:

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

E nós implementamos isso em nossos 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 retornamos todos os filhos como uma lista simples. E cada objeto filho também pode implementar a interface NestedEntityAware, indicando que possui objetos Realm internos para excluir, 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 assim por diante, o aninhamento de objetos pode ser repetido.

Em seguida, escrevemos um método que exclui recursivamente todos os objetos aninhados. Método (feito como uma extensão) deleteAllNestedEntities obtém todos os objetos e métodos de nível superior deleteNestedRecursively Remove recursivamente todos os objetos aninhados usando 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()
   }
 }
}

Fizemos isso com os objetos de crescimento mais rápido e verificamos o que aconteceu.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Como resultado, os objetos que cobrimos com esta solução pararam de crescer. E o crescimento global da base desacelerou, mas não parou.

A solução “normal”

Embora a base tenha começado a crescer mais lentamente, ela ainda cresceu. Então começamos a procurar mais. Nosso projeto faz uso muito ativo do cache de dados no Realm. Portanto, escrever todos os objetos aninhados para cada objeto é trabalhoso, além do risco de erros aumentar, porque você pode esquecer de especificar objetos ao alterar o código.

Eu queria ter certeza de não usar interfaces, mas de que tudo funcionasse por conta própria.

Quando queremos que algo funcione por si só, temos que usar a reflexão. Para fazer isso, podemos percorrer cada campo de classe e verificar se é um objeto Realm ou uma lista de objetos:

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

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

Se o campo for RealmModel ou RealmList, adicione o objeto desse campo a uma lista de objetos aninhados. Tudo é exatamente igual ao que fizemos acima, só que aqui será feito sozinho. O método de exclusão em cascata em si é muito simples e se parece com isto:

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

Extensão filterRealmObject filtra e passa apenas objetos Realm. Método getNestedRealmObjects por meio da reflexão, ele encontra todos os objetos Realm aninhados e os coloca em uma lista linear. Então fazemos a mesma coisa recursivamente. Ao excluir, você precisa verificar a validade do objeto isValid, porque pode ser que objetos pai diferentes possam ter objetos idênticos aninhados. É melhor evitar isso e simplesmente usar a geração automática de id ao criar novos objetos.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Implementação 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, em nosso código cliente usamos “exclusão em cascata” para cada operação de modificação de dados. Por exemplo, para uma operação de inserção fica assim:

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 recebe todos os objetos adicionados e então o método cascadeDelete Exclui recursivamente todos os objetos coletados antes de gravar novos. Acabamos usando essa abordagem em todo o aplicativo. Os vazamentos de memória no Realm desapareceram completamente. Tendo realizado a mesma medição da dependência do tempo de inicialização do número de partidas a frio da aplicação, vemos o resultado.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

A linha verde mostra a dependência do tempo de inicialização do aplicativo no número de inicializações a frio durante a exclusão automática em cascata de objetos aninhados.

Resultados e Conclusões

O crescente banco de dados do Realm fazia com que o aplicativo fosse iniciado muito lentamente. Lançamos uma atualização com nossa própria "exclusão em cascata" de objetos aninhados. E agora monitoramos e avaliamos como nossa decisão afetou o tempo de inicialização do aplicativo por meio da métrica _app_start.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Para análise, pegamos um período de 90 dias e vemos: o tempo de lançamento do aplicativo, tanto a mediana quanto o que cai no percentil 95 dos usuários, começou a diminuir e não aumenta mais.

A história de como a exclusão em cascata no Realm venceu um longo lançamento

Se você observar o gráfico de sete dias, a métrica _app_start parece completamente adequada e tem menos de 1 segundo.

Também vale acrescentar que, por padrão, o Firebase envia notificações se o valor médio de _app_start exceder 5 segundos. No entanto, como podemos ver, você não deve confiar nisso, mas sim entrar e verificar explicitamente.

A particularidade do banco de dados Realm é que ele é um banco de dados não relacional. Apesar da facilidade de uso, semelhança com soluções ORM e vinculação de objetos, não possui exclusão em cascata.

Se isso não for levado em consideração, os objetos aninhados irão se acumular e “vazar”. O banco de dados crescerá constantemente, o que por sua vez afetará a lentidão ou a inicialização do aplicativo.

Compartilhei nossa experiência sobre como fazer rapidamente uma exclusão em cascata de objetos no Realm, que ainda não está pronta para uso, mas já é comentada há muito tempo eles dizem и eles dizem. No nosso caso, isso acelerou bastante o tempo de inicialização do aplicativo.

Apesar da discussão sobre o surgimento iminente desse recurso, a ausência de exclusão em cascata no Realm é feita intencionalmente. Se você estiver projetando um novo aplicativo, leve isso em consideração. E se você já usa o Realm, verifique se você tem esses problemas.

Fonte: habr.com

Adicionar um comentário