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

@ObservedObject y el partido que se reinicia

Al terminar: Ver qué pasa con @ObservedObject cuando la vista se recrea.

Al terminar esta lección, Marcador tendrá un partido en directo: tocas un partido de la lista, se abre una hoja, pulsas «Empezar partido» y el minuto avanza solo. Antes de arreglarlo, provocarás a propósito un fallo que se ve a simple vista: al recalcularse la vista que contiene el partido, el minuto vuelve a 0 y el marcador a 0-0. El culpable es un solo atributo, @ObservedObject, y el arreglo es cambiarlo por @StateObject.

Importa porque el fallo compila, funciona en la primera prueba y no da ningún error: solo se nota cuando el padre se redibuja. Y porque @ObservedObject sí tiene su sitio: el de recibir un objeto que otro ya creó.

El orden de la lección: primero escribes la pantalla bien (con @StateObject), luego cambias una palabra para romperla y ver el fallo, y por último la arreglas.

@ObservedObject: observar sin ser dueño

Hay vistas que no crean el objeto: lo reciben de la vista que sí es dueña y solo lo observan. Para eso es @ObservedObject: la vista se recalcula cuando cambian las propiedades @Published del objeto que le pasan, pero no lo guarda ni lo conserva, y por eso no lleva valor inicial.

La tabla de la lección anterior (ObservableObject y @StateObject) lo resume: @StateObject es la vista dueña (SwiftUI conserva el objeto aunque el struct se recree) y @ObservedObject solo observa un objeto que otro mantiene vivo.

Un detalle de @StateObject, que la lección anterior dejó para aquí: cuando el init escribe _modelo = StateObject(wrappedValue: TemporizadorModelo(nombre: nombre)), el parámetro wrappedValue es un @autoclosure. Eso significa que escribes una expresión normal y el compilador la envuelve en una closure sin llaves, que no se ejecuta en el acto: SwiftUI la ejecuta solo la primera vez que monta la vista y en las recreaciones la ignora. Con @ObservedObject, en cambio, modelo = TemporizadorModelo(…) se ejecuta en cada recreación: de ahí el fallo de hoy.

La dueña crea el modelo con @StateObject y se lo pasa a una hija que solo lo observa. Un ejemplo, con el TemporizadorModelo de la lección anterior (una clase observable con nombre, segundos, contando y arrancar(hasta:)). Tiene tres piezas de SwiftUI que no has visto:

  • .disabled(condicion): deja el control gris mientras la condición sea true.
  • .padding([.horizontal, .top], 16): margen de 16 solo a los lados y arriba; una lista entre corchetes elige los bordes.
  • if … else como valor: en textoDelBoton, cada rama es el texto que devuelve la propiedad (sin return).

Y una más de Swift: let alCambiar: (Int) -> Void es una closure guardada en una propiedad (Closures): una función que recibe un Int y no devuelve nada. BotonesDeAjuste no escribe en ningún sitio: avisa de qué pasó (alCambiar(-10)) y quien la usa decide qué hacer. Es el state hoisting de Compose.

ejemplo mínimo: la dueña, la observadora y una closure (con comentarios)
import SwiftUI
 
// La DUEÑA: crea el modelo una vez y se lo pasa a quien lo necesite.
struct TemporizadorView: View {
    @StateObject private var modelo: TemporizadorModelo
 
    init(nombre: String) {
        _modelo = StateObject(wrappedValue: TemporizadorModelo(nombre: nombre))
    }
 
    var body: some View {
        PanelDelTemporizador(modelo: modelo)       // se lo pasa por el constructor
    }
}
 
// La OBSERVADORA: recibe el modelo ya creado, sin valor inicial.
struct PanelDelTemporizador: View {
    @ObservedObject var modelo: TemporizadorModelo
 
    var body: some View {
        VStack(spacing: 12) {
            Text("\(modelo.nombre): \(modelo.segundos) s")
            Button(textoDelBoton) { modelo.arrancar(hasta: 180) }
                .buttonStyle(.borderedProminent)
                .disabled(modelo.contando)                  // gris mientras la condición sea true
            BotonesDeAjuste { cambio in print(cambio) }     // la hija avisa con una closure
        }
        .padding([.horizontal, .top], 16)                   // margen solo a los lados y arriba
    }
 
