Fuites de mémoire dans React Native : suivi des propriétaires de la pile JS et de la mémoire native
Découvrez pourquoi la collecte de déchets ne peut pas protéger une application React Native des fuites natives, et comment identifier ce qui maintient en vie les callbacks, les objets JSI et les images décodées.
Sources: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory
Lorsqu’une interface React Native est démontée, on s’attend à ce que sa mémoire soit libérée en même temps. Pourtant, après avoir ouvert et fermé cette interface dix fois, l’application devient lente, un téléphone Android à faible mémoire la ferme complètement, et Xcode indique que l’application a dépassé sa limite de mémoire. JavaScript dispose d’un collecteur de déchets, alors comment cela peut-il se produire ?
La réponse est qu’une application React Native évolue dans deux mondes mémoire, et le collecteur de déchets ne gère qu’un d’entre eux. Ce guide explique comment fonctionnent réellement la pile, le tas et le collecteur de déchets, présente trois modes de fuites mémoire spécifiques à React Native ainsi qu’un malentendu courant concernant les hooks, et montre comment utiliser les outils Hermes, Xcode et Android Studio pour identifier ce qui continue de retenir la mémoire en augmentation constante.
Deux mondes mémoire qui se soutiennent mutuellement
D’un côté se trouve le monde JavaScript : les objets, les tableaux, les fonctions, l’état et les props de React, ainsi que les closures capturés par vos callbacks. De l’autre côté se trouve le monde natif : les vues de la plateforme, les pixels d’image décodés, les buffers d’appareil photo et de vidéo, les identifiants de fichiers ainsi que les données gérées par les modules natifs.
Ces deux mondes sont liés, et chacun peut maintenir l’autre en vie. Un heap JavaScript bien organisé ne dit rien sur la mémoire totale de l’application, et le démontage d’un composant ne libère pas automatiquement les ressources natives qu’il a utilisées. Garder cette séparation à l’esprit constitue la base de chaque diagnostic présenté ci-dessous.
Comment fonctionnent la pile d’appels, le heap et le collecteur de déchets
Trois concepts méritent d’être définis avec précision.
- La pile d’appels suit les fonctions qui s’exécutent actuellement. Chaque appel ajoute une petite frame ; le retour en retire une.
Un petit exemple rend cette relation concrète :
function showName(user) {
return user.name.toUpperCase();
}
showName({ name: "Ada" });
Pendant l’exécution de toUpperCase(), la pile contient quelques frames : l’appel à showName et la méthode de chaîne qu’elle invoque. L’objet utilisateur lui-même se trouve sur le heap. Lorsque showName retourne, son frame disparaît, mais l’objet ne peut être collecté que si plus rien d’autre ne fait référence à lui. Dans cet extrait, rien ne le fait donc il est éligible à la collecte ; si un cache, un écouteur ou une variable au niveau du module avait conservé une référence, il resterait en place.
Cela donne la règle qui gouverne tout le reste : le GC récupère les données inaccessibles. Le démontage d’un composant ou la sortie du champ de portée d’une variable locale ne libère rien par eux-mêmes ; ils suppriment simplement une voie possible d’accès aux données.
Le timing est également important. Le collecteur de mémoire ne donne aucune garantie quant au moment à cui il s’exécute. Hermes utilise un collecteur concurrent appelé Hades, conçu pour limiter les temps d’arrêt afin que l’interface reste fluide (voir les versions de Hermes). Par conséquent, le fait que la mémoire ne diminue pas immédiatement après une navigation ne prouve pas d’fuite mémoire. Comme l’expliquent les ingénieurs de Hermes dans une discussion sur le comportement du collecteur de mémoire, les objets inaccessibles peuvent persister jusqu’à ce que le collecteur juge qu’une collecte est nécessaire en raison de la pression mémoire. Une fuite mémoire correspond à une mémoire qui continue d’augmenter lors de multiples exécutions du même parcours, et non à une mémoire dont la baisse est lente.
Où la mémoire reste active dans une application React Native
La plupart des applications React Native fonctionnent aujourd’hui avec Hermes, bien que le moteur exact dépende de l’application et de sa configuration. Ce moteur influence les valeurs des tas mémoire ainsi que les temps d’arrêt du collecteur de déchets, mais pas la première question de débogage : s’agit-il d’une augmentation de la mémoire JavaScript ou de la mémoire native ?
Le diagramme ci-dessous montre les relations typiques à cette frontière :
JavaScript runtime Native runtime
------------------ --------------------
state and props ─────► views and view layers
arrays and objects ─────► decoded image pixels
hooks and callbacks ─────► camera/video buffers
JSI values ◄─────► TurboModules / C++ data
Les états et les props contrôlent les vues natives, les tableaux et les objets font référence à des images décodées, les hooks et les callbacks stockent des buffers vidéo ou de caméra, tandis que les valeurs JSI interagissent dans les deux sens avec TurboModules et des données en C++. Considérez cette frontière comme une ligne de propriété : chaque fois qu’une ressource la traverse, vous devez pouvoir répondre à trois questions :
- Qui possède actuellement cette ressource ?
- Qu’est-ce qui la maintient en vie ?
- Quelle appelation spécifique la libère ?
Si la réponse à la troisième question est « le collecteur de déchets s’en chargera », considérez cela comme un signe d’alerte. Pour les ressources natives, il n’est souvent pas sûr de le faire.
Fuite 1 : un module natif qui conserve votre callback JavaScript
Cet état des choses n’a pas d’équivalent réel dans le JavaScript des navigateurs. Un module natif peut stocker un callback reçu de JavaScript, et ce callback maintient toute sa fermeture en vie : l’état, les propriétés, un grand objet de brouillon, voire le modèle d’une page qui n’existe plus.
Ici, un effet s’abonne aux progrès du téléchargement et renvoie une fonction de nettoyage qui supprime cette abonnement :
useEffect(() => {
const sub = UploadManager.onProgress((done) => {
reportProgress(draft.id, done);
});
return () => sub.remove();
}, [draft.id]);
Cela n’est sûr que si remove() indique réellement au côté natif de libérer le callback. Si la fonction de nettoyage fait défaut, ou si la bibliothèque contient une erreur, une chaîne de rétention comme celle-ci se forme :
native UploadManager → JS callback → closure → draft → image list
Le UploadManager natif contient la fonction de rappel, cette dernière contient sa fermeture, la fermeture contient draft, et draft contient toute une liste d’images. Désactiver l’écran n’a aucun effet sur la rupture de cette chaîne. Seule la nettoyage des abonnements y parvient, c’est pourquoi il est important de vérifier que les abonnements de tiers se détachent réellement du côté natif, et non seulement en JavaScript.
Fuite 2 : un petit objet JSI qui masque une ressource native importante
JSI permet à JavaScript d’interagir avec les objets natifs de manière bien plus directe que l’ancien mécanisme de pont, ce qui est excellent pour les performances et rend la notion de propriété beaucoup plus importante. Imaginez une bibliothèque qui ouvre un décodeur vidéo natif, un curseur de base de données ou une session caméra et remet à JavaScript un objet léger le représentant. L’objet JavaScript peut être très petit, et le GC n’a aucune raison de se hâter pour le collecter, tandis que la ressource native qu’il représente consomme une grande quantité de mémoire.
L’exemple ci-dessous ouvre une session caméra, la lance, puis, lors du nettoyage, l’arrête et la ferme :
useEffect(() => {
const session = CameraSession.open();
session.start();
return () => {
session.stop();
session.close();
};
}, []);
C’est l’appel à close() qui est crucial. Les sessions matérielles, les décodeurs, les fichiers ouverts, les curseurs ainsi que les allocations GPU doivent tous être libérés intentionnellement, et non laissés au collecteur pour qu’il les découvre. Vous ne pouvez pas planifier une collecte, donc elle ne peut pas servir de plan de libération ; de plus, les finalisateurs peuvent s’exécuter en retard ou ne pas s’exécuter du tout.
Si vous maintenez une bibliothèque JSI ou native, attribuez à chaque objet possédant des ressources une méthode de libération explicite et assurez-vous qu’elle soit idempotente, afin que son appel deux fois ne cause aucun problème. Dans le code d’application, appelez cette méthode depuis le point de nettoyage correspondant. L’équipe Hermes donne le même conseil dans la discussion sur le GC : exposez une méthode de type close() pour les ressources natives plutôt que de compter sur la finalisation.
Fuite 3 : le composant image a disparu, mais les pixels non
Cette logique est à l’origine d’une grande partie des pannes dues à un manque de mémoire. Une photo qui occupe peu d’espace sur le disque devient énorme une fois décodée, car des formats compressés comme JPEG doivent être décompressés en pixels bruts pour être affichés. Une estimation approximative de la taille décodée est :
width × height × 4 bytes
Avec quatre octets par pixel, une photo de 4 000 par 3 000 nécessite environ 48 MB, sans compter les copies supplémentaires pour l’agrandissement, le cache ou le téléchargement vers la GPU. Considérons maintenant une miniature qui affiche une telle photo :
<Image
source={{ uri: fullSizePhotoUrl }}
style={{ width: 160, height: 160 }}
/>
La taille de l’affichage n’est que de 160 par 160 points, mais le dispositif peut tout de même télécharger et décoder le fichier en résolution pleine. Dans un flux contenant de nombreuses images volumineuses, la mémoire native peut augmenter progressivement tandis que la pile JavaScript reste stable, ce qui est précisément la situation qui déroute les développeurs qui ne se concentrent que sur la mémoire JavaScript.
Tout accroissement de la mémoire n’est pas dû à une fuite. Un cache d’images qui stocke les photos consultées récemment remplit bien sa fonction. Le problème apparaît lorsque la mémoire continue d’augmenter à chaque passage par le même flux, sans jamais se stabiliser, ce qui finit par entraîner l’arrêt du processus.
Commencez par corriger ce que vous introduisez dans le pipeline d’images :
- Demandez des images dont la taille est proche de celle à laquelle elles seront affichées.
- Utilisez des miniatures dans les listes et ne chargez pas la résolution complète que sur l’écran détaillé.
- Limitez le nombre d’images affichées en même temps, par exemple grâce à la virtualisation des listes.
- Apprenez les règles de mise en cache de votre bibliothèque d’images et configurez ses limites.
- Faites des tests sur un appareil réel à faible mémoire, et non seulement sur un simulateur.
Blâmer Hermes pour un bitmap de 48 MB revient à ne pas comprendre la situation. Le moteur JavaScript ne peut pas récupérer les pixels natifs encore détenus par une vue d’image, le pipeline d’images, un cache ou une texture GPU.
Fermetures obsolètes dans les hooks : identifiez le bug que vous avez
On affirme souvent qu’un tableau de dépendances incorrect provoque une fuite mémoire. Cela peut arriver, mais le plus souvent cela entraîne d’abord un bug lié à des valeurs incorrectes, et cette distinction influence la manière de le corriger.
useEffect(() => {
const sub = player.onProgress(() => {
savePosition(episode.id);
});
return () => sub.remove();
}, []); // episode may change, but this callback will not
La fonction de rappel capture episode lors de la première affichage. Lorsque l’utilisateur change d’épisode, l’avancement est toujours enregistré en fonction de l’ID original. Il s’agit donc d’une fermeture obsolète. Si l’abonnement est supprimé lors du démontage, on a un bug de correction plutôt qu’une fuite mémoire persistante. Si le lecteur natif conserve la fonction de rappel après le démontage, on a les deux problèmes.
La solution n’est pas d’ajouter tous les objets dans l’array de dépendances. Il s’agit plutôt de faire en sorte que l’effet décrit précisément la ressource qu’il gère, en se basant sur la valeur qui la définit :
useEffect(() => {
const sub = player.onProgress(() => {
savePosition(episode.id);
});
return () => sub.remove();
}, [episode.id]);
Désormais, chaque fois que episode.id change, React exécute la fonction de nettoyage pour supprimer l’abonnement ancien avant d’en créer un nouveau, et la fonction de rappel ne capture que la petite valeur dont elle a besoin. S’appuyer sur un ID primitif plutôt que sur l’ensemble de l’objet episode permet également d’éviter de se réabonner à chaque rendu lorsque l’identité de l’objet change mais pas celle de l’épisode.
Mener une véritable enquête sur la mémoire
Des observations vagues comme « l’application utilise beaucoup de mémoire » ne sont pas utiles. Définissez plutôt une séquence reproductible : ouvrez le flux, faites défiler, ouvrez dix photos, revenez en arrière, et répétez toute la séquence cinq fois.
Puis observez quel schéma se dégage des mesures :
- Le heap JavaScript augmente après chaque boucle : la fuite se situe très probablement du côté JavaScript, il faut donc commencer par des captures d’écran du heap.
- Le heap JavaScript reste globalement stable tandis que la mémoire totale de l’application augmente : examinez la mémoire native à l’aide des outils de la plateforme.
Diagnostic de l’augmentation du heap JavaScript
Utilisez l’inspecteur Hermes et prenez des captures d’écran du heap là où votre version de React Native et votre configuration de compilation le permettent. Sélectionnez un objet suspect dans la capture d’écran et suivez son chemin de rétention jusqu’à une racine. Les coupables typiques portent des noms tels que listeners, timers, stores, caches, callbacks ou singletons au niveau du module.
Faites attention à ne pas le confondre avec le profilleur d’échantillonnage Hermes, qui est principalement un outil pour le CPU. Il indique quel JavaScript était actif, mais pas pourquoi la mémoire d’image native augmente. Le guide React Native sur le profilage avec Hermes explique comment enregistrer et ouvrir un profil CPU.
Diagnostic de la mémoire native sur iOS
Démarrez l’application depuis Xcode sur un appareil physique. Commencez par le indicateur de mémoire pour obtenir une vue rapide des tendances, puis ouvrez le Graphique de mémoire en mode débogage afin d’observer les objets et les références entre eux. Instruments, en particulier le modèle Allocations, vous permet de suivre les allocations au fur et à mesure que vous reproduisez le processus. La documentation sur la mémoire d’Xcode fournie par Apple décrivait comment le graphique de mémoire expose les objets, les allocations et leurs liens, ainsi que la manière dont Allocations surveille à la fois la mémoire heap et la mémoire virtuelle.
Lorsque Instruments montre une croissance constante lors d’un flux comportant de nombreuses photos, mais que la mémoire heap Hermes ne bouge presque pas, cessez de chercher un problème lié à une fermeture JavaScript. Le problème se situe du côté natif.
Diagnostic de la mémoire native sur Android
Utilisez le Profilleur de mémoire d’Android Studio sur un appareil réel pour observer l’utilisation de la mémoire par l’application au fil du temps. Son profilage natif, introduit avec Android Studio 4.1, enregistre les allocations et désallouations de mémoire natives ainsi que les objets natifs correspondants.
Les écrans du profilleur varient d’une version d’Android Studio à l’autre, mais la méthode reste identique : enregistrer le parcours répétable, marquer le début de chaque boucle et rechercher une utilisation de la mémoire qui augmente sans revenir à un niveau stable.
Une liste de vérification pour chaque écran
Quoi que commence un écran, sa phase de nettoyage doit la terminer. Un audit rapide couvre :
- Le nettoyage d’un chronomètre qui a été démarré.
- La suppression d’un écouteur ajouté.
- Le désabonnement d’une inscription native.
- L’arrêt et la fermeture d’une caméra, d’un lecteur, d’un fichier ou d’un curseur de base de données.
- L’imposition d’une limite de taille ou d’une politique d’éviction pour le cache que vous créez.
Ce n’est pas une gestion manuelle de la mémoire au sens du langage C. Vous ne libérez pas des octets arbitraires ; vous terminez simplement le traitement et restituez les ressources au moment où votre application cesse d’en être propriétaire.
En résumé
La collecte de déchets ne promet jamais que tout ce qu’un composant a touché disparaîtra lorsqu’il est désinstallé. Lorsque la mémoire augmente, posez deux questions dans cet ordre : l’augmentation concerne-t-elle la pile JavaScript ou le processus natif, et qui est encore propriétaire de l’élément en question ?
La réponse indique directement la solution. Un callback persistant signifie qu’une abonnement n’a jamais été supprimé. Une session native indique l’absence de la fonction close(). Un bitmap de 48 MB signifie que vous avez besoin d’une image plus petite ou d’un cache mieux géré. Les bugs de mémoire dans React Native cessent d’être mystérieux dès que l’on cesse d’attendre le gestionnaire de mémoire pour commencer à identifier la cause et à y remédier.
Tout au long du processus, profilez sur des appareils réels. Un simulateur convient bien pour reproduire un flux, mais il peut masquer la pression sur la mémoire qui fait planter votre application sur le téléphone d’un utilisateur.