Saltar al contenido

Minimizar el uso de memoria RAM en una aplicación desarrollada para Android

Introducción.

La memoria principal (RAM) es un recurso muy preciado en cualquier sistema informático. Como habitualmente se tiene experiencia desarrollando aplicaciones para sistemas PC y similares, éstos perdonan en gran medida a los programadores el mal aprovechamiento de la memoria por disponer de bastante cantidad de ella (además de la generosa partición swap que ofrece el sistema operativo).
Obviamente el problema se acentúa en el desarrollo de dispositivos móviles como es, en este caso, para Android. El desarrollador ingenuo por inexperiencia que trabaje en una app (no de gestión) comenzará a notar como merma el rendimiento, poco a poco, hasta que su aplicación finaliza de forma inesperada por la falta de RAM. También puede darse el caso en el que la aplicación funciona correctamente en una máquina virtual AVD (Android Virtual Device) no siendo así en un dispositivo real conectado al equipo de desarrollo durante el período de depuración.

La gestión de memoria de Android.

Android utiliza la máquina virtual Java conocida como Dalvik y su forma de gestionar la memoria es un tanto peculiar. A grandes rasgos, existen unas diferencias sustanciales, en cuanto a la gestión de memoria en Android con Dalvik respecto a las JVM tradicionales bajo Windows o GNU/Linux. Las diferencias a tener en cuenta a la hora de desarrollar son:
  • Se carece de unidad swap y la memoria virtual sólo sirve para el código de la aplicación. Es decir; los objetos de nuestro programa se encuentran en memoria y no son escritos a la memoria secundaria en ningún momento. Conforme se liberan las referencias de objetos, el recolector de basura (GC) los va destruyendo y la memoria disponible va aumentando.
  • Android comparte la memoria principal entre todos los procesos. El proceso de la app se crea a partir del denominado proceso Zygote una vez que el SO cargó todo el marco de trabajo.
  • Los datos estáticos de la app son mapeados de la memoria a un fichero para ser compartidos con otros procesos o para poder ser recuperados posteriormente. Entre este tipo de datos se encuentran el código Dalvik y el código de la propia aplicación.
  • Android comparte las mismas zonas de memoria entre varios procesos para ciertos menesteres, como por ejemplo, hacer uso de los cursores. Si una app utiliza cursores y hace uso de algún ContentProvider, los cursores estarán en una zona de memoria compartida por el ContentProvider y nuestra app.
  • La pila o heap asociada a cada proceso tiene asignado un tamaño de memoria, el cual puede incrementarse sólo hasta el límite establecido para la aplicación. Este límite varía en función del tamaño de memoria del dispositivo y tiene como principal función garantizar la multi-tarea. Si dicho límite se excede se lanza un OutOfMemoryError.
  • El GC está mejorado para liberar más memoria y hacerlo más rápido a partir de Android 2.3. Si la app no libera los recursos usados el GC no sirve de nada. Suena a perogrullada, pero siempre que se pueda es mejor no reservar memoria que utilizarla (a pesar de que después se libere). Además a mayor memoria usada, más posibilidades hay de que la acción del recolector sobre el proceso de la app se vuelva más agresiva provocando pequeños retardos o lags que perjudiquen la experiencia del usuario.
  • La caché de procesos en ejecución de Android es de tipo LRU por lo que en caso de poca memoria se finalizará con los procesos que menos se estén usando. Aún así, se pueden seleccionar procesos que estén abarcando mucha cantidad de RAM aunque se hayan usado con mayor frecuencia, ya que al eliminar un proceso grande se libera mayor cantidad de memoria. Moraleja: El ahorro de memoria ayuda a mantener nuestros procesos en ejecución.

Optimización del uso de memoria.

Teniendo en mente la optimización durante todo el proceso de desarrollo, las optimizaciones que pueden realizarse a todos los niveles pueden clasificarse en dos tipos: genéricas y específicas. Con este tipo de clasificación no se pretende sentar cátedra, tan sólo ofrecer un mínimo de estructuración para poder discernir cuales de ellas se aplican a cualquier desarrollo (genéricas) y cuales son específicas de un desarrollo para la plataforma Android (específicas).

