Saltar al contenido
K
iOS con SwiftUIPaso a paso30 min de lectura · 10 min de máquina Intermedio

El partido arranca con .task(id:)

Al terminar: Arrancar el partido con .task(id:) y probarlo.

Al terminar, el partido de Marcador arrancará solo al abrir la pantalla, sin botón «Empezar», y se cancelará solo al cerrarla. Cambiar la duración del partido (90 o 60 minutos) lo cancelará y lo lanzará de nuevo. Además tendrás cuatro pruebas del modelo, que juegan el partido entero en un instante, y dos pruebas de interfaz que lo comprueban desde fuera.

Importa porque es la aplicación real de lo que viste en el laboratorio: un partido que sigue contando minutos sin pantalla gasta recursos para nada y modifica un modelo que nadie mira.

El partido sin tarea propia

Hasta ahora el modelo del partido arrancaba con un botón: empezar(duracion:) guardaba en una propiedad, tarea, una Task lanzada a mano. Eso tiene dos pegas: hay que acordarse de cancelarla y la pantalla tiene un botón que sobra. Con .task el partido pasa a ser una función async normal, jugar(duracion:), y la Task la pone SwiftUI: la lanza al aparecer la pantalla y la cancela al irse. Con .task(id: duracion), además, si cambia la duración, cancela el partido en marcha y lanza otro de cero.

Dentro de jugar, el minuto se espera con Task.sleep; si cancelan la tarea, Task.sleep lanza CancellationError, el catch lo anota en el registro y la función sale. Es el mismo patrón del reloj de la lección anterior.

Punto de partida. En la lección anterior, PartidoEnVivoModelo arrancaba el partido con empezar(duracion:): ese método marcaba corriendo y guardaba en la propiedad privada tarea una Task { await jugar(duracion: duracion) }; jugar(duracion:) era un método privado con el bucle de minutos. Esa Task guardada y lanzada a mano es justo lo que este paso elimina: .task la sustituye. Lo que el nuevo jugar reutiliza del modelo:

  • partido.eventos: la lista de eventos del guion del partido (goles); cada EventoDePartido tiene su minuto.
  • marcador.despuesDe(evento, en: partido): método de Marcador (del paquete Torneo) que devuelve el marcador que queda al aplicar un evento; solo los goles lo cambian. Aquí se aplica a golesEnVivo, los goles del guion; golesAMano son los de los botones.
  • identificador: el código corto de cada instancia del modelo, que se usa en el registro para distinguir una de otra.
  • msPorMinuto: los milisegundos que dura un minuto de partido.

Piezas nuevas de la lección.

  • .task(id: valor) { … }: como .task, pero cancela la tarea y lanza otra cuando valor cambia. Aquí el valor es la duración elegida.
  • for evento in eventos where evento.minuto == nuevo: un for que solo recorre los elementos que cumplen la condición (ejemplo debajo).
  • .foregroundStyle(.secondary): el color gris de los textos secundarios.
  • Task { … } con .cancel() y await tarea.value: lanza jugar, la cancela y espera a que termine, como hace SwiftUI con .task al irse la vista (en las pruebas).

Un ejemplo mínimo del for … where: recorrer solo lo que cumple una condición.

ejemplo mínimo: un for con where (con comentarios)
// Una tarea de la cocina: qué hay que hacer y en qué minuto.
struct Tarea {
    let nombre: String
    let minuto: Int
}
 
let tareas = [
    Tarea(nombre: "encender el horno", minuto: 0),
    Tarea(nombre: "meter la masa", minuto: 10),
    Tarea(nombre: "girar la bandeja", minuto: 20),
    Tarea(nombre: "sacar el pan", minuto: 30),
    Tarea(nombre: "avisar a los vecinos", minuto: 30),
]
 
// Recorre solo las tareas cuyo minuto es 30; las demás ni se miran.
for tarea in tareas where tarea.minuto == 30 {
    print("En el minuto 30: \(tarea.nombre)")
}

Lo que imprime:

En el minuto 30: sacar el pan
En el minuto 30: avisar a los vecinos

Reto · Ejercicio: una cuenta que se cancela

