Guides pratiques

Priorité à l'efficacité de la mémoire : étapes essentielles pour Android 17

10 minutes de lecture

Si les performances d'une application sont souvent associées à une interface utilisateur fluide et à des temps de démarrage rapides, la mémoire constitue la base silencieuse sur laquelle reposent ces métriques visibles. Il n'est pas rare de constater que la mémoire de l'appareil est plus importante que jamais. Avec Android 17, nous avons fait des progrès dans l'optimisation de la mémoire Android. Nous fournissons également les outils et la compatibilité avec les API pour vous aider à anticiper les exigences de mémoire plus strictes qui seront mises en place d'ici la fin de l'année.

Pour assurer la stabilité de l'appareil, à partir d'Android 17, le système commencera à appliquer des limites de mémoire pour les applications en fonction de la RAM totale de l'appareil. Si une application dépasse ces limites, Android arrête le processus sans trace de la pile associée.

Au-delà de ces arrêts forcés, une utilisation non optimisée de la mémoire dégrade inévitablement l'expérience utilisateur. Lorsque l'application approche des limites de mémoire du tas, elle déclenche une récupération fréquente de mémoire, ce qui entraîne des saccades visibles dans l'UI. De plus, lorsqu'un appareil manque de mémoire disponible, le système s'efforce de récupérer des pages, ce qui entraîne une surcharge du processeur, une latence de l'interface utilisateur et une décharge de la batterie. Si la pénurie de mémoire est trop importante, elle peut entraîner des événements LMK (Low Memory Killer) qui mettent fin brusquement aux processus en arrière-plan et forcent les applications à avoir des démarrages à froid lents et à perdre l'état de l'utilisateur.

Pour créer des applications très performantes et éviter ces fermetures forcées, nous vous recommandons d'adopter les stratégies d'optimisation de la mémoire suivantes :

  1. Optimiser au maximum le bytecode avec R8
  2. Optimiser le chargement des images
  3. Détecter et corriger les fuites de mémoire avec Android Studio
  4. Réduire la mémoire lorsque l'application quitte l'état visible
  5. Observabilité avancée de la mémoire avec ProfilingManager

Une version condensée de cet article de blog est également disponible au format vidéo. N'hésitez pas à la regarder !

Comprendre les limites de mémoire des applications sous Android 17

Des limites de mémoire pour les applications sont introduites dans Android 17 afin d'empêcher une personne malintentionnée de détruire l'expérience multitâche et la stabilité de l'ensemble de l'appareil de l'utilisateur.

Voici les raisons qui motivent ce changement d'architecture :

  • Éviter les arrêts en cascade : lorsqu'une application devient trop volumineuse ou présente des fuites de mémoire alors qu'elle détient un état privilégié (par exemple, elle exécute un service de premier plan), elle est initialement protégée du Low Memory Killer (LMK) du système. À mesure que cette application unique se développe de manière incontrôlée et accapare la RAM, le LMK est obligé de compenser en arrêtant des dizaines d'applications mises en cache et de tâches en arrière-plan plus petites et bien gérées pour récupérer de l'espace pour la mémoire gourmande.
     
  • Préservation du multitâche et de l'état de l'utilisateur : lorsque le système est obligé de supprimer les applications mises en cache pour s'adapter à un seul processus avec fuite de mémoire, l'expérience multitâche est fortement dégradée. Les utilisateurs qui reviennent à des applications mises en cache précédemment rencontrent des démarrages à froid lents au lieu de reprises à chaud quasi instantanées. Cette inefficacité génère une charge de processeur plus importante et accélère l'épuisement de la batterie. Il peut également détruire le contexte de l'utilisateur dans les applications récemment utilisées, comme les positions de défilement, les piles de navigation et la progression dans les jeux.