    // if … else como valor: cada rama es el texto, sin return.
    private var textoDelBoton: String {
        if modelo.contando { "Contando…" } else { "Arrancar" }
    }
}
 
// Recibe una closure en vez de un enlace: dice QUÉ pasó y el padre decide.
struct BotonesDeAjuste: View {
    let alCambiar: (Int) -> Void                            // recibe un Int y no devuelve nada
 
    var body: some View {
        HStack {
            Button("−10 s") { alCambiar(-10) }
            Button("+10 s") { alCambiar(10) }
        }
    }
}

Al pulsar «−10 s» sale -10 en la consola de Xcode, y al pulsar «+10 s», 10. El botón «Arrancar» se pone gris y dice «Contando…» mientras el temporizador cuenta.

Para abrir una pantalla desde una lista, el paso 2 usa dos piezas más. Una hoja (.sheet) es una pantalla que sube desde abajo y se cierra deslizándola; .sheet(item: $elegido) { valor in … } la abre cuando el estado opcional elegido deja de ser nil y le pasa ese valor (al cerrarla, vuelve a nil). Pide que el tipo sea Identifiable: el PartidoDeLista de Listas en SwiftUI. Y Button { acción } label: { contenido } es un botón cuyo contenido no es un texto sino una vista (aquí, la fila entera): la acción va entre las primeras llaves y la vista, tras label:. Las verás con comentarios en el paso 2.

Ejercicio: la pizarra y sus botones

Reto · Ejercicio: la pizarra y sus botones

Crea la subcarpeta Pizarra dentro de Ejercicios (recuerda: ningún tipo puede llamarse igual que otro del proyecto). En PizarraModelo.swift, una clase observable con los puntos apuntados (empiezan en 0; solo ella los cambia), un método que suma uno y otro que resta uno sin bajar de 0. En PizarraView.swift, tres vistas:

  • PizarraView es la dueña de la pizarra. Enseña «Puntos: 0» y, debajo, los botones.
  • BotonesDePizarra recibe la pizarra ya creada y tiene dos botones en fila, «−1» y «+1».
  • AulaView es el padre. Tiene un botón «Nueva clase (0)» que cuenta sus toques y, debajo, la pizarra.

Añade una vista previa de AulaView. Pulsa «+1» tres veces, «−1» una y después «Nueva clase». Lo que debe verse:

Nueva clase (1)
Puntos: 2

Para pensar: ¿qué pasaría si BotonesDePizarra creara su propia pizarra en vez de recibirla?

Ver solución
solución: PizarraModelo.swift
import Combine
import Foundation
 
// Los puntos apuntados en una pizarra.
final class PizarraModelo: ObservableObject {
    @Published private(set) var puntos = 0
 
    func sumar() { puntos += 1 }
    func restar() { if puntos > 0 { puntos -= 1 } }
}
solución: PizarraView.swift
import SwiftUI
 
// La DUEÑA: crea la pizarra una vez y se la pasa a quien la necesite.
struct PizarraView: View {
    @StateObject private var pizarra = PizarraModelo()
 
    var body: some View {
        VStack(spacing: 12) {
            Text("Puntos: \(pizarra.puntos)")
            BotonesDePizarra(pizarra: pizarra)
        }
    }
}
 
// Solo OBSERVA: la recibe ya creada, sin valor inicial.
struct BotonesDePizarra: View {
    @ObservedObject var pizarra: PizarraModelo
 
    var body: some View {
        HStack {
            Button("−1") { pizarra.restar() }
            Button("+1") { pizarra.sumar() }
        }
        .buttonStyle(.bordered)
    }
}
 
// El padre que se redibuja.
struct AulaView: View {
    @State private var clases = 0
 
    var body: some View {
        VStack(spacing: 24) {
            Button("Nueva clase (\(clases))") { clases += 1 }
            PizarraView()
        }
        .padding()
    }
}
 
#Preview {
    AulaView()
}

