Cómo Administrar el Hilo Principal en Android: Coroutines

Wilson Castiblanco Quintero | 26 de junio, 2019

Apoyado por nuestro experto en innovación Yander Caceres

Cuando un usuario inicia una aplicación, Android crea un proceso Linux nuevo junto con un hilo de ejecución. Este hilo principal, también conocido como ‘UI thread’, es responsable por todo lo que sucede en la pantalla. Entender cómo funciona puede ser de gran ayuda a la hora de diseñar un app y así utilizar el hilo principal para lograr el mejor desempeño.”

https://developer.android.com/topic/performance/threads#main

Nuestras aplicaciones requieren de un marco de tiempo de 16 milisegundos para renderizar 60 Hz en la UI (interfaz de usuario) sin problemas y evitar Janks, o en el peor de los casos, un ANR (Aplicación no Responde). 

Como dice la literatura, debemos entender cómo funciona el Hilo Principal (UI) para lograr el mejor desempeño. ¿Pero cómo podemos lograr el mejor desempeño posible cuando nuestra aplicación conlleva muchos procesos pesados que lo comprometen? Afortunadamente, los procesos asincrónicos/de fondo están allí para ayudar.

En este artículo, mostraremos cómo administrar un hilo UI con el siguiente ejemplo:

El objetivo es mostrar el detalle de un app de Taxis. Para lograrlo, tenemos las siguientes funciones:

fun getTrip(tripId: String): Trip {}
fun getTripDriver(userId: String): Driver {}
fun getTripBill(billId: String): Bill {}

fun showTaxiTripDetail(tripId: String) {
	val trip = getTrip(tripId) -----+ Network
	val driver = getTripDriver(trip.userId) | ---> Proccesses
	val bill = getTripBill(trip.billId) ----+
	renderTripDetail(trip, driver, bill)----+ ---> UI Render
}
 

Ahora, resolveremos esta tarea utilizando Coroutines de Kotlin + Retrofit + MVP. Para entender el concepto de Coroutine Scope y Coroutine Context, es importante tener en mente que el objetivo es mantener el Hilo Principal libre de Janks o ANRs. En conjunto, nos permitirán correr tareas más largas/pesadas o actualizar nuestra UI adecuadamente. 

Esta es la Vista Principal donde se muestran los datos de los resultados:

class TaxiTripDetailFragment() : Fragment(), TaxiTripDetailView {
	override fun onCreateView(..).. {}
    
    override fun renderTripDetail(trip: Trip, driver: Driver, bill: Bill) {
    	Toast.makeText(
          context.applicationContext,
          "Trip: ${trip.id}, Driver: ${driver.name}, Bill: ${bill.price}",
          Toast.LENGTH_LONG
        ).show()
	}
}
 

Tendremos un ‘presenter’ llamado TaxiTripDetailPresenter donde sucederán los procesos asincrónicos:

class TaxiTripDetailPresenter(private val view: TaxiTripDetailView) {
	private val job = Job()
    private val scopeMainThread = CoroutineScope(job + Dispatchers.Main)
    private val scopeIO = CoroutineScope(job + Dispatchers.IO)
    
    private val repository = TaxiTripRepositoryNetwork()
    fun showTaxiTripDetail(tripId: String) {
    	scopeIO.launch {
        	val trip = repository.getTrip(tripId)
            val driver = repository.getTripDriver(trip.userId)
            val bill = repository.getTripBill(trip.billId)
            scopeMainThread.launch {
            	view.renderTripDetail(trip, driver, bill)
            }
        }
    }
    
    fun onDestroy() {
    	job.cancel()
    }
 }   
 
Luego, tenemos la clase TaxiTripRepositoryNetwork para realizar las solicitudes al ‘endpoint’ (punto final de comunicación):
class TaxiTripRepositoryNetwork {
	private val service = TaxiTripService()
    
    suspend fun getTrip(tripId: String): Trip {
    	return service.getTrip(tripId).await()
    }
    
    suspend fun getTripDriver(userId: String): Driver {
    	return service.getTripDriver(userId).await()
    }
    
    suspend fun getTripBill(billId: String): Bill {
    	return service.getTripBill(billId).await()
    }   
 }   

 

Y, finalmente, la clase TaxiTripService, la cual posee los ‘endpoints’ que deseamos consumir utilizando Retrofit:

interface TaxiTripService {

	@GET("trips/{tripId}")
    fun getTrip(@Path("tripId") tripId: String): Deferred<Trip>
    
    @GET("users/{userId}")
    fun getTripDriver(@Path("userId") userId: String): Deferred<Driver>
    
    @GET("bills/{billdId}")
    fun getTripBill(@Path("billdId") billId: String): Deferred<Bill>
}