Pour déterminer si la session de votre application a été affectée par ces contraintes sur le terrain, vous pouvez appeler getDescription() dans ApplicationExitInfo. Si le système a appliqué une limite, le motif de sortie est indiqué comme REASON_OTHER et la chaîne de description contient "MemoryLimiter:AnonSwap". Vous pouvez également utiliser le profilage basé sur des déclencheurs avec TRIGGER_TYPE_ANOMALY pour capturer automatiquement les vidages du tas lorsque la limite de mémoire est atteinte. De plus, Android s'efforce activement de fournir aux développeurs davantage de métriques sur la mémoire sur le terrain dans la Google Play Console.

Nous avons également étoffé notre documentation sur les limites de mémoire pour y inclure des commandes de débogage local. Vous pouvez ainsi simuler des contraintes de mémoire dans votre environnement local et valider le comportement de votre application en cas d'application d'une limite de mémoire.

Optimiser au maximum le bytecode avec R8

Un moyen très efficace de réduire l'espace mémoire utilisé de votre application consiste à activer l'optimiseur R8. En réduisant les classes, les méthodes et les champs en noms plus courts, et en supprimant le code et les ressources inutilisés, R8 réduit considérablement l'espace mémoire utilisé de votre application en minimisant la quantité de code résident requise lors de l'exécution. 

R8 minimise le code résident, ce qui réduit l'espace mémoire utilisé et le risque d'arrêt LMK. Cela permet de privilégier les démarrages tièdes plus fréquents aux démarrages à froid lents. De plus, le bytecode simplifié réduit la surcharge du processeur du thread principal, ce qui diminue directement les taux d'erreurs ANR pour une expérience utilisateur plus fluide. Par exemple, la banque numérique Monzo a activé l'optimisation R8 complète et a constaté une réduction de 35% de son taux d'erreurs ANR, une amélioration de 30% du taux de démarrage à froid et une réduction de 9% de la taille globale de l'application.

pic1-IO26_113_TSV-monzo-casestudy.jpg
La banque numérique Monzo a activé l'optimisation R8 complète et a amélioré ses métriques de performances jusqu'à 35%.

Pour configurer correctement R8 dans votre fichier build.gradle :

  • Définissez isShrinkResources = true et isMinifyEnabled = true.
  • Utilisez proguard-android-optimize.txt au lieu de l'ancienne proguard-android.txt, qui empêche les optimisations et n'est plus prise en charge dans le plug-in Android Gradle 9.
  • Supprimez android.enableR8.fullMode = false de votre gradle.properties.

Si vous utilisez la réflexion dans votre base de code, ajoutez des règles de conservation pour empêcher R8 d'optimiser ces parties du code. Veillez à bien cibler les règles de conservation pour obtenir une optimisation maximale. 

Pour optimiser au maximum votre fichier de règles de conservation, veillez à suivre ces bonnes pratiques.

  • Supprimez les options globales telles que -dontoptimize, -dontshrink et -dontobfuscate qui empêchent R8 d'optimiser l'ensemble de la base de code.
  • Supprimez les règles de conservation qui empêchent l'optimisation des composants Android tels que les activités, les services, les vues ou les broadcast receivers.
  • Affinez les règles de conservation générales à l'échelle du package pour ne cibler que des classes ou des méthodes spécifiques. 

Pour découvrir d'autres bonnes pratiques, consultez la documentation sur les règles de conservation.

Bonnes pratiques R8 pour les développeurs de bibliothèques

Si vous êtes un développeur de bibliothèque, placez strictement les règles dont vos consommateurs ont besoin dans votre fichier consumer-rules file et conservez les règles de protection internes de votre bibliothèque dans votre fichier proguard-rules.pro. Pour en savoir plus sur l'optimisation des bibliothèques, consultez Optimisation pour les auteurs de bibliothèques.

Analyseur de configuration R8

Pour auditer votre optimisation R8, utilisez l'Analyseur de configuration. L'analyseur de configuration affiche l'état actuel de l'optimisation avec les scores d' obscurcissement, d'optimisation et de minification. L'analyseur de configuration vous permet également de comprendre le nombre de classes, de méthodes ou de champs qui ne peuvent pas être optimisés par chaque règle Keep. Afin d'optimiser au maximum votre package, affinez ces règles de conservation générales. 

