Saltar al contenido
También puedes escuchar este post en audio, ¡dale al play!
Hace un par de años, cuando dejé Java y empecé a usar Kotlin, me di cuenta de lo impresionante y potente que es. Incluye muchas características adicionales que simplifican la escritura de nuestro código. Una de esas características es Coroutine, fue añadida desde el lenguaje Kotlin 1.3 que simplifica el código asíncrono.

A través de este blog tendremos una visión general de:
 
  • Definición de Coroutines
  • Por qué elegir Coroutines sobre otros métodos
  • Conceptos de Coroutines
  • Flujos en Coroutines
Y finalmente, veremos cómo usamos Coroutines en una aplicación móvil real usando la Arquitectura limpia + Retrofit.

Como desarrollador de software móvil, es bastante común realizar tareas asíncronas como hacer una llamada a la API y luego esperar el resultado del backend, obtener datos de la base de datos local o cualquier tarea de larga duración. En la mayoría de los lenguajes de programación, escribir código asíncrono es una especie de dolor de cabeza. En este blog, vamos a aprender a lidiar con el código que se ejecuta de forma asíncrona mediante el uso de Coroutines en Kotlin.

image-000.png


Ya tenemos algunas herramientas para manejar la programación asíncrona como Callbacks, RxJava, AsyncTask y Threads así que, ¿por qué usar Coroutines?

Básicamente empezamos a manejarlo usando el mecanismo de Callback, que ayuda a ejecutar una función después de que otra haya terminado. Y si hay una serie de lógica que hacer entonces nos enfrentaremos a un infierno de callbacks. Créeme, a medida que tu proyecto crece, eso podría llevarnos a una ambigüedad en la comprensión del código.

Otra alternativa es RxJava pero la curva de aprendizaje de RxJava es también demasiado. AsyncTask puede introducir fácilmente fugas de memoria en nuestra aplicación.

Aquí es donde entran en juego las Coroutines. Simplemente se dicen hilos ligeros. Las Coroutines nos proporcionan una forma fácil de hacer programación sincrónica y asincrónica.

De la documentación de Kotlin:

"Se puede pensar en una corutina como un hilo ligero. Al igual que los hilos, las coroutinas pueden ejecutarse en paralelo, esperarse unas a otras y comunicarse. La mayor diferencia es que las coroutinas son muy baratas, casi gratis: podemos crear miles de ellas, y pagar muy poco en términos de rendimiento. Los hilos reales, en cambio, son caros de iniciar y mantener. Mil hilos pueden ser un serio desafío para una máquina moderna".

image-001.png

En otras palabras:

  • Cada Coroutine es una pequeña unidad de ejecución asignada a un hilo
  • No hay limitación, podemos iniciar tantas Coroutines como queramos
  • Podemos iniciar y detener Coroutines en cualquier momento
  • Podemos tener Coroutines hijos dentro de cada Coroutine
  • Cada Coroutine puede ser iniciada en un Scope específico
  • Ámbito de la Coroutina

Crea, ejecuta y mantiene un seguimiento de todas sus coroutines. También proporciona eventos del ciclo de vida como el inicio y la pausa de una coroutina.

image-002.png

El retardo es una función especial de suspensión. Suspende la Coroutine durante un tiempo específico. Suspender una Coroutine no bloquea el hilo subyacente, pero permite que otras Coroutines se ejecuten y utilicen el hilo subyacente para su código. Veremos más detalles sobre las funciones de suspensión en la siguiente sección.

Este es el resultado cuando ejecutamos el código anterior:

image-003.png
 
  1. runBlocking { .. } : crea una Coroutine de forma bloqueante. Bloqueará el hilo principal o el hilo en el que se utilice. En el ejemplo anterior la impresión "La ejecución del programa continuará ahora" se ejecutará después de que el bloque runBlocking se complete.
  2. GlobalScope.lauch{ .. } : crea una nueva Coroutine, el ámbito será el ciclo de vida de la aplicación.
  3. coroutineScope { .. } : Crea un nuevo ámbito personalizado y no se completa hasta que todos los Coroutines hijos se completan;
  • Si el padre se cancela, todos los hijos se cancelan.
  • El padre siempre esperará a que se completen sus hijos.

No recomiendo el uso de GlobalScope porque el padre no va a esperar la finalización de sus hijos y una vez que el padre es cancelado, los otros trabajos van a seguir corriendo aparte. Es decir, ahora es responsabilidad del desarrollador llevar el control del tiempo de vida de las coroutines porque no hay sincronización con los trabajos hijos.

 