OPTIMIZACIONES GENÉRICAS.

Aquí se presentan optimizaciones para cualquier desarrollo global, siendo características de las etapas de análisis y diseño principalmente. Las relativas a implementación suelen ser extensibles a la mayoría de lenguajes de programación, concretamente Java. Por norma general estas optimizaciones ahorran memoria pero de forma poco significativa en la mayoría de los casos ya que puede haber excepciones.


Refactorizar el código.

Es difícil escribir a la primera código conciso, de gran calidad y que haga sólo lo que tiene que hacer y nada más. El objetivo es escribir el mínimo código que proporcione la mayor funcionalidad y robustez. Un código muy pequeño y concentrado (típicos idioms de C/C++) van en contra de la legibilidad del programa y dificulta su comprensión por parte de otros programadores. Por tanto se buscará el código mínimo funcional, robusto y de calidad sin deteriorar su comprensión para facilitar el mantenimiento y posibles ampliaciones.


Utilizar bucles for-each siempre que sea posible.
Este tipo de bucle proporciona un mayor rendimiento en recorridos secuenciales y consume menos memoria que un bucle for normal que trabaje explícitamente con una variable a modo de contador. Funciona con vectores y con clases que implementen la interfaz Iterable. Fue incorporado al lenguaje Java a partir de la versión 1.5 y posee la siguiente sintaxis:

for ( Tipo variable : coleciónDeElementosTipo ) {
    // Hacer algo.
}

Utilizar tipos de datos enteros en vez de decimales.

Por muy bueno que sea el hardware hoy en día, no es lo mismo trabajar con números enteros que con decimales. Por motivos de velocidad, y en menor medida de memoria, hay varios escenarios en los que se puede trabajar con tipos básicos int y long en vez de con float y double.
Un típico ejemplo en el que se puede utilizar un float en vez de un long es la clase Monedero. En ella se tiene el atributo privado dinero. Si se declara como long y en ella se almacena el dinero usando como única unidad monetaria los céntimos, no se pierde significado semántico del problema y se puede calcular con suma facilidad la parte equivalente a la entera (euros).
Utilizar vectores en vez de listas.
Siempre que sea factible, utilizar vectores o clases que los implementen (tipo ArrayList) frente a las listas basadas en punteros como LinkedList. Por norma general, también es mejor siempre utilizar tipos de datos básicos antes que clases. Por ejemplo, utilizar el tipo int frente a la clase Integer.
El código y las clases también ocupan.
El código de una clase en Java ocupa aproximadamente y como mínimo, unos 500 bytes. Una instancia de dicha clase ocupa como mínimo, 12 bytes. En memoria va a haber código e instancias, cuanto menos ocupemos de cada una de ellas mejor. ¡Ojo, que aquí no se trata de minimizar el número de clases e instancias a lo bruto! Lo mejor es conseguir el mejor diseño, el más limpio y reutilizable, de forma que nos asegure que no hay instrucciones o funcionalidades de más (el software debe hacer lo que tiene que hacer, ni más, ni menos).

No olvidar marcar las constantes como static final.
Acompañar al modificador final con static ahorra memoria, aunque sólo con los tipos de datos básicos o de tipo String. Esto es así porque el compilador evita que se invoque al método de inicialización la primera vez que se utilice la clase a la que pertenezca la constante, utilizando directamente y en su lugar el valor de la misma.

Evitar el uso de objetos innecesarios.
Otro factor a tener en cuenta y que según todos ya lo hacemos. Pero no es cierto, ya que sucede en mayor o menor medida. Por citar algunos ejemplos tenemos:
  • El típico método que devuelve parte de un String mayor y que crea un objeto intermedio temporal a retornar del mismo tipo, cuando directamente se puede devolver una parte como un «substring» ahorrándose la copia intermedia.
  • Utilizar vectores para casi todo: Un vector ocupa menos espacio en memoria que, por ejemplo, un ArrayList. Si se necesita una matriz con dos filas o crear un objeto de tipo clave-valor compensa crear dos vectores que conjuntamente provean la misma funcionalidad.