L'analyseur de configuration vous permet également d'identifier les règles de conservation qui en englobent d'autres, les règles de conservation redondantes et celles qui ne sont pas utilisées.

pic2-r8-config-analyzer.png
L'analyseur de configuration affiche l'état actuel de l'optimisation avec les scores d'obscurcissement, d'optimisation et de réduction.

Compétence d'agent R8 

Vous pouvez également utiliser la compétence d'agent R8 avec l'agent Android Studio ou d'autres outils d'IA pour résoudre les problèmes de configuration et affiner vos règles, ce qui améliore les performances de l'application. (Les insights issus des compétences basées sur l'IA nécessitent une validation technique.)

Optimiser le chargement des images

Les bitmaps sont généralement les objets communs les plus volumineux résidant dans la mémoire de votre application. Ils représentent la dernière étape du processus de chargement des images, au cours de laquelle les fichiers compressés, tels que les fichiers JPEG ou PNG, sont décodés en données brutes de pixels pour l'affichage. Cela signifie qu'une petite image compressée de 100 Ko peut se transformer en plusieurs mégaoctets de RAM, car la consommation de mémoire est déterminée par les dimensions en pixels et la profondeur de couleur de l'image. Étant donné que les opérations bitmap se trouvent fréquemment sur le chemin critique pour dessiner des frames, les images non optimisées entraînent un gonflement important de la mémoire et des à-coups dans l'UI.

Google recommande d'utiliser les bibliothèques de chargement d'images Coil pour les projets Kotlin-first, en particulier lors du développement avec Jetpack Compose, et Glide pour les applications basées sur Java.

Adoptez ces cinq bonnes pratiques

  1. Sous-échantillonner les images : si vous chargez des bitmaps manuellement, évitez de charger une image volumineuse dans une petite miniature. Utilisez inSampleSize pour charger une version plus petite. Glide et Coil sous-échantillonnent les images par défaut. Vous pouvez configurer cette stratégie de sous-échantillonnage à l'aide de DownsampleStrategy et ImageLoader, respectivement.
  2. Recadrage  : évitez d'intégrer des marges directement dans un fichier image pour le letterboxing (par exemple, en créant une bordure transparente pour agrandir les dimensions d'une image). Au lieu d'intégrer ces bordures, utilisez InsetDrawable ou appliquez un espacement directement dans la vue ou le composable contenant le bitmap.
  3. Config : équilibrez la mémoire et la qualité en choisissant le format de pixel approprié. Utilisez RGB_565 lorsque la transparence n'est pas nécessaire. Ce format utilise la moitié de la mémoire du format ARGB_8888 par défaut. Dans Glide, vous pouvez configurer cela à l'aide de DecodeFormat et dans Coil, vous pouvez utiliser la propriété bitmapConfig.
  4. Priorisez les drawables vectoriels : pour les composants géométriques de base, utilisez ShapeDrawable comme alternative légère au décodage des bitmaps rastérisés. En définissant ces composants une seule fois via XML, vous vous assurez qu'ils s'adaptent parfaitement à toutes les densités d'affichage tout en éliminant efficacement le gonflement de la mémoire lié aux ressources.
  5. Réutilisation : si votre application gère les bitmaps manuellement, elle doit appeler bitmap.recycle() et supprimer immédiatement la référence Bitmap lorsque le bitmap n'est plus nécessaire, afin de minimiser la saturation de la mémoire. Si vous utilisez une bibliothèque de chargement d'images comme Glide ou Coil, renvoyez le bitmap au pool géré de la bibliothèque. En fournissant un tampon existant pour les futurs besoins de mémoire, le pool évite efficacement la surcharge des nouvelles allocations.

Pour en savoir plus, consultez notre documentation sur l'optimisation des performances des images.

Outils Android Studio

