Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso30 min de lectura · 10 min de máquina Intermedio

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 es nil.
  • Una espera por la tarea. await tarea?.value espera a que termine, y vale nil si no había tarea. Es el .value de async/await. Los tests y main.swift lo 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 nonisolated y se llama desde cualquier sitio sin await, 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.

ejemplo mínimo: una clase @MainActor con una Task guardada y nonisolated (con comentarios)
@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 vueltas
Ver solución
solución: una clase @MainActor con una Task guardada
@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).

Torneo
Sources
Torneo
Seguimiento.swiftse crea
torneo-cli
main.swiftse modifica
Tests
TorneoTests
SeguimientoTests.swiftse crea y se rellena con «Copiar fichero»
Dos ficheros nuevos y uno que cambia, en el Project navigator.

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 sin await en cada línea.
  • Nombres que exigen los tests que vas a copiar: SeguimientoEnPantalla, que se crea sin nada, con seguir (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 propiedades marcador y narracion (una lista de textos). Marcador y su despuesDe ya existen de lecciones anteriores, y describir, 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.

  1. En Seguimiento.swift, SeguimientoEnPantalla: una clase pública del actor principal de la que nadie hereda.
    • Su marcador (0-0 al principio) y su narracion (vacía) solo los cambia ella.
    • seguir cancela 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.
    • detener cancela esa tarea y esperarAlFinal espera a que termine.
    • titular devuelve «Marcador: 2-1» con el marcador que reciba.
  2. 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
Torneo/Sources/Torneo/Seguimiento.swift
@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):

Torneo/Sources/torneo-cli/main.swift
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.

  1. En el panel izquierdo de Xcode, clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next, escribe SeguimientoTests y pulsa Create. Se abre el fichero con unas líneas de plantilla.
  2. Pulsa el botón Copiar fichero de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro de SeguimientoTests.swift, selecciona todo con Cmd+A y pega con Cmd+V. Sustituye lo que había.
SeguimientoTests.swift

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:

CambioPara qué
@MainActor en SeguimientoEnPantallaEl estado de pantalla solo se toca desde el actor principal
tarea guardada y tarea?.cancel() al empezarUn partido nuevo no se mezcla con el anterior
Task { … } dentro de seguirHereda el actor principal: cambia el estado sin await
nonisolated en titularNo toca estado: se llama desde cualquier sitio
@MainActor en la suite de testsLos tests usan la clase sin await

Punto de control

  • Desde ~/Marcador-Swift/Torneo, xcrun swift build --build-tests termina sin ninguna línea con warning: ni error:.
  • xcrun swift run torneo-cli imprime al final Marcador: 2-1 y Narración: 5 líneas; primera: 12' Gol de Ana (Rayo FC).
  • xcrun swift test termina con Test run with 129 tests in 17 suites passed.
  • Sabes decir por qué main.swift usa seguimiento.marcador sin await, y por qué dentro de seguir la tarea cambia self.narracion sin await.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué main.swift usa seguimiento.marcador sin await?

  2. 2.Dentro de seguir, tarea = Task { … self.narracion.append(…) } no lleva await delante de self.narracion. ¿Por qué?

  3. 3.¿Qué permite que titular sea nonisolated?

  4. 4.¿Para qué es @MainActor delante de struct SeguimientoTests?

  5. 5.seguir empieza con tarea?.cancel(). ¿Para qué?

  6. 6.En una tarea guardada que espera con Task.sleep, ¿por qué conviene do … catch y no try??

Faltan 6 de 6