Instructivos

Priorización de la eficiencia de la memoria: Pasos esenciales para Android 17

Lectura de 10 min

Si bien el rendimiento de la app suele equipararse con una IU fluida y tiempos de inicio rápidos, la memoria es la base silenciosa sobre la que se construyen estas métricas visibles. No es secreto que estamos viendo un cambio en el que la memoria del dispositivo es más importante que nunca. No solo hemos logrado avances en las optimizaciones de memoria de Android con Android 17, sino que también proporcionamos las herramientas y la compatibilidad con la API para ayudarte a cumplir con los requisitos de memoria más estrictos que se implementarán más adelante este año.

Para garantizar la estabilidad del dispositivo, a partir de Android 17, el sistema comenzará a aplicar de manera forzosa los límites de memoria de las apps en función de la RAM total del dispositivo. Si una app supera esos límites, Android finalizará el proceso sin ningún seguimiento de pila asociado.

Además de estas finalizaciones forzadas, el uso de memoria no optimizado inevitablemente degrada la experiencia del usuario. Cuando la app se acerca a los límites de memoria del montón, se activa la recolección de elementos no utilizados frecuente, lo que genera interrupciones notables en la IU. Además, cuando un dispositivo se queda sin memoria disponible, el sistema se apresura a recuperar páginas, lo que provoca tensión en la CPU, latencia en la IU y agotamiento de la batería. Si la escasez de memoria es demasiado grave, puede provocar eventos de Low Memory Killer (LMK) que finalicen abruptamente los procesos en segundo plano y obliguen a las apps a tener inicios en frío lentos y a perder el estado del usuario.

Para compilar apps con un alto rendimiento y evitar estos cierres forzados, te recomendamos que adoptes las siguientes estrategias de optimización de la memoria:

  1. Maximiza la optimización de bytecode con R8
  2. Optimiza la carga de imágenes
  3. Detecta y corrige fugas de memoria con Android Studio
  4. Recorta la memoria cuando la app deja el estado visible
  5. Observabilidad avanzada de la memoria con ProfilingManager

También hay disponible una versión condensada de esta entrada de blog en formato de video. ¡Mírala!

Información sobre los límites de memoria de las apps en Android 17

En Android 17, se introducen límites de memoria de las apps para evitar que "un mal actor" destruya la experiencia de multitarea y la estabilidad de todo el dispositivo del usuario.

A continuación, se incluye un desglose de los motivos que impulsan este cambio arquitectónico:

  • Cómo evitar la eliminación en cascada: Cuando una app se sobrecarga o tiene pérdidas de memoria mientras mantiene un estado privilegiado (p.ej., ejecuta un servicio en primer plano), inicialmente está protegida del Low Memory Killer (LMK) del sistema. A medida que esta única app crece sin control y acapara RAM, el LMK se ve obligado a compensar la situación cerrando decenas de apps almacenadas en caché y trabajos en segundo plano más pequeños y con buen comportamiento para recuperar espacio para la app que consume mucha memoria.
     
  • Preservación de la multitarea y el estado del usuario: Cuando el sistema se ve obligado a borrar las apps almacenadas en caché para admitir un solo proceso con fugas, la experiencia de multitarea se degrada gravemente. Los usuarios que regresan a aplicaciones almacenadas en caché anteriores experimentan inicios en frío lentos en lugar de reanudaciones en caliente casi instantáneas. Esta ineficiencia genera más carga en la CPU y acelera el agotamiento de la batería. También puede destruir el contexto del usuario en las apps usadas recientemente, como las posiciones de desplazamiento, las pilas de navegación y el progreso en el juego.

Para determinar si tu sesión de la app se vio afectada por estas restricciones en el campo, puedes llamar a getDescription() dentro de ApplicationExitInfo. Si el sistema aplicó un límite, el motivo de salida se informa como REASON_OTHER y la cadena de descripción contendrá "MemoryLimiter:AnonSwap". También puedes aprovechar el registro de perfil basado en activadores con TRIGGER_TYPE_ANOMALY para capturar automáticamente volcados de pila cuando se alcance el límite de memoria. Además, Android está trabajando activamente para mostrar más métricas de memoria en el campo a los desarrolladores en Google Play Console.

También ampliamos nuestra documentación sobre los límites de memoria para incluir comandos de depuración locales, lo que te permite simular restricciones de memoria en tu entorno local y validar el comportamiento de tu aplicación bajo cualquier aplicación de límite de memoria. 