Usar métodos estáticos.
Si en un método de una clase no se accede a ningún dato miembro es altamente aconsejable acompañarlo del modificador static. Según la documentación oficial de Android se consigue acelerar la invocación a dicho un método entre un 15-20% aparte de considerlo una buena práctica: se está indicando implícitamente de cara al exterior que ese método no altera el estado de dicho objeto.
Abstracciones las justas.
Se considera buena práctica crear clases abstractas para categorizarlo todo. Está muy bien, pero son clases con código que ocuparán espacio en memoria. ¿Realmente son necesarias? Pues depende… Hay que buscar el equilibrio entre un buen diseño sin excederse en categorizar. Por ejemplo, si vuestra aplicación va de animales y se tiene como superclases abstractas Animal, Mamífero, Paquidermo, Reptil, etc… y como hijas de estas las clases Perro, Elefante, Iguana… y la categorización no es un factor relevante para nuestra aplicación (da igual conocer de qué familia son), sería mejor crear una interfaz Animal dónde se especifique el comportamiento que tengan que implementar todos los animales. De esta forma tendríamos una clase en vez de más de cuatro como al principio.
No reservar memoria que después no puedas liberar.
Parece fácil, suena a perogrullada… Pero seguro que en los proyectos habitualmente existen flujos sin cerrar y lecturas a buffers enormes cuyos contenidos en memoria podrían ser más eficientes (leídos del disco en bloques) o pocas invocaciones en los destructores de un programa en C++ que haga uso de memoria dinámica (por citar un ejemplo no Java).
Minimizar el uso de librerías externas.
Si de una librería sólo queremos un par de utilidades, ¿realmente es necesario importarla toda? ¿se pueden importar únicamente aquellos componentes que necesitamos? Si la respuesta a esta última pregunta es no, habrá que barajar la posibilidad de utilizar otra librería más ligera como alternativa. Si dicha alternativa no existe, habrá que implementarla.
Divide y vencerás.
Dividir la aplicación en varios procesos minimiza el uso general de memoria siempre que se haga de forma correcta, cuando se debe, con buena comunicación entre ellos y deteniendo temporalmente (o no) la ejecución de los que proceda. De no ser así se tendrán procesos que, sin apenas utilidad, sólo se ejecutarán para consumir recursos.
Escalar las imágenes.

Una imagen más pequeña siempre va a ocupar memor tamaño que una grande. Calcular el tamaño que se desea mostrar (redimensionando la imagen si procede) para tener en memoria la imagen que menos ocupe.

