L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Tous les utilisateurs prennent pour acquis un lancement rapide et une interface utilisateur réactive dans les applications mobiles. Si l'application met beaucoup de temps à se lancer, l'utilisateur commence à se sentir triste et en colère. Vous pouvez facilement gâcher l'expérience client ou perdre complètement l'utilisateur avant même qu'il ne commence à utiliser l'application.

Nous avons découvert un jour que l'application Dodo Pizza met en moyenne 3 secondes à se lancer, et pour certains « chanceux », cela prend 15 à 20 secondes.

Sous la coupe se trouve une histoire avec une fin heureuse : sur la croissance de la base de données Realm, une fuite de mémoire, comment nous avons accumulé des objets imbriqués, puis nous nous sommes ressaisis et avons tout réparé.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement
Auteur de l'article: Maxime Kachinkin — Développeur Android chez Dodo Pizza.

Trois secondes entre le clic sur l'icône de l'application et la fonction onResume() de la première activité sont l'infini. Et pour certains utilisateurs, le temps de démarrage atteignait 15 à 20 secondes. Comment est-ce possible?

Un très court résumé pour ceux qui n’ont pas le temps de lire
Notre base de données Realm s’est développée sans cesse. Certains objets imbriqués n'étaient pas supprimés, mais étaient constamment accumulés. Le temps de démarrage de l'application a progressivement augmenté. Ensuite, nous l'avons corrigé et le temps de démarrage a atteint l'objectif - il est devenu inférieur à 1 seconde et n'a plus augmenté. L'article contient une analyse de la situation et deux solutions : une rapide et une normale.

Recherche et analyse du problème

Aujourd’hui, toute application mobile doit se lancer rapidement et être réactive. Mais il ne s’agit pas seulement de l’application mobile. L’expérience utilisateur d’interaction avec un service et une entreprise est une chose complexe. Par exemple, dans notre cas, la rapidité de livraison est l’un des indicateurs clés du service de pizza. Si la livraison est rapide, la pizza sera chaude et le client qui souhaite manger maintenant n'aura pas à attendre longtemps. Pour l'application, à son tour, il est important de créer une sensation de service rapide, car si l'application ne prend que 20 secondes pour se lancer, combien de temps devrez-vous attendre pour la pizza ?

Au début, nous avons nous-mêmes été confrontés au fait que le lancement de l'application prenait parfois quelques secondes, puis nous avons commencé à entendre des plaintes d'autres collègues concernant le temps nécessaire. Mais nous n’avons pas pu répéter systématiquement cette situation.

Combien de temps dure-t-il ? Selon Documentation Google, si le démarrage à froid d'une application prend moins de 5 secondes, alors cela est considéré « comme si c'était normal ». Lancement de l'application Android Dodo Pizza (selon les métriques Firebase) _app_start) à démarrage à froid en moyenne en 3 secondes - "Pas génial, pas terrible", comme on dit.

Mais ensuite des plaintes ont commencé à apparaître selon lesquelles l'application mettait très, très, très longtemps à se lancer ! Pour commencer, nous avons décidé de mesurer ce qu’est « très, très, très long ». Et nous avons utilisé la trace Firebase pour cela Trace de démarrage de l'application.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Cette trace standard mesure le temps entre le moment où l'utilisateur ouvre l'application et le moment où la onResume() de la première activité est exécutée. Dans la console Firebase, cette métrique est appelée _app_start. Il s'est avéré que :

  • Les temps de démarrage pour les utilisateurs au-dessus du 95e percentile sont de près de 20 secondes (certains même plus), bien que le temps médian de démarrage à froid soit inférieur à 5 secondes.
  • Le temps de démarrage n'est pas une valeur constante, mais augmente avec le temps. Mais parfois il y a des baisses. Nous avons constaté cette tendance lorsque nous avons augmenté l’échelle d’analyse à 90 jours.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Deux pensées me sont venues à l'esprit :

  1. Quelque chose fuit.
  2. Ce « quelque chose » est réinitialisé après la publication, puis fuit à nouveau.

