Last Updated: 2021-05-31

¿Por qué concentrarse en micro optimizar las aplicaciones?

Al momento de desarrollar una aplicación móvil, un desarrollador puede incurrir en prácticas que afectan el rendimiento de la aplicación en términos de memoria, tiempo de ejecución, y consumo general de recursos. Sin embargo, algunos de estos problemas no son tan evidentes, y parecen ser de bajo impacto para el rendimiento, por lo cual suelen ser ignorados.

Es importante tener en cuenta que, como se ha mencionado en tutoriales anteriores, el entorno móvil tiene restricciones en cuanto a sus capacidades. De esta forma, realizar micro optimizaciones es es una parte fundamental del desarrollo de las aplicaciones móviles, pues el impacto resulta bastante significativo para el resultado final de la aplicación.

¿Qué construirá?

En este tutorial partirá de un proyecto que presenta problemas de rendimiento de varios tipos, y los corregirá para mejorar el desempeño de la aplicación.

Al final de este tutorial usted tendrá:

¿Qué aprenderá?

Al desarrollar este tutorial aprenderá:

¿Qué necesita?

A nivel general, las guías de desarrollo, incluyendo el sitio de Android Developers, reconocen dos reglas principales que deberían guiar un desarrollo con buenas prácticas. Estas reglas son:

Se puede ver que ambas reglas se asocian a los recursos computacionales más limitados en un entorno móvil: la CPU y la RAM. A comparación de otros entornos computacionales, estas limitaciones hacen necesario prestar atención a la eficiencia de los algoritmos y la creación de recursos utilizados por el programa.

Las recomendaciones asociadas a estas reglas, no obstante, pueden tener efectos distintos según el entorno de ejecución, lo que incluye factores como la máquina virtual, el método de compilación, la arquitectura del procesador, entre otros. A pesar de esto, es posible optimizar el código para ser eficiente en varios niveles con las siguientes sugerencias puntuales:

  1. Evitar la creación de objetos innecesarios: los objetos creados durante la ejecución de un programa ocupan espacio de la memoria, y pueden activar eventos de recolección de basura (gc) en la máquina virtual al llegar a un límite. La creación de objetos innecesarios puede incrementar las posibilidades de un evento de gc, por lo cual hay que evitar crear objetos no utilizados en escenarios como los siguientes:

Esta recomendación cobra particular relevancia en el caso de Java, pues Kotlin, por su parte, no cuenta con tipos primitivos de datos y, por ende, no cuenta con una alternativa para definir datos que no sea por medio de objetos. A pesar de esto, la sugerencia de reducir su uso sigue teniendo vigencia.

  1. Utilizar estructuras de datos adecuadas: en ocasiones, las implementaciones internas de las estructuras de datos pueden inducir problemas relacionados a la eficiencia temporal en las operaciones de la estructura, o relacionados al espacio ocupado en memoria. Algunos de los casos más comunes son los siguientes:
  1. Implementar los ciclos con ayuda de iteradores cuando es posible: algunas estructuras de datos incluyen un iterador en su implementación, el cual permite recorrer el contenido de una forma más directa que con un ciclo implementado de forma manual. Para algunas estructuras de datos (como los ArrayList), el acceso a un elemento es menos eficiente que en otras, por lo cual existe el campo para la optimización.

En el caso del lenguaje Kotlin, no existen los iteradores, por lo cual hay que recurrir a otras formas de ejercer ciclos. En el caso de Java, es importante recordar la posibilidad de optimizar el uso de objetos al reemplazarlos por tipos primitivos de datos. Por este motivo, es posible reconocer una mejora en cuanto al uso de los ciclos indexados.

  1. Conocer las librerías y utilizar las mejores implementaciones: algunas de las funciones que se incluyen en las librerías pueden tener mejor rendimiento que sus implementaciones manuales equivalentes en el lenguaje desde el cual se invocan.
  2. Eliminar código innecesario: las aplicaciones, y en general los proyectos pueden ser menos eficientes en cuanto al consumo de recursos si existen elementos no utilizados como, por ejemplo, librerías no utilizadas, código muerto y otros recursos no utilizados. Es importante recordar que, en el caso de las aplicaciones de Android, todos los recursos definidos como parte del proyecto serán empaquetados e instalados en el dispositivo como parte de la aplicación.

