.task frente a .onAppear
Al terminar: Lanzar trabajo asíncrono ligado a la vista con .task, que se cancela solo.
Al terminar, tendrás una pantalla de laboratorio en Marcador con dos contadores que demuestran qué pasa con el trabajo asíncrono según cómo lo lances: uno con .task, que se para solo al cerrar la hoja, y otro con .onAppear { Task { … } }, que sigue contando con la pantalla ya cerrada. En la lección siguiente aplicarás lo aprendido al partido.
Importa porque una tarea que sigue viva sin pantalla gasta batería y red para nada, y modifica un estado que nadie ve. .task resuelve eso con una línea: la tarea vive lo que vive la vista.
.task: una tarea con la vida de la vista
.task { … } es un modificador de vista que lanza trabajo asíncrono (puedes escribir await dentro). SwiftUI lo arranca cuando la vista aparece y lo cancela cuando la vista desaparece. Con .task(id: valor) { … }, si valor cambia, SwiftUI cancela la tarea vieja y lanza una nueva; para detectar el cambio compara el valor con el anterior, así que su tipo tiene que ser Equatable (que se puede comparar con ==).
Un ejemplo mínimo con .task: un reloj que cuenta segundos mientras está en pantalla y se para al cerrarla.
import SwiftUI
struct Reloj: View {
// Los segundos contados; al cambiar, la vista se redibuja.
@State private var segundos = 0
var body: some View {
Text("\(segundos) s")
// `.task`: arranca al aparecer la vista y SwiftUI la cancela cuando desaparece.
.task {
for _ in 1...10 { // diez vueltas, una por segundo
do {
try await Task.sleep(for: .seconds(1)) // espera un segundo
} catch {
return // la cancelaron: salir sin seguir contando
}
segundos += 1 // un segundo más
}
}
}
}Lo que se ve, con la vista abierta en una hoja:
0 s (al aparecer)
1 s (un segundo después)
…
10 s (y se queda ahí)Si cierras la hoja a los cuatro segundos, Task.sleep lanza CancellationError, el catch hace return y el contador no sigue.
Ahora el mismo tipo de trabajo con .onAppear. Es un bloque normal (síncrono) que se ejecuta cuando la vista aparece; como ahí no se puede escribir await, para trabajo asíncrono hay que abrir una Task dentro. Esa Task es nueva y no pertenece a la vista: nadie la cancelará.
import SwiftUI
struct Saludo: View {
@State private var texto = "…"
var body: some View {
Text(texto)
.onAppear { // bloque normal: aquí no se puede escribir await
Task { // una Task NUEVA, sin dueño: nadie la cancela
try? await Task.sleep(for: .seconds(1))
texto = "Hola" // corre aunque la vista ya no se vea
}
}
}
}La vista enseña «…» y, un segundo después, «Hola». Si la cierras antes de ese segundo, la Task sigue viva y cambia texto de una pantalla que ya no existe.
.task { } | .onAppear { Task { } } | LaunchedEffect(key) de Compose | |
|---|---|---|---|
Permite await dentro | Sí | No: el bloque es normal; hay que abrir una Task | Sí (es una corrutina) |
| Al desaparecer la vista | SwiftUI la cancela | Nadie la cancela: sigue viva | Se cancela al salir de la composición |
| Cambia una clave | .task(id:) cancela y relanza | Hay que hacerlo a mano | Cancela y relanza |
| Cancelación en el código | Task.sleep lanza CancellationError | Igual, pero nadie la provoca | delay lanza CancellationException |
.onAppear sigue siendo la herramienta correcta para trabajo síncrono y corto (poner un foco, anotar un evento). El problema aparece al abrir dentro una Task.
Reto · Ejercicio: el temporizador de huevos
Crea una subcarpeta nueva, Huevos, dentro de Ejercicios (como en El ciclo de vida de UIViewController), con un fichero, TemporizadorDeHuevos.swift, y una vista, TemporizadorDeHuevos. La vista:
- guarda dos números: la duración elegida (empieza en 5 segundos) y los segundos que quedan;
- enseña en grande «Quedan 5 s» y, cuando llega a 0, «¡Huevos listos!»;
- tiene debajo, en fila, dos botones, «3 s» y «5 s», que eligen la duración;
- lleva una cuenta atrás atada a la vista y a la duración: empieza al aparecer, resta uno cada segundo y, si cambia la duración, vuelve a empezar desde la nueva. Si la cancelan, sale sin restar más.
Añade una vista previa. Lo que debes ver en ella: «Quedan 5 s», «Quedan 4 s»… hasta «¡Huevos listos!». Pulsa «3 s» a mitad de la cuenta: vuelve a «Quedan 3 s» y llega a «¡Huevos listos!».
Para pensar: si pulsas «3 s» otra vez cuando ya acabó, no pasa nada. ¿Por qué?
Ver solución
import SwiftUI
// Cuenta atrás con .task(id:): si cambia la duración, SwiftUI cancela la cuenta y empieza otra.
struct TemporizadorDeHuevos: View {
@State private var duracion = 5 // los segundos elegidos: es el id del .task
@State private var restantes = 5 // lo que se ve antes de que arranque la cuenta
var body: some View {
VStack(spacing: 16) {
Text(restantes > 0 ? "Quedan \(restantes) s" : "¡Huevos listos!")
.font(.title)
.monospacedDigit()
HStack {
Button("3 s") { duracion = 3 }
Button("5 s") { duracion = 5 }
}
.buttonStyle(.bordered)
}
.padding()
.task(id: duracion) {
restantes = duracion
while restantes > 0 {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return // cancelada: se sale sin seguir restando
}
restantes -= 1
}
}
}
}
#Preview {
TemporizadorDeHuevos()
}Con «3 s» a mitad de la cuenta, duracion cambia, SwiftUI cancela la tarea vieja (el catch sale) y lanza otra que empieza en 3. Si pulsas «3 s» cuando ya era 3, el id no cambió, así que SwiftUI no relanza la tarea y no ocurre nada.
El while lo viste en AsyncSequence y AsyncStream: repite mientras se cumpla la condición.
Paso 1 · Un contador con .task
Objetivo. Una hoja de laboratorio con un contador que cuenta cada 200 ms hasta 50 (10 segundos), lanzado con .task. Al cerrar la hoja, la cuenta se detiene sola y lo dice el registro.
Trabajas en ~/Marcador-Swift/MarcadorApp, con los ficheros de la lección anterior, cada uno en su carpeta.
Crea TaskFrenteAOnAppearView.swift en Laboratorio, porque es una prueba para ver cómo funciona SwiftUI, no una parte de Marcador: clic derecho sobre la carpeta Laboratorio → New File from Template… → Swift File → Next, el nombre TaskFrenteAOnAppearView y Create. Si el menú de tu versión de Xcode se llama distinto, usa la Terminal: touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Laboratorio/TaskFrenteAOnAppearView.swift (la carpeta Laboratorio ya existe), y Xcode lo muestra solo.
Piezas nuevas de este paso.
func contar(…) async: una funciónasyncnormal; no crea ningunaTask, la crea.task.actualizar: @escaping (Int) -> Voidy{ conTask = $0 }: una función como parámetro y su forma abreviada de closure (Closures).$0es el primer parámetro.@escapingavisa a Swift de que esa función puede seguir viva después de quecontarempiece a esperar; se escribe así y no hace falta darle más vueltas.Registro.anotar(_:): la función de registro que ya tienes (La app, sus escenas y el paquete Torneo).
Tampoco son nuevos .task (lo acabas de ver) ni el do { try await Task.sleep(…) } catch { … return }, que viste en el ejemplo del reloj y en Cancelar tareas.
Intenta escribir esta primera versión: un contador que cuenta cada 200 ms hasta 50, lanzado con .task, y que anota en el registro cuando la cuenta se cancela. Cuando termines, abre el bloque.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
// Un contador que cuenta cada 200 ms hasta 50 (10 segundos), lanzado con `.task`.
struct TaskFrenteAOnAppearView: View {
// El número que lleva la cuenta.
@State private var conTask = 0
var body: some View {
VStack(spacing: 16) {
Text("Cierra esta hoja y mira el registro").font(.headline)
Text("con .task: \(conTask)").accessibilityIdentifier("contadorTask")
}
.padding()
// `.task`: arranca al aparecer la vista y SwiftUI la cancela cuando desaparece.
.task {
await contar(nombre: "task") { conTask = $0 }
}
}
// Cuenta de 1 a 50, un número cada 200 ms. Cada vez llama a `actualizar` con el número.
private func contar(nombre: String, actualizar: @escaping (Int) -> Void) async {
for tick in 1...50 {
do {
try await Task.sleep(for: .milliseconds(200))
} catch {
// Si cancelan la tarea, `Task.sleep` lanza este error: se anota y se sale.
Registro.anotar("\(nombre): CANCELADO en el tick \(tick)")
return
}
actualizar(tick)
Registro.anotar("\(nombre): tick \(tick)")
}
Registro.anotar("\(nombre): terminó")
}
}
#Preview {
TaskFrenteAOnAppearView()
}Para abrir esta pantalla, LaboratorioView.swift gana un estado, un botón y una hoja. Tres añadidos; el resto del fichero no cambia:
// Si la hoja del experimento está abierta.
@State private var mostrandoTask = false Button(".task frente a .onAppear") { mostrandoTask = true }
.buttonStyle(.bordered) .sheet(isPresented: $mostrandoTask) {
TaskFrenteAOnAppearView()
}Ejecuta con Cmd+R. Abre la pestaña «Laboratorio», pulsa «.task frente a .onAppear», y cierra la hoja deslizándola hacia abajo a los dos segundos. El registro de la app sale en la consola de Xcode (el panel de abajo; si no lo ves, View → Debug Area → Activate Console, o Cmd+Shift+Y): Registro.anotar escribe cada mensaje por dos caminos, con print (la consola de Xcode) y en el registro del sistema, que se lee después con log show (lo harás en el paso siguiente). Verás algo como esto (el tick en que se corta depende de cuándo cierres):
task: tick 1 … task: tick 8
task: CANCELADO en el tick 9Paso 2 · El mismo contador con .onAppear
Objetivo. Añadir a la hoja un segundo contador idéntico, pero lanzado con .onAppear { Task { … } }, y ver la diferencia.
Añade el segundo contador a TaskFrenteAOnAppearView.swift. Intenta escribirlo tú: otro número que empieza en 0, otra línea debajo de la primera que dice «con .onAppear: N» (con el nombre interno contadorOnAppear), y la misma cuenta que el primero, con el nombre «onAppear» en el registro, pero lanzada en una tarea nueva al aparecer la vista. Lo que debes ver en el registro, tras abrir la hoja y cerrarla a los dos segundos (el tick del corte depende de cuándo cierres):
task: CANCELADO en el tick 9
onAppear: tick 50
onAppear: terminóIntenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
// Dos contadores que cuentan cada 200 ms hasta 50 (10 segundos): uno lanzado con `.task` y otro con
// `.onAppear { Task { … } }`.
struct TaskFrenteAOnAppearView: View {
// Los números que llevan las dos cuentas.
@State private var conTask = 0
@State private var conOnAppear = 0
var body: some View {
VStack(spacing: 16) {
Text("Cierra esta hoja y mira el registro").font(.headline)
Text("con .task: \(conTask)").accessibilityIdentifier("contadorTask")
Text("con .onAppear: \(conOnAppear)").accessibilityIdentifier("contadorOnAppear")
}
.padding()
// `.task`: arranca al aparecer la vista y SwiftUI la cancela cuando desaparece.
.task {
await contar(nombre: "task") { conTask = $0 }
}
.onAppear {
// Una `Task` NUEVA y sin dueño: nadie la cancela.
Task { await contar(nombre: "onAppear") { conOnAppear = $0 } }
}
}
// Cuenta de 1 a 50, un número cada 200 ms. Cada vez llama a `actualizar` con el número.
private func contar(nombre: String, actualizar: @escaping (Int) -> Void) async {
for tick in 1...50 {
do {
try await Task.sleep(for: .milliseconds(200))
} catch {
// Si cancelan la tarea, `Task.sleep` lanza este error: se anota y se sale.
Registro.anotar("\(nombre): CANCELADO en el tick \(tick)")
return
}
actualizar(tick)
Registro.anotar("\(nombre): tick \(tick)")
}
Registro.anotar("\(nombre): terminó")
}
}
#Preview {
TaskFrenteAOnAppearView()
}Ejecuta con Cmd+R, abre la hoja desde «Laboratorio», ciérrala a los dos segundos y espera hasta completar unos diez segundos desde que la abriste. La app sigue ejecutándose (solo cerraste la hoja), así que la consola de Xcode va escribiendo el registro. Si prefieres leerlo desde una Terminal, con el simulador arrancado:
xcrun simctl spawn booted log show --last 5m --predicate 'subsystem == "com.sebastiannarvaez.marcador"' | grep -E "CANCELADO|tick|terminó"Verás algo como esto (los tick intermedios se han omitido):
task: tick 1 … task: tick 8
task: CANCELADO en el tick 9
onAppear: tick 1 … onAppear: tick 50
onAppear: terminóEl tick en que se corta el .task depende de cuándo cierres la hoja. Lo que no cambia es el contraste: el .task se para al cerrar y el .onAppear llega hasta 50 con la pantalla ya cerrada. Esa es la fuga de una Task suelta.
Paso 3 · Una prueba de interfaz del laboratorio
Objetivo. Una prueba que abre la hoja del laboratorio, la cierra a los dos segundos y deja la app viva unos segundos más para que el registro enseñe qué Task siguió contando.
Piezas nuevas de este paso.
coordinate(withNormalizedOffset:): un punto de la pantalla dado en proporción (dx: 0.5es la mitad del ancho;dy: 0.15, el 15 % del alto, contando desde arriba). Esas dos proporciones van dentro deCGVector(dx:dy:), que no es más que un par de números con nombre.press(forDuration:thenDragTo:): pulsa en un punto y arrastra hasta otro. Aquí, de arriba de la hoja a abajo del todo: es deslizarla para cerrarla.Thread.sleep(forTimeInterval:): deja pasar los segundos que le digas sin hacer nada.
Las demás piezas (launch, tabBars.buttons, waitForExistence) están en la tabla de XCUITest de Tus primeras pruebas en iOS, y capturar, en El ciclo de vida de UIViewController.
En MarcadorAppUITests.swift, añade esta prueba dentro de la clase, debajo de la última que tienes (antes de la llave que cierra la clase). Intenta escribirla tú primero con esas piezas: abre la pestaña «Laboratorio», pulsa «.task frente a .onAppear», espera a que exista el texto contadorTask, deja pasar dos segundos, saca una captura llamada «contadores», cierra la hoja deslizándola, comprueba que vuelve a ver el botón «Ciclo de vida (UIKit)» y espera cuatro segundos más.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// `.task` frente a `.onAppear { Task }`: se abre y se cierra a los 2 s.
@MainActor
func testTaskFrenteAOnAppear() throws {
let app = XCUIApplication()
app.launch()
app.tabBars.buttons["Laboratorio"].tap()
app.buttons[".task frente a .onAppear"].tap()
XCTAssertTrue(app.staticTexts["contadorTask"].waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 2)
capturar(app, "contadores")
// 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["Ciclo de vida (UIKit)"].waitForExistence(timeout: 5))
// Se deja la app viva unos segundos para que el registro enseñe qué Task siguió contando.
Thread.sleep(forTimeInterval: 4)
}Cmd+U lanza todas las pruebas: las 5 unitarias y las 9 de interfaz (las 8 de antes más esta), 14 en total; las de interfaz tardan.
Punto de control
- En el laboratorio, el botón «.task frente a .onAppear» abre la hoja con los dos contadores.
- Al cerrar la hoja, el registro muestra
task: CANCELADO en el tick …, y elonAppearllega hastaonAppear: terminó. - Tu ejercicio
Huevoscompila y su vista previa cuenta hasta «¡Huevos listos!». - Cmd+U termina en
** TEST SUCCEEDED **con 14 pruebas: 5 unitarias y 9 de interfaz.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Una vista lanza trabajo con
.task { … }. ¿Qué pasa con esa tarea cuando la vista desaparece?2.Una vista lanza una cuenta de 50 pasos con
.onAppear { Task { await contar() } }y cierras la pantalla a mitad de la cuenta. ¿Qué ocurre?3.¿Cuál es el equivalente en Compose de
.task(id: valor) { … }?4.En el laboratorio cierras la hoja a los dos segundos. ¿Hasta dónde llega cada contador?