Vous pouvez également éliminer les bitmaps redondants à l'aide d'Android Studio Narwhal 4. Voici comment les trouver en cinq étapes simples :

  1. Ouvrez l'onglet Profiler (Profileur) dans Android Studio.
  2. Cliquez sur Heap Dump (ou "Analyze Memory Usage") et appuyez sur "Record" (Enregistrer) pour prendre un instantané de l'état actuel de la mémoire de votre application.
  3. Examinez les résultats de l'analyse pour trouver le triangle d'avertissement jaune ⚠️, qu'Android Studio utilise pour signaler les bitmaps en double stockés plusieurs fois. Vous pouvez également accéder à l'en-tête du profileur, choisir "Filtrer par" et sélectionner le paramètre "Bitmaps en double".
  4. Cliquez sur une entrée signalée pour ouvrir le volet Aperçu du bitmap, qui vous permet de voir exactement quelle image est à l'origine du problème.
  5. Utilisez cette confirmation visuelle pour identifier la logique de chargement redondante dans votre code et implémenter une meilleure stratégie de mise en cache.
pic3-IO26_113_TSV -dup-bitmaps-cropped.jpg
Recherchez le triangle d'avertissement jaune ⚠️ dans les empreintes de la mémoire lorsque vous utilisez le profileur Android Studio.

Détecter et corriger les fuites de mémoire avec Android Studio

Les fuites de mémoire sur Android se produisent lorsque votre code conserve la référence d'un objet longtemps après la fin de son cycle de vie. Cela empêche le récupérateur de mémoire (GC, Garbage Collector) de récupérer cette mémoire, ce qui finit par entraîner des performances lentes ou une erreur OutOfMemoryError (OOM).

Android Studio Panda 3 propose une tâche de profileur LeakCanary dédiée, qui permet aux développeurs d'analyser les fuites de mémoire en temps réel et de mapper les traces directement dans l'IDE.

La tâche de profileur LeakCanary dans Android Studio transfère activement l'analyse des fuites de mémoire de votre appareil vers votre ordinateur de développement. Cela permet d'améliorer considérablement les performances pendant la phase d'analyse des fuites par rapport à l'analyse des fuites sur l'appareil.

pic4-android-studio-leaks.png
 Analyse des fuites de mémoire LeakCanary contextualisée avec l'option "Accéder à la déclaration" pour le débogage

De plus, l'analyse des fuites est désormais contextualisée dans l'IDE et entièrement intégrée à votre code source. Elle fournit des fonctionnalités telles que "Aller à la déclaration" et d'autres connexions de code utiles qui réduisent considérablement les frictions et le temps nécessaires pour examiner et corriger les fuites de mémoire.  

Exemples de fuites de mémoire courantes 

Les fuites de mémoire se produisent lorsqu'un objet persiste en mémoire au-delà de sa durée de vie prévue. Cela se produit généralement pour les raisons suivantes :

  • Conserver des références à des fragments, des activités ou des vues qui ne sont plus utilisés.
  • Mauvaise gestion des références de contexte.
  • Ne pas désenregistrer correctement les observateurs, les écouteurs et les récepteurs.
  • Créer des références statiques à des objets liés à des composants dont le cycle de vie est plus court.

Voici quelques exemples de scénarios :

ScénarioExemple basé sur ComposeExemple basé sur les vues
Fuite de contexte

Exemple :
Transmettre LocalContext.current à un ViewModel

Correction :
Conservez la logique dépendante du contexte dans la couche UI. Pour les couches non liées à l'UI, refactorisez-les pour utiliser l'injection de dépendances ou observez l'état de l'UI à l'aide du flux Kotlin.

Exemple :
Stockage d'un Activity dans un objet compagnon ou une variable statique.

Correction :
Ne conservez pas de références statiques aux composants d'UI. Refactorisez pour utiliser l'injection de dépendances ou observez l'état de l'UI à l'aide du flux Kotlin.

Fuite d'écouteurs

Exemple :
Utilisation de DisposableEffect pour démarrer un écouteur, mais en laissant onDispose vide.