Maximiza la optimización de bytecode con R8

Una forma muy eficaz de reducir el espacio en memoria de tu app es habilitar el optimizador R8. Al reducir las clases, los métodos y los campos a nombres más cortos, y quitar el código y los recursos sin usar, R8 reduce significativamente el espacio en memoria de tu app, ya que minimiza la cantidad de código residente que se requiere durante la ejecución. 

R8 minimiza el código residente, lo que reduce el espacio en memoria y el riesgo de finalización del LMK. Esto genera inicios en tibio más frecuentes que los inicios en frío lentos. Además, el bytecode optimizado reduce la sobrecarga de la CPU del subproceso principal, lo que disminuye directamente las tasas de ANR para brindar una experiencia del usuario más fluida. Por ejemplo, el banco digital Monzo habilitó la optimización completa de R8 y observó una reducción del 35% en su tasa de ANR, una mejora del 30% en la tasa de inicio en frío y una reducción del 9% en el tamaño general de la app.

pic1-IO26_113_TSV-monzo-casestudy.jpg
El banco digital Monzo habilitó la optimización completa de R8 y aumentó las métricas de rendimiento hasta en un 35%.

Para configurar correctamente R8 en tu archivo build.gradle, haz lo siguiente:

  • Establece isShrinkResources = trueisMinifyEnabled = true.
  • Usa proguard-android-optimize.txt en lugar del proguard-android.txt heredado, que en realidad impide las optimizaciones y ya no se admite en el complemento de Android para Gradle 9.
  • Quita android.enableR8.fullMode = false de tu gradle.properties.

Si usas la reflexión en tu base de código, agrega reglas de conservación para evitar que R8 optimice esas partes del código. Asegúrate de definir el alcance de las reglas de conservación de forma precisa para obtener la máxima optimización. 

Para obtener la máxima optimización, asegúrate de seguir estas prácticas recomendadas en tu archivo de reglas de conservación.

  • Quita las opciones globales como -dontoptimize-dontshrink-dontobfuscate que impiden que R8 optimice toda la base de código.
  • Quita las reglas de conservación que impiden optimizar los componentes de Android, como Activity, Services, Views o Broadcast receivers.
  • Refina las reglas de conservación generales del paquete para segmentar solo clases o métodos específicos. 

Para ver más prácticas recomendadas, consulta nuestra documentación sobre reglas de conservación.

Prácticas recomendadas para desarrolladores de bibliotecas con R8

Si eres desarrollador de bibliotecas, coloca estrictamente las reglas que necesitan tus consumidores en tu consumer-rules file y conserva las reglas de protección internas de tu biblioteca en el archivo proguard-rules.pro. Para obtener más información sobre cómo optimizar las bibliotecas, consulta Optimización para autores de bibliotecas.

Analizador de configuración de R8

Para auditar la optimización de R8, usa el Configuration Analyzer. El Analizador de configuración muestra el estado actual de la optimización con las puntuaciones de Ofuscación, Optimización y Reducción. Con el Analizador de configuración, también puedes comprender cuántas clases, métodos o campos impide la optimización cada regla de conservación. Ajusta estas reglas de conservación amplias a nivel del paquete para desbloquear la optimización máxima. 

Con el Analizador de configuración, también puedes identificar las reglas de conservación que abarcan otras reglas de conservación, las reglas de conservación redundantes y las reglas de conservación sin usar.

pic2-r8-config-analyzer.png
El Analizador de configuración muestra el estado actual de la optimización con las puntuaciones de ofuscación, optimización y reducción.

Habilidad de agente de R8 

También puedes aprovechar la habilidad del agente de R8 con el agente de Android Studio o con otras herramientas de IA para resolver errores de configuración y perfeccionar tus reglas, lo que mejorará el rendimiento de la app. (Las estadísticas de las habilidades potenciadas por IA requerirán verificación técnica)

Optimiza la carga de imágenes

Por lo general, los mapas de bits son los objetos comunes más grandes que residen en la memoria de tu app. Representan la etapa final del proceso de carga de imágenes, en la que los archivos comprimidos, como los JPEG o PNG, se decodifican en datos de píxeles sin procesar para su visualización. Esto significa que una pequeña imagen comprimida de 100 KB puede aumentar a varios megabytes de RAM, ya que el consumo de memoria se determina según las dimensiones de píxeles y la profundidad de color de la imagen. Dado que las operaciones de mapa de bits suelen estar en la ruta crítica para dibujar fotogramas, las imágenes no optimizadas provocan una gran expansión de la memoria y tirones en la IU.