Nota: Las clases y arquitectura definidas para este ejemplo se pueden cambiar o mejorar.

Tenemos muchos conceptos nuevos que abarcar. Todos nos permiten crear procesos asincrónicos/de fondo para administrar el Hilo Principal:

Coroutine

Los Coroutines son hilos livianos.

Los Coroutines son componentes de programas de cómputo que generalizan las subrutinas para multitareas apropiativas, y que además, permiten la suspensión o el reinicio de una ejecución.

De acuerdo con Donal Knuth, fue Melwin Conway el que utilizó el término coroutine por primera vez en 1958, cuando lo aplicó a la construcción de un programa de ensamblaje. [1]  La primera publicación donde se explicaba el término coroutine se dio más tarde, en 1963.

Conway, M. E. (Julio, 1963). "Design of a Separable Transition-Diagram Compiler". Communications of the ACM. 6 (7): 396–408. doi:10.1145/366663.366704

Los coroutines no son un concepto nuevo. Han sido adoptados por muchos lenguajes de programación desde 1963. Actualmente, C# utiliza este concepto y crea procesos asincrónicos/de fondo prácticamente del mismo modo. Kotlin adopta este concepto para crear procesos asincrónicos/de fondo livianos, por lo cual son más fáciles de utilizar y leer.

‘Suspend’ (Suspender):

Las funciones con el modificador suspend son funciones regulares que permiten la suspensión de la ejecución de un coroutine. 

El modificador se agrega al principio del signo de la función regular para ejecutar otras funciones de suspensión. Estas funciones se pueden llamar dentro de otras funciones de suspensión o un coroutine.

Estructura: (Como una función regular)

suspend fun getTripBill(billId: String): Bill {
	return service.getTripBill(billId).await()
}

Para este ejemplo, corremos otra función de suspensión: .await(). Esta es la firma de la función ‘await’:

public suspend fun await(): T

 

‘Await’ (Esperar):

Esta función ‘espera’ la finalización del valor computado sin bloquear un hilo. Reinicia la ejecución devolviendo el valor final o tirando la excepción correspondiente si el deferred se cancela o si hay un error.

Estructura: 

Await se debe utilizar si se desea obtener un resultado de la transacción Deferred. Por lo tanto, en nuestro ejemplo, utilizamos:

return service.getTripBill(billId).await()

Esto se debe a que la función getTripBill(billId)devolverá un valor Deferred que contiene nuestro resultado; en este caso, el objeto Bill.

@GET("bills/{billdId}")
fun getTripBill(@Path("billdId") billId: String): Deferred<Bill>

Deferred<T> (Diferido):

Un valor ‘Deferred’ es un futuro cancelable sin bloqueos. Es cancelable porque podemos cancelar el proceso y el futuro ya que obtendremos un valor de él. ¿Pero cómo obtenemos un valor de un ‘Deferred’? Lo mencionamos anteriormente: await().

Es como utilizar un Single de RxJava (Single<Bill>) o un Call de Retrofit (Call<Bill>). Se pueden cancelar y/o obtener un valor en el futuro. 

Un Deferred es como un Job porque tiene un ciclo de vida que culmina en su finalización, la diferencia es que un Deferred devolverá un valor por medio de la función await() la cual pertenece EXCLUSIVAMENTE al Deferred.

Estructura: 

@GET("bills/{billdId}")
fun getTripBill(@Path("billdId") billId: String): Deferred<Bill>
 

En este caso, la función getTripBill(..) devolverá un Bill como resultado cuando el servicio da un respuesta. 

Job:

Como vimos en la sección anterior, conceptualmente, un Job es un elemento cancelable con un ciclo de vida que culmina en su finalización. A diferencia de un Deferred, un Job no produce un valor como resultado. 

Estructura: 

En el TaxiTripDetailPresenter, declaramos un Job que nos permite cancelar todos los hijos asociados para prevenir drenajes de memoria a la hora de correr los Coroutines.

private val job = Job()
private val scopeMainThread = CoroutineScope(job + Dispatchers.Main)
private val scopeIO = CoroutineScope(job + Dispatchers.IO)
 

El Job + Dispatchers.Main o Dispatchers.IO son parte de un CoroutineContext que contiene el CoroutineScope  en la implementación de los ‘coroutine builders’ (async, launch), es decir, donde se ejecutará el Coroutine.

CoroutineScope:

Los Coroutines pertenecen a un ‘scope’ (ámbito) que, ojalá, finalizará en vez de correr indefinidamente. 

Estructura: 

En el TaxiTripDetailPresenter, declaramos dos ‘Scopes’:

Uno correrá en el Hilo Principal de Android,

private val scopeMainThread = CoroutineScope(job + Dispatchers.Main)

Y el otro correrá operaciones I/O en un hilo secundario,