Correction :
Effectuez la désinscription et la logique de nettoyage dans le bloc onDispose.

Exemple :
S'inscrire aux mises à jour SensorManager et oublier de se désinscrire.

Correction :
Appelez manuellement unregisterListener() dans le cycle de vie onStop() ou onDestroy().

Fuite de vues

Exemple :
Conserver une référence à un ancien View dans un AndroidView sans stratégie de publication.


Correction
 : utilisez le bloc release du composable AndroidView pour nettoyer l'ancien View.

Exemple :
Conserver une référence à un objet de liaison de vue après la destruction de Fragment.

 

Correction :
Définissez la variable de liaison sur null dans la méthode de cycle de vie onDestroyView().

Réduire la mémoire lorsque l'application quitte l'état visible

Android peut récupérer la mémoire de votre application ou l'arrêter complètement si nécessaire afin de libérer de la mémoire pour les tâches critiques, comme expliqué dans la présentation de la gestion de mémoire. Android récupère généralement la mémoire de votre application lorsqu'elle n'est pas visible par l'utilisateur, par exemple en supprimant certaines pages de code et de données de votre application en mémoire ou en compressant vos allocations de tas. Lorsque l'utilisateur reprend votre application et que celle-ci tente d'accéder à de la mémoire récupérée, l'OS réactive cette mémoire à la demande. Ce comportement d'échange peut être lent et entraîner des à-coups ou des bégaiements inattendus dans votre application.

Si vous laissez l'OS décider quelle mémoire récupérer de votre application, vous constaterez peut-être que l'OS a récupéré de la mémoire dont vous aurez besoin peu de temps après la reprise de votre application. Au lieu de cela, votre application peut volontairement supprimer les allocations de mémoire qu'elle peut régénérer ultérieurement, à la demande et à faible coût. Pour ce faire, vous pouvez implémenter l'interface ComponentCallbacks2. Vous pouvez implémenter onTrimMemory dans votre classe Activity, Fragment, Service ou même dans votre classe Application personnalisée. Son utilisation dans la classe Application est très efficace pour la gestion globale du cache.

La méthode de rappel onTrimMemory() fournie avertit votre application des événements liés au cycle de vie ou à la mémoire qui lui offrent une bonne occasion de réduire volontairement son utilisation de la mémoire.

En termes de gestion du cycle de vie de la mémoire, votre implémentation doit se concentrer exclusivement sur TRIM_MEMORY_UI_HIDDEN et TRIM_MEMORY_BACKGROUND. Depuis Android 14, le système ne distribue plus de notifications pour d'autres constantes héritées, qui ont été officiellement abandonnées dans Android 15.

TRIM_MEMORY_UI_HIDDEN : ce signal indique que l'UI de votre application n'est plus visible par l'utilisateur. Cela permet de libérer des allocations de mémoire importantes liées strictement à l'interface, telles que les bitmaps, les tampons de lecture vidéo ou les ressources d'animation complexes.

TRIM_MEMORY_BACKGROUND : à ce niveau, votre processus réside en arrière-plan et peut maintenant être arrêté pour répondre aux besoins de mémoire globaux du système. Pour prolonger la durée pendant laquelle votre processus reste en état mis en cache et réduire le nombre de démarrages à froid de l'application, vous devez libérer de manière agressive toutes les ressources qui peuvent être facilement reconstruites une fois que l'utilisateur reprend sa session.

import android.content.ComponentCallbacks2
// Other import statements.

class MainActivity : AppCompatActivity(), ComponentCallbacks2 {

    /**
     * Release memory when the UI becomes hidden or when system resources become low.
     * @param level the memory-related event that is raised.
     */
    override fun onTrimMemory(level: Int) {

        if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
            // Release memory related to UI elements, such as bitmap caches.
        }

        if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            // Release memory related to background processing, such as by
            // closing a database connection.
        }
    }
}