OPTIMIZACIONES ESPECÍFICAS.
Estas son las optimizaciones exclusivas para un desarrollo Android, estando la mayoría recogidas en su documentación para desarrolladores. También pueden ser generalizadas y aplicadas a otras plataformas de dispositivos móviles. Su impacto en la utilización de RAM suele ser mayor al de las optimizaciones genéricas.
Usar procesos de forma económica.
Al comenzar un proceso background sin interacción con el usuario se reserva memoria para él que ningún otro proceso va a poder usar, reduciéndose la multi-tarea. Es importante que el proceso retarde su ejecución hasta que tenga que realizar su trabajo y finalice lo antes posible. Android proporciona el proceso IntentService, el cual nos facilita cumplir la norma anterior: recibe un Intent, trabaja con él y en cuanto termine su labor, finaliza su ejecución.
Liberar memoria cuando la GUI esté oculta.
Cuando el usuario cambia de una aplicación a otra, la primera pasa a estar «oculta». En ese momento se invoca el método onTrimMemory() de la actividad que se estuvo ejecutando. En este método se pueden liberar los recursos que la actividad estuviera usando, consiguiendo de este modo transiciones más fluidas entre apps. No confundir onTrimMemory() con el método onStop() de Activity, ya que son dos cosas diferentes.
El método onTrimMemory() puede ser utilizado también para conocer el consumo de memoria que realiza el proceso de la aplicación y fue incorporado a partir de la versión 14 de la Android API. Para asegurar la retrocompatibilidad igual hay que trabajar con onLowMemory().
Invocar getters y setters desde fuera de la interfaz.
En Android, invocar a un método virtual (con ligadura dinámica de por medio) es sumamente costoso. Por eso, como buena práctica de la POO se debe acceder desde fuera del objeto a sus propiedades a través de estos métodos, pero por rendimiento y menor consumo de memoria, desde las clases se accede siempre a los atributos directamente, sin invocarlos a través de dichos métodos.
Medir dinámicamente el tamaño del heap.
Android proporciona la posibilidad de que nuestra aplicación pueda conocer el tamaño disponible de la pila asignada a la misma mediante una llamada a getMemoryClass(). Como esta puede variar en función de cada dispositivo y sus especificaciones, se puede conocer en tiempo de ejecución de cuanto espacio libre se dispone y poder actuar en consecuencia antes de conseguir un OutOfMemoryError.
Utilizar contenedores de datos optimizados.
Android proporciona alternativas diversas al HashMap. Una de ellas es SparceArray que proporciona un mayor rendimiento y eficiencia ya que requiere de una menor cantidad de memoria.
Utilizar ProGuard y Zipalign.
La primera utilidad no sólo permite ofuscar el código para evitar la ingeniería inversa: refactoriza clases, atributos, nombres… hasta el punto que elimina código redundante y empequeñece el código resultante, con lo que al ejecutarse el programa va a requerir menor cantidad de memoria principal. Puedes conocer más acerca de ProGuard en Android Developers.
Zipalign se utiliza antes de desplegar nuestra aplicación tras firmarla con nuestro certificado. Actúa sobre el fichero .apk final, alineando sus bits y haciendo que ocupe algo menos. Este paso es obligatorio si quieres subir tu app a Google Play, ya que esta no acepta aplicaciones sin pasar por dicho programa.

Supervisión del uso de memoria de una aplicación.

La optimización del uso de memoria debe formar parte de todo el proceso de desarrollo de la aplicación Android, desde el análisis y diseño hasta las pruebas. Ahora que se conocen bastantes pautas para optimizar la memoria, se debe proceder a la supervisión de la misma durante el desarrollo para poder ser conscientes de la cantidad de memoria ahorrada tras los cambios efectuados así como para detectar los factores que enfatizan su mayor consumo. Y no sólo eso, ya que todo lo anterior no es garantía de éxito y podemos encontrarnos con bugs que desbordan la memoria.

Existen varios métodos para supervisar la memoria: interpretar los mensajes del log, visualizar las actualizaciones de la pila, realizar un seguimiento de la asignación de memoria y utilizar la herramienta MAT que proporciona el IDE Eclipse.

INTERPRETACIÓN DE LOGS.
Al correr una aplicación en el AVD o en un dispositivo conectado al equipo de desarrollo (con el modo de depuración USB activado), desde Eclipse se nos muestra una pestaña con el LogCat. En dicho log se muestra toda la información relativa que proporciona Android durante la depuración de una app, entre ella qué pasa cuando el recolector de basura entra en acción. Los distintos tipos de mensaje que proporciona el GC son:

  • GC_CONCURRENT: indica que una nueva estancia concurrente del recolector de basura ha iniciado su ejecución para liberar memoria de la aplicación ya que la pila se encuentra casi completa al máximo.
  • GC_FOR_MALLOC/GC_FOR_ALLOC: El recolector se ha lanzado cuando tu aplicación intenta que se le asigne más memoria y ya posee la pila completa. El SO puede detener la aplicación. Existen dos posibles mensajes porque el primero fue renombrado al segundo, según con que versión de la API se trabaje (aunque significan lo mismo).
  • GC_HPROF_DUMP_HEAP: El recolector de basura es lanzado para crear un volcado de la pila a un fichero HPROF, respondiendo así a la petición del desarrollador.
  • GC_EXPLICIT: El GC es invocado mediante una llamada explícita en el código de la aplicación (su uso es poco recomendable).
  • GC_EXTERNAL_ALLOC: El GC es invocado para limpiar una zona externa de memoria a la de la aplicación (está en deshuso y sólo funciona con la API 10 o anteriores).
  • GC_BEFORE_OOM: Lo peor de lo peor sucede al leer esto. El GC es iniciado porque no queda memoria disponible en la pila y al intertar desbordarla la app al asignar más memoria se intenta liberar la máxima posible. Si la memoria liberada es inferior a la nueva que se intenta asignar se produce el fatídico OutOfMemoryError.