private val scopeIO = CoroutineScope(job + Dispatchers.IO)

Como dijimos, el Coroutine Scope debe de finalizar. Debemos considerar el ciclo de vida de nuestra aplicación, como lo hacemos aquí en el TaxiTripDetailPresenter:

fun onDestroy() {
	job.cancel()
}

 

El Job cancelará todos los hijos asociados y liberará la memoria que se utilizó para correr los Coroutines. Si el Job no se cancela, puede resultar en drenajes de memoria porque seguirá pendiente de las actualizaciones de los Coroutines. 

Si se desea ‘launch’ (lanzar) un scope que no está asociado a un ciclo de vida, se puede utilizar GlobalScope. Pero la literatura no lo recomienda:

‘Global Scope’ se utiliza para lanzar coroutines de alto nivel que operan a lo largo de todo el tiempo de vida y que no se cancelan prematuramente. 

El código de una aplicación debería de utilizar un CoroutineScope definido por la aplicación (como en nuestro ejemplo). Utilizar ‘async’ o ‘launch’ en una instancia de GlobalScope es poco aconsejable.

Por lo tanto, que un Coroutine corra a lo largo de toda la vida de una aplicación puede resultar en drenajes de memoria.

Dispatchers o CoroutineDispatcher:

El CoroutineContext, como vimos anteriormente, está compuesto por un Job + Dispatcher:

private val scopeIO = CoroutineScope(job + Dispatchers.IO)

Con el Dispatcher, le estamos indicando al Coroutine en qué hilo correrá.

Los Coroutine Builders (async, launch) reciben un parámetro opcional, un CoroutineContext.

Los Dispatchers disponibles son:

  1. Dispatchers.Default: Se ejecuta en un conjunto compartido de hilos. Por defecto, este dispatcher creará un hilo para cada núcleo de CPU disponible. 
  2. Dispatchers.IO: Diseñados para bloquear tareas I/O, como solicitudes HTTP, procesamiento de imágenes, lectura/escritura de disco, lectura/escritura de una base de datos local. Por defecto, este dispatcher se limita a 64 núcleos de CPU.
  3. Dispatchers.Main: Permite a los Coroutines correr en un el Hilo Principal de UI. Es necesaria una dependencia kotlinx-coroutines-android.
  4. Dispatchers.Unconfined: Este dispatcher no se limita a ningún hilo. Se lanzará en el caller thread y resumirá en la función de suspensión que se invoque. Es apropiado para Coroutines que consumen mucho CPU o que no requieren actualizaciones.


Así que, con respecto a estas líneas en TaxiTripDetailPresenter:

private val scopeMainThread = CoroutineScope(job + Dispatchers.Main)
 

Será utilizado para correr el resultado en el Hilo Principal de Android (UI) luego de obtener el resultado de los servicios.

private val scopeIO = CoroutineScope(job + Dispatchers.IO)
 

Será ejecutado en el conjunto compartido de hilos por una tarea I/O; en este caso, una solicitud de red.

Coroutine Builders:

Async: cuando deseamos ejecutar algo y luego await (esperar) el resultado.

Launch: cuando deseamos ejecutar y olvidar, es decir, no se devuelve un resultado.

En el presenter tenemos:

scopeIO.launch { 		                                    --------------+
	val trip = repository.getTrip(tripId) 						  	      |
	val driver = repository.getTripDriver(trip.userId                     |
	val bill = repository.getTripBill(trip.billId) 				  		  |--IO Scope
	scopeMainThread.launch {		 			------+                   | 
    	view.renderTripDetail(trip, driver, bill) 	  | - UI Scope        |
	} 											------+                   |
} 														    --------------+
 

En nuestro ejemplo, no utilizamos el Coroutine Builder async porque no necesitamos un resultado cuando lanzamos nuestro Coroutine pero será exactamente lo mismo que launch.

Si removemos scopeMainThread.launch (UI Scope), obtendremos una excepción:

java.lang.RuntimeException: No se puede utilizar un hilo principal que no se llame Looper.prepare()

Esto se da porque estamos intentando actualizar/mostrar algo en la UI desde un hilo (IO Scope) que no es un hilo UI.

Finalmente, si se desea utilizar los Coroutines de Android en un proyecto, se deben utilizar estas dependencias:

{last_version}: https://github.com/Kotlin/kotlinx.coroutines
dependencies {
	implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:{last_version}'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:{last_version}'
}

 

Referencias:

https://en.wikipedia.org/wiki/Coroutine

https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/

https://github.com/Kotlin/kotlinx.coroutines/tree/master/docs

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines.experimental/index.html

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-global-scope/

 

Contáctenos

Contenido

Categorías

Compartir Artículo

Artículos Destacados