« Probablement quelque chose avec la base de données », avons-nous pensé, et nous avions raison. Tout d'abord, nous utilisons la base de données comme cache ; lors de la migration, nous la vidons. Deuxièmement, la base de données est chargée au démarrage de l'application. Tout s'emboîte.

Quel est le problème avec la base de données Realm

Nous avons commencé à vérifier comment le contenu de la base de données évolue au cours de la vie de l'application, dès la première installation et ensuite lors de son utilisation active. Vous pouvez afficher le contenu de la base de données Realm via Stétho ou plus en détail et clairement en ouvrant le fichier via Studio de royaume. Pour afficher le contenu de la base de données via ADB, copiez le fichier de la base de données Realm :

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

Après avoir examiné le contenu de la base de données à différents moments, nous avons découvert que le nombre d'objets d'un certain type augmente constamment.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement
L'image montre un fragment de Realm Studio pour deux fichiers : à gauche - la base de l'application quelque temps après l'installation, à droite - après une utilisation active. On voit que le nombre d'objets ImageEntity и MoneyType a considérablement augmenté (la capture d'écran montre le nombre d'objets de chaque type).

Relation entre la croissance de la base de données et le temps de démarrage

La croissance incontrôlée des bases de données est très mauvaise. Mais comment cela affecte-t-il le temps de démarrage de l’application ? Il est assez facile de mesurer cela via ActivityManager. Depuis Android 4.4, logcat affiche le journal avec la chaîne Displayed et l'heure. Ce temps est égal à l'intervalle depuis le lancement de l'application jusqu'à la fin du rendu de l'activité. Pendant cette période, les événements suivants se produisent :

  • Démarrez le processus.
  • Initialisation des objets.
  • Création et initialisation des activités.
  • Création d'une mise en page.
  • Rendu des applications.

Nous convient. Si vous exécutez ADB avec les indicateurs -S et -W, ​​vous pouvez obtenir une sortie étendue avec l'heure de démarrage :

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

Si tu le récupères à partir de là grep -i WaitTime temps, vous pouvez automatiser la collecte de cette métrique et consulter visuellement les résultats. Le graphique ci-dessous montre la dépendance du temps de démarrage de l'application sur le nombre de démarrages à froid de l'application.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Dans le même temps, la relation entre la taille et la croissance de la base de données est de même nature, qui passe de 4 Mo à 15 Mo. Au total, il s'avère qu'au fil du temps (avec la multiplication des démarrages à froid), tant le temps de lancement de l'application que la taille de la base de données ont augmenté. Nous avons une hypothèse entre nos mains. Il ne restait plus qu'à confirmer la dépendance. Par conséquent, nous avons décidé de supprimer les « fuites » et de voir si cela accélérerait le lancement.

Raisons de la croissance sans fin des bases de données

Avant de supprimer les « fuites », il convient de comprendre pourquoi elles sont apparues en premier lieu. Pour ce faire, rappelons ce qu’est Realm.

Realm est une base de données non relationnelle. Il vous permet de décrire les relations entre les objets de la même manière que le nombre de bases de données relationnelles ORM sur Android. Dans le même temps, Realm stocke les objets directement en mémoire avec le moins de transformations et de mappages. Cela vous permet de lire les données du disque très rapidement, ce qui est la force de Realm et pourquoi il est apprécié.

