Une fois que nous avons trouvé que l'application Dodo Pizza démarre en 3 secondes en moyenne, 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, les fuites de mémoire, comment nous avons sauvegardé des objets imbriqués, puis nous nous sommes ressaisis et avons tout réparé.
L'auteur de l'article: Maxim Kachinkin est un développeur Android chez Dodo Pizza.
Trois secondes entre un clic sur l'icône de l'application et le onResume () de la première activité, c'est l'infini. Et pour certains utilisateurs, le temps de lancement a atteint 15-20 secondes. Comment est-ce possible?
Un très court résumé pour ceux qui n'ont pas le temps de lire
Realm. , . . , — 1 . — - -.
Recherche et analyse du problème
Aujourd'hui, toute application mobile doit se lancer rapidement et être réactive. Mais ce n'est pas seulement l'application mobile. L'expérience utilisateur d'interagir 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 d'un service de pizza. Si la livraison est rapide, la pizza sera chaude et le client qui veut manger maintenant n'aura pas à attendre longtemps. Pour l'application, à son tour, il est important de créer la sensation d'un service rapide, car si l'application ne démarre que 20 secondes, combien de temps cela prendra-t-il pour une pizza?
Au début, nous avons nous-mêmes été confrontés au fait que parfois l'application est lancée pendant quelques secondes, puis des plaintes d'autres collègues ont commencé à nous parvenir selon lesquelles c'était «long». Mais nous n'avons pas réussi à répéter cette situation de manière stable.
Combien de temps est-ce? SelonDocumentation Google , si un démarrage à froid d'une application prend moins de 5 secondes, alors il est considéré comme "normal". L'application Android Dodo Pizza a été lancée (selon la métrique Firebase _app_start ) sur un démarrage à froid en 3 secondes en moyenne - "Pas génial, pas terrible", comme on dit.
Mais alors des plaintes ont commencé à apparaître selon lesquelles l'application était lancée depuis très, très, très longtemps! Pour commencer, nous avons décidé de mesurer ce qui est "très, très, très long". Et nous avons utilisé la trace de démarrage de l'application Firebase trace pour cela .
Cette trace standard mesure le temps entre le moment où l'utilisateur ouvre l'application et le moment où l'onResume () de la première activation est exécuté. La console Firebase appelle cette métrique _app_start. Il s'est avéré que:
- Les utilisateurs au-dessus du 95e centile ont un temps de démarrage de près de 20 secondes (certains en ont plus), malgré un temps de démarrage à froid médian de moins de 5 secondes.
- Le temps de démarrage n'est pas constant, mais augmente avec le temps. Mais parfois, des chutes sont observées. Nous avons trouvé ce modèle lorsque nous avons augmenté l'échelle d'analyse à 90 jours.
Deux pensées me sont venues à l'esprit:
- Quelque chose fuit.
- Ce «quelque chose» est jeté après la libération, puis s'échappe à 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, nous la vidons lors de la migration. Deuxièmement, la base de données est chargée au démarrage de l'application. Tout va ensemble.
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 change au cours de la durée de vie de l'application, à partir de la première installation et plus loin dans le processus d'utilisation active. Vous pouvez afficher le contenu de la base de données Realm via Stetho ou plus en détail et visuellement en ouvrant le fichier via Realm Studio . Pour afficher le contenu de la base de données via ADB, copiez le fichier de 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'image montre un fragment de Realm Studio pour deux fichiers: à gauche - la base de données de l'application après un certain temps après l'installation, à droite - après une utilisation active. On peut voir que le nombre d'objets
ImageEntity
et 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 les heures de démarrage
La croissance incontrôlée de la base de données est très mauvaise. Mais comment cela affecte-t-il l'heure de lancement de l'application? Il est assez facile de le mesurer via ActivityManager. À partir d'Android 4.4, logcat affiche un journal avec la chaîne et l'heure affichées. Ce temps est égal à l'intervalle entre le moment où l'application a été lancée et la fin du rendu de l'activité. Pendant ce temps, des événements se produisent:
- Démarrage du processus.
- Initialisation d'objet.
- Création et initialisation d'activité.
- Création de mise en page.
- Rendu d'application.
Convient pour nous. Si vous exécutez ADB avec les indicateurs -S et -W, vous pouvez obtenir une sortie étendue avec l'heure de début:
adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN
Si vous gagnez du
grep -i WaitTime
temps à partir de là , vous pouvez automatiser la collecte de cette métrique et voir les résultats graphiquement. Le graphique ci-dessous montre la dépendance de l'heure de lancement de l'application sur le nombre de démarrages à froid de l'application.
Dans le même temps, la dépendance de la taille et de la croissance de la base était la même, qui est passée de 4 Mo à 15 Mo. En conséquence, il s'avère qu'avec le temps (avec la croissance des démarrages à froid), le temps de lancement de l'application et la taille de la base de données ont également augmenté. Nous avons une hypothèse entre nos mains. Il restait maintenant à confirmer la dépendance. Par conséquent, nous avons décidé de supprimer les "fuites" et de voir si cela accélérera le lancement.
Raisons de la croissance infinie de la base de données
Avant de supprimer les «fuites», il convient de comprendre pourquoi elles sont apparues. Pour ce faire, rappelons-nous ce qu'est le royaume.
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 de nombreuses bases de données relationnelles ORM sur Android. Dans le même temps, Realm enregistre directement les objets en mémoire avec le moins de transformations et de mappages. Cela vous permet de lire très rapidement les données du disque, ce qui est une force de Realm, pour lequel il est apprécié.
(Pour les besoins de cet article, cette description nous suffira. Vous pouvez en savoir plus sur Realm dans la documentation sympa ou dans leur académie ).
De nombreux développeurs ont l'habitude de 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 de données en cascade semblent souvent être une évidence. Mais pas dans le royaume.
À propos, la fonction de suppression en cascade a été demandée depuis longtemps. Cette révision et une autre qui y est liée ont été activement discutées. Il y avait un sentiment que ce serait bientôt fait. Mais ensuite, tout s'est transformé en l'introduction de liens forts et faibles, ce qui résoudrait également automatiquement ce problème. Pour cette tâche, il y avait une pull request plutôt vivante et active , qui a été suspendue pour l'instant en raison de difficultés internes.
Fuite de données sans suppression en cascade
Comment les données fuient-elles exactement si vous espérez 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 a différents champs, y compris une image
ImageEntity
, des ingrédients personnalisés CustomizationEntity
. En outre, 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 leur connexion avec le parent et resteront dans la base de données.
Puisque la connexion avec eux est perdue, nous ne les lisons plus ou ne les supprimons plus (à moins que nous n'y fassions explicitement référence ou que nous effacions le «tableau» entier). Nous avons appelé 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 pas d'autres Realms imbriqués dans l'image, customizationEntity et cartComboProducts, donc il n'y a pas d'autres boucles et suppressions imbriquées.
Solution rapide
Tout d'abord, nous avons décidé de nettoyer les objets à la croissance la plus rapide et de vérifier les résultats - si cela résoudra notre problème initial. Tout d'abord, la solution la plus simple et la plus intuitive a été faite, à savoir: chaque objet devrait être responsable de supprimer ses enfants après lui-même. Pour ce faire, nous avons introduit l'interface suivante, qui a renvoyé 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
)
}
}
Comme
getNestedEntities
nous retournons à tous les enfants une liste plate. Et chaque objet enfant peut également implémenter l'interface NestedEntityAware, informant 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. La méthode (réalisée sous la forme d'une extension)
deleteAllNestedEntities
récupère tous les objets de niveau supérieur et deleteNestedRecursively
supprime récursivement 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 à la croissance la plus rapide et avons vérifié ce qui s'était passé.
En conséquence, les objets que nous avons couverts 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»
La base, bien qu'elle ait commencé à croître plus lentement, continuait de croître. Nous avons donc commencé à chercher plus loin. Dans notre projet, la mise en cache des données dans Realm est très activement utilisée. Par conséquent, écrire tous les objets imbriqués pour chaque objet est laborieux, et le risque d'erreur augmente, car vous pouvez oublier de spécifier les objets lors de la modification du code.
Je voulais m'assurer de ne pas utiliser d'interfaces, mais que tout fonctionne par lui-même.
Lorsque nous voulons que quelque chose fonctionne par lui-même, nous devons utiliser la réflexion. Pour ce faire, nous pouvons parcourir chaque champ de la 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 RealmList, ajoutez alors l'objet de ce champ à la liste des objets imbriqués. Tout est exactement le même que nous l'avons fait ci-dessus, seulement ici, cela se fera par lui-même. 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()
}
}
}
}
L'extension
filterRealmObject
filtre et transmet uniquement les objets Realm. La méthode getNestedRealmObjects
recherche tous les objets Realm imbriqués par réflexion et les ajoute 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 aient les mêmes objets imbriqués. Il est préférable d'éviter cela et d'utiliser simplement la génération automatique d'ID lors de la création de nouveaux objets.
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)
}
Par conséquent, dans notre code client, nous utilisons une «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 }
))
}
Tout d'abord, la méthode
getManagedEntities
obtient 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 lancement sur le nombre de démarrages à froid de l'application, on voit le résultat.
La ligne verte montre la dépendance de l'heure de lancement 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 augmentation a considérablement ralenti le lancement de l'application. Nous avons publié une mise à jour avec notre propre «suppression en cascade» des objets imbriqués. Et maintenant, nous suivons et évaluons comment notre décision a affecté le temps de lancement de l'application via la métrique _app_start.
Pour l'analyse, nous prenons un intervalle de temps de 90 jours et voyons: l'heure de lancement de l'application, à la fois la médiane et celle qui tombe sur le 95e centile d'utilisateurs, a commencé à diminuer et n'augmente plus.
Si vous regardez le graphique sur sept jours, la métrique _app_start semble tout à fait adéquate et dure moins d'une seconde.
Nous devons également ajouter que, par défaut, Firebase envoie des notifications si la valeur médiane _app_start dépasse 5 secondes. Cependant, comme nous pouvons le voir, vous ne devriez pas vous fier à cela, mais plutôt aller 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é son utilisation simple, la similitude de travailler avec des solutions ORM et de lier des objets, il n'a pas de suppression en cascade.
Si cela n'est pas pris en compte, alors les objets imbriqués vont s'accumuler, "fuite". La base de données augmentera constamment, ce qui affectera à son tour le ralentissement ou le lancement de l'application.
Je partagéNos expérience comment faire vite un objets suppression en cascade du Royaume, ce qui est pas hors de la boîte, mais qui depuis longtemps de parler et de parler . Dans notre cas, cela a grandement accéléré le temps de lancement de l'application.
Malgré la discussion sur l'apparition imminente de cette fonctionnalité, l'absence de suppression en cascade dans Realm est due à la conception. Considérez ceci si vous concevez une nouvelle application. Et si vous utilisez déjà Realm, vérifiez si vous rencontrez de tels problèmes.