Google recomienda usar bibliotecas de carga de imágenes Coil para proyectos que priorizan Kotlin, en especial cuando se desarrolla con Jetpack Compose, y Glide para aplicaciones basadas en Java.

Adopta estas cinco prácticas recomendadas

  1. Reduce el muestreo de las imágenes: Si cargas mapas de bits de forma manual, evita cargar una imagen enorme en una vista en miniatura pequeña. Usa inSampleSize para cargar una versión más pequeña. Glide y Coil reducen la resolución de las imágenes de forma predeterminada, y puedes configurar esta estrategia de reducción de resolución con DownsampleStrategyImageLoader, respectivamente.
  2. Recorte: Evita incorporar relleno directamente en un archivo de imagen para fines de letterboxing (p.ej., crear un borde transparente para expandir las dimensiones de una imagen). En lugar de incorporar estos bordes, usa InsetDrawable o aplica padding directamente dentro del objeto View o del elemento componible que contiene el mapa de bits.
  3. Configuración: Equilibra la memoria y la calidad eligiendo el formato de píxel adecuado. Usa RGB_565 cuando no se necesite transparencia, ya que usa la mitad de la memoria del formato ARGB_8888 predeterminado. En Glide, puedes configurar esto con DecodeFormat, y en Coil, puedes usar la propiedad bitmapConfig.
  4. Prioriza los elementos de diseño vectoriales: Para los recursos geométricos básicos, aprovecha ShapeDrawable como una alternativa ligera para decodificar mapas de bits rasterizados. Si defines estos recursos una vez a través de XML, te aseguras de que se ajusten sin problemas a todas las densidades de pantalla y, al mismo tiempo, eliminas de manera eficaz el aumento de la memoria impulsado por los recursos.
  5. Reutilización: Si tu aplicación administra mapas de bits de forma manual, para minimizar la saturación de la memoria, cuando ya no se requiera un mapa de bits, la app debe llamar a bitmap.recycle() y descartar de inmediato la referencia Bitmap. Si usas una biblioteca de carga de imágenes, como Glide o Coil, devuelve el mapa de bits al grupo administrado de la biblioteca. Al proporcionar un búfer existente para las necesidades futuras de memoria, el grupo evita de manera eficaz la sobrecarga de las asignaciones nuevas.

Consulta nuestra documentación sobre cómo optimizar el rendimiento de las imágenes para obtener más información.

Herramientas de Android Studio

También puedes eliminar mapas de bits redundantes con Android Studio Narwhal 4. Sigue estos cinco pasos sencillos para encontrarlos:

  1. Abre la pestaña Profiler en Android Studio.
  2. Haz clic en Heap Dump (Volcado de montón) o en "Analyze Memory Usage" (Analizar el uso de memoria) y presiona grabar para tomar una instantánea del estado de memoria actual de tu app.
  3. Analiza los resultados del análisis en busca del triángulo amarillo de advertencia ⚠️, que Android Studio usa para marcar los mapas de bits duplicados que se almacenan varias veces. También puedes navegar al encabezado del generador de perfiles, elegir "Filtrar por:" y seleccionar el parámetro de configuración "Bitmaps duplicados".
  4. Haz clic en cualquier entrada marcada para abrir el panel Vista previa de mapa de bits, que te permite ver exactamente qué imagen es la infractora recurrente.
  5. Usa esa confirmación visual para rastrear la lógica de carga redundante en tu código y, luego, implementar una mejor estrategia de almacenamiento en caché.
pic3-IO26_113_TSV -dup-bitmaps-cropped.jpg
Busca el triángulo amarillo de advertencia ⚠️ en los volcados de montón cuando uses el Generador de perfiles de Android Studio.

Detecta y corrige fugas de memoria con Android Studio

Las pérdidas de memoria en Android se producen cuando tu código mantiene una referencia a un objeto mucho después de que finaliza su ciclo de vida. Esto evita que el recolector de elementos no utilizados (GC) reclame esa memoria, lo que, con el tiempo, genera un rendimiento lento o un error OutOfMemoryError (OOM).

