Saltar al contenido
K
Corrutinas y FlowPaso a paso35 min de lectura · 5 min de máquina Intermedio

El partido, minuto a minuto

Al terminar: Simular un partido minuto a minuto con una función suspend y delay, y comprobarlo con un test.

En la lección anterior viste qué es suspend y por qué delay no bloquea el hilo. Hoy lo aplicas a Marcador: escribes jugarPartido, una función suspend que juega un partido minuto a minuto, con una pausa por cada minuto. Al terminar, ./gradlew :torneo:run imprimirá el resultado de un partido (2-1) en torno a 1 s de espera real (90 minutos de 10 ms son 900 ms, y cada pausa añade un poco), y tendrás un test que comprueba que el partido jugado reproduce el guion.

Antes del paso, un ejemplo con ollas de cocina (para que veas las piezas nuevas sin los partidos de por medio) y un ejercicio con una receta minuto a minuto. Los dos los puedes probar en el Kotlin Playground.

Piezas nuevas: constante, argumentos y corrutinas lanzadas

Cuatro cosas que aún no se han explicado: launch { … } (arranca una corrutina nueva y sigue sin esperarla; lo verás entero en launch y async), maxOf y maxOfOrNull (el mayor de varios valores), y main(args: Array<String>) con "lento" in args. args es un Array<String> (el Array de tamaño fijo que viste con vararg en object, companion y vararg) con las palabras que se escriben al ejecutar el programa, y in pregunta si una colección contiene algo, como ya viste en Propiedades y visibilidad. También aparece const val, que ya viste en object, companion y vararg. Todo junto en un ejemplo con ollas en vez de partidos, explicado línea a línea:

ejemplo mínimo: constante, argumentos y corrutinas lanzadas (con comentarios)
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
 
const val PAUSA_MS = 100L                  // const val: constante conocida al compilar, en mayúsculas
 
fun main(args: Array<String>) {            // args: las palabras escritas tras --args= al ejecutar
    if ("lento" in args) println("modo lento")   // in: ¿está esa palabra en el array?
 
    runBlocking {
        for (i in 0..2) {                  // i vale 0, 1 y 2
            launch {                       // arranca una corrutina y sigue sin esperarla
                delay(PAUSA_MS)
                println("Olla $i lista")
            }
        }                                  // runBlocking espera a que acaben las tres
    }
 
    println(maxOf(3, 8))                                  // el mayor de dos valores: 8
    println(listOf(4, 9, 2).maxOfOrNull { it * 10 })      // el mayor de lo que da la lambda: 90
    println(emptyList<Int>().maxOfOrNull { it })          // lista vacía: null
}

Al ejecutarlo, imprime:

Olla 0 lista
Olla 1 lista
Olla 2 lista
8
90
null

Con eso ya puedes practicar lo que usa el paso, con otros datos.

Reto · Ejercicio: una receta minuto a minuto

En el Kotlin Playground, escribe una función que cocine una receta minuto a minuto. Recibe una lista de pasos, cada uno una pareja con su minuto y su texto, y cuánto dura un minuto en milisegundos (10 si no se dice). Por cada minuto, desde el 1 hasta el del último paso, se pausa sin bloquear el hilo e imprime los pasos de ese minuto. Aunque no haya pasos, la receta dura al menos 5 minutos (una constante del programa). Devuelve cuántos minutos duró. Pruébala con «dar la vuelta» en el minuto 2, «añadir queso» en el 5 y «sacar del horno» en el 7, y después con una lista vacía. La consola debe mostrar:

minuto 2: dar la vuelta
minuto 5: añadir queso
minuto 7: sacar del horno
Pizza lista en 7 minutos
Horno vacío: 5 minutos
Ver solución
solución del reto
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
 
const val MINUTOS_MINIMOS = 5
 