Funciones de suspensión

Las funciones de suspensión son como la columna vertebral de las Coroutinas. Así que es realmente importante entender completamente este concepto antes de avanzar.

image-004.png

Una función de suspensión es simplemente una función que puede ser pausada y reanudada en un momento posterior. Pueden ejecutar una operación de larga duración y esperar a que se complete sin bloquearse. La sintaxis de una función de suspensión es similar a la de una función regular, excepto por la adición de la palabra clave suspender.

Tenga en cuenta que las funciones de suspensión se sincronizan automáticamente con otras variables y funciones del hilo principal.

image-005.png

Este es el resultado cuando ejecutamos el código anterior:

image-006.png

El Contexto

Las coroutines siempre se ejecutan en algún contexto que está representado por un valor del tipo CoroutineContext.
 

image-007.png


El contexto de Coroutine es un conjunto de varios elementos. Los elementos principales son el
Job de la Coroutine, su dispatcher y también su Exception handler.
 

El Job

De acuerdo con la documentación oficial "Un Job es una cosa cancelable con un ciclo de vida que culmina con su finalización. Los trabajos de coroutine se crean con launch coroutine builder. Ejecuta un bloque de código especificado y se completa a la finalización de este bloque". 
 
Estas son algunas de las propiedades del Job:
 
  • Se crea con el constructor de coroutinas "launch". Ejecuta un bloque de código especificado y se completa al finalizar este bloque;
  • Una vez creado, el trabajo se inicia automáticamente;
  • Nos permite manipular el ciclo de vida de la coroutina;
  • Tienen jerarquía, podemos tener trabajos padres e hijos;
  • Un trabajo se cancela mediante la función cancel();
  • Si un trabajo es cancelado, todos sus padres e hijos serán cancelados también;
  • La ejecución de un trabajo no produce un valor de resultado. Deberíamos utilizar una interfaz Deferred para un trabajo que produzca un resultado. 

image-008.png

Este es el resultado cuando ejecutamos el código anterior:

image-009.png

¿Cómo podemos recuperar el valor de un trabajo al final de la
ejecución?

Como hemos mencionado antes, usaremos Deferred que es un Job con un resultado. Esperará y bloqueará el hilo actual hasta que recuperemos el resultado.

Básicamente se crea con el constructor async Coroutine y el resultado se puede recuperar con el método await(), que lanza una excepción si el Deferred ha fallado.

Aquí hay un ejemplo de su uso:

image-010.png

Este es el resultado cuando ejecutamos el código anterior:

image-011.png

El Dispatcher

En Kotlin, todas las Coroutines deben ejecutarse en un dispatcher incluso cuando se ejecutan en el hilo principal. Las coroutines pueden suspenderse a sí mismas, y el dispatcher es el que sabe cómo reanudarlas.

Para especificar dónde deben ejecutarse las coroutines, Kotlin proporciona tres Dispatchers
que puedes utilizar:
 
  • Dispatchers.Main : Hilo principal en Android, interactúa con la UI y realiza trabajos ligeros
➢ Llamar a funciones de suspensión
➢ Llamar a funciones de la interfaz de usuario
➢ Actualizar LiveData
 
  • Dispatchers.IO : Optimizado para la E/S de disco y red fuera del hilo principal
➢ Solicitudes de bases de datos;
➢ Lectura/escritura de archivos;
➢ Conexión en red.
 
  • Dispatchers.Default : Optimizado para el trabajo intensivo de la CPU fuera del hilo principal
➢ Ordenar una lista
➢ Análisis de JSON
➢ Utilidades

Pero espera, ¿qué pasa si iniciamos una Coroutine usando Dispatchers.Main y queremos
hacer otra acción usando Dispatchers.IO?
 

¿Cómo podemos cambiar el contexto de una Coroutine?

Esto se puede hacer fácilmente utilizando la función suspender conContexto(Despachador). Permite fácilmente cambiar el contexto, iniciar el ámbito de una Coroutine y cambiar entre despachadores.

Este es un ejemplo de cómo podemos usarlo para cambiar del dispatcher Default al dispatcher IO:


image-012.png

Manejador de excepciones

Manejar las excepciones de forma adecuada tiene un gran impacto en cómo los usuarios perciben nuestra aplicación. Si tu aplicación sigue fallando, el usuario se sentirá decepcionado y puede que no vuelva a utilizarla.

Para este objetivo, tenemos dos opciones:
 
  • Usar try/catch
  • Usar CoroutineExceptionHandler