En tu Playground (reutiliza el mismo y borra lo anterior; si te sirve de recordatorio, la receta está en Tu primer paquete de Swift). Si tu Playground no ejecuta un await suelto, mételo dentro de Task { … }.

Escribe una función asíncrona, contarHasta, que cuente desde 1 hasta el número que recibe: espera un segundo y luego imprime el número, y así con cada uno. Si la cancelan durante una espera, tiene que imprimir cancelada en el N (con el número que estaba esperando) y salir.

  1. Lanza la cuenta hasta 10 dentro de una tarea, espera tres segundos y medio, cancela la tarea y espera a que termine. Lo que debe salir:

    1
    2
    3
    cancelada en el 4
  2. Cambia la forma de atrapar el error de la espera por la variante de try que lo convierte en nada y lo ignora, y repite. ¿Qué diferencia ves y por qué? Lo que sale esta vez:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    (Del 1 al 3 salen uno por segundo; del 4 al 10, todos a la vez.)

Ver solución
solución del ejercicio
func contarHasta(_ n: Int) async {
    for numero in 1...n {
        do {
            try await Task.sleep(for: .seconds(1))
        } catch {
            print("cancelada en el \(numero)")
            return
        }
        print(numero)
    }
}
 
let tarea = Task { await contarHasta(10) }
try? await Task.sleep(for: .seconds(3.5))
tarea.cancel()
await tarea.value

Verás 1, 2, 3 y cancelada en el 4: la cancelación llega durante la cuarta espera, y Task.sleep lanza CancellationError. Con try?, ese error se convierte en nil y el bucle no se entera: imprime del 4 al 10 casi de golpe (cada Task.sleep de una tarea ya cancelada lanza al instante y try? lo ignora), así que la tarea sigue «trabajando» tras la cancelación. Por eso el partido atrapa el error con do/catch y sale con return.

Paso 1 · El partido arranca con .task

Objetivo. Abrir un partido y verlo arrancar solo («En juego», el minuto avanza), cerrarlo y ver en el registro partido CANCELADO en el minuto ….

En Android el partido arranca con un botón «Empezar»; aquí desaparece: .task lo lanza al aparecer la pantalla. El partido se abre todavía en una hoja (la que ya tenías); la navegación de verdad llega en NavigationStack.

Trabajas en ~/Marcador-Swift/MarcadorApp, con los ficheros de la lección anterior, cada uno en su carpeta (Marcador/UI, MarcadorAppTests…). No se crea ningún fichero: se editan cuatro.

MarcadorApp
MarcadorApp
Marcador
UI
PartidoEnVivoModelo.swiftse modifica
PartidoEnDirectoView.swiftse modifica
MarcadorAppTests
MarcadorAppTests.swiftse modifica
MarcadorAppUITests
MarcadorAppUITests.swiftse modifica
Cuatro ficheros que ya existen y que se modifican.

El modelo. En PartidoEnVivoModelo.swift, borra la propiedad tarea y el método empezar, y sustituye jugar por una versión nueva. El resto del fichero no cambia. Intenta escribirla tú: jugar deja de ser privada y pasa a ser asíncrona, sin tarea propia. Recibe la duración, pone todo a cero (el minuto, los dos marcadores y el aviso, «ninguno»), marca que corre y se queda con los eventos del guion que caben en esa duración. Después, minuto a minuto: espera lo que dura un minuto; si la cancelan, anota «partido CANCELADO en el minuto 7» con el identificador del modelo entre paréntesis, deja de correr y sale; si no, aplica los goles de ese minuto, actualiza el minuto y, cada 15, el aviso «minuto 15 con 1-0». Al final, deja de correr y anota «partido TERMINADO en el minuto 90», también con el identificador.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoEnVivoModelo.swift: jugar, versión nueva
    // Juega el partido ENTERO: un minuto cada `msPorMinuto` ms. Es `async` y la llama la vista desde
    // `.task`, así que:
    //   - no crea ninguna `Task` propia: si la pantalla se va, SwiftUI cancela la tarea del `.task`;
    //   - empieza siempre de cero, porque `.task(id:)` la vuelve a lanzar cuando cambia la duración.
    func jugar(duracion: Int) async {
        // Todo a cero antes de empezar.
        minuto = 0
        golesEnVivo = Marcador(local: 0, visitante: 0)
        golesAMano = Marcador(local: 0, visitante: 0)
        ultimoAviso = "ninguno"
        corriendo = true
        // Solo los eventos que caben en la duración elegida.
        let eventos = partido.eventos.filter { $0.minuto <= duracion }
        for nuevo in 1...duracion {
            do {
                // Espera un minuto de partido.
                try await Task.sleep(for: .milliseconds(msPorMinuto))
            } catch {
                // `Task.sleep` lanza `CancellationError` si cancelan la tarea. NO se traga en
                // silencio: se deja constancia en el registro y se sale del bucle.
                Registro.anotar("partido CANCELADO en el minuto \(minuto) (\(identificador))")
                corriendo = false
                return
            }
            // Aplica los eventos de este minuto; solo los goles cambian el marcador.
            for evento in eventos where evento.minuto == nuevo {
                golesEnVivo = golesEnVivo.despuesDe(evento, en: partido)
            }
            minuto = nuevo
            if nuevo % 15 == 0 { ultimoAviso = "minuto \(nuevo) con \(marcador)" }
        }
        corriendo = false
        Registro.anotar("partido TERMINADO en el minuto \(minuto) (\(identificador))")
    }

