Corrutinas, concurrencia y operaciones asíncronas en Android
Kotlin Coroutines, suspensión, concurrencia estructurada, cancelación y dispatchers
Introducción
Una aplicación móvil debe responder a los gestos del usuario mientras carga información, espera una respuesta de red o ejecuta operaciones que requieren tiempo. Si estas operaciones bloquean el hilo principal, la interfaz deja de actualizarse y puede dejar de responder. Kotlin ofrece corrutinas para expresar trabajo asíncrono con un estilo de código secuencial y mecanismos explícitos para controlar su ejecución, sus resultados y su cancelación.
Esta guía estudia las funciones de suspensión, la diferencia entre secuencialidad y concurrencia, las herramientas launch, async y coroutineScope, la relación de las corrutinas con Jetpack Compose y ViewModel, y la selección de ámbitos y dispatchers. Los ejemplos representan tareas genéricas, consultas y componentes de interfaz que pueden adaptarse a diferentes aplicaciones Android.
El objetivo es interpretar qué hace el código, cuándo se ejecuta cada instrucción, cuál es el ciclo de vida del trabajo y qué errores aparecen cuando se confunden suspensión, concurrencia y paralelismo.
Resultados de aprendizaje
Al estudiar el material, se espera que puedas:
- Distinguir suspensión y bloqueo para explicar por qué determinadas operaciones afectan la respuesta de la interfaz.
- Interpretar el orden de ejecución de funciones
suspend,launch,asyncyawaitpara estimar el comportamiento temporal del programa. - Aplicar concurrencia estructurada para coordinar tareas relacionadas y esperar su finalización.
- Reconocer los mecanismos de cancelación cooperativa y su relación con el ciclo de vida de la interfaz.
- Seleccionar ámbitos y dispatchers según la responsabilidad, la duración y el tipo de trabajo.
Lo imprescindible para el examen
Diferenciar suspensión, bloqueo y concurrencia; interpretar launch, async y await; elegir ámbitos y dispatchers; explicar cancelación y concurrencia estructurada.
Se evalúa la capacidad de explicar, interpretar fragmentos y justificar decisiones. Las versiones puntuales de librerías, URLs, nombres de proyecto y comandos accesorios quedan como contexto de consulta.
Criterios transversales para interpretar esta guía
Principio transferible. Conviene distinguir la responsabilidad de una tecnología de la forma concreta en que se utilizó en la práctica. Un ejemplo de ViewModel, Retrofit, Hilt o WebSocket ilustra una solución posible; su configuración exacta no es una regla universal.
Decisión del proyecto. Los nombres, rutas, versiones, intervalos, tamaños y elecciones de librerías del caso original sirven para interpretar el código. Salvo indicación expresa, no se exige memorizarlos para el examen.
Alcance del código. Los fragmentos son recursos didácticos; pueden omitir imports, dependencias, permisos, detalles de manejo de errores o infraestructura. Comprender un fragmento no equivale a comprobar que compila como proyecto independiente.
Referencias cruzadas. La síntesis transversal de la colección reúne las distinciones entre estado y evento, persistencia, reintentos, idempotencia y límites de las pruebas.
1. Trabajo síncrono, hilos y suspensión
1.1 Por qué importa el hilo principal
En Android, el hilo principal procesa interacciones del usuario y trabajo relacionado con la interfaz. Cuando una operación larga ocupa ese hilo sin ceder el control, otros eventos deben esperar. El resultado puede ser una pantalla que no responde o que tarda en actualizarse.
Un hilo es un recurso de ejecución del sistema operativo. Una corrutina es una unidad de trabajo que puede suspenderse y reanudarse sin mantener necesariamente ocupado un hilo durante la espera. Las corrutinas se ejecutan sobre hilos, pero no necesitan disponer de uno exclusivo durante toda su existencia.
1.2 delay() frente a Thread.sleep()
import kotlinx.coroutines.delay
suspend fun esperarRespuesta() {
delay(1000)
println("Espera finalizada")
}
delay(1000) suspende la corrutina aproximadamente un segundo sin bloquear el hilo mientras espera. En cambio, Thread.sleep(1000) bloquea el hilo desde el que se invoca. Si ese hilo es el principal de Android, se impide temporalmente que procese otros eventos de la interfaz.
| Mecanismo | Qué espera | ¿Bloquea el hilo durante la espera? | Uso típico |
|---|---|---|---|
delay(1000) |
La corrutina | No | Simulación de espera, temporización asíncrona |
Thread.sleep(1000) |
El hilo actual | Sí | Código bloqueante fuera del hilo principal, en casos justificados |
Importante: que una función tenga el modificador suspend no garantiza que su implementación sea no bloqueante. Si ejecuta un cálculo prolongado o invoca una API bloqueante sin cambiar a un dispatcher adecuado, puede seguir ocupando el hilo principal.
1.3 runBlocking() y el contexto de los ejemplos
Para ejecutar pequeños ejemplos desde una función main() convencional puede utilizarse:
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("Inicio")
delay(1000)
println("Fin")
}
runBlocking permite acceder a funciones de suspensión desde código convencional, pero bloquea el hilo que lo invoca hasta terminar. Resulta útil en ejemplos de consola y en determinados contextos de prueba; no debe utilizarse en el hilo principal para ejecutar operaciones de una aplicación Android. Más adelante se presentan los ámbitos adecuados para Compose y ViewModel.
2. Funciones suspend y ejecución secuencial
Una función declarada con suspend puede invocar otras funciones de suspensión. El modificador indica que su ejecución puede suspenderse; no crea por sí mismo otra corrutina ni implica concurrencia.
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
suspend fun consultarPerfil(): String {
delay(1000)
return "Perfil"
}
suspend fun consultarPreferencias(): String {
delay(1000)
return "Preferencias"
}
fun main() = runBlocking {
val perfil = consultarPerfil()
val preferencias = consultarPreferencias()
println("$perfil y $preferencias")
}
Las consultas se realizan una después de la otra. En este ejemplo, como cada función tarda aproximadamente un segundo, la operación completa tarda alrededor de dos segundos. La segunda función empieza después de que la primera devuelve su resultado.
Una función suspend suele llamarse desde otra función de suspensión o desde el bloque de una corrutina. El código de un controlador de clic convencional, por ejemplo, no puede llamar directamente a delay() sin iniciar o disponer de una corrutina.
Principio: suspensión y concurrencia son conceptos diferentes. El orden de las llamadas sigue siendo secuencial mientras no se inicie trabajo concurrente.
3. launch, Job y ejecución concurrente
launch inicia una nueva corrutina dentro de un CoroutineScope y devuelve un Job, que permite observar o cancelar ese trabajo. El bloque que invoca launch puede seguir ejecutando sus instrucciones sin esperar explícitamente la finalización de la nueva corrutina.
import kotlinx.coroutines.*
fun main() = runBlocking {
println("A. Inicio")
val primera: Job = launch {
delay(1000)
println("C. Primera consulta terminada")
}
val segunda: Job = launch {
delay(1000)
println("D. Segunda consulta terminada")
}
println("B. Consultas iniciadas")
}
En este ejemplo, B aparece antes de los mensajes que siguen a los delay, y las dos consultas pueden progresar concurrentemente. Su duración total es cercana a un segundo, con una pequeña sobrecarga. El orden entre C y D no debe asumirse como una garantía general.
3.1 Concurrencia no equivale a paralelismo
- Secuencialidad: una operación termina antes de que comience la siguiente.
- Concurrencia: varias operaciones están en curso durante intervalos que se superponen y pueden alternar su ejecución.
- Paralelismo: diferentes operaciones se ejecutan físicamente al mismo tiempo, normalmente en distintos núcleos o hilos.
Dos corrutinas que utilizan delay() pueden progresar concurrentemente aunque se reanuden en un solo hilo. launch no garantiza ejecución paralela: esto depende del dispatcher y de los recursos disponibles.
3.2 Job.join()
launch devuelve un Job, pero el trabajo lanzado no tiene por qué producir un resultado de tipo T. Cuando se necesita esperar su finalización puede utilizarse join():
suspend fun sincronizar(): Unit = coroutineScope {
val tarea = launch { delay(500) }
tarea.join()
println("Sincronización completada")
}
join() suspende hasta que termina el trabajo correspondiente; no bloquea el hilo mientras espera. La diferencia entre llamar a launch y esperar con join es esencial al interpretar el orden de los mensajes y las actualizaciones de pantalla.
4. async, Deferred<T> y await()
Cuando una operación concurrente debe producir un valor, puede utilizarse async. Este constructor devuelve un Deferred<T>, un trabajo que contiene un resultado futuro. La función await() suspende a quien la llama hasta que el resultado está disponible, o propaga el fallo correspondiente.
import kotlinx.coroutines.*
suspend fun cargarInicio(): String = coroutineScope {
val perfil = async { consultarPerfil() }
val preferencias = async { consultarPreferencias() }
"${perfil.await()} y ${preferencias.await()}"
}
Ambos trabajos se inician antes del primer await(). Si cada consulta tiene una espera independiente de un segundo, el tiempo conjunto será aproximadamente un segundo.
4.1 Ubicación de await() y tiempo total
Versión secuencial por la ubicación de la espera:
suspend fun cargaSecuencial(): String = coroutineScope {
val perfil = async { consultarPerfil() }.await()
val preferencias = async { consultarPreferencias() }.await()
"$perfil y $preferencias"
}
Aunque aparecen dos llamadas a async, la segunda tarea no se crea hasta que ha terminado la primera. El tiempo esperado será aproximadamente dos segundos.
Versión concurrente:
suspend fun cargaConcurrente(): String = coroutineScope {
val perfil = async { consultarPerfil() }
val preferencias = async { consultarPreferencias() }
"${perfil.await()} y ${preferencias.await()}"
}
El tiempo esperado será aproximadamente un segundo. Estas duraciones son ilustrativas, no compromisos exactos de planificación del sistema.
| Herramienta | Retorno | Finalidad principal | Cómo esperar |
|---|---|---|---|
launch |
Job |
Ejecutar un trabajo que no necesita devolver un valor | join() |
async |
Deferred<T> |
Ejecutar un trabajo que producirá un resultado | await() |
En ambos casos, el trabajo puede fallar o ser cancelado. async no debe verse como una forma automática de acelerar cualquier función: solo aporta ventajas de tiempo cuando las operaciones pueden desarrollarse concurrentemente y la coordinación está bien planteada.
5. Concurrencia estructurada y coroutineScope
En la concurrencia estructurada, el trabajo concurrente se organiza dentro de ámbitos con relaciones de responsabilidad y ciclo de vida. Un ámbito espera a sus tareas hijas y coordina su cancelación y sus fallos.
suspend fun obtenerResumen(): String = coroutineScope {
val perfil = async { consultarPerfil() }
val preferencias = async { consultarPreferencias() }
val resultado = "${perfil.await()} / ${preferencias.await()}"
resultado
}
La función coroutineScope suspende a su llamador hasta que su bloque y todas las corrutinas hijas han terminado. Por eso obtenerResumen() ofrece una sola operación coherente a quien la invoca, aunque internamente coordine varias tareas.
5.1 Un error de coordinación frecuente
suspend fun ejecutarImportaciones() = coroutineScope {
launch { importarProductos() }
launch { importarPrecios() }
println("Importaciones terminadas") // Mensaje prematuro
}
launch devuelve el control de inmediato, por lo que el mensaje puede imprimirse antes de que las dos importaciones finalicen. El hecho de que coroutineScope espere a sus hijas al salir del bloque no retrasa automáticamente las instrucciones que ya están dentro.
Una solución es añadir un coroutineScope interior que espere antes de continuar:
suspend fun ejecutarImportaciones() = coroutineScope {
coroutineScope {
launch { importarProductos() }
launch { importarPrecios() }
}
println("Importaciones terminadas")
}
Otra alternativa consiste en conservar los Job y hacer join() en ambos antes de mostrar el mensaje. importarProductos() e importarPrecios() representan funciones suspend de la aplicación.
5.2 Errores y tareas hermanas
Con el coroutineScope convencional, si una hija falla con una excepción distinta de una cancelación normal, el ámbito falla y cancela las demás tareas hijas. Es una propiedad útil cuando todas participan en una operación que debe completarse como un conjunto. Existen ámbitos de supervisión para otros requisitos, pero no forman parte del desarrollo principal de esta guía.
6. Cancelación cooperativa de corrutinas
Las corrutinas pueden cancelarse por decisión del programa o cuando termina el ámbito del que dependen. La cancelación es cooperativa: el trabajo debe llegar a puntos que respeten esa señal, como llamadas a funciones suspendidas cancelables, o comprobar explícitamente su estado.
6.1 Cancelar una operación
import kotlinx.coroutines.*
suspend fun demostracionCancelacion() = coroutineScope {
val trabajo = launch {
repeat(10) { paso ->
delay(200)
println("Paso $paso")
}
}
delay(650)
trabajo.cancelAndJoin()
println("Trabajo detenido")
}
cancelAndJoin() solicita la cancelación y espera la terminación. Como el bucle contiene delay, la señal puede atenderse sin bloquear el hilo. El número de pasos impresos depende de la planificación y no debe interpretarse como un valor estrictamente garantizado.
6.2 CancellationException y manejo de errores
La cancelación habitual de una corrutina se comunica mediante CancellationException. Esta excepción forma parte del mecanismo de control, y no debe tratarse como un error funcional ordinario.
suspend fun cargarDatos() {
try {
delay(1000)
println("Datos disponibles")
} catch (e: kotlinx.coroutines.CancellationException) {
// Liberar recursos o registrar la cancelación, si hace falta.
throw e
}
}
Si se captura una excepción general, debe evitarse consumir silenciosamente una CancellationException:
try {
ejecutarOperacion()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
// Procesar o comunicar el error real.
}
En muchos casos no se necesita ningún catch para la cancelación: basta con permitir que se propague. Un bloque finally puede utilizarse para limpieza. Capturar una excepción de cancelación sin relanzarla puede producir resultados incoherentes o permitir que se ejecuten instrucciones posteriores que ya no deberían realizarse; no significa necesariamente que la tarea siga ejecutándose indefinidamente.
6.3 Cuando no hay puntos de suspensión
Un bucle de cálculo intensivo que nunca suspende puede ignorar temporalmente la solicitud de cancelación. Para esos casos existen comprobaciones como ensureActive() o isActive, según el contexto. No conviene confiar solo en Job.cancel() si el código realiza trabajo de CPU sin interrupciones cooperativas.
6.4 Cancelación y conservación del estado
Cancelar una corrutina y reiniciar el estado son operaciones distintas. Una descarga o una tarea de procesamiento puede pausarse o cancelarse sin que la interfaz borre necesariamente la información que ya mostraba. La decisión depende del modelo de estado de la pantalla y de si la operación admite una reanudación real.
7. Corrutinas y Jetpack Compose
Las funciones @Composable describen la interfaz y pueden ejecutarse repetidamente durante las recomposiciones. Por ello, iniciar trabajo asíncrono directamente desde el cuerpo de un Composable sin utilizar una API de efectos puede generar ejecuciones repetidas no deseadas.
7.1 LaunchedEffect
LaunchedEffect inicia una corrutina vinculada a una instancia del efecto dentro de la composición. Se cancela cuando el efecto abandona la composición y se reinicia cuando cambian sus claves.
@Composable
fun MensajeTemporal(mensaje: String) {
var visible by remember { mutableStateOf(true) }
LaunchedEffect(mensaje) {
visible = true
delay(2000)
visible = false
}
if (visible) {
Text(mensaje)
}
}
Al cambiar mensaje, se cancela el trabajo anterior y se inicia uno nuevo para la nueva clave. Una recomposición con la misma clave no reinicia automáticamente el efecto.
Nota: una clave debe representar la identidad del trabajo que se desea repetir; elegir claves inestables puede ocasionar reinicios no previstos.
7.2 Un efecto condicionado por estado
@Composable
fun TemporizadorSimple() {
var activo by remember { mutableStateOf(false) }
var segundos by remember { mutableIntStateOf(0) }
if (activo) {
LaunchedEffect(Unit) {
while (true) {
delay(1000)
segundos++
}
}
}
Button(onClick = { activo = !activo }) {
Text(if (activo) "Pausar: $segundos" else "Continuar: $segundos")
}
}
Al cambiar activo a false, el LaunchedEffect deja de formar parte de la composición y se cancela. El valor de segundos permanece porque su estado pertenece al Composable exterior, que sigue en la composición. Si este Composable desaparece o se recrea en determinadas condiciones, remember no garantiza la conservación del contador. Para preservar el valor ante recreaciones de configuración se puede evaluar rememberSaveable o un ViewModel, como se estudió en la guía anterior.
7.3 rememberCoroutineScope() para eventos
Cuando un evento puntual debe lanzar trabajo, rememberCoroutineScope() proporciona un ámbito relacionado con la composición:
@Composable
fun BotonDeNotificacion() {
val scope = rememberCoroutineScope()
var texto by remember { mutableStateOf("Listo") }
Button(onClick = {
scope.launch {
texto = "Procesando"
delay(500)
texto = "Completado"
}
}) {
Text(texto)
}
}
En este caso launch se invoca desde el controlador del evento, que no es por sí mismo una función suspend. Cuando el Composable abandona la composición, el ámbito recordado se cancela.
Criterio de selección: LaunchedEffect es adecuado para trabajo que debe iniciarse o reiniciarse como consecuencia de la presencia del componente y de sus claves. rememberCoroutineScope() sirve para iniciar trabajo desde eventos de interfaz. Para cargas y operaciones que pertenecen al estado de pantalla, suele ser más adecuado delegar en un ViewModel.
8. Ámbitos y ciclo de vida
Un CoroutineScope define el contexto en el que se inician y organizan las corrutinas. Seleccionar el ámbito significa decidir a qué componente pertenece el trabajo y cuándo debe cancelarse.
| Ámbito o herramienta | Duración asociada | Uso habitual |
|---|---|---|
LaunchedEffect |
Mientras su efecto permanece en la composición con las claves correspondientes | Trabajo reactivo a la composición |
rememberCoroutineScope() |
Mientras permanece en composición el punto que creó el ámbito | Trabajos iniciados por eventos de UI |
viewModelScope |
Hasta que el ViewModel se limpia (onCleared) |
Cargas de datos y operaciones de estado de pantalla |
lifecycleScope |
Hasta la destrucción del Lifecycle asociado |
Trabajo vinculado a Activity o Fragment |
runBlocking |
Bloquea hasta que acaba su bloque y su trabajo hijo | Ejemplos de consola y casos de prueba específicos |
8.1 Uso de viewModelScope
class ProductosViewModel(
private val repositorio: ProductosRepository
) : ViewModel() {
var cargando by mutableStateOf(false)
private set
var productos by mutableStateOf<List<Producto>>(emptyList())
private set
fun cargarProductos() {
viewModelScope.launch {
cargando = true
try {
productos = repositorio.obtenerProductos()
} finally {
cargando = false
}
}
}
}
En este ejemplo, Producto y ProductosRepository representan tipos del dominio y de la capa de datos. Se presupone que obtenerProductos() es una función suspend segura para invocarse desde el hilo principal. Para una aplicación real también sería necesario decidir cómo representar y comunicar errores.
La corrutina pertenece al ViewModel. Si se destruye una pantalla pero el ViewModel continúa siendo propiedad de otro ámbito vigente, su trabajo puede continuar. Por ello, la duración no se decide por el nombre del método viewModelScope, sino por quién es el propietario del ViewModel.
Una corrutina del ViewModel no sobrevive por sí misma a la terminación del proceso de la aplicación. Si una tarea debe continuar de forma confiable tras abandonar la app o finalizar el proceso, se requiere un mecanismo de trabajo persistente adecuado, fuera del alcance de esta guía.
9. Dispatchers y seguridad del hilo principal
Un dispatcher determina dónde se programa la ejecución de una corrutina. La elección depende de si se requiere trabajar con la UI, realizar operaciones bloqueantes o ejecutar cálculos intensivos.
| Dispatcher | Aplicación habitual | Ejemplo |
|---|---|---|
Dispatchers.Main |
Estado e interacciones de la interfaz | Actualizar UiState |
Dispatchers.IO |
Trabajo de E/S bloqueante | Leer archivos mediante API bloqueante |
Dispatchers.Default |
Trabajo intensivo de CPU | Procesar grandes colecciones o imágenes |
La función withContext permite ejecutar un bloque utilizando otro contexto y esperar su resultado:
suspend fun calcularEstadisticas(datos: List<Int>): Double =
withContext(Dispatchers.Default) {
if (datos.isEmpty()) 0.0 else datos.average()
}
Aquí el cálculo se realiza en un dispatcher apropiado para CPU. Al terminar, la ejecución vuelve al contexto del llamador, salvo que se haya cancelado o haya ocurrido un fallo.
9.1 suspend no mueve automáticamente el trabajo a IO
Este fragmento puede bloquear el hilo que lo llama si leerArchivoBloqueante() realiza E/S bloqueante:
suspend fun leerContenido(): String {
return leerArchivoBloqueante()
}
Para hacer segura su invocación desde el hilo principal se puede encapsular la operación:
suspend fun leerContenido(): String = withContext(Dispatchers.IO) {
leerArchivoBloqueante()
}
Marcar una función suspend no reemplaza la selección de un dispatcher cuando se utiliza una API bloqueante.
9.2 Bibliotecas que gestionan su propio trabajo asíncrono
Muchas APIs suspendidas de bibliotecas como Room, y las integraciones habituales de Retrofit con funciones suspend, gestionan internamente sus operaciones sin exigir un withContext(Dispatchers.IO) en cada llamada. En esos casos puede invocarse el método suspendido desde viewModelScope, sujeto a las garantías de la API utilizada. Esto no significa que cualquier implementación propia de repositorio sea segura para el hilo principal.
Conexión transversal: alcance y decisiones
Cancelar una operación también libera recursos
La cancelación de una corrutina debe detener el trabajo que depende de ella y liberar lo que abrió cuando corresponda. No se debe absorber inadvertidamente CancellationException en un catch (Exception): el trabajo cancelado no es un fallo de negocio para mostrar o reintentar. En flujos conectados a sockets, la liberación puede requerir awaitClose { fuente.cancel() } (guía 08) o cerrar explícitamente la conexión (guía 12). La existencia de una corrutina no implica que una biblioteca bloqueante respete automáticamente su cancelación.
10. Errores frecuentes y diagnóstico
| Síntoma | Causa probable | Qué revisar |
|---|---|---|
| Una pantalla deja de responder durante una operación | Código bloqueante o cálculo pesado en el hilo principal | Thread.sleep, E/S síncrona, bucles CPU y dispatcher |
| Dos tareas tardan la suma de sus tiempos | Llamadas suspendidas secuenciales o await demasiado temprano |
Inicio de async o launch y puntos de espera |
| El mensaje «terminado» aparece antes de finalizar | launch inicia sin esperar |
join, un ámbito interior o una coordinación explícita |
| Una operación se repite tras recomposiciones | Trabajo lanzado directamente en composición o claves inadecuadas | LaunchedEffect y ciclo de vida de sus claves |
| La pausa/cancelación parece ignorarse | Se consumió CancellationException o el cálculo no coopera |
Propagación de cancelación y puntos de comprobación |
| Un trabajo se cancela al salir de una pantalla | Su ámbito dependía de la composición o del ViewModel de ese destino | Propietario del ámbito y duración necesaria |
Suspend function ... should be called only from a coroutine |
Llamada suspend desde contexto no suspendido |
Iniciar la operación en un ámbito pertinente |
Unresolved reference: launch |
Falta un import o el contexto de CoroutineScope |
Dependencia de corrutinas, import y receptor |
| El contador no se recompone | El valor no es estado observable por Compose | mutableIntStateOf, mutableStateOf y lecturas de estado |
11. Vocabulario esencial
| Concepto | Significado |
|---|---|
| Corrutina | Unidad de trabajo que puede suspenderse y reanudarse en un contexto de ejecución |
suspend |
Modificador de una función que puede invocar operaciones de suspensión |
| Suspensión | Pausa de una corrutina sin bloquear necesariamente el hilo |
| Bloqueo | Ocupación de un hilo mientras espera o realiza trabajo |
| Concurrencia | Progreso de varias tareas durante periodos superpuestos |
| Paralelismo | Ejecución simultánea real de tareas |
CoroutineScope |
Contexto de organización y ciclo de vida del trabajo concurrente |
Job |
Representación de un trabajo cancelable y esperable |
Deferred<T> |
Job que produce un resultado de tipo T |
await() |
Espera suspendida del resultado de un Deferred |
join() |
Espera suspendida de la finalización de un Job |
| Concurrencia estructurada | Organización del trabajo hijo dentro de ámbitos con espera y cancelación coordinadas |
| Dispatcher | Política de ejecución que determina los hilos disponibles para el trabajo |
| Cancelación cooperativa | Cancelación que requiere alcanzar puntos suspendibles o comprobar la señal |
LaunchedEffect |
Efecto de Compose que ejecuta una corrutina vinculada a claves y composición |
| Main-safe | Propiedad de una función que se puede invocar desde el hilo principal sin bloquearlo indebidamente |
12. Preguntas de autoevaluación
Comprensión conceptual
- ¿En qué se diferencian una corrutina y un hilo? Explica por qué
delay(1000)yThread.sleep(1000)no tienen el mismo efecto sobre Android. - ¿Qué garantiza el modificador
suspendy qué no garantiza sobre concurrencia, paralelismo y cambio de hilo? - Compara
launchyasyncen términos de valor devuelto, espera y casos de uso. - ¿Qué es la concurrencia estructurada y por qué un ámbito debe esperar a sus tareas hijas?
- ¿En qué se diferencian
LaunchedEffect,rememberCoroutineScope()yviewModelScoperespecto al inicio y duración del trabajo? - ¿Qué significa que una función suspendida sea main-safe? ¿Toda función
suspendlo es?
Interpretación de código
-
Supón que
a()yb()son funciones suspendidas, cada una con un únicodelay(1000). ¿Cuánto tarda este fragmento, aproximadamente, y por qué?coroutineScope { a() b() } -
¿Qué cambia respecto a la pregunta anterior?
coroutineScope { val uno = async { a() } val dos = async { b() } uno.await() dos.await() } -
¿Por qué
println("Listo")puede ejecutarse antes de la finalización de las tareas?coroutineScope { launch { a() } launch { b() } println("Listo") } -
Un estudiante escribe
async { a() }.await()antes de iniciarasync { b() }. ¿Qué efecto tiene sobre la concurrencia? -
Se modifica el valor de una clave de
LaunchedEffect(clave). ¿Qué sucede con el trabajo del efecto anterior? -
Una corrutina ejecuta un
while (true)con cálculos de CPU y sin llamadas suspendidas. ¿Por quécancel()puede no detenerla oportunamente?
Aplicación y diagnóstico
- Una pantalla de productos inicia una consulta cuando se abre. El trabajo no debe reiniciarse solo porque un texto de la UI cambió. Propón dónde ubicar la operación y justifica tu elección.
- Una operación de sincronización actualiza el estado de pantalla a «completado» apenas comienza, aunque todavía hay tres tareas en curso. Explica la causa probable y dos formas de coordinar correctamente la finalización.
- Una implementación captura
Exception, muestra un mensaje de error y continúa. ¿Qué precaución requiere para no alterar el comportamiento de cancelación? - Debes leer un archivo mediante una API bloqueante y después ordenar una colección muy grande. ¿Qué dispatcher utilizarías para cada etapa y por qué?
- Un formulario inicia un trabajo desde un botón. Compara las consecuencias de lanzarlo con
rememberCoroutineScope()y conviewModelScopesi el usuario abandona el destino. - Una tarea debe seguir ejecutándose aunque se cierre la Activity y el proceso termine. ¿Basta con
viewModelScope? Explica qué limitación existe.
13. Respuestas posibles
- Un hilo es un recurso de ejecución del sistema; una corrutina es un trabajo suspendible ejecutado sobre hilos.
delaysuspende sin mantener ocupado el hilo, mientrasThread.sleepbloquea el hilo invocador, lo que puede congelar la UI si es el principal. suspendhabilita funciones de suspensión y llamadas desde un contexto apropiado. No inicia otra corrutina, no garantiza simultaneidad ni cambia automáticamente a un dispatcher de trabajo.launchdevuelve unJoby suele utilizarse para trabajo sin resultado; se espera conjoin.asyncdevuelveDeferred<T>para resultados y se espera conawait.- Las tareas quedan vinculadas a un ámbito con responsabilidades de espera, cancelación y gestión de fallos. Así se evita tratar el trabajo hijo como operaciones independientes sin dueño claro.
LaunchedEffectse inicia por composición y claves;rememberCoroutineScopepermite lanzar desde eventos y se cancela al abandonar composición;viewModelScopeestá ligado a la vida del ViewModel.- Significa que la función puede invocarse desde el hilo principal sin bloquearlo indebidamente. No todas las funciones
suspendcumplen esa propiedad: depende de su implementación. - Aproximadamente dos segundos. Una llamada empieza después de la otra, porque
suspendno implica concurrencia. - Aproximadamente un segundo si ambas esperas son independientes. Los dos
asyncse crean antes del primerawait, permitiendo que sus intervalos se superpongan. launchdevuelve el control enseguida.coroutineScopeespera a sus hijas al terminar el bloque, pero no retrasa elprintlnsituado antes de esa salida.- Hace que la segunda tarea no se inicie hasta haber obtenido el resultado de la primera; el comportamiento temporal vuelve a ser secuencial.
- La instancia anterior del efecto se cancela y se inicia una nueva con la clave actual, siempre que el efecto permanezca en la composición.
- La cancelación requiere cooperación. Sin puntos suspendibles ni comprobaciones como
ensureActive, un cálculo puede continuar ocupando CPU. - Una opción habitual es delegar la carga a un ViewModel mediante
viewModelScope, con un estado que represente carga, resultados y error. Alternativamente,LaunchedEffectcon claves estables puede servir si el trabajo pertenece estrictamente a la composición. La elección depende del propietario deseado. - Probablemente se inicia trabajo con
launchy se cambia el estado inmediatamente después. Se puede esperar con losJob.join()pertinentes o crear uncoroutineScopeinterior que agrupe las tareas antes de actualizar el estado. - Debe reconocer y relanzar
CancellationException, o capturar excepciones más específicas. Consumir la cancelación puede hacer que el código trate una interrupción normal como un fallo convencional y continúe indebidamente. Dispatchers.IOpara la API de lectura bloqueante yDispatchers.Defaultpara el ordenamiento intensivo de CPU, utilizandowithContexten la implementación adecuada.- Si la composición desaparece, el ámbito obtenido con
rememberCoroutineScope()se cancela.viewModelScopepuede continuar mientras el propietario del ViewModel siga vivo; si ese ViewModel se elimina al abandonar el destino, también se cancela. - No.
viewModelScopese cancela al limpiar el ViewModel y no sobrevive a la terminación del proceso. La ejecución confiable más allá de esa duración requiere una solución de trabajo persistente apropiada.
Cierre
Comprender las corrutinas implica distinguir qué trabajo puede suspenderse, qué tareas avanzan de forma concurrente, cuándo se espera un resultado y quién es responsable de cancelar la ejecución. Estas decisiones afectan directamente la respuesta de la interfaz, la coherencia del estado y el consumo de recursos de una aplicación Android.
La práctica de analizar pequeñas secuencias de código permite reconocer fallos que pueden quedar ocultos en aplicaciones más grandes. Antes de incorporar una operación asíncrona, conviene identificar su ámbito, su dispatcher, los puntos de espera y su comportamiento ante errores y cancelaciones.
Para cerrar el estudio, reflexiona sobre estas preguntas:
- ¿Qué indica que una operación debería ejecutarse fuera del hilo principal?
- ¿Cómo distinguirías un problema de secuencialidad de un problema de bloqueo?
- ¿Qué ocurriría si una pantalla desaparece mientras una operación asíncrona sigue en curso?
- ¿En qué situaciones interesa esperar todas las tareas y en cuáles necesitas únicamente el primer resultado disponible?
- ¿Cómo comprobarías que la cancelación de una operación conserva un estado coherente en la interfaz?