(Pour les besoins de cet article, cette description nous suffira. Vous pouvez en savoir plus sur Realm dans le cool documentation ou dans leur l'académie).

De nombreux développeurs sont habitués à travailler davantage avec des bases de données relationnelles (par exemple, des bases de données ORM avec SQL sous le capot). Et des choses comme la suppression en cascade des données semblent souvent aller de soi. Mais pas dans le Royaume.

À propos, la fonctionnalité de suppression en cascade est demandée depuis longtemps. Ce révision и un autre, qui y est associé, a été activement discuté. On avait le sentiment que ce serait bientôt fait. Mais ensuite, tout s’est traduit par l’introduction de liens forts et faibles, ce qui résoudrait automatiquement ce problème. Était assez vif et actif sur cette tâche demande de tirage, qui a été suspendu pour l'instant en raison de difficultés internes.

Fuite de données sans suppression en cascade

Comment exactement les données fuient-elles si vous comptez sur une suppression en cascade inexistante ? Si vous avez des objets Realm imbriqués, ils doivent être supprimés.
Regardons un exemple (presque) réel. Nous avons un objet 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()

Le produit dans le panier comporte différents champs, dont une image ImageEntity, ingrédients personnalisés CustomizationEntity. De plus, le produit dans le panier peut être un combo avec son propre ensemble de produits. RealmList (CartProductEntity). Tous les champs répertoriés sont des objets Realm. Si nous insérons un nouvel objet (copyToRealm() / copyToRealmOrUpdate()) avec le même identifiant, alors cet objet sera complètement écrasé. Mais tous les objets internes (image,customizationEntity et cartComboProducts) perdront la connexion avec le parent et resteront dans la base de données.

La connexion avec eux étant perdue, nous ne les lisons plus ni ne les supprimons (sauf si nous y accédons explicitement ou effaçons toute la « table »). Nous appelons cela des « fuites de mémoire ».

Lorsque nous travaillons avec Realm, nous devons explicitement parcourir tous les éléments et tout supprimer explicitement avant de telles opérations. Cela peut être fait, par exemple, comme ceci :

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 vous faites cela, tout fonctionnera comme il se doit. Dans cet exemple, nous supposons qu'il n'y a aucun autre objet Realm imbriqué dans image,customizationEntity et cartComboProducts, il n'y a donc pas d'autres boucles et suppressions imbriquées.

Solution "rapide"

La première chose que nous avons décidé de faire a été de nettoyer les objets à croissance la plus rapide et de vérifier les résultats pour voir si cela résoudrait notre problème initial. Tout d’abord, la solution la plus simple et la plus intuitive a été élaborée, à savoir : chaque objet doit être responsable de la suppression de ses enfants. Pour ce faire, nous avons introduit une interface qui renvoie une liste de ses objets Realm imbriqués :

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

Et nous l'avons implémenté dans nos objets 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 nous renvoyons tous les enfants sous forme de liste plate. Et chaque objet enfant peut également implémenter l'interface NestedEntityAware, indiquant qu'il a des objets Realm internes à supprimer, par 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
   )
 }
}

Et ainsi de suite, l’imbrication des objets peut être répétée.

Ensuite, nous écrivons une méthode qui supprime récursivement tous les objets imbriqués. Méthode (faite comme une extension) deleteAllNestedEntities obtient tous les objets et méthodes de niveau supérieur deleteNestedRecursively Supprime de manière récursive tous les objets imbriqués à l'aide de l'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()
   }
 }
}

Nous l'avons fait avec les objets à croissance la plus rapide et avons vérifié ce qui s'était passé.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

En conséquence, les objets que nous avons recouverts avec cette solution ont cessé de croître. Et la croissance globale de la base a ralenti, mais ne s’est pas arrêtée.

La solution "normale"

Bien que la base ait commencé à croître plus lentement, elle a quand même grandi. Nous avons donc commencé à chercher plus loin. Notre projet utilise très activement la mise en cache des données dans Realm. Par conséquent, écrire tous les objets imbriqués pour chaque objet demande beaucoup de travail et le risque d'erreurs augmente, car vous pouvez oublier de spécifier les objets lors de la modification du code.

Je voulais m’assurer que je n’utilisais pas d’interfaces, mais que tout fonctionnait tout seul.