La vista. En PartidoEnDirectoView.swift, tres cambios. Primero, el botón «Empezar partido» se sustituye por un texto de estado (dentro del VStack del minuto, donde estaba el botón), y el selector de duración deja de desactivarse (quita su .disabled(…)):

PartidoEnDirectoView.swift: el texto de estado, en lugar del botón
                    // Ya no hay botón: el partido arranca solo. Un texto cuenta cómo va.
                    Text(estado)
                        .foregroundStyle(.secondary)
                        .accessibilityIdentifier("estado")

Segundo, .task(id:) se añade como modificador del ScrollView, igual que el .onChange(of: fase) de la lección anterior. Las líneas de arriba y de abajo ya existen; añade las del medio (entre ellas puede haber un comentario // Cambio 4… de la lección anterior; déjalo donde está):

PartidoEnDirectoView.swift: el .task, entre el cierre del ScrollView y el .onChange
            .padding(16)
        }                                     // ← cierra el ScrollView (ya existe)
        // Lanza el partido al aparecer la vista y lo CANCELA al desaparecer. Con `id:`, si cambia
        // la duración, cancela el partido en marcha y lanza otro de cero.
        .task(id: ajustes.duracion) {
            await modelo.jugar(duracion: ajustes.duracion)
        }
        .onChange(of: fase) { _, nueva in     // ← ya existe

Y tercero, la propiedad calculada textoDelBoton pasa a llamarse estado:

PartidoEnDirectoView.swift: estado, en lugar de textoDelBoton
    // El texto de estado según cómo vaya el partido.
    private var estado: String {
        if modelo.minuto >= ajustes.duracion { "Partido terminado" }
        else if modelo.corriendo { "En juego" }
        else { "Sin empezar" }
    }

Mira la cancelación en el registro

Ejecuta con Cmd+R. Abre un partido de la lista y ciérralo (desliza la hoja hacia abajo) cuando el minuto vaya por unos 7 (un minuto de partido dura 250 ms, así que son unos dos segundos). Verás algo como esto; el minuto depende de cuándo cierres y el código entre paréntesis cambia en cada ejecución:

partido CANCELADO en el minuto 7 (fd045)

Error típico: .onAppear { Task { … } }

Con lo que acabas de escribir, esta es la versión «que parece igual»:

con el bug: una Task suelta
.onAppear {
    Task { await modelo.jugar(duracion: ajustes.duracion) }
}
corregido: .task
.task(id: ajustes.duracion) {
    await modelo.jugar(duracion: ajustes.duracion)
}

Falla porque la Task de onAppear no pertenece a la vista: nadie guarda esa Task ni llama a cancel(). Aunque cierres la pantalla, el partido sigue contando minutos y actualizando un modelo que ya nadie mira; además el modelo se queda en memoria mientras dure la tarea, porque la tarea lo retiene. La alternativa manual (guardar la Task en una propiedad y cancelarla en .onDisappear) funciona, pero te obliga a acordarte cada vez; .task lo hace por construcción. Lo viste con tus ojos en el laboratorio de .task frente a .onAppear.

Paso 2 · Las pruebas

El botón «Empezar partido» ya no existe, así que la prueba de interfaz de la lección anterior (testElPartidoSobreviveAlRedibujadoDelPadre) tiene que cambiar, y hay pruebas nuevas del modelo.

Las pruebas de interfaz. Son dos cambios en MarcadorAppUITests.swift. Las piezas están en la tabla de XCUITest de Tus primeras pruebas en iOS; el gesto de deslizar la hoja, en .task frente a .onAppear; y launchArguments, en @ObservedObject y el partido que se reinicia.

Primero, la prueba del redibujado ya no toca «Empezar partido», que no existe: el partido empieza solo. Borra la línea que lo pulsa y deja lo de alrededor tal cual:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: testElPartidoSobreviveAlRedibujadoDelPadre, la línea que se borra
        app.launchArguments = ["-msPorMinuto", "40"]
        app.launch()
        app.buttons["partido-1"].tap()
        app.buttons["Empezar partido"].tap()          // ← BORRA esta línea
        let minuto = app.staticTexts["minuto"]

Segundo, una prueba nueva: abre el partido 2 con minutos de 100 ms, comprueba que el texto pasa a «En juego», desliza la hoja hacia abajo para cerrarla y comprueba que vuelve a ver la lista. El registro dirá partido CANCELADO. Añádela dentro de la clase, debajo de la última prueba que tienes.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: la prueba que se añade, al final de la clase
    // Al cerrar la hoja se cancela el `.task` del partido (mira el registro: «partido CANCELADO»).
    @MainActor
    func testAlSalirDelPartidoSeCancelaElTask() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-msPorMinuto", "100"]
        app.launch()
        app.buttons["partido-2"].tap()
        XCTAssertTrue(app.staticTexts["minuto"].waitForExistence(timeout: 5))
        let enJuego = app.staticTexts["En juego"]
        XCTAssertTrue(enJuego.waitForExistence(timeout: 5))
        capturar(app, "en-juego")
        // Se desliza la hoja de arriba abajo para cerrarla.
        app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
            .press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
        XCTAssertTrue(app.buttons["partido-2"].waitForExistence(timeout: 5))
    }