Remarque : L'intégration de onTrimMemory peut dépendre de la compatibilité du SDK. Par exemple, certains jeux s'appuient sur leur moteur de jeu pour activer cette fonctionnalité. Veuillez consulter les documents sur l'optimisation de la mémoire des jeux.

Observabilité avancée de la mémoire avec ProfilingManager

Pour détecter et diagnostiquer les problèmes de mémoire sur le terrain qui ne peuvent pas être reproduits localement, vous devez utiliser l'API ProfilingManager. Introduite dans Android 15, cette API d'observabilité avancée vous permet de collecter de manière programmatique les profils Perfetto des utilisateurs réels. 

Pour les équipes qui ne disposent pas d'une infrastructure dédiée pour gérer et héberger les artefacts de performances, Crashlytics étudie une solution spécialisée pour simplifier ce workflow. Ils invitent les développeurs à donner leur avis.

Android 17 introduit de nouveaux déclencheurs basés sur des événements, notamment TRIGGER_TYPE_OOM et TRIGGER_TYPE_ANOMALY :

  • Le déclencheur OOM collecte automatiquement une empreinte de la mémoire Java au moment exact où se produit un plantage OutOfMemoryError, ce qui fournit des états d'allocation précis. Un profil OOM collecté est fourni la prochaine fois que l'application démarre et enregistre le rappel registerForAllProfilingResults.
  • Le déclencheur d'anomalie détecte les problèmes de performances graves, tels que le spam excessif du binder ou les seuils de mémoire dépassés. L'anomalie de mémoire fournit un vidage du tas juste avant que le système ne mette fin à l'application.
    val profilingManager = 
applicationContext.getSystemService(ProfilingManager::class.java)
    val triggers = ArrayList<ProfilingTrigger>()  


    triggers.add(ProfilingTrigger.Builder(
                 ProfilingTrigger.TRIGGER_TYPE_ANOMALY))
    val mainExecutor: Executor = Executors.newSingleThreadExecutor()
    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (profilingResult.errorCode != ProfilingResult.ERROR_NONE) {
            // upload profile result to server for further analysis          
            setupProfileUploadWorker(profilingResult.resultFilePath)
        } 

    profilingManager.registerForAllProfilingResults(mainExecutor, resultCallback)
    profilingManager.addProfilingTriggers(triggers)

Une fois l'empreinte de la mémoire collectée, vous pouvez télécharger le profil depuis le serveur ou localement via adb pull, puis glisser-déposer le fichier dans l'UI Perfetto. Pour simplifier votre workflow de débogage de la mémoire, utilisez l'explorateur d'empreintes de la mémoire, qui est la nouvelle vue par défaut pour les empreintes de la mémoire dans l'UI Perfetto. Cet outil fournit une interface intuitive pour inspecter les vidages du tas Java. Il vous permet de visualiser les hiérarchies d'allocation d'objets, de calculer la taille de la mémoire retenue et d'identifier le chemin le plus court à partir de la racine de la récupération de mémoire. En tirant parti de l'explorateur d'empreintes de la mémoire, vous pouvez identifier rapidement les fuites de mémoire, les objets retenus volumineux tels que les allocations de bitmap excessives et analyser les allocations d'objets de tas au même endroit.

pic5-perfettoheapdump-analyzer.png
Utilisez le flame graph intégré à l'explorateur d'empreinte de la mémoire pour inspecter visuellement les objets ayant les allocations de tas les plus élevées et parcourir ces objets.

Conclusion

L'optimisation du bytecode avec R8, l'adoption des bonnes pratiques de chargement d'images et la résolution des fuites de mémoire sont des étapes essentielles pour offrir une expérience utilisateur de haute qualité tout en gérant efficacement les ressources sous pression. L'adoption de ces mesures proactives permet de maintenir la stabilité et les performances de l'application, d'éviter les arrêts inattendus et de préserver le contexte utilisateur. Pour approfondir vos connaissances sur les performances, consultez nos conseils révisés sur la mémoire.

Écrit par :
Continuer à lire