Android Studio Panda 3 incluye una tarea de LeakCanary del generador de perfiles dedicada, que permite a los desarrolladores analizar las fugas de memoria en tiempo real y mapear los registros directamente en el IDE.

La tarea del generador de perfiles de LeakCanary en Android Studio mueve de forma activa el análisis de fugas de memoria de tu dispositivo a tu máquina de desarrollo, lo que genera un aumento significativo del rendimiento durante la fase de análisis de fugas en comparación con el análisis de fugas integrado en el dispositivo.

pic4-android-studio-leaks.png
 Análisis de fugas de memoria de LeakCanary contextualizado con Ir a declaración para la depuración

Además, el análisis de fugas ahora se contextualiza dentro del IDE y se integra por completo con tu código fuente, lo que proporciona funciones como ir a la declaración y otras conexiones de código útiles que reducen drásticamente la fricción y el tiempo necesarios para investigar y corregir las fugas de memoria.  

Ejemplos de pérdidas de memoria comunes 

Las fugas de memoria se producen cuando un objeto persiste en la memoria más allá de su vida útil prevista. Por lo general, esto se debe a los siguientes motivos:

  • Retener referencias a fragmentos, actividades o vistas que ya no se usan
  • Administración incorrecta de las referencias de Contexto
  • No cancelar el registro de observadores, objetos de escucha y receptores de forma adecuada
  • Crear referencias estáticas a objetos vinculados a componentes con ciclos de vida más cortos

A continuación, se incluyen algunos ejemplos de situaciones:

SituaciónEjemplo basado en Compose Ejemplo basado en vistas
Filtración de contexto

Ejemplo:
Cómo pasar LocalContext.current a un ViewModel

Corrección:
Mantén la lógica dependiente del contexto dentro de la capa de la IU. Para las capas que no son de la IU, refactoriza el código para usar la inyección de dependencias o observa el estado de la IU con flujo de Kotlin.

Ejemplo: 
Almacenar un Activity en un objeto complementario o una variable estática

Corrección:
No mantengas referencias estáticas a los componentes de la IU. Refactoriza para usar la inserción de dependencias o observa el estado de la IU con flujo de Kotlin.

Fugas de objetos de escucha

Ejemplo:
Usar DisposableEffect para iniciar un objeto de escucha, pero dejar onDispose vacío.

Corrección:
Realiza la anulación del registro y la lógica de limpieza dentro del bloque onDispose.

Ejemplo:
Registrarse para recibir actualizaciones de SensorManager y olvidarse de cancelar el registro.

Corrección:
Llama manualmente a unregisterListener() en el ciclo de vida de onStop()onDestroy().

Fugas de vistas

Ejemplo:
Mantener una referencia a un View heredado dentro de un AndroidView sin una estrategia de lanzamiento.


Corrección:
Usa el bloque release del elemento AndroidView componible para limpiar el View heredado.

Ejemplo:
Conservar una referencia a un objeto de vinculación de vistas después de que se destruye Fragment.

 

Corrección:
Establece la variable de vinculación en null dentro del método de ciclo de vida onDestroyView().

Recorta la memoria cuando la app deja el estado visible

Android puede reclamar memoria de tu app o cerrar la app por completo si es necesario para liberar memoria para tareas críticas, como se explica en Descripción general de la administración de memoria. Por lo general, Android recupera la memoria de tu app cuando no es visible para el usuario, por ejemplo, descartando algunas de las páginas de código y datos de tu app en la memoria o comprimiendo las asignaciones de la pila. Cuando el usuario reanuda tu app y esta intenta acceder a memoria que se recuperó, el SO intercambiará esa memoria a pedido. Este comportamiento de intercambio puede ser lento y provocar bloqueos o interrupciones inesperados en tu app.

Si dejas que el SO decida qué memoria reclamar de tu app, es posible que el SO reclame memoria que necesitarás poco después de reanudar la app. En cambio, tu app puede descartar voluntariamente las asignaciones de memoria que puede regenerar más adelante, a pedido y a un bajo costo. Para ello, puedes implementar la interfaz ComponentCallbacks2. Puedes implementar onTrimMemory en tu clase ActivityFragmentService o incluso en tu clase Application personalizada. Usarlo en la clase Application es muy eficaz para la administración de la caché global.

El método de devolución de llamada onTrimMemory() proporcionado notifica a tu app sobre eventos relacionados con el ciclo de vida o la memoria que representan una buena oportunidad para que tu app reduzca voluntariamente su uso de memoria.