Tras «Nueva clase» siguen los «Puntos: 2»: el padre recrea PizarraView, pero el @StateObject conserva la pizarra. Respuesta de «para pensar»: los botones cambiarían otra pizarra y el número de arriba no se movería. Además, cada «Nueva clase» crearía una pizarra nueva.

Paso 1 · La vista del partido, con @StateObject

Objetivo. Una pantalla que posee su modelo con @StateObject, enseña el minuto y el marcador, y tiene el botón «Empezar partido» y los dos botones de gol.

MarcadorApp
MarcadorApp
Marcador
UI
PartidoEnDirectoView.swiftse crea
Un fichero nuevo, en el Project navigator (Cmd+1).

Trabajas en ~/Marcador-Swift/MarcadorApp. Crea PartidoEnDirectoView.swift en Marcador/UI, con las demás vistas del marcador: clic derecho sobre la carpeta UI (dentro de Marcador) → New File from Template… → Swift File → Next, nombre PartidoEnDirectoView. Contiene tres vistas: PartidoEnDirectoView (el partido), BotonesDeGolConAccion (los botones, la versión con closure de BotonesDeGol) y PartidoContenedor (su padre, con un botón «Redibujar el padre» que sirve para reproducir el fallo del paso 3). TarjetaDePartido es la que dejaste con su marcador en @Binding.

Intenta escribirlo tú, con la vista como dueña del modelo (@StateObject):

  1. PartidoEnDirectoView recibe un partido y crea con él su modelo en el init. Dentro de una zona que se desplaza, en columna: la tarjeta con el marcador del modelo; los botones de gol, que avisan al modelo del lado que marcó; el minuto en grande («Minuto 12'», con el nombre interno minuto); un botón destacado que empieza el partido de 90 minutos, gris mientras corre o si ya empezó, y que dice «Empezar partido», «En juego» o «Partido terminado»; y el texto «Último aviso (cada 15'): …».
  2. BotonesDeGolConAccion recibe el partido y qué hacer cuando marca un lado: dos botones destacados en fila, «Gol Rayo FC» y «Gol Toros», que solo avisan.
  3. PartidoContenedor recibe un item de la lista: arriba, el título «Partido n.º 1» y un botón «Redibujar el padre (0)» que cuenta los toques; debajo, la pantalla del partido.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
import SwiftUI
import Torneo
 
// La pantalla del partido en directo.
struct PartidoEnDirectoView: View {
    // `@StateObject`: esta vista es la DUEÑA del modelo. SwiftUI lo crea una vez y lo conserva
    // aunque el struct de la vista se vuelva a crear.
    @StateObject private var modelo: PartidoEnVivoModelo
 
    init(partido: Partido) {
        // El guion bajo es el almacenamiento oculto del `@StateObject`: el modelo se entrega
        // aquí, envuelto en `StateObject(wrappedValue:)`.
        _modelo = StateObject(wrappedValue: PartidoEnVivoModelo(partido: partido))
    }
 
    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                // La tarjeta de «@Binding», con el marcador que calcula el modelo.
                TarjetaDePartido(partido: modelo.partido, marcador: modelo.marcador)
 
                // Los botones avisan de qué lado marcó; no escriben en nada.
                BotonesDeGolConAccion(partido: modelo.partido) { lado in
                    modelo.golAMano(lado)
                }
 
                VStack(alignment: .leading, spacing: 8) {
                    Text("Minuto \(modelo.minuto)'")
                        .font(.title)
                        .monospacedDigit()
                        .accessibilityIdentifier("minuto")   // para las pruebas de interfaz
                    Button(textoDelBoton) { modelo.empezar(duracion: 90) }
                        .buttonStyle(.borderedProminent)
                        // Gris mientras el partido corre o ya empezó.
                        .disabled(modelo.corriendo || modelo.minuto > 0)
                    Text("Último aviso (cada 15'): \(modelo.ultimoAviso)")
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .padding(16)
        }
    }
 
    // El texto del botón según cómo vaya el partido.
    private var textoDelBoton: String {
        if modelo.minuto >= 90 { "Partido terminado" }
        else if modelo.corriendo { "En juego" }
        else { "Empezar partido" }
    }
}
 