Para el caso de este tutorial, se creó una versión del proyecto de los vinilos, el cual se ha venido trabajando en el curso. En esta versión, se modificó el código intencionalmente para incluir varias oportunidades de mejora en términos del rendimiento. A lo largo del tutorial, usted implementará dichas mejoras con el fin de que aprenda a reconocer y evitar ciertas malas prácticas al momento de desarrollar, las cuales pueden impactar el rendimiento de sus aplicaciones.

Es necesario, entonces, obtener la nueva versión del código fuente para poder trabajar sobre este proyecto. Para esto, acceda al repositorio, alojado en el siguiente enlace: https://github.com/TheSoftwareDesignLab/MISW4104-Ejemplos/tree/main/starters/CL17-microoptimizations, descargue el código fuente en su máquina y abra el proyecto en Android Studio.

Como con todos los proyectos de GitHub, usted puede descargar los archivos del repositorio a su máquina local por varios medios.

Si lo desea, puede optar por descargar el código fuente directamente en forma de archivo .zip desde la página web del repositorio en GitHub, haciendo clic en el botón "Download Zip" del desplegable Code, el cual se ve en la siguiente imagen:

Sección del sitio web del repositorio donde se muestran las diferentes opciones para descargar el código del proyecto> Clone, Open with Github Desktop, Download ZIP

Imagen 1. Vista desplegable del botón Code

No obstante, si cuenta con GitHub Desktop, puede hacer clic en el botón Open with GitHub Desktop. Si cuenta con Git instalado localmente en su máquina, puede clonar el repositorio a un directorio local vacío por medio del comando git clone https://github.com/TheSoftwareDesignLab/MISW4104-Ejemplos.git, o por medio de la interfaz gráfica de git. Finalmente, también puede optar por descargar el proyecto directamente con Android Studio haciendo uso de la opción Get from Version Control del menú inicial de Android Studio, la cual se muestra en la siguiente imagen:

Al abrir Android Studio sin un proyecto se muestra un menú con varios botones para abrir o importar un proyecto, perfilar un APK o abrir configuraciones del IDE

Imagen 2. Menú de inicio de Android Studio

Una vez haya importado el proyecto en Android Studio, inspeccione el contenido del mismo en el panel lateral izquierdo, llamado "Project Structure". Podrá notar que, en un principio, la estructura de la aplicación es exactamente la misma que en los tutoriales anteriores. Lea ahora los siguientes pasos para continuar con la exploración del proyecto en busca de las oportunidades de mejora.

En Android Studio existen herramientas que permiten realizar análisis estático del código teniendo en cuenta posibles mejoras en desempeño o buenas prácticas. Una de estas herramientas es conocida como Lint. Esta permite realizar validaciones con respecto al código fuente del proyecto, y es ampliamente utilizada por las librerías para generar advertencias al desarrollador sobre oportunidades de mejora en diferentes aspectos tales como usos inadecuados de las funciones o de los recursos importados.
Lint tiene múltiples formas de ser utilizado: por consola, por el IDE, por medio de un standalone, y generando alertas automáticas. En el caso más general, usted querrá realizar una inspección amplia del código de su proyecto. Para este fin, en su ventana Android Studio, con el proyecto abierto, seleccione la opción Analyze > Inspect Code del menú superior. Esta opción abrirá un panel como el de la siguiente imagen:

Imagen 3. Panel de configuración de la inspección

Con las opciones tal como vienen por defecto, presione el botón OK para iniciar la inspección del código. Podrá ver un panel de nombre "Inspection results" en la parte inferior del IDE. En este panel se muestran los problemas diagnosticados organizados por su categoría. Preste atención en particular a las advertencias categorizadas como Android/Lint/Performance para identificar problemas como los que se han mencionado en este tutorial. Podrá ver varias advertencias relacionadas a los recursos no utilizados, como se muestra a continuación:

Imagen 4. Categorización de los problemas detectados

Si bien estas advertencias son valiosas e indican lugares del código donde existen recursos no utilizados, se puede ver que el análisis estático no advierte de todas las oportunidades de mejora del código, sobre todo si estas se relacionan a la ejecución dinámica del programa.

