El estado de pantalla en el actor principal
Al terminar: Mantener el estado de una pantalla en el actor principal.
Al terminar esta lección tienes SeguimientoEnPantalla: una clase del actor principal que guarda lo que una pantalla mostraría (el marcador y la narración de un partido) y se va actualizando sola mientras el partido avanza. Al ejecutar torneo-cli, imprime Marcador: 2-1 y Narración: 5 líneas; primera: 12' Gol de Ana (Rayo FC).
Importa porque es la forma de todas las pantallas de la app de iOS: una clase con el estado, que vive en el actor principal, y una tarea guardada para poder cancelarla. Aquí no hay pantalla todavía, pero la clase ya es la que usará. Temas: una clase @MainActor con una Task guardada, await tarea?.value y nonisolated para lo que no toca estado.
Una clase que guarda el estado de una pantalla
El estado de pantalla es el conjunto de datos que una pantalla muestra y que cambian con el tiempo: el marcador de un partido, las líneas de la narración, si está cargando. Una pantalla no calcula nada por sí sola: dibuja lo que dice ese estado. Por eso se guarda en una clase aparte y, como la pantalla se dibuja en el actor principal, la clase también vive ahí: la marcas @MainActor y el compilador impide que otro hilo toque el estado a escondidas.
Esa clase suele necesitar tres piezas:
- Una tarea en marcha que se pueda cancelar. Si el usuario cambia de partido, la tarea del anterior debe parar. Se guarda en una propiedad de tipo
Task<Void, Never>?: una tarea que no devuelve nada (Void), no puede fallar (Never) y es opcional (?) porque al principio no hay ninguna (nil).tarea?.cancel()la cancela si existe, y no hace nada si esnil. - Una espera por la tarea.
await tarea?.valueespera a que termine, y valenilsi no había tarea. Es el.valuede async/await. Los tests ymain.swiftlo usan para saber cuándo mirar el resultado. - Lo que no toca estado, fuera del aislamiento. Un método que solo da formato a un texto se marca
nonisolatedy se llama desde cualquier sitio sinawait, como viste en @MainActor y el hilo principal.
Dentro de la clase hay una pieza más que ya conoces: una Task { … } creada en un método @MainActor hereda el actor principal, así que su cuerpo cambia el estado sin escribir await delante de cada propiedad. Lo que sí lleva await es cada pausa: mientras espera, la tarea suelta el hilo principal y la pantalla sigue viva.
@MainActor // todo lo de dentro se toca desde el actor principal
final class Contador {
private(set) var cuenta = 0 // lo que la pantalla mostraría: solo la clase lo cambia
private(set) var historial: [String] = [] // una lista de textos, vacía al principio
private var tarea: Task<Void, Never>? // la tarea en marcha; nil mientras no hay ninguna
func empezar(hasta limite: Int) {
tarea?.cancel() // si ya había una, se cancela (con nil no hace nada)
tarea = Task { // hereda el actor principal: toca self sin await
for numero in 1...limite {
do {
try await Task.sleep(for: .milliseconds(5)) // pausa; aquí suelta el hilo un momento
} catch {
return // si cancelan la tarea, sale del bucle
}
self.cuenta = numero
self.historial.append("van \(numero)")
}
}
}
func esperar() async {
await tarea?.value // espera a que la tarea termine (si hay tarea)
}
nonisolated func etiqueta(_ n: Int) -> String { // no toca estado: se llama desde cualquier sitio
"Cuenta: \(n)"
}
}
let contador = Contador() // el nivel superior del Playground es del actor principal
contador.empezar(hasta: 3)
await contador.esperar()
print(contador.etiqueta(contador.cuenta))
print(contador.historial)Cuenta: 3
["van 1", "van 2", "van 3"]Fíjate en el do … catch de la pausa: Task.sleep lanza un error si cancelan la tarea, y ese error es el aviso para salir. Con try? el error se ignoraría y el bucle seguiría contando aunque nadie lo quiera. En main.swift y en un Playground no hace falta await para leer contador.cuenta, porque el código del nivel superior corre en el actor principal.
Reto · Ejercicio: una tostadora en el actor principal
En tu Playground (borra lo anterior), escribe una clase final, Tostadora, del actor principal. Guarda una lista de pasos, que solo cambia ella, y la tarea que está tostando. Tiene tres métodos:
tostar, que recibe cuántas vueltas dar. Cancela lo que estuviera tostando, vacía los pasos y lanza una tarea que, en cada vuelta, espera 10 milisegundos y apunta «vuelta» seguido del número de vuelta. Si cancelan la tarea, sale.esperar, que espera a que esa tarea termine.aviso, que recibe un número, devuelve «Tostada en» seguido de ese número y «vueltas», y se puede llamar desde cualquier sitio sin esperar.
Pide tostar 5 vueltas e, inmediatamente, 2. Espera, imprime los pasos e imprime el aviso con cuántos pasos hay. Lo que debe salir:
["vuelta 1", "vuelta 2"]
Tostada en 2 vueltasVer solución
@MainActor
final class Tostadora {
private(set) var pasos: [String] = []
private var tarea: Task<Void, Never>?
func tostar(_ veces: Int) {
tarea?.cancel() // si ya tostaba, se cancela
pasos = []
tarea = Task { // hereda el actor principal
for vuelta in 1...veces {
do { try await Task.sleep(for: .milliseconds(10)) } catch { return } // cancelada: sale
self.pasos.append("vuelta \(vuelta)")
}
}
}
func esperar() async { await tarea?.value }
nonisolated func aviso(_ cuantas: Int) -> String { "Tostada en \(cuantas) vueltas" }
}
let tostadora = Tostadora()
tostadora.tostar(5)
tostadora.tostar(2) // cancela la primera antes de su primera vuelta
await tostadora.esperar()
print(tostadora.pasos)
print(tostadora.aviso(tostadora.pasos.count))La primera tarea (5 vueltas) se cancela antes de su primera vuelta, y por eso solo quedan las dos de la segunda. Con try? await Task.sleep en lugar de do … catch, la tarea cancelada no se pararía: el sleep lanza el error al momento, try? lo traga y el bucle sigue. aviso no toca los pasos, así que puede ser nonisolated.
Paso · SeguimientoEnPantalla, el estado de pantalla en el actor principal
Objetivo: escribir SeguimientoEnPantalla, una clase marcada @MainActor que guarda lo que una pantalla mostraría (marcador y narración) y se va actualizando mientras el partido avanza; y probarla. Al terminar, xcrun swift run torneo-cli imprime Marcador: 2-1 y Narración: 5 líneas; primera: 12' Gol de Ana (Rayo FC).
Trabajas en ~/Marcador-Swift/Torneo. Crea Seguimiento.swift: en el panel izquierdo de Xcode, clic derecho sobre Sources → Torneo → New File from Template… → Swift File → Next, escribe Seguimiento y pulsa Create. En un paquete no eliges «target» como en una app: el fichero pertenece a la carpeta donde lo creas. Si prefieres la Terminal, touch Sources/Torneo/Seguimiento.swift, desde ~/Marcador-Swift/Torneo, hace lo mismo. SeguimientoTests.swift no lo escribes: lo rellenas con un botón, más abajo.
Piezas nuevas de este paso:
@MainActor struct …Tests: una suite de tests entera que corre en el actor principal, para usar la clase sinawaiten cada línea.- Nombres que exigen los tests que vas a copiar:
SeguimientoEnPantalla, que se crea sin nada, conseguir(recibe un partido en vivo sin etiqueta),detener,esperarAlFinal(que espera, y por eso es asíncrona),titular(recibe un marcador sin etiqueta, devuelve un texto y se puede llamar desde cualquier sitio), y las propiedadesmarcadorynarracion(una lista de textos).Marcadory sudespuesDeya existen de lecciones anteriores, ydescribir, de Enums con valores asociados.
Ya las viste: @MainActor delante de una clase, Task<Void, Never>?, await tarea?.value y nonisolated, todo en el ejemplo y el ejercicio de arriba.
Intenta escribirlo tú, en dos partes.
- En
Seguimiento.swift,SeguimientoEnPantalla: una clase pública del actor principal de la que nadie hereda.- Su
marcador(0-0 al principio) y sunarracion(vacía) solo los cambia ella. seguircancela lo que estuviera siguiendo, vuelve a empezar de cero y lanza una tarea que recorre los eventos del partido según llegan, añadiendo cada uno descrito a la narración y actualizando el marcador.detenercancela esa tarea yesperarAlFinalespera a que termine.titulardevuelve «Marcador: 2-1» con el marcador que reciba.
- Su
- Al final de
main.swift, con un título: sigue un Rayo contra Toros en vivo a 2 ms por minuto, espera al final, e imprime el titular y cuántas líneas tiene la narración, con la primera.
Lo que debes ver:
Marcador: 2-1
Narración: 5 líneas; primera: 12' Gol de Ana (Rayo FC)Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@MainActor
public final class SeguimientoEnPantalla {
public private(set) var marcador = Marcador(local: 0, visitante: 0)
public private(set) var narracion: [String] = []
private var tarea: Task<Void, Never>?
public init() {}
public func seguir(_ partido: PartidoEnVivo) {
tarea?.cancel()
marcador = Marcador(local: 0, visitante: 0)
narracion = []
let guion = partido.guion // una copia del guion en una constante: la tarea usa esa copia
tarea = Task {
for await evento in partido.eventos {
self.narracion.append(describir(evento))
self.marcador = self.marcador.despuesDe(evento, en: guion)
}
}
}
public func detener() {
tarea?.cancel()
tarea = nil
}
public func esperarAlFinal() async {
await tarea?.value
}
public nonisolated func titular(_ marcador: Marcador) -> String {
"Marcador: \(marcador)"
}
}let guion = partido.guion guarda una copia del guion del partido en una constante antes de lanzar la tarea: dentro, la tarea usa esa copia en cada evento y al partido solo le pide sus eventos.
En main.swift, añade al final del fichero (el título de la primera línea es el tuyo: ponle el que quieras):
print("== El estado de pantalla vive en el actor principal ==")
let seguimiento = SeguimientoEnPantalla()
seguimiento.seguir(PartidoEnVivo(guion: Ejemplo.rayoContraToros, msPorMinuto: 2))
await seguimiento.esperarAlFinal()
print(seguimiento.titular(seguimiento.marcador))
print("Narración: \(seguimiento.narracion.count) líneas; primera: \(seguimiento.narracion.first ?? "-")")El código de nivel superior de main.swift corre siempre en el actor principal en Swift 6, por eso usa seguimiento sin await en cada línea (el único await es el de esperarAlFinal(), que sí espera).
Los cuatro tests de SeguimientoTests.swift no los escribes: comprueban la clase que acabas de escribir, y lo que enseñan es la clase. Para ese tipo de fichero la guía te da un botón, Copiar fichero, que ya usaste en Clases y objetos: copia el contenido del fichero completo al portapapeles y no escribe nada en tu proyecto. El orden es siempre el mismo: crear (o abrir) el fichero → pulsar el botón → seleccionar todo → pegar.
- En el panel izquierdo de Xcode, clic derecho sobre
Tests → TorneoTests→ New File from Template… → Swift File → Next, escribeSeguimientoTestsy pulsa Create. Se abre el fichero con unas líneas de plantilla. - Pulsa el botón Copiar fichero de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro de
SeguimientoTests.swift, selecciona todo con Cmd+A y pega con Cmd+V. Sustituye lo que había.
El fichero abre con @MainActor struct …Tests: la suite entera corre en el actor principal, que es donde vive SeguimientoEnPantalla.
Lo que hace cada cambio de la lección:
| Cambio | Para qué |
|---|---|
@MainActor en SeguimientoEnPantalla | El estado de pantalla solo se toca desde el actor principal |
tarea guardada y tarea?.cancel() al empezar | Un partido nuevo no se mezcla con el anterior |
Task { … } dentro de seguir | Hereda el actor principal: cambia el estado sin await |
nonisolated en titular | No toca estado: se llama desde cualquier sitio |
@MainActor en la suite de tests | Los tests usan la clase sin await |
Punto de control
- Desde
~/Marcador-Swift/Torneo,xcrun swift build --build-teststermina sin ninguna línea conwarning:nierror:. xcrun swift run torneo-cliimprime al finalMarcador: 2-1yNarración: 5 líneas; primera: 12' Gol de Ana (Rayo FC).xcrun swift testtermina conTest run with 129 tests in 17 suites passed.- Sabes decir por qué
main.swiftusaseguimiento.marcadorsinawait, y por qué dentro deseguirla tarea cambiaself.narracionsinawait.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué
main.swiftusaseguimiento.marcadorsinawait?2.Dentro de
seguir,tarea = Task { … self.narracion.append(…) }no llevaawaitdelante deself.narracion. ¿Por qué?3.¿Qué permite que
titularseanonisolated?4.¿Para qué es
@MainActordelante destruct SeguimientoTests?5.
seguirempieza contarea?.cancel(). ¿Para qué?6.En una tarea guardada que espera con
Task.sleep, ¿por qué convienedo … catchy notry??