// Los dos botones de gol. En vez de un `@Binding`, reciben una closure: avisan de QUÉ pasó
// y quien los usa decide qué hacer.
struct BotonesDeGolConAccion: View {
    let partido: Partido
    let alGol: (Lado) -> Void   // `(Lado) -> Void`: una función que recibe un `Lado` y no devuelve nada
 
    var body: some View {
        HStack(spacing: 8) {
            Button("Gol \(partido.local.nombre)") { alGol(.local) }
            Button("Gol \(partido.visitante.nombre)") { alGol(.visitante) }
        }
        .buttonStyle(.borderedProminent)
    }
}
 
// El PADRE de la pantalla del partido. Su botón fuerza que se recalcule su `body`, y con él se
// vuelve a crear el struct de `PartidoEnDirectoView`: justo lo que hay que vigilar.
struct PartidoContenedor: View {
    let item: PartidoDeLista
    @State private var redibujados = 0   // un contador que solo sirve para provocar el recálculo
 
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Text("Partido n.º \(item.id)").font(.headline)
                Spacer()
                Button("Redibujar el padre (\(redibujados))") { redibujados += 1 }
                    .buttonStyle(.bordered)
            }
            .padding([.horizontal, .top], 16)
 
            PartidoEnDirectoView(partido: item.partido)
        }
    }
}

Compila con Cmd+B. Todavía no se ve en la app: falta abrirla desde la lista.

Paso 2 · La lista abre el partido

Objetivo. Tocar una fila de «Partidos» abre una hoja con PartidoContenedor.

MarcadorApp
MarcadorApp
Partidos
UI
ListaDePartidosView.swiftse modifica
Un fichero se modifica.

Hacen falta tres cambios en ListaDePartidosView.swift, con las dos piezas del ejemplo de las recetas. Intenta escribirlos tú:

  1. Un estado nuevo, elegido: el partido tocado, o ninguno.
  2. Cada fila pasa a ser un botón que guarda su partido en elegido, con la fila de siempre como contenido y con aspecto de fila normal, no de botón azul. El nombre para las pruebas se queda en el botón.
  3. Tras el estilo de la lista, una hoja que se abre cuando hay un partido elegido y enseña su PartidoContenedor.

Lo que debes ver: al tocar el primer partido, la hoja con la tarjeta y los dos botones de gol.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
ListaDePartidosView.swift: lo que cambia
// …
struct ListaDePartidosView: View {
    let partidos: [PartidoDeLista]
 
    // El partido tocado. `nil` = ninguno: la hoja está cerrada.
    @State private var elegido: PartidoDeLista?
 
    var body: some View {
        List(partidos) { item in
            // Cada fila es ahora un botón que guarda el partido tocado.
            Button {
                elegido = item
            } label: {
                FilaDePartido(
                    local: item.partido.local.nombre,
                    visitante: item.partido.visitante.nombre,
                    resultado: item.partido.marcador().description
                )
            }
            // `.plain`: que parezca una fila normal y no un botón azul.
            .buttonStyle(.plain)
            .accessibilityIdentifier("partido-\(item.id)")
        }
        .listStyle(.plain)
        // `sheet(item:)`: se abre una hoja cuando `elegido` deja de ser `nil`, con ese valor dentro.
        .sheet(item: $elegido) { item in
            PartidoContenedor(item: item)
        }
    }
}
// …

Ejecuta con Cmd+R. Toca el primer partido: se abre la hoja con la tarjeta y los dos botones de gol. Pulsa «Empezar partido»: el texto pasa a «En juego», el minuto avanza (un minuto cada cuarto de segundo) y cada 15 minutos cambia el último aviso. Pulsa «Redibujar el padre» un par de veces: con @StateObject el minuto y el marcador no cambian. Cierra la hoja deslizándola hacia abajo.

Paso 3 · Provoca el fallo

Objetivo. Ver con tus ojos cómo @ObservedObject reinicia el partido, y que una prueba de interfaz lo detecta.