// Cada paso es una pareja (minuto, texto): `first` es el minuto
suspend fun cocinar(pasos: List<Pair<Int, String>>, msPorMinuto: Long = 10): Int {
    // El último minuto es el mayor entre el mínimo y el del último paso (0 si no hay pasos)
    val ultimo = maxOf(MINUTOS_MINIMOS, pasos.maxOfOrNull { it.first } ?: 0)
    for (minuto in 1..ultimo) {
        delay(msPorMinuto)    // pausa sin bloquear el hilo
        pasos.filter { it.first == minuto }.forEach { println("minuto $minuto: ${it.second}") }
    }
    return ultimo
}
 
fun main() = runBlocking {
    val pizza = listOf(2 to "dar la vuelta", 5 to "añadir queso", 7 to "sacar del horno")
    println("Pizza lista en ${cocinar(pizza)} minutos")
    println("Horno vacío: ${cocinar(emptyList())} minutos")
}

Paso · Un partido que se juega minuto a minuto

Objetivo. Apartar el main de antes, crear jugarPartido (suspendida, con un delay por minuto), traer los datos de ejemplo y escribir un main nuevo. Al terminar, ./gradlew :torneo:run imprime el resultado de un partido.

torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo
DemoKotlin.ktera Main.kt; cambia su firma
Main.ktse crea de nuevo
Simulacion.ktse crea
Ejemplo.ktse rellena con el botón
torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo (test)
SimulacionTest.ktse crea
Un fichero movido, uno copiado con el botón, dos nuevos y un test.

Primero, el main de antes se aparta a su propio fichero, para dejar Main.kt libre para la lección nueva:

  1. En la vista Android, en torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo, clic derecho sobre Main.kt → Refactor → Rename… y escribe DemoKotlin. Si el IDE pregunta qué más renombrar, deja marcado solo el fichero y desmarca «Search in comments and strings» y «Search for text occurrences» (buscar en comentarios y cadenas de texto, y buscar apariciones del nombre como texto), para que no toque nada más. Una comprobación: abre Gradle Scripts → build.gradle.kts (Module :torneo) y mira que la línea mainClass.set(...) sigue terminando en MainKt. No la cambies: apunta al Main.kt nuevo que vas a crear dentro de un momento. Marcador se ejecuta con ./gradlew desde la Terminal, no con el botón verde de Android Studio.

  2. Abre DemoKotlin.kt. Cambia fun main() por fun demoKotlin() (sin parámetros). Borra el comentario que hay encima de fun demoKotlin() (// Punto de entrada de consola: ./gradlew :torneo:run) y pon en su lugar estos dos, debajo de los import:

    DemoKotlin.kt: los dos comentarios nuevos
    // La demo del módulo de Kotlin, sin corrutinas. Ya no es el punto de entrada:
    // se lanza con ./gradlew :torneo:run --args=kotlin

    Lo que hace la segunda línea se ve más abajo, en el main nuevo: si pasas la palabra kotlin al ejecutar, el programa lanza esta demo.

Después, los ficheros nuevos. Todos se crean igual: clic derecho sobre el paquete (com.sebastiannarvaez.marcador.torneo para el código, com.sebastiannarvaez.marcador.torneo (test) para el test) → New → Kotlin Class/File → File → el nombre.

Ejemplo.kt se rellena con el botón. Son unos datos de prueba (40 líneas de jugadores y eventos) que no enseñan nada de corrutinas, así que no los escribes. El botón Copiar fichero funciona como se explicó en Clases y objetos: copia el contenido al portapapeles y no escribe nada en tu proyecto. El orden es siempre el mismo: crea el fichero Ejemplo.kt en el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test)), pulsa el botón, haz clic dentro del fichero, selecciona todo con Cmd+A y pega con Cmd+V.

Ejemplo.kt

Dentro hay un object Ejemplo con equipos y dos partidos. Es lo que usarás:

