Last Updated: 2021-30-05
Como se ha mencionado en discusiones anteriores de este curso, en el entorno móvil de Android existen limitaciones a causa de los recursos computacionales, entre los cuales cabe resaltar la memoria. Recuerde que los datos de una aplicación en tiempo de ejecución son manejados por la memoria RAM, y que esta también se encarga de la ejecución de otras aplicaciones y el sistema operativo.
Además, como las aplicaciones se ejecutan en una máquina virtual de Java (JVM), se rigen por la misma política de garbage collection. Esta operación, si bien ayuda a que la memoria se preserve, genera congelamientos en el runtime de Android, los cuales se hacen evidentes para el usuario, ya que hay una demora en sus operaciones y también en la fluidez de su interfaz gráfica.
Por lo anterior, es importante que al momento de elegir la representación de sus datos, optimice la creación de objetos, y que busque tipos de datos más adecuados en cuanto a tamaño ocupado en la memoria. En este tutorial comprenderá las particularidades que tienen varias estructuras de datos en cuanto al manejo de memoria y por qué podrían ayudar a optimizar el desempeño de su aplicación.
En este tutorial se trabajará sobre una aplicación desarrollada para otros tutoriales anteriores. Dicha aplicación permite visualizar la información de un API REST que contiene información acerca de coleccionistas de álbumes musicales y los comentarios que realizan sobre ellos.
A lo largo del tutorial probará los cambios que existen en la aplicación al implementar varios tipos de estructuras de datos para almacenar sus datos en memoria. De esta forma, al final de este tutorial usted tendrá:
Al desarrollar este tutorial aprenderá:
Dado que Android reconoce las limitaciones de su entorno, varias estructuras de datos se han creado con el fin de representar la información de formas adecuadas, evitando al máximo la creación de objetos innecesarios, y optimizando el acceso a los datos allí contenidos.
La estructura básica para almacenar parejas clave-valor es el HashMap
. Esta es una implementación de hashtable que implementa la interfaz MutableMap
. Por ende, las propiedades de cualquier tabla de hash aplican para esta estructura, como, por ejemplo, la falta de un concepto de orden, la unicidad de llaves, etc.
Internamente, esta estructura se implementa como un Array de nodos, que son a su vez representados por una lista encadenada. El Array de nodos es recreado y expandido por un factor en caso de que una nueva clave ingrese y supere el tamaño del factor internamente definido.
Existen varios problemas con esta estructura, como el mencionado anteriormente, los cuales, aunque parezcan de bajo impacto, pueden afectar seriamente el desempeño de una aplicación móvil. Además, como las claves son hasheadas con el método hashCode
, es posible que existan colisiones en la búsqueda, puesto que objetos distintos en Java pueden tener el mismo código de hash. De esta forma, el acceso a un valor puede pasar de tener complejidad O(1) a O(n).
Esta estructura busca representar la información de la misma forma que el HashMap
. Sin embargo, su implementación consume mucha menos memoria. Su implementación interna reemplaza la existencia de un único Array de nodos, por dos Arrays, de los cuales uno contiene los hashes en formato entero, y el otro contiene tanto las llaves como los valores. La forma en que estos se relacionan se explica en la siguiente imagen (disponible en el siguiente artículo: https://proandroiddev.com/all-you-need-to-know-about-arraymap-sparsearray-49759c2ecbf9):
Imagen 1. Representación gráfica de un ArrayMap
De esta forma, al tener referencias de las tuplas a partir de un arreglo de hash codes, se evita la necesidad de crear un objeto extra para cada clave que es ingresada en el mapa. Además, en la implementación de esta estructura se busca controlar el crecimiento de los arreglos de una mejor forma, ya que al expandirse, no es necesario reconstruir toda la tabla, sino únicamente copiar las entradas en el Array. Así mismo, esta estructura reduce el tamaño del Array con la eliminación de elementos.
Cabe resaltar que esta estructura no es óptima para grandes cantidades de datos, ya que las búsquedas, inserciones y eliminaciones tardan más que para un HashMap
.
Esta estructura de datos también busca representar la información como lo hace el HashMap
, utilizando claves numéricas de valor entero. La principal característica de un SparseArray
es que las llaves de todos los valores de la estructura son números enteros, de forma que no existe ningún tipo de hash para las tuplas.
Si se fuera a hacer un mapa donde la llave es de tipo entero utilizando HashMap
, se tendría una estructura de datos donde, para cada clave, sería necesario crear un objeto de tipo Int. Esto es algo indeseable como se mencionó anteriormente, dado que no se puede representar los números con primitivas, y los objetos ocupan memoria, haciendo más probable un garbage collection.
Los SparseArrays
fueron creados pensando en las mejoras que podría haber en un programa al utilizar primitivas.
Por este motivo, además de la implementación genérica SparseArray<T>
, se tienen en cuenta los mapas con valores de tipo entero SparseIntArray
, de tipo flotante SparseLongArray
, y de tipo booleano SparseBooleanArray
, para poder representarlos también por medio de primitivas.
Esta estructura de datos organiza los elementos que contiene según el orden de uso. Esto permite identificar con rapidez los elementos que más recientemente se han usado, y los que tiempo han pasado sin ser utilizados. Lo anterior es útil para manejar información en el caché. La implementación interna de esta estructura de datos se basa en una lista doblemente encadenada (con el elemento más reciente en la cabeza); la cual es referenciada por un HashMap
que asocia las llaves de los elementos con sus nodos en la lista. La siguiente imagen (tomada de este tutorial: https://www.interviewcake.com/concept/java/lru-cache) explica la implementación interna de la estructura de datos:
Imagen 2. Representación gráfica de un LRUCache
Al utilizar un LruCache
se puede acceder y actualizar la información con una complejidad O(1), lo cual hace que estas operaciones sean muy rápidas. Sin embargo, es importante tener presente que esta estructura de datos ocupa el espacio correspondiente a n elementos para un HashMap
y n elementos para una lista doblemente encadenada. No obstante, teniendo en cuenta las demás estructuras aquí mencionadas, este aparente trade-off es aceptable, además de que la complejidad se mantiene en O(n).
Las estructuras mencionadas en el paso anterior permiten optimizar el uso de recursos para representar las colecciones de datos. Además de los beneficios mencionados para cada una, estas estructuras de datos son adecuadas para almacenar información en la memoria local. Esto se debe a que ocupan menor espacio de memoria, facilitando que el caché no se llene. Adicionalmente, se puede tolerar la complejidad extra en el acceso a los datos, ya que en cualquier caso, esta es considerablemente menor al impacto de realizar las peticiones por medio de la red para obtener la información.
A continuación se hablará acerca de algunas de las aproximaciones existentes para implementar caché en Android.
La clase SharedPreferences
de Android permite manejar un módulo de almacenamiento y gestión de datos que persisten de forma local en el dispositivo por medio de uno o varios ficheros, siguiendo un esquema llave-valor. La intención original de esta clase, como su nombre lo indica, es almacenar localmente una serie de preferencias y ajustes del usuario que pueden afectar algunos aspectos no funcionales de la aplicación. Sin embargo, no está limitada a este fin, pues es posible utilizarla para guardar datos de los tipos de datos principales de Kotlin: Int, Long, Boolean, String
y Float
.
Las preferencias están vinculadas a un contexto, que puede ser una actividad, o la aplicación entera. Para acceder a las preferencias existen los siguientes métodos:
Context.getPreferences(mode[int])
: retorna un único archivo de preferencias compartidas de forma predeterminada para la actividad. Context.getSharedPreferences(name[str], mode[int])
: retorna varios archivos de preferencias compartidas, según el nombre especificado. PreferenceManager.getDefaultSharedPreferences(context)
: retorna el archivo de preferencias compartidas de forma predeterminada para toda la aplicación.Los objetos de tipo SharedPreferences
pueden ser modificados y consultados por medio de un editor, el cual se crea con el método SharedPreferences.edit
. Las operaciones de un editor son las siguientes:
Editor.putBoolean
, para agregar un valor booleano.Editor.putInt
, para agregar un valor entero.Editor.putLong
, para agregar un valor real.Editor.putFloat
, para agregar un valor de punto flotante.Editor.putString
, para agregar una cadena de caracteres.Editor.putStringSet
, para agregar un conjunto de cadenas de caracteres..Editor.remove
, para eliminar una preferencia con la llave indicada.Editor.clear
, para eliminar todas las preferencias.Editor.commit
y Editor.apply
, para confirmar los cambios. El primer método retorna un booleano indicando el éxito de la operación.Usualmente se busca almacenar información de datos más complejamente estructurados y relacionados entre sí en el caché de la aplicación. Para estos casos se quedan cortas las preferencias de la aplicación, pero tampoco es ideal recurrir a archivos de almacenamiento, puesto que implican operaciones de lectura/escritura en disco y de formateo de la información que pueden ser pesadas.
Las bases de datos locales atacan este problema, ya que son almacenadas como parte de la memoria de la aplicación y funcionan con esquemas de bases de datos relacionales típicos, los cuales, además, implementan patrones de diseño que facilitan su uso desde las aplicaciones de Android.
Las siguientes son algunas de las opciones más conocidas en cuanto a bases de datos locales para Android:
Además de las opciones mencionadas, existen muchas implementaciones de caché para Android. Incluso gran parte de las librerías de invocación (incluyendo Volley, Retrofit, OkHttp) de APIs REST y clientes HTTP incluyen soluciones para incorporar caché y almacenar peticiones y respuestas de forma local.
Así mismo, existen librerías especializadas para almacenar en caché otros tipos de datos, como por ejemplo, las imágenes. En este caso, resaltan las librerías Glide, Picasso y Fresco (las cuales conocerá en otro tutorial).
En este tutorial usted va a agregar una estructura de datos que almacene la información de los comentarios de cada álbum, de forma que, una vez se hayan consultado los comentarios de uno de ellos, estos se almacenen para evitar una consulta de red la próxima vez que se requieran.
Para este fin, se puede optar por incorporar una estructura de tipo HashMap
en un nuevo archivo CacheManager, que sea accesible por medio del patrón singleton, y en el momento de cambiar a la vista de comentarios de un álbum, que el repositorio cargue la información desde esta fuente, evitando peticiones de red innecesarias.
En el directorio network
, cree un archivo llamado CacheManager.kt
. Para utilizar el patrón singleton, es necesario que cree un companion object
con la instancia de la misma forma que está en el NetworkServiceAdapter
. La clase CacheManager
debe recibir un contexto, y además del companion object
, debe tener una variable que represente los comentarios cacheados y un método para modificar el valor de esta. El código que debe agregar es el siguiente:
import android.content.Context
import com.example.vinyls_jetpack_application.models.Comment
class CacheManager(context: Context) {
companion object{
var instance: CacheManager? = null
fun getInstance(context: Context) =
instance ?: synchronized(this) {
instance ?: CacheManager(context).also {
instance = it
}
}
}
private var comments: HashMap<Int, List<Comment>> = hashMapOf()
fun addComments(albumId: Int, comment: List<Comment>){
if (!comments.containsKey(albumId)){
comments[albumId] = comment
}
}
fun getComments(albumId: Int) : List<Comment>{
return if (comments.containsKey(albumId)) comments[albumId]!! else listOf<Comment>()
}
}
Note la presencia tanto de un método addComments
, como de un método getComments
que toman el identificador de un álbum y retorna o asigna el valor de la colección de comentarios al HashMap
según el identificador indicado.
Ahora, debe modificar el archivo CommentsRepository.kt
, de forma que, cuando se haga una petición de red se almacene el resultado en esta estructura, y en caso de que ya se haya almacenado, prevenir una petición de red innecesaria. Esta lógica condicional es simple y se representa en el siguiente código:
suspend fun refreshData(albumId: Int): List<Comment> {
var potentialResp = CacheManager.getInstance(application.applicationContext).getComments(albumId)
if(potentialResp.isEmpty()){
Log.d("Cache decision", "get from network")
var comments = NetworkServiceAdapter.getInstance(application).getComments(albumId)
CacheManager.getInstance(application.applicationContext).addComments(albumId, comments)
return comments
}
else{
Log.d("Cache decision", "return ${potentialResp.size} elements from cache")
return potentialResp
}
}
De esta forma, cuando se abra la vista de comentarios de un álbum, tendrá en cuenta la información almacenada localmente en la memoria volátil, y se imprimirá en consola la información que indique de dónde se obtiene la información.
Ejecute la aplicación y podrá ver que no existen diferencias en la funcionalidad. Interactúe con la aplicación de forma que acceda a la vista de comentarios de un mismo álbum dos veces seguidas. Esto forzará que la primera vez se obtenga la información desde la red, y de ahí en adelante, se obtenga de la estructura en memoria RAM.
Ya habrá implementado una estructura de caché para optimizar el número de consultas de información por medio de internet. Ahora, pruebe todas las estructuras mencionadas anteriormente. Notará que la funcionalidad es similar y que, a nivel de la programación no existe mucha diferencia, aunque la decisión pueda afectar según el escenario de uso. En primer lugar, reemplace la estructura de tipo HashMap
por una de tipo ArrayMap
, luego SparseArray
y finalmente, LruCache
. Notará que la implementación no cambia virtualmente en nada, ya que el HashMap
está pensado para relacionar llaves de tipo entero con valores de tipo Lista de comentarios.
Los cambios de código en cada caso serán los siguientes:
En el caso del ArrayMap
, los contratos de los métodos utilizados son idénticos al caso del HashMap
.
private var comments: ArrayMap<Int, List<Comment>> = arrayMapOf()
Para el caso del SparseArray
, los métodos a utilizar cambian, ya que la implementación varía y no incluye un método containsKey
, ni una forma de asignación directa, ya que se abstrae el HashMap
con un listado de enteros para las claves.
private var comments: SparseArray<List<Comment>> = SparseArray()
fun addComments(albumId: Int, comment: List<Comment>){
if (comments[albumId]==null){
comments.setValueAt(albumId, comment)
}
}
fun getComments(albumId: Int) : List<Comment>{
return if (comments[albumId]!=null) comments[albumId]!! else listOf<Comment>()
}
Para el caso del LruCache
, los métodos a utilizar cambian, ya que la implementación varía y no incluye un método containsKey
, ni una forma de asignación directa.
private var comments: LruCache<Int, List<Comment>> = LruCache(3)
fun addComments(albumId: Int, comment: List<Comment>){
if (comments[albumId] == null){
comments.put(albumId, comment)
}
}
fun getComments(albumId: Int) : List<Comment>{
return if (comments[albumId]!=null) comments[albumId]!! else listOf<Comment>()
}
¡Felicidades!
Al finalizar este tutorial, pudo familiarizarse con el entorno del desarrollo de aplicaciones en Android haciendo uso de las herramientas de Android Studio y el lenguaje de programación Kotlin.
Ahora podrá crear sus propios proyectos de Android, compilar y ejecutar aplicaciones y ahondar en las particularidades del desarrollo con Kotlin para crear aplicaciones modernas y cada vez más complejas.
Juan Sebastián Espitia Acero | Autor |
Norma Rocio Héndez Puerto | Revisora |
Mario Linares Vásquez | Revisor |