Junto con el tipo de mensaje, en la salida del LogCat, se encuentra información adicional como la cantidad de memoria liberada por el recolector de basura, el estado actual de la pila, el estado de la asignación de memoria externa (API 10 o inferior) y el tiempo en que se detuvo la pila antes y después del GC. Por ejemplo, esta es la salida de una aplicación que se está desarrollando:

Captura del LogCat bajo Eclipse en un proyecto real.

Hay tres líneas en este log que son clave:

D/dalvikvm(3346): GC_CONCURRENT freed 6560K, 39% free 29237K/47331K, paused 2ms+29ms
D/dalvikvm(3346): GC_FOR_ALLOC freed 6632K, 40% free 28845K/47331K, paused 99ms
D/dalvikvm(3346): GC_BEFORE_OOM freed 9K, 40% free 28836K/47331K, paused 73ms

Todas tienen el mismo formato. En la primera (no sale en la captura de pantalla) se indica que la máquina virtual Dalvik (la de Android) ha iniciado el recolector para la aplicación ya que la pila de la misma está bastante llena. Se indica que se liberaron 6560K de memoria, que ahora se dispone de un 39% de espacio libre en la pila (29237K es lo que ocupan los objetos y 47331K el tamaño total de la pila) y finalmente los tiempos de espera: 2ms estuvo sin ejecutarse la el proceso de la aplicación esperando que el GC actuase y, tras su limpieza, se tardaron 29ms más en continuar con su ejecución. Las unidades son K, supongo que Kibibytes pero no puedo asegurarlo.

La siguiente ejecución del GC se remite a una de tipo GC_FOR_ALLOC, en la cual la aplicación necesita que se le asigne más memoria pues no llega con la que hay libre en la pila. Se libera una poca pero no es suficiente. Al reclamar la aplicación más memoria con la pila llena, se lanza de nuevo el GC con un tipo de llamada GC_BEFORE_OOM, el cual consigue liberar un 40% de la pila pero no es suficiente: A continuación se produce un OutOfMemoryError, pues la aplicación requería más espacio nuevo del que ha sido liberado.

Tras seguir las trazas de depuración, justo se da con la posible causa del desbordamiento de pila de la app: un ByteArrayOutputStream ocupó excesiva memoria al devolver un vector de bytes.

Llegados a este punto se podría pensar en ampliar explícitamente el tamaño del heap para la aplicación. Aunque es posible, es totalmente desaconsejable hacerlo, ya que el tamaño seguiría realmente variando en función del dispositivo (Android se encarga de hacerlo a partir del tamaño anteriormente preestablecido) y tan sólo se conseguirían más ejecuciones del recolector de basura lo que ralentizaría la ejecución de los procesos asociados y una disminución del rendimiento.