lo que trae Ejemplo.kt (recortado)
val rayoContraToros = Partido(
    local = rayo,
    visitante = toros,
    eventos = listOf(
        Gol(12, ana, rayo),                              // minuto 12
        Tarjeta(30, ivan, ColorDeTarjeta.AMARILLA),
        Gol(55, ivan, toros),
        Gol(80, ana, rayo),
        Cambio(85, sale = ana, entra = luis),
    ),
)

Intenta escribirlo tú, en tres ficheros (usa lo de las ollas y la receta de arriba):

  • Simulacion.kt. jugarPartido(partido, msPorMinuto) recibe el guion (todo lo que va a pasar) y lo juega: parte de un partido vacío con los mismos equipos, se pausa sin bloquear msPorMinuto milisegundos (10 si no se dice) por cada minuto del 1 al 90 y va registrando los eventos de cada minuto. Los eventos del minuto 0 entran antes de empezar, y si el guion tiene alguno después del 90 (tiempo añadido), el partido dura hasta ese minuto. Devuelve el partido jugado. Los 90 minutos son una constante del fichero.
  • Main.kt. Si al ejecutar se pasa la palabra kotlin, lanza la demo de antes y termina. Si no, juega el Rayo FC-Toros de Ejemplo y muestra el resultado.
  • SimulacionTest.kt. Tres tests, con los minutos a 0 ms para que no esperen: el partido jugado reproduce el guion (los mismos eventos y 2-1), jugarlo no modifica el guion (sigue con 5 eventos) y un partido sin eventos termina sin goles.

Al ejecutar ./gradlew :torneo:run en la Terminal de Android Studio debe verse:

== un partido suspendido ==
Final: 2-1 con 5 eventos

El Main.kt de abajo lo escribes tú: no lleva la demo de las 10.000 corrutinas, que ya probaste en el Playground.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Simulacion.kt:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Simulacion.kt
package com.sebastiannarvaez.marcador.torneo
 
import kotlinx.coroutines.delay
 
const val MINUTOS_DEL_PARTIDO = 90
 
// `suspend` NO significa «corre en otro hilo». Significa «esta función puede
// pausarse»: dentro puede llamar a otras `suspend` (como `delay`) y, mientras
// espera, el hilo queda libre para hacer otra cosa. Solo se puede llamar desde
// otra `suspend` o desde una corrutina.
//
// `partido` es el GUION: todo lo que va a pasar. La función lo «juega» minuto a
// minuto y devuelve el partido tal como quedó. `msPorMinuto` es cuánto dura un
// minuto de juego en tiempo real (10 ms hace un partido de menos de un segundo).
suspend fun jugarPartido(partido: Partido, msPorMinuto: Long = 10): Partido {
    var jugado = Partido(partido.local, partido.visitante)
    // Lo que pasa «antes de empezar» (minuto 0) no necesita esperar.
    partido.eventos.filter { it.minuto == 0 }.forEach { jugado = jugado.registrar(it) }
    val ultimoMinuto = maxOf(MINUTOS_DEL_PARTIDO, partido.eventos.maxOfOrNull { it.minuto } ?: 0)
    for (minuto in 1..ultimoMinuto) {
        // `delay` suspende SIN bloquear el hilo. `Thread.sleep` lo bloquearía.
        delay(msPorMinuto)
        partido.eventos.filter { it.minuto == minuto }.forEach { jugado = jugado.registrar(it) }
    }
    return jugado
}

Una duda razonable sobre ultimoMinuto: un partido dura 90 minutos, ¿por qué no recorrer siempre 1..90? Porque un evento del guion podría estar anotado en el minuto 93 (el tiempo añadido por el árbitro); con maxOf el bucle llega hasta ahí y ese evento se registra en lugar de perderse.

Main.kt, el nuevo:

Main.kt
package com.sebastiannarvaez.marcador.torneo
 
import kotlinx.coroutines.runBlocking
 