Quand on veut que quelque chose fonctionne tout seul, il faut recourir à la réflexion. Pour ce faire, nous pouvons parcourir chaque champ de classe et vérifier s'il s'agit d'un objet Realm ou d'une liste d'objets :

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

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

Si le champ est un RealmModel ou un RealmList, ajoutez l'objet de ce champ à une liste d'objets imbriqués. Tout est exactement comme nous l'avons fait ci-dessus, seulement ici cela se fera tout seul. La méthode de suppression en cascade elle-même est très simple et ressemble à ceci :

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

Extension filterRealmObject filtre et transmet uniquement les objets Realm. Méthode getNestedRealmObjects par réflexion, il trouve tous les objets Realm imbriqués et les place dans une liste linéaire. Ensuite, nous faisons la même chose de manière récursive. Lors de la suppression, vous devez vérifier la validité de l'objet isValid, car il se peut que différents objets parents puissent avoir des objets identiques imbriqués. Il est préférable d'éviter cela et d'utiliser simplement la génération automatique d'identifiant lors de la création de nouveaux objets.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Implémentation complète de la méthode 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)
}

De ce fait, dans notre code client nous utilisons la « suppression en cascade » pour chaque opération de modification de données. Par exemple, pour une opération d'insertion, cela ressemble à ceci :

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

La méthode d’abord getManagedEntities reçoit tous les objets ajoutés, puis la méthode cascadeDelete Supprime de manière récursive tous les objets collectés avant d'en écrire de nouveaux. Nous finissons par utiliser cette approche tout au long de l’application. Les fuites de mémoire dans Realm ont complètement disparu. Après avoir effectué la même mesure de la dépendance du temps de démarrage sur le nombre de démarrages à froid de l'application, on voit le résultat.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

La ligne verte montre la dépendance du temps de démarrage de l'application sur le nombre de démarrages à froid lors de la suppression automatique en cascade des objets imbriqués.

Résultats et conclusions

La base de données Realm en constante évolution rendait le lancement de l'application très lent. Nous avons publié une mise à jour avec notre propre « suppression en cascade » des objets imbriqués. Et maintenant, nous surveillons et évaluons l'impact de notre décision sur le temps de démarrage de l'application via la métrique _app_start.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Pour l'analyse, nous prenons une période de 90 jours et constatons : le temps de lancement de l'application, tant médian que celui qui tombe sur le 95e centile des utilisateurs, a commencé à diminuer et n'augmente plus.

L'histoire de la façon dont la suppression en cascade dans Realm a remporté un long lancement

Si vous regardez le graphique sur sept jours, la métrique _app_start semble tout à fait adéquate et dure moins d'une seconde.

Il convient également d'ajouter que par défaut, Firebase envoie des notifications si la valeur médiane de _app_start dépasse 5 secondes. Cependant, comme nous pouvons le constater, vous ne devez pas vous y fier, mais plutôt le vérifier explicitement.

La particularité de la base de données Realm est qu'il s'agit d'une base de données non relationnelle. Malgré sa facilité d'utilisation, sa similitude avec les solutions ORM et la liaison d'objets, il n'a pas de suppression en cascade.

Si cela n’est pas pris en compte, les objets imbriqués s’accumuleront et « s’échapperont ». La base de données augmentera constamment, ce qui affectera à son tour le ralentissement ou le démarrage de l'application.

J'ai partagé notre expérience sur la façon d'effectuer rapidement une suppression en cascade d'objets dans Realm, ce qui n'est pas encore prêt à l'emploi, mais dont on parle depuis longtemps говорят и говорят. Dans notre cas, cela a considérablement accéléré le temps de démarrage de l’application.

Malgré la discussion sur l'apparition imminente de cette fonctionnalité, l'absence de suppression en cascade dans Realm est intentionnelle. Si vous concevez une nouvelle application, tenez-en compte. Et si vous utilisez déjà Realm, vérifiez si vous rencontrez de tels problèmes.

Source: habr.com

Ajouter un commentaire