El CoroutineExceptionHandler es un elemento opcional de un CoroutineContext que nos permite manejar excepciones no capturadas.

Así es como podemos definir un CoroutineExceptionHandler, cada vez que se atrapa una excepción, tiene información sobre el CoroutineContext donde ocurrió la excepción y la propia excepción.

image-013.png

Este es el resultado cuando ejecutamos el código anterior:

image-014.png

Cuando la Coroutine falla con una Excepción, propagará esta excepción a su
padre. Entonces el padre:
 
  • Cancela el resto de sus hijos
  • Se cancelará a sí misma
  • Propagar la excepción a su padre hasta la raíz de la jerarquía y todas las Coroutines que ya han comenzado en el CoroutineScope serán canceladas también

image-015.png

Este enfoque no siempre es una buena idea. Por ejemplo tenemos la función init() que inicializa las interacciones del usuario con los componentes de la UI usando un CoroutineScope. Imagina que si una coroutina hija falla, el scope se cancelará automáticamente y la UI dejará de responder porque todo el scope se cancela.

Para solucionar esto podemos usar SupervisorJob que es una implementación diferente de un Job.

image-016.png
Podemos crear un CoroutineScope utilizando uno de los siguientes métodos:
 
  • val uiScope = CoroutineScope(SupervisorJob())
  • supervisorScope { .. }
 

Flujos en Coroutines

Otra cosa interesante de las Coroutines es la posibilidad de utilizar Flows. Se trata de un flujo de valores que se calculan de forma asíncrona desde una Coroutine.
 
  • Los Flows emiten valores con la función emit()
  • Los Flows reciben los valores con la función collect()
  • La función constructora de Flows es la función flow{..}
  • Un flujo se cancela cuando la coroutina se cancela
  • Los flujos son flujos fríos, en otras palabras, el código dentro de un constructor de flujo no se ejecuta hasta que el flujo se recoge
En este ejemplo intentaremos emitir los valores de una lista de números. Cada vez que emitamos un valor, suspenderemos la corutina por un retardo específico usando la función delay:

image-017.png

Este es el resultado cuando ejecutamos el código anterior:

image-018.png
  • Una lista también puede convertirse en un flujo utilizando la función asFlow():
image-019.png
 
  • Podemos crear un flujo directamente a partir de cualquier tipo de objetos utilizando la función flowOf():

image-020.png

 

Operadores de flujo


Flow tiene un montón de operadores interesantes para facilitarnos la codificación:
 
  • Map: mapea un flujo a otro flujo
En este ejemplo vamos a mapear un flujo de Int a otro flujo de String. Cada elemento de la lista se transformará en el siguiente String: "mapeo (valor del número de la lista)".

image-021.png

Aquí está el resultado impreso:

image-022.png
  • Filtro: Filtra los valores de flujo con una condición específica

En este ejemplo tomaremos un flujo de Int y lo convertiremos en un flujo filtrado que sólo contenga valores pares:

image-023.png

Aquí está el resultado impreso:

image-024.png
  • Transformar: Operador de transformación general que puede emitir cualquier valor en cualquier punto.
En lugar de emitir sólo un tipo, podemos utilizar el operador transform para emitir también otros valores de cualquier tipo. En el siguiente ejemplo tendremos un Flujo Int, entonces emitiremos cada vez una Cadena "Emitiendo valor de cadena (valor del número)" seguido del propio número al mismo tiempo:

image-025.png

Aquí está el resultado impreso: 

image-026.png
 
  • Tomar: Utiliza sólo un número de valores, no tiene en cuenta el resto de los valores emitidos

En el siguiente ejemplo tenemos un flujo de Int de 1 a 10. Utilizando el operador take, podremos tomar sólo los dos primeros elementos.
 
image-027.png

Aquí está el resultado impreso: 

image-028.png
 
  • toList: convierte un flujo en una lista
  • toSet: convierte un flujo en un conjunto que sólo tiene valores únicos (sin valores duplicados)
  • flowOn: nos permite cambiar el contexto del flujo
En el siguiente ejemplo, utilizaremos flowOn para establecer el contexto actual de
a Dispatchers.IO:

image-029.png


Coroutines en una aplicación Android real

En el desarrollo de Android, básicamente estamos haciendo una petición de red y devolviendo el resultado al hilo principal, donde la aplicación puede entonces mostrar el resultado al usuario.