Primero la prueba, que te doy hecha. Casi todas las piezas están en la tabla de XCUITest de Tus primeras pruebas en iOS (XCUIApplication, launch, buttons[…], staticTexts[…], label, NSPredicate, expectation(for:evaluatedWith:) y waitForExpectations). La prueba nueva, testElPartidoSobreviveAlRedibujadoDelPadre:

  1. arranca la app con app.launchArguments = ["-msPorMinuto", "40"]: los argumentos de lanzamiento son los de la lección anterior, y hacen que un minuto de partido dure 40 ms;
  2. toca partido-1 (abre la hoja) y «Empezar partido»;
  3. espera, con un NSPredicate escrito como closure, a que el texto de minuto marque 10 o más;
  4. toca el botón «Redibujar el padre» (como su nombre lleva un número, lo busca con buttons.matching(NSPredicate(format: "label BEGINSWITH 'Redibujar'")).firstMatch: el primer botón cuyo texto empieza así);
  5. comprueba que el minuto de después no es menor que el de antes. Las dos llamadas a capturar guardan una captura de pantalla como adjunto de la prueba, con la función que ya tienes en Ayudas.swift.

Tres líneas del bloque merecen una explicación:

  • (objeto as? XCUIElement)?.label ?? "": el NSPredicate recibe el elemento como un tipo genérico; as? XCUIElement intenta verlo como elemento de interfaz (o da nil), ?.label pide su texto si lo es y ?? "" pone un texto vacío si no.
  • Int(texto.filter(\.isNumber)) ?? 0: filter(\.isNumber) se queda solo con los caracteres que son dígitos («Minuto 31'» pasa a «31»); Int(…) lo convierte en número, pero devuelve un opcional (el texto podría no ser un número), y ?? 0 pone 0 si falla.
  • XCTAssertGreaterThanOrEqual(a, b, "mensaje"): falla la prueba si a no es mayor o igual que b, y muestra el mensaje.

Abre MarcadorAppUITests → MarcadorAppUITests.swift y añade la prueba debajo de la última que ya tienes, antes de la llave que cierra la clase:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: la prueba que se añade
    // … la última prueba que ya tienes termina aquí
    }
 
    // El partido NO se reinicia cuando el padre se redibuja (con @ObservedObject sí lo hacía).
    @MainActor
    func testElPartidoSobreviveAlRedibujadoDelPadre() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-msPorMinuto", "40"]
        app.launch()
        app.buttons["partido-1"].tap()
        app.buttons["Empezar partido"].tap()
        let minuto = app.staticTexts["minuto"]
        // Espera a que el reloj pase del minuto 10.
        let pasaDel10 = NSPredicate { objeto, _ in
            let texto = (objeto as? XCUIElement)?.label ?? ""
            let numero = Int(texto.filter(\.isNumber)) ?? 0
            return numero >= 10
        }
        expectation(for: pasaDel10, evaluatedWith: minuto)
        waitForExpectations(timeout: 15)
        let antes = minuto.label
        capturar(app, "partido-antes-de-redibujar")
        app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Redibujar'")).firstMatch.tap()
        let despues = minuto.label
        capturar(app, "partido-despues-de-redibujar")
        let numeroAntes = Int(antes.filter(\.isNumber)) ?? 0
        let numeroDespues = Int(despues.filter(\.isNumber)) ?? -1
        XCTAssertGreaterThanOrEqual(numeroDespues, numeroAntes, "El partido se reinició: antes «\(antes)», después «\(despues)»")
    }
}   // ← la llave que cierra la clase

Pásala con Cmd+U, o solo esta con la Terminal (-only-testing: lleva el camino Objetivo/Clase/método: el grupo de pruebas, la clase y la prueba, como en Tus primeras pruebas en iOS):

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

(El nombre del simulador tiene que existir en tu Mac: xcrun simctl list devices available te enseña los que tienes.)

Con @StateObject pasa. Ahora rómpelo: en PartidoEnDirectoView.swift, sustituye la declaración del modelo y el init (desde @StateObject private var modelo hasta la llave que cierra el init, con sus comentarios) por esto, con @ObservedObject:

con el bug: el @ObservedObject crea el objeto
    @ObservedObject private var modelo: PartidoEnVivoModelo
    init(partido: Partido) {
        modelo = PartidoEnVivoModelo(partido: partido)
    }

Pasa la prueba otra vez: falla con un mensaje como este (los minutos varían en cada ejecución):

XCTAssertGreaterThanOrEqual failed: ("0") is less than ("31") - El partido se reinició: antes «Minuto 31'», después «Minuto 0'»

Para verlo a mano, en el esquema de Xcode añade el argumento -msPorMinuto 40 (Product → Scheme → Edit Scheme… → Run → Arguments → Arguments Passed On Launch → +), ejecuta con Cmd+R, toca el partido 1, pulsa «Empezar partido» y, pasados unos minutos, «Redibujar el padre».

Para contar los modelos, mira la consola de Xcode (el panel de abajo; si no lo ves, View → Debug Area → Activate Console, o Cmd+Shift+Y). Con @ObservedObject salen dos líneas modelo del partido CREADO con un identificador distinto: una al abrir la hoja y otra al pulsar «Redibujar el padre» (y una más por cada toque siguiente):

modelo del partido CREADO (Rayo FC-Toros) 9b45a
modelo del partido CREADO (Rayo FC-Toros) 8b33b

Con @StateObject sale una sola línea, la de abrir la hoja: ninguna más al redibujar el padre. Devuelve el @StateObject y repite la prueba: pasa.

Quita el argumento -msPorMinuto 40 del esquema antes de seguir (el mismo menú, y la casilla del argumento, o el botón −). Si se queda puesto, un minuto dura 40 ms en lugar de 250 y las lecciones siguientes (que cuentan con 250 ms por minuto) no cuadrarían.

Los síntomas son los que acabas de ver: una vista es un struct barato que SwiftUI recrea cada vez que su padre recalcula el body, y con @ObservedObject cada recreación fabrica un modelo nuevo. El minuto vuelve a 0, el marcador a 0-0 y el botón «Empezar partido» se rehabilita. El primer modelo no se libera mientras su Task lo retenga; por eso no hay ningún error.

La regla para recordar: el que crea, @StateObject; el que recibe, @ObservedObject. Para devolverlo a su sitio, pon de nuevo el @StateObject y su init:

corregido: el @StateObject lo crea una sola vez
    @StateObject private var modelo: PartidoEnVivoModelo
    init(partido: Partido) {
        _modelo = StateObject(wrappedValue: PartidoEnVivoModelo(partido: partido))
    }

Punto de control

  • Tocar una fila de «Partidos» abre la hoja; «Empezar partido» hace avanzar el minuto y el botón pasa a «En juego».
  • Con @StateObject, «Redibujar el padre» no cambia el minuto ni el marcador, y la consola de Xcode (ejecutando con Cmd+R) muestra un solo modelo del partido CREADO, el de abrir la hoja.
  • Con @ObservedObject, la prueba falla con el mensaje XCTAssertGreaterThanOrEqual failed: … del paso 3 y hay un «CREADO» más por cada «Redibujar». Déjalo otra vez en @StateObject.
  • El esquema de Xcode ya no lleva el argumento -msPorMinuto 40: un minuto de partido vuelve a durar 250 ms.
  • Con el @StateObject puesto, Cmd+U termina en ** TEST SUCCEEDED ** con 11 pruebas: 3 unitarias y 8 de interfaz (7 de MarcadorAppUITests y testLaunch).

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Una vista declara @ObservedObject private var modelo y lo crea en su init. El padre se redibuja. ¿Qué ocurre?

  2. 2.Con @ObservedObject creando el modelo, el partido vuelve al minuto 0 al redibujarse el padre, pero la app no da ningún error. ¿Por qué no se queja nadie del modelo descartado?

  3. 3.Para abrir una hoja con .sheet(item:), ¿qué tiene que cumplir el tipo del valor que se pasa?

  4. 4.Una subvista recibe del padre un modelo ya creado y solo lo observa. ¿Qué escribes en su declaración?

Faltan 4 de 4