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); cadaEventoDePartidotiene suminuto.marcador.despuesDe(evento, en: partido): método deMarcador(del paqueteTorneo) que devuelve el marcador que queda al aplicar un evento; solo los goles lo cambian. Aquí se aplica agolesEnVivo, los goles del guion;golesAManoson 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 cuandovalorcambia. Aquí el valor es la duración elegida.for evento in eventos where evento.minuto == nuevo: unforque solo recorre los elementos que cumplen la condición (ejemplo debajo)..foregroundStyle(.secondary): el color gris de los textos secundarios.Task { … }con.cancel()yawait tarea.value: lanzajugar, la cancela y espera a que termine, como hace SwiftUI con.taskal irse la vista (en las pruebas).
Un ejemplo mínimo del for … where: recorrer solo lo que cumple una condición.
// 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 vecinosReto · 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.
-
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 -
Cambia la forma de atrapar el error de la espera por la variante de
tryque 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
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.valueVerá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.
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
// 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(…)):
// 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á):
.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 existeY tercero, la propiedad calculada textoDelBoton pasa a llamarse estado:
// 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»:
.onAppear {
Task { await modelo.jugar(duracion: ajustes.duracion) }
}.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
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
// 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 modeEs 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:
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».unPartidoDeSesentaMinutosIgnoraLosGolesPosteriores: con 60 minutos, acaba 1-1.losGolesAManoSeSumanAlDelGuion: sin jugar, un gol a mano del local y dos del visitante dejan el marcador 1-2.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
// 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 testSi 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
.taskse cancela al cerrar y el.onAppearllega hastaonAppear: 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.En
.task(id: ajustes.duracion) { … }, el usuario cambia la duración de 90 a 60. ¿Qué hace SwiftUI?2.El bucle de
jugarespera contry? await Task.sleep(…)en vez de atrapar el error condo/catch. Cancelan la tarea. ¿Qué pasa?3.
jugarya no crea su propiaTask. ¿Por qué no hace falta?4.En la prueba
cancelarLaTareaDetieneElPartido, trastarea.cancel()se escribeawait tarea.value. ¿Para qué?