Las pruebas del modelo. El guion de Ejemplo.rayoContraToros tiene tres goles: el minuto 12 (Rayo FC), el 55 (Toros) y el 80 (Rayo FC). Con 90 minutos el marcador final es 2-1; con 60 solo cuentan los dos primeros: 1-1. En MarcadorAppTests.swift se añaden cuatro pruebas al final del struct, que ya lleva @MainActor desde @Observable. Las pruebas esperan a que jugar termine (son async) y llaman al modelo, que vive en el hilo principal: por eso hace falta ese @MainActor.

Sin él, Xcode avisa con un mensaje como este (entre otros avisos parecidos):

main actor-isolated initializer 'init(partido:msPorMinuto:)' cannot be called from outside of the actor; this is an error in the Swift 6 language mode

Es un aviso y no un error porque tu proyecto está en el modo de Swift 5; en el modo Swift 6 sí lo sería. Con @MainActor no sale.

Intenta escribir las pruebas tú, todas con el partido de ejemplo:

  1. jugarAplicaLosGolesDelGuion: con minutos de 0 ms y 90 de duración, acaba en el minuto 90, 2-1, sin correr y con el aviso «minuto 90 con 2-1».
  2. unPartidoDeSesentaMinutosIgnoraLosGolesPosteriores: con 60 minutos, acaba 1-1.
  3. losGolesAManoSeSumanAlDelGuion: sin jugar, un gol a mano del local y dos del visitante dejan el marcador 1-2.
  4. cancelarLaTareaDetieneElPartido: con minutos de 20 ms, lanza el partido en una tarea, espera 150 ms, la cancela y espera a que termine; el minuto no llegó a 90 y el partido no corre.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests.swift: lo que se añade
