ObservableObject y @StateObject
Al terminar: Distinguir @StateObject de @ObservedObject y de @EnvironmentObject, con el bug que reinicia el partido.
Al terminar esta lección, Marcador tendrá PartidoEnVivoModelo: el objeto que cuenta los minutos de un partido, aplica sus goles y suma los goles «a mano». Todavía no lo usará ninguna pantalla (eso llega en la lección siguiente), pero ya sabrás distinguir quién crea un objeto y quién lo conserva, que es lo que más cuesta de SwiftUI.
Importa porque es el fallo más común al pasar del estado simple (@State) a un objeto con lógica propia: compila, funciona en la primera prueba y se rompe cuando el padre se redibuja. Hoy aprendes la pieza que lo evita, @StateObject; en la siguiente provocas el fallo a propósito.
Quién es el dueño del objeto
Hasta ahora el estado de tus vistas era un valor pequeño (@State con un Marcador, un Int, un Bool). Un partido en directo es otra cosa: tiene un reloj que corre, goles que aplicar y una tarea en marcha. Eso se escribe como una clase, y una clase tiene vida propia: alguien la crea y alguien la mantiene viva.
Una vista, en cambio, es un struct barato. SwiftUI lo vuelve a crear cada vez que su padre recalcula el body: no es el mismo struct de antes, es uno nuevo con los mismos datos. Si el objeto viviera dentro de ese struct, nacería de nuevo con cada recreación y el partido volvería al minuto 0.
Quién es el «alguien» que crea y mantiene el objeto es la diferencia entre los atributos con los que una vista usa una clase que avisa de sus cambios (la «clase observable» que escribes en la sección siguiente). Hoy ves dos; el tercero llega en @EnvironmentObject: los ajustes para toda la app:
| Atributo | La vista… | El objeto lo crea… | Si se recrea el struct de la vista |
|---|---|---|---|
@StateObject | es su dueña | la propia vista | SwiftUI conserva el mismo objeto |
@ObservedObject | solo observa | otro, que lo mantiene vivo | Recibe otra vez el que le pasan |
En Android, @StateObject equivale a viewModel(): el sistema guarda el ViewModel fuera de la pantalla.
Una clase que avisa: ObservableObject y @Published
Para que una clase avise a SwiftUI cuando cambia, se escribe como un ObservableObject: una clase cuyas propiedades marcadas con @Published avisan a las vistas que la observan, y cada aviso hace que esas vistas recalculen su body.
ObservableObject y @Published son de Combine, una librería de Apple más antigua que async/await para avisar de cambios. De ella solo necesitas estas dos piezas, y por eso el fichero que las usa empieza con import Combine.
Un @Published suele ir con private(set): cualquiera puede leer la propiedad, pero solo la clase puede escribirla (ya lo viste en Estado de pantalla y @MainActor en Marcador). Así el estado tiene un solo dueño y las vistas no lo cambian por su cuenta.
Un ejemplo ajeno al proyecto, para leer: un temporizador de cocina.
import Combine // ObservableObject y @Published viven aquí
import Foundation
// Una clase que avisa a SwiftUI cuando cambia una propiedad marcada con @Published.
final class TemporizadorModelo: ObservableObject {
let nombre: String // no cambia: un let normal, sin @Published
private let msPorSegundo: Int // cuánto dura un «segundo» del temporizador
// @Published: cada cambio avisa a SwiftUI. private(set): todos leen, solo la clase escribe.
@Published private(set) var segundos = 0
@Published private(set) var contando = false
// Task<Void, Never>: una tarea que no devuelve nada (Void) y no puede fallar (Never).
private var tarea: Task<Void, Never>? // la tarea que cuenta, guardada en una propiedad
// msPorSegundo tiene valor por defecto: TemporizadorModelo(nombre: "Té") también vale.
init(nombre: String, msPorSegundo: Int = 1_000) {
self.nombre = nombre
self.msPorSegundo = msPorSegundo
}
func arrancar(hasta limite: Int) {
guard !contando else { return } // si ya cuenta, no hace nada
contando = true
tarea = Task { // se pone en marcha ya; no se espera aquí
for s in 1...limite {
try? await Task.sleep(for: .milliseconds(msPorSegundo))
segundos = s // cada cambio avisa a la vista que lo lea
}
contando = false
}
}
}No imprime nada: lo que hace se ve en la pantalla de una vista que lo use, como la del ejemplo siguiente. Task { … } lanza trabajo asíncrono sin esperar a que termine, como en async/await y Task, y se guarda en una propiedad para tenerla a mano.
Una vista que es dueña de su objeto
La vista que crea el objeto lo declara con @StateObject, que es un property wrapper (una etiqueta que añade comportamiento a una propiedad, como el @State que ya conoces). Si necesita pasarle algo al crearlo, hay una forma de escribirlo que desconcierta la primera vez, así que cópiala tal cual:
- En el
initde la vista no se escribemodelo = …(dacannot assign to property: 'modelo' is a get-only property), sino_modelo = StateObject(wrappedValue: …): el guion bajo es el nombre del almacén oculto de@StateObject. - Aunque el
initde la vista se ejecute muchas veces, SwiftUI usa esa expresión solo la primera vez: el objeto no se vuelve a crear. El detalle de cómo lo logra lo verás donde lo uses, en @ObservedObject y el partido que se reinicia.
import SwiftUI
struct TemporizadorView: View {
// La vista es la DUEÑA del modelo: SwiftUI lo crea una vez y lo conserva.
@StateObject private var modelo: TemporizadorModelo
init(nombre: String) {
// _modelo: el almacenamiento oculto del @StateObject. La expresión se ejecuta solo la primera vez.
_modelo = StateObject(wrappedValue: TemporizadorModelo(nombre: nombre))
}
var body: some View {
VStack(spacing: 12) {
Text("\(modelo.nombre): \(modelo.segundos) s") // se recalcula cuando cambia `segundos`
Button("Arrancar") { modelo.arrancar(hasta: 180) }
}
}
}Cuando no hace falta pasar nada al init, es más corto: @StateObject private var modelo = TemporizadorModelo(nombre: "Té") crea el modelo con el mismo cuidado, y es lo que hace el ejercicio.
Ejercicio: la cafetera que no se olvida
Reto · Ejercicio: la cafetera que no se olvida
Los ejercicios de SwiftUI viven en la carpeta Ejercicios del proyecto, una subcarpeta por ejercicio, como en El ciclo de vida de UIViewController. Todo lo que pones ahí compila junto con la app y no se borra, así que ningún tipo puede llamarse igual que otro de otro ejercicio o de la app: usa los nombres que pide el enunciado.
Crea una subcarpeta nueva, Cafetera, dentro de Ejercicios, con dos ficheros. En CafeteraModelo.swift, una clase observable que cuenta las tazas servidas: empieza en 0, todos pueden leer la cuenta pero solo la clase la cambia, y tiene un método que sirve una taza más. En BarraDeCafe.swift, dos vistas:
CafeteraViewes la dueña de su cafetera. Enseña «Tazas servidas: 0» y, debajo, un botón destacado «Servir».BarraDeCafees su padre. Tiene un botón «Nuevo pedido (0)» que cuenta sus toques y, debajo, la cafetera.
Añade una vista previa de BarraDeCafe. En la vista previa, pulsa «Servir» tres veces y después «Nuevo pedido». Lo que debe verse:
Nuevo pedido (1)
Tazas servidas: 3Ver solución
import Combine // ObservableObject y @Published
import Foundation
// La cafetera: cuenta las tazas que sirve. Una clase observable: avisa al cambiar `tazas`.
final class CafeteraModelo: ObservableObject {
@Published private(set) var tazas = 0 // todos la leen; solo la cafetera la cambia
func servir() { tazas += 1 }
}import SwiftUI
// La vista es la DUEÑA de su cafetera: @StateObject la crea una sola vez.
struct CafeteraView: View {
@StateObject private var cafetera = CafeteraModelo()
var body: some View {
VStack(spacing: 12) {
Text("Tazas servidas: \(cafetera.tazas)")
Button("Servir") { cafetera.servir() }
.buttonStyle(.borderedProminent)
}
}
}
// El padre: su botón cambia un @State y obliga a recalcular su body (y a recrear CafeteraView).
struct BarraDeCafe: View {
@State private var pedidos = 0
var body: some View {
VStack(spacing: 24) {
Button("Nuevo pedido (\(pedidos))") { pedidos += 1 }
.buttonStyle(.bordered)
CafeteraView()
}
.padding()
}
}
#Preview {
BarraDeCafe()
}Tras «Nuevo pedido», la cuenta sigue en 3: el padre se redibuja y recrea CafeteraView, pero el @StateObject conserva la misma cafetera. Si cambias @StateObject por @ObservedObject en esa declaración, tras «Nuevo pedido» verías «Tazas servidas: 0»: es el fallo que provocarás en la lección siguiente.
Paso 1 · Un ajuste de ejecución para las pruebas
Objetivo. Un fichero nuevo, Configuracion.swift, que dice cuánto dura un minuto de partido. No enseña nada de SwiftUI, así que lo rellenas con un botón.
Trabajas en ~/Marcador-Swift/MarcadorApp. Los ficheros viven en la carpeta MarcadorApp que contiene ContentView.swift (la de dentro del proyecto, no el icono azul de arriba). Para crear cada fichero nuevo: clic derecho sobre la carpeta que te indique el paso → New File from Template… → Swift File → Next, el nombre y Create. Si el menú de tu versión de Xcode se llama distinto, usa la Terminal: touch del fichero dentro de ~/Marcador-Swift/MarcadorApp/MarcadorApp/<carpeta>/ (o directamente en ~/Marcador-Swift/MarcadorApp/MarcadorApp/ si va en la raíz), y Xcode lo muestra solo.
Ya conoces el botón Copiar fichero (Clases y objetos): copia el contenido al portapapeles y el orden es crear el fichero, pulsar el botón, seleccionar todo y pegar.
- Crea
Configuracion.swiftcomo siempre, sobre la carpetaMarcadorApp(clic derecho → New File from Template… → Swift File →Configuracion). Se queda en la raíz, junto aMarcadorApp.swift, y no dentro de una funcionalidad: es un ajuste que leerán varias partes de la app. Desde la Terminal,touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Configuracion.swift: la raíz ya existe y no hace faltamkdir. - Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro del editor con el fichero abierto, selecciona todo con Cmd+A y pega con Cmd+V.
Configuracion.msPorMinuto dice cuánto dura un minuto de partido: 250 ms por defecto. Lo lee de UserDefaults, el almacén de ajustes pequeños de la app (pares clave-valor que iOS guarda en el dispositivo; lo verás a fondo en Ajustes que sobreviven: UserDefaults y @AppStorage). iOS también rellena UserDefaults con los argumentos de lanzamiento que empiezan por guion: arrancar la app con -msPorMinuto 40 hace que valga 40. Así las pruebas de interfaz no esperan 22 segundos por partido.
Paso 2 · El modelo del partido en directo
Objetivo. PartidoEnVivoModelo.swift: un ObservableObject que cuenta los minutos del partido, aplica sus goles y suma los goles «a mano». Compila, y todavía no lo usa ninguna vista.
Va en Marcador/UI, junto a PantallaMarcador: no dibuja nada, pero es lo que la pantalla lee y mueve, y por eso va con ella. Clic derecho sobre la carpeta UI (dentro de Marcador) → New File from Template… → Swift File → Next, nombre PartidoEnVivoModelo. Desde la Terminal: touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Marcador/UI/PartidoEnVivoModelo.swift.
Es el Directo de Kotlin (Capas en Marcador: el directo): cuenta, aplica goles y la pantalla solo lo lee. Escribes la declaración como ObservableObject, las propiedades @Published y empezar(duracion:); el resto (el bucle, el registro y los goles «a mano») ya está hecho y lo pegas debajo.
Piezas de este paso. Casi todo es lo del temporizador de arriba. Lo nuevo está en el registro:
Registro.anotar("…"): la función de registro que escribiste en La app, sus escenas y el paquete Torneo.ObjectIdentifier(self): un identificador único de la instancia (struct frente a class), que aquí sirve para distinguir un modelo de otro en el registro.
Intenta escribirlo tú. Lo que debe tener PartidoEnVivoModelo:
- Es una clase observable de la que nadie hereda (
final), con elpartidoque se juega y los milisegundos de cada minuto (si no se dan, los deConfiguracion). - Lo que la pantalla lee, avisa al cambiar y solo la clase escribe: el
minuto(0), si vacorriendo(no), elultimoAviso(«ninguno») y dos marcadores a 0-0,golesEnVivoygolesAMano. - La tarea que juega el partido, guardada en una propiedad.
- Al crearse, anota en el registro «modelo del partido CREADO (Rayo FC-Toros)» seguido de su identificador.
empezar, que con la etiquetaduracionarranca el partido una sola vez (si ya corre o ya pasó algún minuto, no hace nada): marca que corre y lanza una tarea que lo juega.
Cuando termines, abre el bloque, compara y pega la parte que va hecha.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Combine
import Foundation
import Torneo
// El partido en directo: cuenta los minutos, aplica los goles del guion y suma los goles «a mano»
// de los botones. La pantalla no cuenta nada: lee este objeto y lo pinta. Es el `Directo` de Kotlin.
// MARK: - Lo que escribes tú
// Una clase y no un struct: tiene vida propia (alguien la crea y alguien la mantiene viva).
// `ObservableObject` hace que avise a SwiftUI cuando cambie una propiedad `@Published`.
final class PartidoEnVivoModelo: ObservableObject {
let partido: Partido // el partido que se juega; no cambia
private let msPorMinuto: Int // cuánto dura un minuto de partido, en milisegundos
// `@Published`: cada cambio avisa a SwiftUI. `private(set)`: cualquiera lee, solo esta clase escribe.
@Published private(set) var minuto = 0
@Published private(set) var corriendo = false
@Published private(set) var ultimoAviso = "ninguno"
// Los goles que marca el guion y los que se ponen «a mano» con los botones.
@Published private(set) var golesEnVivo = Marcador(local: 0, visitante: 0)
@Published private(set) var golesAMano = Marcador(local: 0, visitante: 0)
// La tarea que juega el partido. Se guarda para tenerla a mano.
private var tarea: Task<Void, Never>?
init(partido: Partido, msPorMinuto: Int = Configuracion.msPorMinuto) {
self.partido = partido
self.msPorMinuto = msPorMinuto
// Deja una línea en el registro cada vez que nace un modelo: así se ve cuántos hay.
Registro.anotar("modelo del partido CREADO (\(partido.local.nombre)-\(partido.visitante.nombre)) \(identificador)")
}
// Arranca el partido. Solo se puede iniciar una vez.
func empezar(duracion: Int) {
// `guard`: si ya está corriendo o ya pasó algún minuto, no hace nada.
guard !corriendo, minuto == 0 else { return }
corriendo = true
// Una `Task` lanzada a mano: se pone en marcha ya, y nadie la cancelará si la pantalla
// se va (la lección de `.task` lo arregla). Además retiene `self` hasta que termina.
tarea = Task {
await jugar(duracion: duracion)
}
}
// MARK: - Ya hecho: pégalo debajo de `empezar`, dentro de la clase (no es el tema de la lección)
deinit {
Registro.anotar("modelo del partido LIBERADO")
}
// Un código corto para distinguir una instancia de otra en el registro. `& 0xFFFFF` se queda
// solo con los últimos dígitos del número y `radix: 16` lo escribe en hexadecimal: no hace falta entenderlo.
var identificador: String { String(UInt(bitPattern: ObjectIdentifier(self).hashValue) & 0xFFFFF, radix: 16) }
// El marcador que se ve: el del guion más los goles «a mano».
var marcador: Marcador {
Marcador(local: golesEnVivo.local + golesAMano.local, visitante: golesEnVivo.visitante + golesAMano.visitante)
}
// Suma un gol «a mano» al lado que llega.
func golAMano(_ lado: Lado) {
switch lado {
case .local: golesAMano = Marcador(local: golesAMano.local + 1, visitante: golesAMano.visitante)
case .visitante: golesAMano = Marcador(local: golesAMano.local, visitante: golesAMano.visitante + 1)
}
}
// El bucle del partido: un minuto cada `msPorMinuto` ms. En cada minuto aplica los goles del
// guion y, cada 15 minutos, apunta un aviso con el marcador que acaba de quedar.
private func jugar(duracion: Int) async {
// Solo los eventos que caben en la duración elegida.
let eventos = partido.eventos.filter { $0.minuto <= duracion }
for nuevo in 1...duracion {
// Espera un minuto de partido. `try?`: si cancelan la tarea, el error se ignora (¡ojo!).
try? await Task.sleep(for: .milliseconds(msPorMinuto))
// `for … where`: recorre solo los eventos cuyo minuto es el de esta vuelta.
// 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
}
}Compila con Cmd+B. El Lado de golAMano es el enum que creaste en @Binding y que se usa aquí por primera vez; lo verás trabajar en @ObservedObject y el partido que se reinicia, donde los botones de gol lo reciben.
Fíjate en dos cosas del bucle, porque la lección .task frente a .onAppear las arregla: la Task de empezar no la cancela nadie, y try? se traga el error que avisaría de la cancelación.
Punto de control
- Cmd+B termina en Build Succeeded, sin errores y sin avisos:
Configuracion.swiftyPartidoEnVivoModelo.swiftcompilan, aunque ninguna vista los use todavía. - Cmd+U sigue ejecutando 10 pruebas (3 unitarias y 7 de interfaz) y todas pasan.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Una vista crea su propio modelo (
ObservableObject) y debe conservarlo aunque su padre recalcule elbody. ¿Qué atributo usas?2.¿Qué dice el compilador si en el
initde una vista con@StateObject private var modelo: Modeloescribesmodelo = Modelo(...)?3.¿De qué módulo son
ObservableObjecty@Published, y qué pasa si el fichero que los usa no lo importa (proyecto de Xcode 26)?4.El
initde una vista se ejecuta dos veces porque su padre se redibuja, y en él escribes_modelo = StateObject(wrappedValue: Modelo()). ¿Cuántos modelos se crean?