En términos de administración del ciclo de vida de la memoria, tu implementación debe enfocarse exclusivamente en TRIM_MEMORY_UI_HIDDENTRIM_MEMORY_BACKGROUND. Desde Android 14, el sistema dejó de entregar notificaciones para otras constantes heredadas, que se dejaron de usar formalmente en Android 15.

TRIM_MEMORY_UI_HIDDEN: Este indicador señala que la IU de tu aplicación dejó de estar a la vista del usuario. Esto brinda la oportunidad de liberar asignaciones de memoria sustanciales vinculadas estrictamente a la interfaz, como mapas de bits, búferes de reproducción de video o recursos de animación complejos.

TRIM_MEMORY_BACKGROUND: En este nivel, tu proceso reside en segundo plano y ahora es candidato para la finalización para satisfacer las necesidades globales de memoria del sistema. Para extender la duración durante la que tu proceso permanece en el estado almacenado en caché y reducir la cantidad de inicios en frío de la app, debes liberar de forma agresiva todos los recursos que se puedan reconstruir fácilmente una vez que el usuario reanude su sesión.

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

Nota: La integración de onTrimMemory puede depender de la compatibilidad del SDK. Por ejemplo, algunos juegos dependen de su motor de juego para habilitar esta capacidad. Consulta los documentos sobre la optimización de la memoria del juego.

Observabilidad avanzada de la memoria con ProfilingManager

Para detectar y diagnosticar problemas de memoria en el campo que no se pueden reproducir de forma local, debes aprovechar la API de ProfilingManager. Esta API de observabilidad avanzada, que se introdujo en Android 15, te permite recopilar de forma programática perfiles de Perfetto de usuarios reales. 

Para los equipos que no tienen una infraestructura dedicada para administrar y alojar artefactos de rendimiento, Crashlytics está explorando una solución especializada para optimizar este flujo de trabajo. Invitan a los desarrolladores a proporcionar comentarios.

Android 17 presenta nuevos activadores basados en eventos, en particular TRIGGER_TYPE_OOMTRIGGER_TYPE_ANOMALY:

  • El activador de OOM recopila automáticamente un volcado de montón de Java en el momento exacto en que se produce una falla de OutOfMemoryError, lo que proporciona estados de asignación precisos. Se proporciona un perfil de OOM recopilado la próxima vez que se inicia la app y se registra la devolución de llamada registerForAllProfilingResults.
  • El activador de anomalías detecta problemas graves de rendimiento, como spam excesivo del binder o incumplimiento de los umbrales de memoria. La anomalía de memoria entrega un volcado de montón justo antes de que el sistema finalice la app.
    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)

Una vez que hayas recopilado el volcado de montón, puedes descargar el perfil del servidor o de forma local a través de adb pull y arrastrar y soltar el archivo en la IU de Perfetto. Para optimizar tu flujo de trabajo de depuración de memoria, usa el Explorador de volcado de montón, que es la nueva vista predeterminada para los volcados de montón en la IU de Perfetto. Esta herramienta proporciona una interfaz intuitiva para inspeccionar volcados de montón de Java, lo que te permite visualizar jerarquías de asignación de objetos, calcular tamaños de memoria retenida y, también, identificar la ruta más corta desde la raíz de la recolección de elementos no utilizados. Con Heap Dump Explorer, puedes identificar rápidamente las pérdidas de memoria, los objetos retenidos inflados, como las asignaciones excesivas de mapas de bits, y analizar las asignaciones de objetos del montón, todo en un solo lugar.

pic5-perfettoheapdump-analyzer.png
Usa el gráfico de llamas integrado del Explorador de volcado del montón para inspeccionar visualmente los objetos con las asignaciones de montón más altas y navegar por ellos.

Conclusión

Optimizar el bytecode con R8, adoptar las prácticas recomendadas de carga de imágenes y resolver las pérdidas de memoria son pasos fundamentales para brindar una experiencia del usuario de alta calidad y, al mismo tiempo, administrar los recursos de manera eficaz bajo presión. Adoptar estas medidas proactivas ayuda a mantener la estabilidad y el rendimiento de la app, ya que evita las finalizaciones inesperadas y protege el contexto del usuario. Para ampliar tus conocimientos sobre el rendimiento, explora nuestra guía de memoria revisada.

Escrito por:
Continuar leyendo