Como pudo notar, la inspección de código apunta puntualmente a los recursos que no están siendo utilizados. Estos recursos se ubican en el directorio res del proyecto, y, en particular, las cadenas de texto del archivo strings.xml identificadas con los ids hello_blank_fragment, txt_no_collectors y txt_no_albums pueden ser fácilmente eliminadas. Abra dicho archivo y elimine las etiquetas que corresponden a estos identificadores.
Además de estos recursos, podrá ver que en el archivo network/NetworkServiceAdapter.kt existen imports que no son utilizados. Estos no son detectados como parte de la inspección en la misma categoría, pero sí generan advertencias del linter en el IDE, como se muestra a continuación:

Imagen 5. Alerta acerca de una directiva import no utilizada

Podrá identificar todos los imports no utilizados por su color gris, el ícono del bombillo amarillo junto a la línea correspondiente, y por la advertencia que se muestra al pasar el cursor encima de las líneas de código. Elimine todas las líneas de código que cumplan con estas características para evitar el uso de recursos innecesarios de las librerías.

Preste atención, primeramente, al archivo network/NetworkServiceAdapter.kt. Preste atención a los métodos getAlbums, getCollectors y getComments. Notará que cada uno de ellos está implementado de la misma forma. Cada uno de estos métodos busca el mismo objetivo, que es llenar una lista con los elementos que se leen desde una respuesta en forma de String desde el servidor. No obstante, el problema con la implementación consiste en la cantidad de objetos que se crean para cada traducción de las lecturas. El código dentro del ResponseListener, para el método getComments actualmente se ve de la siguiente forma:

val resp = JSONArray(response)
val list = mutableListOf<Comment>()
for (i in 0 until resp.length()) {
   val item = resp.getJSONObject(i)
   val rating = item.getInt("rating").toString()
   val description = item.getString("description")
   Log.d("Response", item.toString())
   list.add(i, Comment(albumId = albumId, rating = rating, description = description))
}
onComplete(list)

Como puede notarlo, dentro del ciclo for que se hace sobre el JSONArray de respuesta, se crea una variable llamada item para cada iteración. Esto implica que en cada ciclo se reserva un nuevo espacio de memoria con la declaración de esta variable y su valor cambia al elemento actual de la iteración. No obstante, la referencia creada en la iteración anterior se pierde y no es eliminada hasta que ocurre un evento de gc. Esto mismo ocurre con las variables rating y description, las cuales extraen una propiedad del JSONObject. En el caso de estas últimas variables, es posible eliminarlas y utilizar el retorno del método getInt y getString de forma directa.

Así, la primera optimización que puede incluir en la aplicación consiste en extraer la variable item del ciclo para utilizar un único espacio de memoria que cambia de valor instantáneamente, y acceder a sus propiedades por medio de los métodos, en lugar de utilizar variables con un corto tiempo de vida. De esta forma, el código resultante se ve de la siguiente forma:

val resp = JSONArray(response)
val list = mutableListOf<Comment>()
var item:JSONObject? = null
for (i in 0 until resp.length()) {
   item = resp.getJSONObject(i)
   Log.d("Response", item.toString())
   list.add(i, Comment(albumId = albumId, rating = item.getInt("rating").toString(), description = item.getString("description")))
}
onComplete(list)

En segundo lugar, observe el archivo viewmodels/CommentsViewModel.kt. Note que existe un método llamado printByRating, el cual imprime en el registro del sistema los comentarios que corresponden a un rango de calificaciones. Este método itera sobre la lista de comentarios que existe actualmente en el LiveData, y filtra aquellos que cumplen con el criterio de la calificación para sumar los primeros caracteres de su descripción a un StringBuffer. Finalmente el resultado construido con el StringBuffer se imprime en los registros del sistema. El código actual se ve de la siguiente forma:

fun printByRating(lower:Int, upper:Int){
   var stringBuffer = StringBuffer()
   if(!_comments.value.isNullOrEmpty()){
       for (i in 0 until _comments.value!!.size) {
           if (_comments.value!!.get(i).rating.toInt() < upper && _comments.value!!.get(i).rating.toInt() > lower) {
               val string = _comments.value!!.get(i).description
               stringBuffer.append(string + "\n")
           }
       }
   }
   Log.d("result", "Comentarios en rating: [ $lower , $upper ]: ${stringBuffer.toString()}")
}