// Punto de entrada de consola: ./gradlew :torneo:run
// (la demo del módulo de Kotlin sigue disponible: ./gradlew :torneo:run --args=kotlin)
//
// `runBlocking` es el puente entre el mundo normal (`main` no es suspend) y el
// de las corrutinas: bloquea el hilo actual hasta que el bloque termina.
fun main(args: Array<String>) {
    if ("kotlin" in args) {
        demoKotlin()
        return
    }
    runBlocking {
        println("== un partido suspendido ==")
        val final = jugarPartido(Ejemplo.rayoContraToros)
        println("Final: ${final.golesLocal}-${final.golesVisitante} con ${final.eventos.size} eventos")
    }
}

Dos detalles. Primero, return sale de main en ese momento, sin ejecutar nada más: por eso, con la palabra kotlin, solo se lanza la demo. Segundo, args son las palabras que escribes tras --args= al ejecutar.

runBlocking también sirve en los tests: lo usa este, con msPorMinuto = 0 para que delay(0) vuelva enseguida. Se crea en el paquete com.sebastiannarvaez.marcador.torneo (test), igual que los otros.

SimulacionTest.kt
package com.sebastiannarvaez.marcador.torneo
 
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
 
class SimulacionTest {
    // `= runBlocking { … }` es un cuerpo de expresión (como en Funciones): la función
    // devuelve lo que da el bloque. `runBlocking` permite probar código suspendido sin más librerías. Más adelante
    // verás `runTest`, que además adelanta el reloj en vez de esperar de verdad.
    @Test
    fun jugarUnPartidoReproduceElGuion() = runBlocking {
        val jugado = jugarPartido(Ejemplo.rayoContraToros, msPorMinuto = 0)
        assertEquals(Ejemplo.rayoContraToros.eventos, jugado.eventos)
        assertEquals(2, jugado.golesLocal)
        assertEquals(1, jugado.golesVisitante)
    }
 
    @Test
    fun elPartidoNoModificaElGuion() = runBlocking {
        val guion = Ejemplo.rayoContraToros
        jugarPartido(guion, msPorMinuto = 0)
        assertEquals(5, guion.eventos.size)
    }
 
    @Test
    fun unPartidoSinEventosTerminaSinGoles() = runBlocking {
        val jugado = jugarPartido(Partido(Ejemplo.rayo, Ejemplo.toros), msPorMinuto = 0)
        assertEquals(0, jugado.eventos.size)
    }
}

Punto de control

  • En torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo se ven DemoKotlin.kt, Main.kt, Simulacion.kt y Ejemplo.kt.
  • En la Terminal de Android Studio, ./gradlew :torneo:run termina en BUILD SUCCESSFUL y imprime:
== un partido suspendido ==
Final: 2-1 con 5 eventos
  • ./gradlew :torneo:run --args=kotlin lanza la demo de antes, la del módulo de Kotlin sin corrutinas.
  • ./gradlew :torneo:test termina en BUILD SUCCESSFUL. Abre el informe (open torneo/build/reports/tests/test/index.html, como en Bucles y tu primer test) y comprueba que arriba pone 85 tests (los 82 de antes más los 3 de SimulacionTest), y que en el paquete (test) está SimulacionTest.kt.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.jugarPartido calcula ultimoMinuto con maxOf(MINUTOS_DEL_PARTIDO, …) en lugar de recorrer siempre 1..90. ¿Por qué?

  2. 2.¿Qué hace ./gradlew :torneo:run --args=kotlin en Marcador?

  3. 3.Los tests de SimulacionTest llaman a jugarPartido(…, msPorMinuto = 0). ¿Para qué?

  4. 4.En listOf(4, 9, 2).maxOfOrNull { it * 10 } y en emptyList<Int>().maxOfOrNull { it }, ¿qué devuelven?

  5. 5.Dentro de runBlocking, for (i in 0..2) { launch { delay(100); println("Olla $i lista") } } tarda unos 100 ms en total, no 300. ¿Por qué?

Faltan 5 de 5