Last Updated: 2021-28-07
Las corrutinas son una herramienta de Android que brinda concurrencia y simplifica considerablemente la ejecución asíncrona del código. Estas ayudan a realizar operaciones de larga duración sin interrumpir la ejecución del hilo principal dedicado a la aplicación y sobre todo, a su interfaz gráfica de usuario. Además, esta distribución de las operaciones permite que el sistema tenga un mejor rendimiento, por lo cual tienen campo de aplicación en casi cualquier tipo de aplicación móvil.
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.
Al final de este tutorial usted tendrá:
Al desarrollar este tutorial aprenderá:
La implementación de una corrutina más básica se ve de la siguiente forma:
fun main() = runBlocking { // this: CoroutineScope
launch { // lanza una nueva corrutina y avanza
delay(1000L) // agrega delay por 1 segundo sin bloquear
println("World!") // imprime luego de esperar el delay
}
println("Hello") // la corrutina principal se sigue ejecutando mientras la otra espera el delay
}
En el anterior código vale la pena resaltar la existencia de los bloques runBlocking{}
y launch{}
. El primero es un bloque que crea un elemento CoroutineScope
que puede ser utilizado en el código dentro del mismo bloque, por medio de la variable this
. Este bloque permite conectar el mundo no asíncrono de la función ordinaria main()
con las corrutinas que sean lanzadas internamente como parte del bloque, por medio de un bloqueo del hilo indicado. El segundo bloque es uno de los constructores de corrutinas que opcionalmente toma como parámetro un dispatcher y lanza una corrutina que ejecuta el código dentro del cuerpo del bloque. Este método está ligado únicamente a objetos del tipo CoroutineScope
, por lo cual no puede ser llamado de la misma forma fuera del primer bloque.
Así mismo, en el código anterior vale la pena observar que se utiliza una función llamada delay
. Esta es una función de suspensión especial, la cual genera un tiempo de espera en la ejecución de la aplicación indicado en milisegundos.
Cabe aclarar que existen otras formas de implementar corrutinas, las cuales van a depender del contexto en el cual se estén lanzando. Para ejecutar código en corrutinas es necesario haber establecido un puente entre el mundo del código no asíncrono en el que se ejecuta la aplicación y el mundo asíncrono en el que se ejecutarán las corrutinas, como se explicó anteriormente.
Además del método runBlocking
, una forma más usual de iniciar una corrutina desde el mundo no asíncrono es utilizar el método launch
del objeto GlobalScope
. Este objeto está disponible para toda la aplicación y provee un contexto para lanzar corrutinas mientras la aplicación siga "con vida". Asimismo, es importante mencionar que no es recomendable utilizar el método runBlocking
para tareas de larga duración, ya que es un método que bloquea el hilo indicado por contexto. Por su parte, el método launch
ejecuta el código asíncrono sin bloquear el hilo, y permite controlar la corrutina por medio de un objeto de la clase Job
, el cual contiene un ciclo de vida que será explicado en un apartado posterior.
Así, la mejor forma de implementar una corrutina básica es por medio de una estructura de código como la siguiente:
fun main() = GlobalScope.launch { // this: CoroutineScope
launch {
delay(1000L)
println("World!")
}
println("Hello")
}
Las corrutinas se fundamentan en el uso de funciones de suspensión. Estas son funciones capaces de bloquear la ejecución de la corrutina mientras ejecutan su trabajo, y tienen la particularidad de retornar el resultado de la operación para su posterior uso. Esto permite que las operaciones asíncronas dentro de una corrutina sean programadas en código secuencial.
Es muy importante tener siempre presente que las funciones de suspensión solamente pueden ser ejecutadas dentro de una corrutina o dentro de otra función de suspensión. Este aspecto tiene mucho impacto en el desarrollo de corrutinas, ya que siempre va a tener que existir una estructura de corrutinas que se lancen desde el contexto adecuado y ejecuten las funciones necesarias que se definen en otros elementos del sistema.
Como se muestra a continuación, una función de suspensión maneja la misma estructura que cualquier función de Kotlin, haciendo uso de la palabra reservada suspend
:
suspend fun suspendingFunction() : Int {
// Long running task
return 0
}
Como se mencionó anteriormente, para construir una corrutina con el método launch se necesita tener un scope. Un scope es básicamente un contexto, o un ámbito en el cual se ejecutará cierto código. Gracias a la arquitectura de Android y Jetpack, existen varios scopes que suelen ser utilizados dentro de una aplicación de Android. Estos se describen a continuación:
GlobalScope
: es el scope más general. Permite ejecutar corrutinas mientras la aplicación también siga siendo ejecutada. Por este motivo, no deben estar atadas a ningún componente que pueda ser destruido.View.lifecycleScope
: es un scope que existe a nivel de los componentes de las vistas: es decir, Actividades y Fragmentos. Este scope permite ejecutar corrutinas vinculadas al ciclo de vida del componente. Cada clase que extienda de Activity
o Fragment
tendrá por defecto el atributo lifecycleScope
.ViewModel.viewmodelScope
: es un scope que existe como propiedad por defecto de las clases que extiendan de ViewModel
. Un ViewModel suele invocar operaciones asíncronas relacionadas a la información de una vista y, por este motivo, requieren de un scope distinto al de la vista misma.CoroutineScope()
o MainScope()
: son scopes estándar que se instancian directamente en el código como variables. La diferencia entre los dos constructores radica en el dispatcher del scope que se crea. Es necesario cancelar explícitamente estos scopes cuando no se necesiten más.CoroutineScope
: cualquier clase puede implementar la interfaz para convertirse en un scope válido, siempre y cuando incluya una propiedad del tipo Job
y maneje su estado de forma adecuada, y que sobreescriba la propiedad coroutineContext
, asignando en el método get
de la propiedad, uno de los dispatchers que se explorarán en la siguiente sección.Con los anteriores objetos de tipo CoroutineScope se puede llamar directamente el método launch para lanzar una corrutina desde un entorno no asincrónico. No obstante, existen también las siguientes formas de crear CoroutineScope dentro de funciones de suspensión:
withContext()
: esta función permite cambiar el contexto en el que se ejecutará una corrutina. Sirve también para separar el código que respecta a diferentes operaciones dentro de una misma corrutina.coroutineScope{}
: permite definir un bloque con un CoroutineScope en forma de función de suspensión.Los dispatchers, también conocidos como CoroutineContext
, son contextos que especifican los hilos que la corrutina va a utilizar para ejecutar el código. Algunos de estos dispatchers cuentan con un único hilo, mientras que otros definen un grupo de hilos optimizados para ejecutar varias corrutinas. Los dispatchers son los siguientes:
newSingleThreadContext
y newFixedThreadPoolContext
. También pueden crearse a partir de un Executor
(Un objeto que ejecuta tareas del tipo Runnable
) que se transforma con la función asCoroutineDispatcher
.Las corrutinas no son solo bloques de código con instrucciones a ejecutar. Al momento de crear una corrutina, se retorna un objeto de tipo Job
. Este objeto se puede combinar con otros Jobs,
y también permite manejar el ciclo de vida de los threads que se utilizan para ejecutar las operaciones.
Cuando se lanza una corrutina, el Job
se encuentra en estado New, indicando que esta fue recién creada. Luego, cuando esta comienza a ejecutar sus operaciones, pasa al estado Active, desde el cual pueden ocurrir excepciones de sistema en caso de haber errores en la ejecución. Si no existen excepciones, el estado pasa a ser Completing, indicando que la operación está por terminar. Puede ser necesario, en varios escenarios, cancelar el trabajo según factores exteriores. Si esto sucede, o si se presentaron excepciones, el estado de la corrutina pasa a ser Cancelling y, eventualmente, Cancelled. En este caso, la corrutina, ni su scope pueden seguir siendo utilizados. De lo contrario, si la ejecución terminó de forma adecuada, la corrutina logrará llegar a un estado Completed. Este flujo se explica en el diagrama a continuación:
Imagen 1. Ciclo de vida de las corrutinas
El proyecto que se ha desarrollado en tutoriales anteriores no incluye soporte para corrutinas. En general con los proyectos nuevos de Android que usted cree, necesitará incluir las dependencias para utilizar corrutinas dentro de su aplicación. Únicamente se requiere de una dependencia en particular alojada en el paquete org.jetbrains.kotlinx
. Para incluirla, es necesario que siga estos pasos:
build.gradle
del módulo :app en el editor de Android Studio.dependencies
. Agregue las siguientes líneas de código:dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}
Luego de que termine el proceso de sincronización, revise si el resultado es adecuado o hubo algún error que se muestre en el panel inferior de Android Studio. Es posible que existan errores relacionados con su versión de Gradle o la versión de alguna de las librerías. Si este es el caso, Android Studio le ayudará a actualizarlas a las versiones adecuadas por medio de sugerencias del lint (la herramienta de análisis estático de código de Android Studio).
Actualmente, la versión de la aplicación que ha desarrollado en los tutoriales anteriores implementa los llamados de red en el archivo NetworkServiceAdapter
, y las operaciones a realizar luego de obtener la respuesta son configuradas desde los ViewModels, y ejecutadas desde el mismo Service Adapter, pasando por medio de parámetros como callbacks. El fundamento de esto consiste en enviar un parámetro de tipo Unit
entre los métodos de las clases intermedias, y hacer una invocación de este parámetro en los Response Listeners del método del Service Adapter.
En el archivo CollectorViewModel
podrá observar que en el método refreshDataFromNetwork
, se llama al método refreshData
del repositorio y en la invocación se pasa un parámetro que contiene instrucciones a ejecutar con la respuesta, y otro parámetro que contiene instrucciones a ejecutar en caso de un error.
private fun refreshDataFromNetwork() {
collectorsRepository.refreshData({
_collectors.postValue(it)
_eventNetworkError.value = false
_isNetworkErrorShown.value = false
},{
Log.d("Error", it.toString())
_eventNetworkError.value = true
})
}
Las instrucciones de los parámetros utilizan variables disponibles solo en el ViewModel y estas pueden ser ejecutadas posteriormente, ya que el parámetro es una referencia a la función, que es un callable. Ahora, observe el método refreshData
del archivo CollectorsRepository
, el cual declara los parámetros de tipo Unit
para el caso de éxito y el caso de error en la petición. Este método invoca al método getCollectors
del Service Adapter y pasa como parámetro los mismos Units
que recibe.
class CollectorsRepository (val application: Application){
fun refreshData(callback: (List<Collector>)->Unit, onError: (VolleyError)->Unit) { NetworkServiceAdapter.getInstance(application).getCollectors({
callback(it)
},
onError
)
}
}
Finalmente, observe el método getCollectors
del archivo NetworkServiceAdapter
. Podrá ver que este método agrega a la cola de Volley una petición del tipo get
, transforma la respuesta en el Response Listener, y luego ejecuta el método onComplete
con la información procesada de la respuesta. Así mismo, en el llamado se configura un Error Listener que reacciona a las excepciones de Volley y ejecuta el método onError
definido en el ViewModel con dichas excepciones.
fun getCollectors( onComplete:(resp:List<Collector>)->Unit , onError: (error:VolleyError)->Unit) {
requestQueue.add(getRequest("collectors",
Response.Listener<String> { response ->
val resp = JSONArray(response)
val list = mutableListOf<Collector>()
for (i in 0 until resp.length()) {
val item = resp.getJSONObject(i)
val collector = Collector(collectorId = item.getInt("id"),name = item.getString("name"), telephone = item.getString("telephone"), email = item.getString("email"))
list.add(i, collector)
}
onComplete(list)
},
Response.ErrorListener {
onError(it)
}))
}
Anteriormente se ha explicado por qué se utilizan los callbacks para comunicar al ViewModel con el Service Adapter que hace peticiones de red, pasando por el Repository como componente intermedio. Quizás en un primer acercamiento a estos problemas para aplicaciones Android no es inmediato el pensamiento de implementar funciones asíncronas por medio de callbacks.
En otros lenguajes de programación existen mecanismos para invocar código asíncrono y esperar por sus respuestas para seguir procesando la información de forma secuencial. Por este motivo, es posible que a un desarrollador se le ocurra la idea de implementar las operaciones asíncronas de la misma forma.
En este paso, usted hará una implementación del código de esta forma para comprender por qué esta aproximación no es acertada en un principio, y para comprender por qué es necesario incluir el concepto de corrutinas.
En primer lugar, modifique el código del método getCollectors
en el NetworkServiceAdapter
para que retorne un objeto de tipo List
de Collectors
, en vez de recibir callbacks. En el cuerpo del método, declare la variable de retorno y retorne su valor como última instrucción. Estos cambios en código deben verse como en el siguiente fragmento de código:
fun getCollectors(): List<Collector>{ //se agrega un tipo de retorno
val list = mutableListOf<Collector>() //inicializado como variable de retorno
requestQueue.add(getRequest("collectors",
Response.Listener<String> { response ->
val resp = JSONArray(response)
for (i in 0 until resp.length()) {
val item = resp.getJSONObject(i)
val collector = Collector(collectorId = item.getInt("id"),name = item.getString("name"), telephone = item.getString("telephone"), email = item.getString("email"))
list.add(i, collector) //se agrega a medida que se procesa la respuesta
}
},
Response.ErrorListener {
throw it //se relanza la excepción
}))
return list //se retorna la variable
}
Con esta actualización, también es necesario modificar el código del CollectorsRepository
para invocar el método del Adapter de forma secuencial, y retornar su valor tal como se recibe. Este cambio en el código se ve de la siguiente forma:
fun refreshData(): List<Collector>{
//Determinar la fuente de datos que se va a utilizar. Si es necesario consultar la red, ejecutar el siguiente código
return NetworkServiceAdapter.getInstance(application).getCollectors()
}
Finalmente, es necesario modificar el código del CollectorViewModel
, para que este también invoque el método del repositorio sin los parámetros y para que utilice la información que este retorna de forma secuencial. Adicionalmente, es necesario que este maneje la excepción que se puede obtener con un error en el llamado de red. Estos cambios se ven en el código de la siguiente forma:
private fun refreshDataFromNetwork() {
try {
var data = collectorsRepository.refreshData()
_collectors.postValue(data)
_eventNetworkError.value = false
_isNetworkErrorShown.value = false
}
catch (e:Exception){ //se procesa la excepcion
Log.d("Error", e.toString())
_eventNetworkError.value = true
}
}
Ejecute la aplicación en su dispositivo Android (físico o virtual). Podrá ver que la pantalla principal, que antes mostraba un listado de coleccionistas, ya no muestra ningún contenido. Aún así, no ocurre ningún error en la aplicación, y el lint de Android Studio tampoco indica ningún problema con el código. Esto pues la estructura sintáctica es correcta, las variables están definidas, las excepciones son capturadas y no existen inconsistencias en el llamado a las funciones.
En este momento, esta situación no le permite ver la información que se debería obtener de la fuente remota, ya que no existe una señalización entre los hilos que se encargan de ejecutar la información principal y el llamado a la red, la cual indique que el segundo proceso terminó y el primero ya puede utilizar la información.
Con las corrutinas y la implementación de funciones de suspensión, usted podrá resolver esta situación y generar, como resultado, un código que se lee de forma secuencial, gracias a la definición de scopes.
Anteriormente, usted pudo ver que utilizar callbacks es una opción válida para garantizar un flujo de la información, la cual se obtiene de forma asíncrona desde las fuentes de datos hasta las vistas. No obstante, esta forma de pasar información entre elementos del sistema puede hacer que el código sea menos comprensible y también es un obstáculo para crear un sistema que sea desacoplado.
En el anterior paso se habló de utilizar código secuencial, lo cual permite una mejor organización del código y separación de responsabilidades. No obstante, para poder implementar secuencialidad, es necesario que utilice corrutinas.
Las funciones que implementó en el paso anterior reemplazan el uso de los callbacks y, a pesar de que su funcionalidad no es adecuada, son una buena base para pasar el código a corrutinas. Esto puesto que las corrutinas se basan en funciones de suspensión, cuya implementación no dista mucho de la de una función normal.
La capa de acceso a los datos es el primer lugar en el que se requiere implementar funciones de suspensión, puesto que el service adapter que consulta la red hace peticiones directamente asíncronas a una fuente de datos. En este caso, lo ideal es esperar a que el servidor web entregue una respuesta y procesar la información que se recibe de la forma que sea necesaria en cada caso.
Abra el archivo NetworkServiceAdapter
, y para cada una de las funciones getAlbums
, getCollectors
y getComments
, agregue la palabra reservada suspend
para transformarlas en funciones de suspensión. Luego de esto, envuelva el bloque de código de cada función con un bloque suspendCoroutine
. Este bloque también crea un CoroutineContext
y retorna una función de suspensión, con la particularidad de que puede manipular también la continuación de la corrutina. Es decir, entregar un resultado a un objeto de continuación para utilizar cuando finalice la corrutina.
Con los cambios mencionados anteriormente, el código correspondiente a la función getCollectors
debe verse de la siguiente forma:
suspend fun getCollectors() = suspendCoroutine<List<Collector>>{ cont->
requestQueue.add(getRequest("collectors",
Response.Listener<String> { response ->
val resp = JSONArray(response)
val list = mutableListOf<Collector>()
for (i in 0 until resp.length()) {//inicializado como variable de retorno
val item = resp.getJSONObject(i)
val collector = Collector(collectorId = item.getInt("id"),name = item.getString("name"), telephone = item.getString("telephone"), email = item.getString("email"))
list.add(i, collector) //se agrega a medida que se procesa la respuesta
}
cont.resume(list)
},
Response.ErrorListener {
cont.resumeWithException(it)
}))
}
Ahora modifique los repositorios de cada entidad de forma que su método refreshData
sea una función de suspensión. Tan pronto abra el archivo podrá ver que el lint de Android Studio le señala, en el editor, que las funciones de suspensión sólo pueden ser llamadas desde otras funciones de suspensión o corrutinas (como lo muestra la siguiente imagen). Como este todavía no es el origen de la corrutina, basta con que vuelva esta función en una de suspensión con la palabra reservada suspend
.
Imagen 2. Aviso del linter acerca del scope de invocación de una función
En este diálogo, el botón "Make refreshData suspend" agregaría la palabra reservada suspend
en la declaración de la función y con esto basta para corregir el error. En este método no es necesario modificar nada más, ya que se retorna el valor que se obtiene al llamar la función correspondiente del NetworkServiceAdapter
, el cual ya es manejado como una función de suspensión. Únicamente se sugiere asegurarse de que los parámetros callback
y onError
no estén más presentes en ninguna de las funciones, y que efectivamente esté presente la palabra return
antes del llamado al método del NetworkServiceAdapter
, y que en la declaración del método del repositorio se encuentre el tipo de dato adecuado, como se hizo en el paso anterior.
En el caso de las tres entidades de esta aplicación, las corrutinas surgen desde los ViewModels, ya que son los que actualizan constantemente la información de los LiveData
correspondientes a cada vista por medio de consultas al repositorio. Por este motivo, se utiliza el viewModelScope
para lanzar la corrutina desde la cual se llaman las funciones de suspensión recién creadas y para actualizar los valores de los LiveData
según el resultado de dichas operaciones asíncronas. Así mismo, es necesario tener en cuenta el caso de excepción que existe si la solicitud de red hecha por Volley presenta algún error, por lo cual debe incluirse la corrutina en el cuerpo de un bloque try-catch
. El código del método refreshDataFromNetwork
, en el caso del CollectorViewModel
, se ve de la siguiente forma con las modificaciones:
private fun refreshDataFromNetwork() {
try {
viewModelScope.launch (Dispatchers.Default){
withContext(Dispatchers.IO){
var data = collectorsRepository.refreshData()
_collectors.postValue(data)
}
_eventNetworkError.postValue(false)
_isNetworkErrorShown.postValue(false)
}
}
catch (e:Exception){
_eventNetworkError.value = true
}
}
Podrá ver en el código que se especifica en el launch
inicial del dispatcher por defecto, ya que la operación es genérica y no realiza procesamientos especiales. Dentro de este bloque, se implementa un bloque withContext
con el dispatcher de IO, con el fin de realizar la operación de actualización de la información en otro contexto, y que afuera solo se modifiquen variables de tipo LiveData
.
Una vez haya terminado de hacer esto, repita los pasos con las demás entidades del sistema, ya que la implementación de sus métodos tiene la misma estructura. Una vez haya terminado, ejecute la aplicación y podrá ver que nuevamente se obtiene la información del API REST y se muestran los listados en la interfaz gráfica de la aplicación.
¡Felicidades!
Al finalizar este tutorial pudo familiarizarse con el concepto de corrutinas y asincronía en las aplicaciones de Android.
Ahora podrá implementar sus propias corrutinas para ejecutar operaciones de alta carga de trabajo, teniendo presente los diferentes escenarios y las variaciones en la implementación de las mismas según la necesidad. De esta forma, sus aplicaciones harán un mejor manejo de la concurrencia y de los recursos computacionales para proteger la ejecución principal.
Juan Sebastián Espitia Acero | Autor |
Norma Rocio Héndez Puerto | Revisora |
Mario Linares Vásquez | Revisor |