En este punto existen múltiples optimizaciones por hacer. En primer lugar, es importante tener en cuenta que la forma en que se concatena el texto por medio de un StringBuffer implica una cantidad innecesaria de objetos con corto tiempo de vida. Por este motivo, se debe modificar el procedimiento para utilizar una única variable de tipo String e ir concatenando el contenido directamente en ella.

Además de esto, el ciclo for se realiza con un rango creado a partir de la cantidad de elementos en la lista. Esto es innecesario, dado que la clase de tipo List expone un iterador que ya permite recorrer todos los elementos de la lista sin necesidad de llamar a la operación de acceso ni de conteo de elementos. Luego de modificar los ciclos para hacer uso del iterador, el código debe verse de la siguiente forma:

fun printByRating(lower:Int, upper:Int){
   var stringBuffer = StringBuffer()
   _comments.value?.forEach{
       if(it.rating.toInt() in lower until upper){
           stringBuffer.append("${it.description}\n")
       }
   }
   Log.d("result", "Comentarios en rating [ $lower , $upper ]: ${stringBuffer.toString()}")
}

Note que, ahora, el recorrido sobre la lista de comentarios se realiza con la sintaxis forEach. Esto evita que el ciclo dependa de una consulta a la longitud del arreglo, y evita así mismo la necesidad de utilizar un rango extra a partir de ella. Además, al concatenar el contenido de la lista directamente, se evita la necesidad de crear objetos nuevos para este propósito.

Ahora, preste atención al método printListOfCommentsStartingUpper, presente en el archivo de este mismo ViewModel. Este método filtra e imprime los comentarios cuya descripción comienza con un carácter en mayúscula. Actualmente, esta implementación se realiza de forma manual con un recorrido sobre la lista, almacenando en cada iteración el valor del comentario en un objeto, y concatenando en la lista de resultado los comentarios que cumplan con el criterio de la primera letra en mayúscula. El código que encontrará es el siguiente:

fun printListOfCommentsStartingUpper(){
   var list = mutableListOf<String>()
   if(!_comments.value.isNullOrEmpty()){
       for (i in 0 until _comments.value!!.size) {
           val comment = _comments.value!!.get(i).description
           if (comment.toCharArray()[0].isUpperCase()) {
               list.add(comment)
           }
       }
   }
   Log.d("result", "Comentarios con mayúscula:"+list.toString())
}

Allí, es posible reducir todo el trabajo a la función filter de las librerías de colecciones de Kotlin. Como se mencionó en pasos anteriores, una de las sugerencias más comunes para el desempeño consiste en aprovechar los métodos de las librerías que puedan tener implementaciones más eficientes, y este es un claro caso. Así mismo, hacer uso de la función get es una implementación mucho más directa comparada con acceder al arreglo de caracteres de forma manual y luego acceder al índice. Luego de las optimizaciones, el código de este método debe verse de la siguiente forma:

fun printListOfCommentsStartingUpper(){
   if(!_comments.value.isNullOrEmpty()){
       Log.d("result", "Comentarios con mayúscula: ${_comments.value!!.filter { it.description[0].isUpperCase() }}")
   }
}

Note que, además de los beneficios mencionados anteriormente, el código también resulta simplificado y es más fácil de comprender.

¡Felicidades!

Al finalizar este tutorial, pudo familiarizarse con algunos de los problemas más comunes relacionados al rendimiento de una aplicación en Android, y pudo conocer consejos de cómo optimizarlos. Así mismo, pudo implementar mejoras al código fuente, las cuales hacen que su aplicación tenga un mejor rendimiento en los dispositivos móviles donde se ejecutará.

Ahora podrá incluir estas sugerencias al código fuente de sus proyectos en Android, de forma que sus desarrollos incluyan siempre las mejores prácticas.

Créditos

Versión 1.0 - Mayo 30, 2021

Juan Sebastián Espitia Acero

Autor

Norma Rocio Héndez Puerto

Revisora

Mario Linares Vásquez

Revisor