A partir de los mensajes de log es posible deducir hasta qué punto está bien implementada (o no) una aplicación a pesar del hecho de que aparentemente funcione correctamente. Por ejemplo: la mayoría de mensajes pueden ser de tipo GC_CONCURRENT y el heap, por tanto, nunca llega a llenarse. La aplicación finaliza su ejecución correctamente pero ha estado trabajando al límite, pues la gestión que hace del uso de memoria está mal optimizada. Si la aplicación corriera sobre un dispositivo con menos memoria RAM disponible, o en el mismo dispositivo con más procesos en ejecución, seguramente se recibirían mensajes de tipo GC_FOR_ALLOC pudiendo llegar al punto de un GC_BEFORE_OOM o, lo que es peor, a que nuestra aplicación finalice su ejecución por petición del SO. Por tanto, todo se resume en la máxima de «ser eficientes con la memoria maximiza la posibilidad de supervivencia de la ejecución de la aplicación en cualquier entorno multi-tarea Android».

Como consejo adicional, indicar que es sumamente útil crear un filtro en el LogCat para depurar asuntos de memoria relacionados con el recolector de basura. De este modo, seleccionando sólo el filtro tendríamos todas las notificaciones del GC sin tener que buscarlas a mano o con Ctrl+f. Para crear el filtro, en la pestaña LogCat de Eclipse, pulsar sobre el botón cruz verde Add a new logcat filter y rellenar el formulario.

Crear un filtro de LogCat para visualizar cómodamente las salidas del GC agiliza la depuración.

SUPERVISAR LA MEMORIA DESDE DDMS.
La perspectiva DDMS (Dalvik Debug Monitor Server) en Eclipse ofrece dos pestañas de valiosa utilidad: Heap y Allocation Tracker.

En la primera de ellas, se nos muestra en tiempo real el estado de la pila durante la ejecución de la aplicación como cuánta esta libre y ocupada, porcentajes de uso, tipos de elementos en memoria, etc… Incluso se proporciona el botón Cause GC el cual realiza una invocación explícita sobre el recolector de basura, algo similar a invocar la función gc() desde el código.

Para que la monitorización del heap funcione es necesario que en la pestaña Devices se despliegue el menú del dispositivo de prueba escogido, luego seleccionar el proceso a monitorizar y pulsar sobre el botón verde superior Update Heap. Importante resaltar que la información sólo se mostrará en pantalla durante la ejecución del proceso, desapareciendo cuando éste finalice.

Monitorización de la pila de un proceso mediante la perspectiva DDMS en Eclipse.

La segunda pestaña hace referencia al seguimiento de las asignaciones de memoria en la pila. De este modo se facilita la labor de localizar qué objetos son los problemáticos a la hora de ocupar memoria para poder optimizar y actuar en consecuencia. En la pestaña Allocation Tracker > Start Tracking (con el proceso de la aplicación seleccionado en la sección Devices) y pulsar en Get Allocations para conseguir las asignaciones de memoria en un momento dado. Al igual que con la monitorización de la pila, una vez finalice la ejecución del proceso se borrará toda la información de la pantalla.

Monitorización de las asignaciones de memoria mediante DDMS.

En caso de que estas dos monitorizaciones no fuesen suficientes, siempre se puede realizar un volcado de la pila a un fichero HPROF, el cual contendría toda la información de la pila en el momento del volcado así como su contenido desglosado junto a todo tipos de datos para una depuración más exhaustiva. Para visualizar el contenido del fichero HPROF es necesario el uso de Eclipse MAT.

Conclusiones.

La optimización del uso que hace una aplicación de la memoria principal de un sistema puede considerarse una disciplina aparte. Son requisitos indispensables una elevada experiencia y conocimientos a todos los niveles, tanto de APIs, lenguajes de programación, sistema operativos y hardware.

En aplicaciones de escritorio o pequeñas apps de gestión para dispositivos móviles, las optimizaciones pueden no ser necesarias. Aún así, al final todo desarrollador acabará enfrentándose a una optimización seria de memoria antes o después. Para cuando eso suceda, espero que este humilde artículo les sirva de guía y les ayude en la difícil labor de optimización.

Referencias web:


Publicado enDesarrollo de softwareDesarrollo para dispositivos móvilesHardwareManual