// Dentro del `struct MarcadorAppTests`, tras la última prueba que ya tienes:
 
    // El partido ENTERO con un minuto de 0 ms: los goles del guion suben el marcador.
    @Test func jugarAplicaLosGolesDelGuion() async {
        let modelo = PartidoEnVivoModelo(partido: Ejemplo.rayoContraToros, msPorMinuto: 0)
        await modelo.jugar(duracion: 90)
        #expect(modelo.minuto == 90)
        #expect(modelo.marcador == Marcador(local: 2, visitante: 1))
        #expect(!modelo.corriendo)
        #expect(modelo.ultimoAviso == "minuto 90 con 2-1")
    }
 
    // Un partido de 60 minutos ignora el gol del minuto 80 (los goles del guion: minutos 12, 55 y 80).
    @Test func unPartidoDeSesentaMinutosIgnoraLosGolesPosteriores() async {
        let modelo = PartidoEnVivoModelo(partido: Ejemplo.rayoContraToros, msPorMinuto: 0)
        await modelo.jugar(duracion: 60)
        #expect(modelo.marcador == Marcador(local: 1, visitante: 1))
    }
 
    // Los goles «a mano» se suman a los del guion.
    @Test func losGolesAManoSeSumanAlDelGuion() async {
        let modelo = PartidoEnVivoModelo(partido: Ejemplo.rayoContraToros, msPorMinuto: 0)
        modelo.golAMano(.local)
        modelo.golAMano(.visitante)
        modelo.golAMano(.visitante)
        #expect(modelo.marcador == Marcador(local: 1, visitante: 2))
    }
 
    // Cancelar la tarea detiene el bucle: es lo que hace SwiftUI con `.task` al irse la vista.
    @Test func cancelarLaTareaDetieneElPartido() async {
        let modelo = PartidoEnVivoModelo(partido: Ejemplo.rayoContraToros, msPorMinuto: 20)
        let tarea = Task { await modelo.jugar(duracion: 90) }      // lanza el partido
        try? await Task.sleep(for: .milliseconds(150))             // deja pasar unos minutos
        tarea.cancel()                                             // lo cancela
        await tarea.value                                          // espera a que termine
        #expect(modelo.minuto < 90)                                // no llegó al final
        #expect(!modelo.corriendo)                                 // y quedó parado
    }

Cmd+U lanza todas las pruebas: las 9 unitarias y las 10 de interfaz (las 9 de MarcadorAppUITests más testLaunch), 19 en total, y las de interfaz tardan. Para pasar solo las unitarias, desde la Terminal (-only-testing deja fuera las de interfaz):

cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppTests test

Si tu simulador no se llama iPhone 17, mira los que tienes con xcrun simctl list devices available y pon el nombre de uno de ellos.

Con esta lección termina el módulo «iOS con SwiftUI»: ya tienes pantallas, estado, listas, identidad y tareas. El siguiente módulo, «Arquitectura en iOS», parte de esta misma app y la ordena en capas (MVVM, repositorio y caso de uso), con navegación, persistencia, pruebas y red.

Punto de control

  • Al abrir un partido no hay botón «Empezar»: el texto pasa de «Sin empezar» a «En juego» y el minuto avanza.
  • Al cerrar la hoja, el registro muestra partido CANCELADO en el minuto …. Cambiar la duración a 60 con el partido en marcha lo reinicia.
  • En el laboratorio, el .task se cancela al cerrar y el .onAppear llega hasta onAppear: terminó.
  • Las pruebas pasan: el comando de arriba termina con ** TEST SUCCEEDED ** (9 unitarias) y Cmd+U, con las 19, también.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.En .task(id: ajustes.duracion) { … }, el usuario cambia la duración de 90 a 60. ¿Qué hace SwiftUI?

  2. 2.El bucle de jugar espera con try? await Task.sleep(…) en vez de atrapar el error con do/catch. Cancelan la tarea. ¿Qué pasa?

  3. 3.jugar ya no crea su propia Task. ¿Por qué no hace falta?

  4. 4.En la prueba cancelarLaTareaDetieneElPartido, tras tarea.cancel() se escribe await tarea.value. ¿Para qué?

Faltan 4 de 4