Corrutinas: dos corredores y un hilo que no se bloquea
Media hora en Kotlin Playground para entender qué es suspender, y luego una app donde dos barras de progreso avanzan a la vez sin que la interfaz se congele.
Prerrequisitos. Kotlin básico —funciones, lambdas, clases— y saber armar una pantalla sencilla en Compose con Column, Row, Button y remember { mutableStateOf(...) }. Si hiciste la Práctica 2, vas sobrado.
Relación con el reto. Todo lo que tu proyecto haga contra una base de datos o contra la red va a pasar por aquí. Hoy no hay ni una cosa ni la otra: delay() simula el trabajo lento para que el tema sea la concurrencia y nada más.
La Parte 0 es a mano. Escribe tú el código de Kotlin Playground, aunque sea lento: son diez líneas por ejercicio y ahí es donde se entiende. Del Bloque A en adelante, copia y pega.
Lo que vas a construir
Cuando termines, esto tiene que ser cierto
- Las dos barras avanzan al mismo tiempo, no una después de la otra.
- El botón cambia a “Arrancar” exactamente cuando el segundo corredor termina.
- “Pausa” detiene la carrera y conserva el avance; “Reiniciar” la manda a cero.
- Mientras la carrera corre, la interfaz no se siente trabada.
Parte 0 · 30 min · en Kotlin PlaygroundEl taller de conceptos
Abre https://play.kotlinlang.org y no cierres esa pestaña en media hora. Sin proyecto, sin emulador, sin Gradle: solo main() y la salida.
En Playground, activa kotlinx.coroutines en el menú de la derecha (el desplegable de librerías). Sin eso, delay no existe.
P0.1 — Síncrono, y el primer delay6 min
Empieza por lo aburrido:
fun main() {
println("Arranca la carrera")
println("Jugador 1 en la meta")
}
Las dos líneas salen al instante y en orden. Eso es código síncrono: una cosa, luego la otra.
Ahora mete una espera:
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Arranca la carrera")
delay(1000)
println("Jugador 1 en la meta")
}
}
Tarda un segundo. Dos piezas nuevas:
delay(1000) es una función de suspensión: pausa la corrutina un segundo. No es Thread.sleep. La diferencia es todo el tema de hoy.
runBlocking { } construye una corrutina y bloquea el hilo hasta que termina todo lo que hay dentro. Es la puerta de entrada desde código normal — y es para aprender y para pruebas, no para una app. En Android nunca vas a escribir runBlocking.
delay no es Thread.sleep
Thread.sleep(1000) deja el hilo parado un segundo: no puede hacer nada más. delay(1000) suelta el hilo, que se va a atender otras cosas, y programa la reanudación para dentro de un segundo.
En una app, ese hilo es el que dibuja la pantalla. Por eso sleep congela la interfaz y delay no.
P0.2 — suspend6 min
Saca las esperas a sus propias funciones:
import kotlinx.coroutines.*
suspend fun correJugadorUno() {
delay(1000)
println("Jugador 1 en la meta")
}
suspend fun correJugadorDos() {
delay(1000)
println("Jugador 2 en la meta")
}
fun main() {
runBlocking {
println("Arranca la carrera")
correJugadorUno()
correJugadorDos()
println("Se acabó")
}
}
Córrelo y cuenta los segundos: son dos, no uno.
El modificador suspend marca una función que puede pausarse y reanudarse. Solo se puede llamar desde otra función suspend o desde dentro de una corrutina — por eso main() necesita el runBlocking.
Y fíjate en lo importante: aunque las dos funciones son suspend, corrieron una después de la otra. Suspender no es correr en paralelo.
suspend no significa “esto va en paralelo”. Significa “esto se puede pausar”.
P0.3 — launch, la primera concurrencia6 min
fun main() {
runBlocking {
println("Arranca la carrera")
launch { correJugadorUno() }
launch { correJugadorDos() }
println("Se acabó")
}
}
Ahora tarda un segundo, y la salida sale desordenada respecto a lo que escribiste:
Arranca la carrera
Se acabó
Jugador 1 en la meta
Jugador 2 en la meta
launch { } arranca una corrutina nueva y regresa de inmediato, sin esperarla. Por eso “Se acabó” se imprime antes que las metas: es lanzar y olvidarse.
Lo que sí espera es el runBlocking: no termina hasta que sus corrutinas hijas terminan. Ahí está la palabra estructurada de “concurrencia estructurada” — nadie se queda huérfano.
P0.4 — async y await6 min
launch no devuelve nada. Cuando sí necesitas el resultado, se usa async:
import kotlinx.coroutines.*
suspend fun tiempoJugadorUno(): String {
delay(1000)
return "1: 12.4 s"
}
suspend fun tiempoJugadorDos(): String {
delay(1000)
return "2: 11.8 s"
}
fun main() {
runBlocking {
println("Arranca la carrera")
val uno: Deferred<String> = async { tiempoJugadorUno() }
val dos: Deferred<String> = async { tiempoJugadorDos() }
println("${uno.await()} — ${dos.await()}")
println("Se acabó")
}
}
Un segundo en total, y la salida en orden.
async devuelve un Deferred<T>: la promesa de que el valor va a estar. await() es donde lo cobras — y ahí sí se espera.
await importa
Los dos async se lanzan antes del primer await. Por eso los dos trabajos corren encima del otro y el total es un segundo.
Si escribieras val uno = async { … }.await() en una línea y luego el segundo, volverías a dos segundos: estarías cobrando la primera promesa antes de haber emitido la segunda.
P0.5 — coroutineScope6 min
Falta empaquetar: que “la carrera” sea una operación que por dentro hace dos cosas a la vez.
suspend fun resultados(): String = coroutineScope {
val uno = async { tiempoJugadorUno() }
val dos = async { tiempoJugadorDos() }
"${uno.await()} — ${dos.await()}"
}
fun main() {
runBlocking {
println("Arranca la carrera")
println(resultados())
println("Se acabó")
}
}
coroutineScope { } crea un ámbito y no regresa hasta que todas sus corrutinas hijas terminaron. Desde fuera, resultados() es una función suspendida normal: quien la llama no se entera de que por dentro hubo concurrencia.
Esa es la idea que vas a usar el resto de la práctica.
Escriban en papel qué imprime cada uno y cuánto tarda. Después lo corren y comparan.
| Código | Salida | Segundos | |
|---|---|---|---|
| 1 | launch { a() } y luego launch { b() }, con a y b de 1 s |
||
| 2 | val x = async { a() }.await() y luego val y = async { b() }.await() |
||
| 3 | Un coroutineScope con dos async de 1 s y los dos await al final |
Respuestas
- Se lanzan las dos y
runBlockingespera a ambas: 1 s. Lo que siga después de loslaunchse imprime primero. - 2 s. El primer
awaitcobra la promesa antes de que exista la segunda: esasyncescrito como si fuera secuencial. - 1 s, y el
coroutineScopeno devuelve hasta que las dos terminan.
El 2 es el error más común de la práctica: usar async y no ganar nada porque el await está en el lugar equivocado.
Bloque A · 20 minLa app y el corredor
Cierra Playground. De aquí en adelante es Android Studio.
A1 — Proyecto nuevo5 min
Empty Activity (Compose), paquete mx.tec.racetracker.
mx/tec/racetracker/
├─ MainActivity.kt
├─ RaceParticipant.kt ← el corredor: estado y la función suspend
└─ ui/
└─ RaceTrackerApp.kt ← la pantalla y el arranque de la carrera
No hace falta agregar ninguna dependencia: kotlinx-coroutines ya viene con Compose, y LaunchedEffect es parte de androidx.compose.runtime.
1. AGP 9 trae Kotlin integrado. Si el app/build.gradle.kts tiene alias(libs.plugins.kotlin.android), bórralo: se quedan solo com.android.application y org.jetbrains.kotlin.plugin.compose.
2. compileSdk = 37, no es opcional.
A2 — El corredor10 min
Una clase, un dato y una función que tarda.
package mx.tec.racetracker
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
class RaceParticipant(
val name: String,
val maxProgress: Int = 100,
val progressDelayMillis: Long = 100L,
private val progressIncrement: Int = 1
) {
var currentProgress by mutableIntStateOf(0)
private set
/** Avanza de a poco hasta la meta. Tarda, pero no bloquea el hilo. */
suspend fun run() {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
}
fun reset() {
currentProgress = 0
}
}
Tres cosas que vale la pena mirar despacio:
private set. Igual que en el ViewModel de la Práctica 2: desde fuera se lee el avance, pero solo el corredor lo cambia.
mutableIntStateOf y no mutableStateOf. Para un Int existe la versión especializada, que evita empaquetar el número en un objeto en cada cambio. Aquí son cientos de cambios por carrera.
El while con delay adentro. Ese ciclo se ve como código bloqueante y no lo es: en cada vuelta suelta el hilo 400 ms.
A3 — La pantalla5 min
package mx.tec.racetracker.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import mx.tec.racetracker.RaceParticipant
@Composable
fun RaceTrackerScreen(
playerOne: RaceParticipant,
playerTwo: RaceParticipant,
isRunning: Boolean,
onRunStateChange: (Boolean) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Carrera", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(32.dp))
StatusIndicator(playerOne)
Spacer(Modifier.height(16.dp))
StatusIndicator(playerTwo)
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { onRunStateChange(!isRunning) }) {
Text(if (isRunning) "Pausa" else "Arrancar")
}
OutlinedButton(onClick = onReset) { Text("Reiniciar") }
}
}
}
@Composable
private fun StatusIndicator(participant: RaceParticipant, modifier: Modifier = Modifier) {
Column(modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(participant.name, style = MaterialTheme.typography.titleMedium)
Text("${participant.currentProgress} %")
}
Spacer(Modifier.height(6.dp))
LinearProgressIndicator(
progress = { participant.currentProgress / participant.maxProgress.toFloat() },
modifier = Modifier.fillMaxWidth().height(8.dp)
)
}
}
RaceTrackerScreen no recibe corrutinas, ni ámbitos, ni nada asíncrono: recibe dos corredores, un Boolean y dos lambdas. Es la misma UI tonta de la Práctica 2. Toda la concurrencia va a vivir arriba, en el Bloque B.
Y ojo con LinearProgressIndicator: el parámetro progress es una lambda () -> Float. La versión que recibe un Float directo está deprecada — mucho código y muchos tutoriales de hace dos años todavía la usan.
Bloque B · 30 minArrancar la carrera, y el bug
B1 — LaunchedEffect10 min
Ya tienes una función suspend y una pantalla. Falta el puente: quién llama a run().
No puede ser el onClick, porque un onClick no es una corrutina. Y tampoco puede ser el cuerpo del composable, porque eso se ejecuta en cada recomposición — decenas de veces por segundo.
La pieza es LaunchedEffect:
@Composable
fun RaceTrackerApp() {
val playerOne = remember { RaceParticipant(name = "Jugador 1", progressIncrement = 1) }
val playerTwo = remember { RaceParticipant(name = "Jugador 2", progressIncrement = 2) }
var raceInProgress by remember { mutableStateOf(false) }
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
raceInProgress = false
}
}
RaceTrackerScreen(
playerOne = playerOne,
playerTwo = playerTwo,
isRunning = raceInProgress,
onRunStateChange = { raceInProgress = it },
onReset = {
playerOne.reset()
playerTwo.reset()
raceInProgress = false
}
)
}
Y en MainActivity:
setContent {
RaceTrackerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
RaceTrackerApp()
}
}
}
LaunchedEffect hace tres cosas por ti:
- Lanza una corrutina cuando el composable entra en la composición, así que dentro de sus llaves ya puedes llamar funciones
suspend. - No la relanza mientras sus llaves —aquí
playerOneyplayerTwo— no cambien. Sin eso, cada recomposición arrancaría otra carrera. - La cancela cuando el composable sale de la composición. Ese
if (raceInProgress)no es decorativo: al ponerse enfalse, elLaunchedEffectdesaparece y la corrutina se cancela sola. Es lo que hace funcionar el botón de Pausa.
B2 — 🔴 El bug8 min
No lo arregles todavía. Míralo.
La barra del Jugador 1 llega al 100 % y hasta entonces arranca la del Jugador 2. Son dos carreras en fila, no una carrera de dos.
Y sin embargo el código dice suspend por todos lados. Ese es justo el malentendido que la Parte 0 venía a romper.
Contesten en la bitácora, en una frase cada una:
playerOne.run()yplayerTwo.run()son funcionessuspend. ¿Por qué entonces no corren a la vez?- ¿Qué fragmento de la Parte 0 tiene exactamente esta forma?
- ¿Qué palabra falta?
Respuestas
- Porque
suspendsolo dice que la función puede pausarse, no que se lance aparte. Dentro de una corrutina, dos llamadas seguidas son secuenciales — igual que dosprintln. - El P0.2: dos
suspendllamadas una tras otra, dos segundos. launch. Concurrencia estructurada quiere decir que por defecto todo es secuencial, y que la concurrencia se pide explícitamente.
B3 — launch: los dos a la vez12 min
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
launch { playerTwo.run() }
raceInProgress = false
}
}
Necesitas el import kotlinx.coroutines.launch.
Corre. Las dos barras avanzan juntas y el Jugador 2 llega al doble de velocidad, como debe ser.
Bloque C · 25 minEsperar y cancelar
C1 — El segundo bug10 min
Este es más sutil que el del Bloque B, y por eso importa más.
Arranca la carrera y mira el botón: dice “Pausa” apenas un instante y vuelve a decir “Arrancar” de inmediato, con las dos barras todavía a la mitad.
El estado raceInProgress está mintiendo.
Vean estas tres líneas y digan en qué orden se ejecutan:
launch { playerOne.run() }
launch { playerTwo.run() }
raceInProgress = false
Respuesta
Las tres, casi en el mismo milisegundo. launch es lanzar y olvidarse: regresa de inmediato, sin esperar. Así que raceInProgress = false no corre “al final de la carrera”, corre justo después de haber lanzado las dos corrutinas.
Es el mismo “Se acabó” que en el P0.3 se imprimía antes que las metas. Ahí era gracioso; aquí rompe la interfaz.
C2 — coroutineScope7 min
Hace falta algo que espere a las dos corrutinas antes de seguir. Ya lo conoces:
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
coroutineScope {
launch { playerOne.run() }
launch { playerTwo.run() }
}
raceInProgress = false
}
}
Import: kotlinx.coroutines.coroutineScope.
coroutineScope { } no devuelve el control hasta que todas sus corrutinas hijas terminaron. La línea de abajo se convierte, por fin, en “cuando acabe la carrera”.
launch lanza. coroutineScope espera. La concurrencia se pide; la espera también.
C3 — Cancelar sin romper nada8 min
Cuando tocas “Pausa”, el LaunchedEffect cancela sus corrutinas. Cancelar en Kotlin se hace lanzando una CancellationException dentro de la corrutina, y eso tiene una consecuencia que conviene conocer antes de toparse con ella.
Prueba a envolver el ciclo en un try/catch mal escrito:
// ❌ A PROPÓSITO MAL
suspend fun run() {
try {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
} catch (e: Exception) {
println("algo pasó")
}
}
Con eso, “Pausa” deja de funcionar bien: atrapaste la CancellationException y te la quedaste, así que la corrutina cree que la cancelación no ocurrió.
La forma correcta:
suspend fun run() {
try {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
} catch (e: CancellationException) {
// Se registra si hace falta, pero SIEMPRE se vuelve a lanzar:
// es como el sistema de corrutinas sabe que la cancelación surtió efecto.
throw e
}
}
Import: kotlin.coroutines.cancellation.CancellationException.
Exception a secas dentro de una corrutina
CancellationException es una excepción de verdad, pero no es un error: es el mecanismo normal de cancelación. Un catch (e: Exception) la intercepta sin volver a lanzarla, y deja corrutinas que siguen trabajando aunque alguien ya las haya cancelado.
Si necesitas atrapar errores, atrapa el tipo concreto. Y si tienes que atrapar algo amplio, vuelve a lanzar la cancelación explícitamente.
Paso D · 13 minDónde vive cada corrutina
Los últimos minutos son de mapa, no de teclado.
El ámbito decide el ciclo de vida6 min
Toda corrutina vive dentro de un CoroutineScope, y ese ámbito está atado a algo que muere:
| Ámbito | Vive mientras… | Dónde se usa |
|---|---|---|
LaunchedEffect |
el composable esté en la composición | efectos de una pantalla, como hoy |
rememberCoroutineScope() |
el composable esté en la composición | lanzar desde un onClick |
viewModelScope |
viva el ViewModel | el de tu proyecto real |
lifecycleScope |
viva la Activity o el Fragment | código fuera de Compose |
runBlocking |
— | pruebas y main(). Nunca en una app |
La regla que amarra todo: cuando el ámbito muere, cancela a todos sus hijos. Por eso no tuviste que escribir ni una línea para que “Pausa” funcione.
Los hilos, y por qué no los tocaste7 min
Un dispatcher decide en qué hilo corre una corrutina. Hay tres:
| Dispatcher | Para qué | Ejemplo |
|---|---|---|
Dispatchers.Main |
tocar la interfaz | actualizar estado, navegar |
Dispatchers.IO |
esperar a disco o red | leer una base de datos, una petición HTTP |
Dispatchers.Default |
cálculo pesado | procesar una imagen, ordenar 100 000 elementos |
Se cambia con withContext:
suspend fun cargarResultados(): List<String> = withContext(Dispatchers.IO) {
// aquí ya estamos en un hilo de trabajo
leerDelDisco()
}
Hoy no usaste ninguno, y no fue un descuido: delay no bloquea, así que el hilo principal nunca estuvo ocupado. Si hubieras puesto Thread.sleep(400) en lugar de delay(400), la app se habría congelado — barras trabadas y botones que no responden.
Room y Retrofit ya cambian de hilo por su cuenta: sus funciones suspend son main-safe, así que las llamas directo desde viewModelScope sin withContext.
El withContext(Dispatchers.Default) lo vas a necesitar el día que hagas un cálculo pesado tuyo. Para leer de una base de datos, no.
Extensión para entregar — la quiniela
Los tres cambios de esta sección son parte de la entrega, y se hacen fuera de clase. La práctica en el salón termina en el Paso D; esto es lo que demuestra que entendiste lo que hiciste.
La idea: antes de arrancar, el usuario apuesta por un corredor. Si acierta, suma un punto. El marcador se conserva carrera tras carrera.
1. Un tercer corredor
Agrega al Jugador 3 a la carrera y a la pantalla.
Hazlo primero como venías: una tercera variable, un tercer launch, un tercer
StatusIndicator. Cuando funcione, mira las tres variables juntas y decide si
no era una lista. Esa decisión, y por qué la tomaste, va en la bitácora.
2. La apuesta
Antes de arrancar, el usuario elige a uno de los tres como su candidato a ganar. Mientras la carrera corre, la elección no se puede cambiar.
Con las velocidades fijas de hoy —1, 2 y 3— el Jugador 3 gana siempre y apostar no tiene sentido. Así que cada carrera tiene que sortear las velocidades de los tres corredores al arrancar. Sin eso, los otros dos puntos de la extensión no miden nada.
3. El marcador de aciertos
Al terminar la carrera, la app dice quién ganó y si el usuario acertó. Si acertó, el marcador sube un punto.
El marcador cuenta aciertos sobre carreras corridas —“3 de 7”— y sobrevive a todas las carreras de la sesión. “Reiniciar” pone la carrera en cero, no el marcador: para eso agrega un botón aparte.
Saber quién llegó primero. launch no devuelve nada, y await() tampoco te
sirve: te entrega los resultados en el orden en que los pides, no en el que
terminaron. Vas a necesitar que cada corredor registre algo en el momento de
cruzar la meta. Piensa qué, y quién lo lee.
Dónde vive el marcador. Tiene que seguir ahí después de la carrera, y también
después de que el usuario gire el teléfono. remember sobrevive a las
recomposiciones, pero no a girar la pantalla. Esa pregunta —de quién es este
estado— es la misma de la Práctica 2.
Criterios de aceptación
- Corren tres barras a la vez, con velocidades distintas en cada carrera.
- No se puede apostar con la carrera en curso, ni arrancar sin haber apostado.
- Al terminar, la app declara ganador y actualiza el marcador solo si acertaste.
- El marcador se conserva entre carreras y al girar el teléfono.
- Sigue sin haber un solo
Thread.sleep, y “Pausa” sigue funcionando.
Retos para casa
Opcionales, en orden de dificultad. Van después de la extensión.
- Velocidad configurable. Un
Sliderque cambieprogressDelayMillisantes de arrancar. - Cancelar solo a uno. Un botón por corredor que lo saque de la carrera sin afectar a los otros. (Pista:
launchdevuelve unJob, yJobtienecancel().) - Trabajo de verdad. Cambia el
delaypor un cálculo pesado real dentro dewithContext(Dispatchers.Default)y comprueba que la interfaz sigue fluida. - Podio completo. No solo el ganador: primero, segundo y tercero, en orden de llegada.
- Racha. Además del marcador, cuántos aciertos seguidos lleva el usuario.
Problemas comunes
| Síntoma | Causa | Solución |
|---|---|---|
Suspend function 'run' should be called only from a coroutine or another suspend function |
Llamaste run() desde un onClick o desde el cuerpo del composable |
Va dentro de LaunchedEffect |
| La carrera arranca sola, o se reinicia sin parar | El LaunchedEffect no está dentro del if (raceInProgress) |
Condiciónalo, o cambia sus llaves |
| Las barras avanzan una después de la otra | Faltó launch |
launch { playerOne.run() } |
| El botón vuelve a “Arrancar” de inmediato | launch no espera |
Envuelve los dos launch en coroutineScope { } |
| “Pausa” no detiene nada | Atrapaste CancellationException sin volver a lanzarla |
catch (e: CancellationException) { throw e } |
| La barra no se mueve aunque el número sí cambia | LinearProgressIndicator con la API vieja |
progress = { … }, una lambda |
Type mismatch: Int / Float en la barra |
División entera | currentProgress / maxProgress.toFloat() |
| La interfaz se traba | Thread.sleep en vez de delay |
delay suspende; sleep bloquea |
| El avance no se dibuja | currentProgress no es estado de Compose |
by mutableIntStateOf(0) con private set |
Unresolved reference: launch |
Falta el import | kotlinx.coroutines.launch |
| La carrera se pierde al girar | El estado vive en el composable | Es el reto 6: llévalo al ViewModel |
IA en esta práctica — nivel tutor
Permitido: explicar, diagnosticar, contrastar tu diseño. Prohibido: generar el código de los entregables.
- “Explícame la diferencia entre
Thread.sleep(1000)ydelay(1000)en términos de qué pasa con el hilo. Sin código.” - “Tengo dos
launchseguidos y la línea de abajo se ejecuta antes de tiempo. Explícame por qué, sin corregirme el código.” - “¿Por qué hay que volver a lanzar
CancellationExceptionen lugar de tragársela?”
Y como en la Práctica 2: verifica rompiendo el código a propósito. Los pasos B2, C1 y el checkpoint final son exactamente eso.
Rúbrica
| Criterio | Pts | Qué se ve |
|---|---|---|
| suspensión | 20 | run() es suspend y usa delay; nada de Thread.sleep; el estado con private set |
| concurrencia | 25 | Los launch dentro de un coroutineScope; las barras avanzan juntas |
| ciclo de vida | 20 | LaunchedEffect bien condicionado; el botón refleja el estado real de la carrera |
| cancelación | 10 | CancellationException se vuelve a lanzar; “Pausa” conserva el avance |
| la quiniela | 20 | Los cinco criterios de aceptación de la extensión |
| bitácora | 5 | Ejercicios 0, B2 y C1, más la decisión sobre la lista de corredores |
Defensa oral — 2 min, aleatoria, obligatoria para acreditar
- Quita el
coroutineScopey deja los doslaunch. ¿Qué se rompe y por qué? - ¿Por qué
run()no puede llamarse desde unonClick? - ¿Quién cancela las corrutinas cuando tocas “Pausa”? Nadie escribió
cancel(). - Si
delayno bloquea el hilo, ¿qué hilo estaba usando la carrera? - ¿Cuándo usarías
asyncen vez delaunchen esta app? - En tu quiniela, ¿cómo supiste quién llegó primero? ¿Por qué no bastaba con
await()?
Entregables
- Repositorio con un commit por bloque:
bloque-a,bloque-b,bloque-c,paso-d, y uno más llamadoquinielacon la extensión. - Video de 40 s: arrancar, pausar a media carrera, reanudar y terminar, mostrando el botón.
- Video de 40 s de la quiniela: apostar, correr dos carreras seguidas —una acertando y otra no— y girar el teléfono para mostrar que el marcador sigue ahí.
docs/bitacora.mdcon los ejercicios 0, B2 y C1, más la decisión sobre la lista de corredores.
La práctica adapta dos codelabs de Google, publicados bajo licencia Creative Commons Attribution 4.0: Introduction to coroutines in Kotlin Playground y Introduction to coroutines in Android Studio.
Los conceptos y la idea de la app de carrera son de ahí. El proyecto se arma desde cero con el andamiaje de 2026 en vez de clonar el repositorio de inicio del codelab, que quedó en versiones de 2024; el ejemplo del clima se cambió por el de la carrera para que la Parte 0 y el resto de la práctica hablen del mismo dominio; y las pruebas unitarias del codelab original se dejaron para una sesión aparte.