Usando la arquitectura limpia, el componente de la arquitectura ViewModel llama a la capa de casos de uso en el hilo principal para lanzar la petición de red desde la capa de repositorio. Nuestro objetivo es utilizar coroutines para mantener el hilo principal desbloqueado.

En el siguiente ejemplo veremos cómo podemos utilizar Coroutines en una Arquitectura Limpia con Retrofit:

image-030.png

  • He creado una aplicación de demostración que busca artistas por su nombre. Una vez que se encuentra el artista se puede hacer clic en él para ver sus álbumes que se puede hacer clic en para mostrar todos los detalles sobre el mismo. No dude en ponerse en contacto conmigo si desea obtener el código fuente completo.

image-032.jpgimage-033.jpgimage-034.jpg

En esta sección sólo nos centraremos en obtener los detalles del disco.
 
  • Antes de comenzar nuestro proyecto debe contener las siguientes dependencias para tener soporte de Coroutine:
image-035.png
  • Result.kt es la envoltura de todas las llamadas a la red en la Aplicación. El resultado puede ser un valor, un error o un dato vacío:
image-036.png
  • Capa de origen de datos: MusicApiService.kt
Comencemos con la Fuente de Datos donde Retrofit se utiliza principalmente para las solicitudes de Red. Desde la versión 2.6.0, Retrofit tiene ahora soporte para Coroutines y hace que las llamadas a la API sean mucho más fáciles.

La función suspender para obtener los detalles de los álbumes se escribe brevemente así con la palabra clave suspender:

image-037.png
  • Capa del repositorio : MusicRepositoryImpl.kt
Desde nuestro repositorio, se llamará a la función de suspensión que obtiene los detalles del álbum y mapeamos el resultado recuperado a un Objeto Álbum.

image-038.png

BaseDataSource.getResult() es sólo una envoltura para cualquier llamada a la API en la aplicación.

Como podemos ver, creamos un flujo de NetworkResponse<Album>. Dentro de él llamamos a la función suspender dataSource para obtener los detalles del Álbum. Luego mapeamos el resultado recuperado a un objeto Album.

Finalmente emitimos esta respuesta a través de nuestro flujo.
 
  • Capa de caso de uso : GetAlbumDetailsUseCase.kt
En esta capa recuperamos el flujo de la capa Repositorio y lo mapeamos a otro flujo de Objeto Resultado. En esta capa tenemos todas las manos en el resultado recuperado y en este punto donde podemos hacer cualquier cambio en él antes de entregarlo a la viewModel.

image-039.png
  • Capa de ViewModel : GetAlbumDetailsViewModel.kt

El componente ViewModel tiene un conjunto de extensiones KTX que trabajan directamente con
coroutines. Se llama "lifecycle-viewmodel-ktx".

image-040.png

Se define un ViewModelScope para cada ViewModel. Lo bueno es que no necesitamos borrar las coroutines creadas dentro de nuestro ViewModel. ViewModelScope hace todo el trabajo por nosotros. Cualquier Coroutine lanzada en este ámbito se cancela automáticamente si se borra el ViewModel evitando cualquier consumo extra de recursos.

Este es el aspecto de nuestro ViewModel:

image-041.png

Comenzamos cancelando el trabajo actual e iniciamos uno nuevo utilizando Dispatcher.IO para las llamadas a la API. Si el nombre del artista y el nombre del álbum no están correctamente configurados, publicamos una respuesta vacía a través del liveData del álbum. En caso contrario, llamamos a nuestra capa useCase y recogemos los valores del flujo mediante la función collect. Por último, publicamos los detalles del álbum a través del liveData del álbum.
 
  • Ver capa : SearchAlbumDetailsFragment.kt

Desde nuestra Vista, observaremos los objetos Livedata de la capa ViewModel.

image-042.png

Una vez que el usuario llega a la pantalla de detalles del Álbum, el viewModel se activará utilizando esta función:

image-043.png

Entonces la vista actualizará la UI dependiendo del resultado del liveData.

image-044.png

Esto es todo, espero que hayas disfrutado de este blog. No dudes en ponerte en contacto conmigo si tienes alguna pregunta o para tener acceso al código fuente completo. 

¡Feliz codificación!
Othmane  Lamrani
Othmane Lamrani Perfil en Linkedin

A Moroccan Software Engineer graduated from the National School of Applied Sciences in Kenitra. My passion is programming, especially developing mobile applications. I love discovering new technologies and working with the latest best practices to make my work scalable and maintainable for my Team. I also love sharing my knowledge with the other developers and learning from them too.

Más post de Othmane Lamrani