@Observable
Al terminar: Migrar a @Observable con @Bindable y @Environment.
Al terminar tendrás los dos modelos de Marcador (AjustesModelo y PartidoEnVivoModelo) migrados a @Observable: sin ObservableObject, sin @Published y sin import Combine. La pantalla del partido usará @State, @Environment y @Bindable en lugar de la tripleta @StateObject / @ObservedObject / @EnvironmentObject, y tendrás dos pruebas nuevas: una que demuestra que @Observable solo avisa de lo que se leyó y otra que comprueba que un modelo sin arrancar se libera.
Importa porque @Observable es la forma actual de escribir un modelo en SwiftUI (necesita iOS 17; Marcador apunta a iOS 18). Es más corta y, sobre todo, más precisa: una vista se redibuja solo cuando cambia algo que ella misma leyó.
El problema: ObservableObject avisa a todos
Un ObservableObject avisa con cada @Published que cambia, y avisa a todas las vistas que observan el objeto, lean lo que lean. Si el modelo del partido tiene minuto y ultimoAviso, una vista que solo pinta minuto también recalcula su body cuando cambia ultimoAviso.
@Observable cambia la regla: SwiftUI apunta qué propiedades lee cada body mientras se calcula, y solo lo recalcula si cambia una de esas.
La macro, en una frase
Una macro es una etiqueta que le pide al compilador que escriba código por ti; ya usaste una con @Test. @Observable reescribe cada propiedad guardada (cada var con valor) de la clase para que anote quién la lee y avise al cambiar, y hace que la clase cumpla el protocolo Observable. Tú solo escribes @Observable delante de la clase.
Ejemplo mínimo: solo avisa de lo que se leyó
withObservationTracking(_:onChange:) es la pieza de bajo nivel que SwiftUI usa por dentro. Ejecuta el primer bloque y apunta las propiedades que lee; el segundo bloque se llama una vez, cuando cambia una de ellas. Como onChange puede ejecutarse desde otro hilo, el contador de avisos va dentro de un Mutex: un cerrojo que protege un valor y que ya conoces de Sendable y el modo Swift 6.
Pruébalo en tu Playground (reutiliza el mismo y borra lo anterior, como en Tu primer paquete de Swift):
import Observation
import Synchronization
// Una clase observable con dos propiedades.
@Observable
final class Contador {
var cuenta = 0
var nota = ""
}
let c = Contador()
let avisos = Mutex(0) // cuántas veces se ha avisado
withObservationTracking {
_ = c.cuenta // solo se LEE `cuenta`
} onChange: {
avisos.withLock { $0 += 1 } // se llama una vez, cuando cambia lo leído
}
c.nota = "hola"
print("tras cambiar nota:", avisos.withLock { $0 })
c.cuenta = 1
print("tras cambiar cuenta:", avisos.withLock { $0 })
c.cuenta = 2
print("tras cambiar cuenta otra vez:", avisos.withLock { $0 })Verás esto:
tras cambiar nota: 0
tras cambiar cuenta: 1
tras cambiar cuenta otra vez: 1Tres cosas: cambiar nota no avisa porque no se leyó; cambiar cuenta avisa; y el segundo cambio de cuenta ya no avisa porque onChange se dispara una sola vez y hay que volver a llamar a withObservationTracking para seguir vigilando (SwiftUI lo hace solo con cada body).
La migración, pieza a pieza
Antes (ObservableObject) | Ahora (@Observable) |
|---|---|
final class M: ObservableObject | @Observable final class M |
@Published var x = 0 | var x = 0 |
import Combine | import Observation |
@StateObject var m (la vista es dueña) | @State var m |
@ObservedObject var m (solo mira) | var m, a secas |
@EnvironmentObject var m | @Environment(M.self) var m |
.environmentObject(m) | .environment(m) |
$m.x | @Bindable var m, y entonces $m.x |
@Bindable existe porque un objeto @Observable que no es un @State no trae el $. Un enlace (binding) es una referencia de lectura y escritura a un valor que vive en otro sitio, y el Picker lo necesita para escribir la elección en duracion. @Bindable fabrica esos enlaces para las propiedades de un objeto @Observable.
Tres detalles que la tabla no dice:
@ObservationIgnoredexcluye una propiedad de la observación: cambiarla no avisa a nadie. Tiene sentido en unavarque ninguna pantalla pinta (unaTaskguardada, por ejemplo). En unletno hace falta, porque una constante no cambia y no se observa.- Los
private(set)siguen valiendo: la vista leeminuto, solo el modelo lo escribe. - Si nadie puso el objeto en el entorno,
@Environment(AjustesModelo.self)no falla al compilar: la app se detiene al abrir la pantalla que lo pide.
| Kotlin (Compose) | SwiftUI (@Observable) |
|---|---|
var minuto by mutableStateOf(0) | var minuto = 0 dentro de una clase @Observable |
Un composable que lee minuto se recompone solo si cambia | Un body que lee minuto se recalcula solo si cambia |
remember { Modelo() } | @State private var modelo = Modelo() |
CompositionLocal | @Environment(Modelo.self) |
StateFlow + collectAsState(): hay que suscribirse | No hay suscripción explícita: leer la propiedad es suscribirse |
Dos ejemplos para leer
El temporizador de la lección anterior, migrado, con @ObservationIgnored:
import Foundation
import Observation
@Observable
final class TemporizadorModelo {
let nombre: String // let: no cambia, no se observa
private(set) var segundos = 0 // se observa sola: sin @Published
// Ninguna pantalla pinta la tarea: que cambiarla no avise a nadie.
@ObservationIgnored private var tarea: Task<Void, Never>?
init(nombre: String) { self.nombre = nombre }
}Y las tres piezas de las vistas juntas, con ese temporizador y unas preferencias que llegan por el entorno:
@State private var modelo: Tcon_modelo = State(wrappedValue: …)en elinit: el valor inicial depende de un parámetro, así que se crea en elinit. El guion bajo es el almacenamiento del@State(lo viste con_modelo = StateObject(wrappedValue: …)).@Environment(Preferencias.self)y.environment(preferencias): leer y poner un objeto por tipo.@Bindable var preferencias = preferencias: dentro delbody, para poder escribir$preferencias.sonido. Envuelve el objeto que ya tienes y le da el$; la variable local tapa a la propiedad del mismo nombre, y solo la declara la vista que necesita el enlace.
import SwiftUI
@Observable
final class Preferencias {
var sonido = true
}
struct TemporizadorView: View {
// La vista es la dueña: @State, también para una clase @Observable.
@State private var modelo: TemporizadorModelo
// Lo puso un ancestro con .environment(preferencias); se recoge por tipo.
@Environment(Preferencias.self) private var preferencias
init(nombre: String) {
_modelo = State(wrappedValue: TemporizadorModelo(nombre: nombre)) // el almacenamiento del @State
}
var body: some View {
// @Bindable da el $ a un objeto @Observable que no lo trae.
@Bindable var preferencias = preferencias
VStack {
Text("\(modelo.nombre): \(modelo.segundos) s")
Toggle("Sonido", isOn: $preferencias.sonido) // el enlace escribe en el objeto
}
}
}
// En la raíz: @State para el dueño y .environment para repartirlo.
struct RaizDeEjemplo: View {
@State private var preferencias = Preferencias()
var body: some View {
TemporizadorView(nombre: "Té")
.environment(preferencias)
}
}Ejercicio: el termostato de casa
Reto · Ejercicio: el termostato de casa
Crea la subcarpeta Termostato dentro de Ejercicios (recuerda: ningún tipo puede llamarse igual que otro del proyecto). En Termostato.swift, una clase observable con la macro (sin el protocolo antiguo ni @Published), con los grados (20) y si la calefacción está encendida (sí). En CasaView.swift, tres vistas:
CasaViewes la dueña del termostato y lo deja en el entorno de una pantalla y un mando, en columna.PantallaDelTermostatolo recoge del entorno y enseña «20 °C · calefacción encendida» (o «apagada»).MandoDelTermostatolo recoge del entorno y tiene un interruptor, «Calefacción», que la enciende y la apaga, y un botón «Subir un grado».
Añade una vista previa de CasaView. Pulsa «Subir un grado» dos veces y apaga el interruptor. Lo que debe verse:
22 °C · calefacción apagadaVer solución
import Foundation
import Observation
// El termostato de casa: una clase @Observable, sin @Published.
@Observable
final class Termostato {
var grados = 20
var calefaccion = true
}import SwiftUI
// La dueña del termostato: @State, y lo reparte con .environment.
struct CasaView: View {
@State private var termostato = Termostato()
var body: some View {
VStack(spacing: 16) {
PantallaDelTermostato()
MandoDelTermostato()
}
.padding()
.environment(termostato)
}
}
// Solo lee: lo recoge del entorno por su tipo.
struct PantallaDelTermostato: View {
@Environment(Termostato.self) private var termostato
var body: some View {
Text("\(termostato.grados) °C · calefacción \(termostato.calefaccion ? "encendida" : "apagada")")
}
}
// Lee y escribe: necesita un enlace para el Toggle, y @Bindable se lo da.
struct MandoDelTermostato: View {
@Environment(Termostato.self) private var termostato
var body: some View {
@Bindable var termostato = termostato
VStack(spacing: 12) {
Toggle("Calefacción", isOn: $termostato.calefaccion)
Button("Subir un grado") { termostato.grados += 1 }
.buttonStyle(.bordered)
}
}
}
#Preview {
CasaView()
}Se ve 20 °C · calefacción encendida; tras subir dos grados, 22 °C · calefacción encendida; y al apagar el interruptor, 22 °C · calefacción apagada. Sin .environment(termostato), el proyecto compila, pero la app se detiene al abrir la pantalla que lo pide, con este mensaje:
SwiftUICore/Environment+Objects.swift:34: Fatal error: No Observable object of type Termostato found. A View.environmentObject(_:) for Termostato may be missing as an ancestor of this view.Paso 1 · AjustesModelo.swift, el modelo más pequeño
Objetivo. Migrar los ajustes a @Observable. Es el fichero más corto, y los tres cambios que haces aquí son los mismos que harás en el modelo del partido.
Trabajas en ~/Marcador-Swift/MarcadorApp, con los ficheros que creaste en las lecciones anteriores, cada uno en su carpeta (Marcador/UI, Marcador/Data, Partidos/UI…). No se crea ningún fichero: se editan cinco.
Abre AjustesModelo.swift. Intenta escribirlo tú: que los ajustes dejen Combine y se observen con la macro @Observable. Cambian tres cosas: la librería que se importa (ahora, Observation), la declaración de la clase (marcada con la macro y sin el protocolo antiguo) y la propiedad duracion, que pasa a ser una propiedad normal, sin marca. Lo que debes ver: Cmd+B compila este fichero (las vistas fallarán hasta el paso 3).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation // `@Observable` vive en Observation (ya no hace falta Combine)
// Los ajustes del usuario, compartidos por toda la app.
// `@Observable` es una MACRO: el compilador reescribe cada propiedad guardada para que anote
// quién la lee y avise solo a esas vistas cuando cambie. Ya no hay `@Published` ni `: ObservableObject`.
@Observable
final class AjustesModelo {
// Las duraciones que se ofrecen, en minutos.
static let duraciones = [90, 60]
// 90 o 60 minutos. Una propiedad normal: sin `@Published`.
var duracion = 90
}Si compilas ahora (Cmd+B), Xcode marca un error en MarcadorApp.swift y otro en PartidoEnDirectoView.swift: esas vistas todavía tratan a AjustesModelo como un ObservableObject. Es lo esperado, y lo arreglas en el paso 3. Los números de línea cambian con tus comentarios, y en una compilación incremental puede salir solo el primero:
MarcadorApp.swift:17:6: error: generic struct 'StateObject' requires that 'AjustesModelo' conform to 'ObservableObject'
PartidoEnDirectoView.swift:12:6: error: generic struct 'EnvironmentObject' requires that 'AjustesModelo' conform to 'ObservableObject'Paso 2 · PartidoEnVivoModelo.swift
Objetivo. El modelo del partido con @Observable, sin tocar su lógica.
Abre PartidoEnVivoModelo.swift. Intenta escribirlo tú: los mismos tres cambios del paso 1 (la importación, la declaración y las propiedades que se pintan, sin marca), y además la tarea guardada, que ninguna pantalla pinta, excluida de la observación con @ObservationIgnored, como en el ejemplo del temporizador. El resto del fichero (el init, deinit, golAMano, empezar y jugar) no cambia.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation // aquí vive `@Observable`
import Torneo
// `@Observable` hace que SwiftUI sepa qué propiedades lee cada vista y avise solo cuando cambien.
@Observable
final class PartidoEnVivoModelo {
let partido: Partido
private let msPorMinuto: Int
// Sin `@Published`: toda propiedad guardada se observa sola. `private(set)`: cualquiera lee,
// solo esta clase escribe.
private(set) var minuto = 0
private(set) var corriendo = false
private(set) var ultimoAviso = "ninguno"
// Los goles que marca el guion y los que se ponen «a mano» con los botones.
private(set) var golesEnVivo = Marcador(local: 0, visitante: 0)
private(set) var golesAMano = Marcador(local: 0, visitante: 0)
// La tarea que juega el partido. Ninguna pantalla la pinta, así que se excluye de la observación.
@ObservationIgnored private var tarea: Task<Void, Never>?
// … el resto del fichero no cambiaPaso 3 · Las vistas: @State, @Environment y @Bindable
Objetivo. Que la pantalla del partido compile y se comporte igual que antes, pero con @Observable: el partido no se reinicia al pulsar «Redibujar el padre», porque el modelo vive en @State.
PartidoEnDirectoView.swift. Cambian las dos propiedades (el modelo y los ajustes), el init y la primera línea del body. El resto del fichero no cambia. Intenta escribirlo tú: la vista es la dueña del modelo del partido, que crea en su init a partir del partido que recibe; los ajustes los recoge del entorno por su tipo; y, al empezar el body, consigue un enlace a los ajustes para que el selector de duración pueda escribir en ellos. Lo que debes ver: «Redibujar el padre» ya no reinicia el partido.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
struct PartidoEnDirectoView: View {
// Dueña del modelo: `@State`. Como el valor inicial depende de un parámetro, se crea en el
// `init` con `State(wrappedValue:)`. SwiftUI se queda con el de la PRIMERA vez y conserva ese
// aunque la vista se recree.
@State private var modelo: PartidoEnVivoModelo
// Este NO lo crea esta vista: lo puso la raíz de la app con `.environment(ajustes)`.
// Se recoge por TIPO: `AjustesModelo.self`. Si nadie lo puso, la app se detiene, igual que antes.
@Environment(AjustesModelo.self) private var ajustes
// … `pausas` y `fase` no cambian
init(partido: Partido) {
// El guion bajo es el almacenamiento oculto del `@State`: el modelo se entrega aquí.
_modelo = State(wrappedValue: PartidoEnVivoModelo(partido: partido))
}
var body: some View {
// `@Bindable` da `$ajustes.duracion` (un enlace) a un objeto `@Observable` que no trae `$`.
// Se declara aquí, dentro del `body`, porque es donde se necesita el enlace.
@Bindable var ajustes = ajustes
ScrollView {
// … el resto del `body` no cambiaMarcadorApp.swift. La raíz pasa de @StateObject a @State y de .environmentObject a .environment:
// …
// Con `@Observable` la app guarda los ajustes en `@State` (antes, `@StateObject`)
// y los reparte con `.environment(ajustes)` (antes, `.environmentObject`).
@State private var ajustes = AjustesModelo()
var body: some Scene {
WindowGroup {
ContentView()
.environment(ajustes)
}
// …Compila con Cmd+B: ya no hay errores. Ejecuta con Cmd+R, abre un partido, pulsa «Empezar partido» y, pasados unos minutos, «Redibujar el padre»: el minuto sigue donde estaba. Cambia el selector de duración antes de empezar: sigue funcionando (es el @Bindable).
Paso 4 · Dos pruebas
Objetivo. Una prueba que demuestra que @Observable solo avisa de lo que se leyó, y otra que comprueba que un modelo sin arrancar se libera.
Piezas nuevas de este paso.
PartidoEnVivoModelo(partido: …, msPorMinuto: 1): el inicializador del modelo;msPorMinuto: 1hace que un minuto de partido dure 1 ms.Ejemplo.rayoContraToroses un partido de ejemplo que trae el paqueteTorneo.weak var referencia: una referencia que no mantiene vivo el objeto y pasa anilcuando se libera (de ARC).do { … }a secas: crea un ámbito; al salir, lo que se declaró dentro deja de existir.@MainActordelante delstructde pruebas: la app aísla su código al hilo principal por defecto y el target de pruebas no (Estado de pantalla y @MainActor en Marcador). Las dos pruebas de hoy compilan sin él; lo pones ya porque lo necesitarán las pruebas asíncronas de.taskfrente a.onAppear, que explica el aviso que sale sin él.
Abre MarcadorAppTests.swift (en MarcadorAppTests). Añade dos importaciones arriba, antes de import Testing (Observation y Synchronization), @MainActor delante del struct y dos pruebas dentro, tras la última que ya tenías. Intenta escribirlas tú:
observableAvisaSoloDeLoQueSeLee: como en el ejemplo del Playground, con unos ajustes nuevos, usawithObservationTrackingleyendo su duración, y cuenta los avisos (en un cerrojo, porque el aviso puede llegar desde otro hilo); al cambiar la duración a 60, tiene que haber exactamente un aviso y la duración tiene que ser 60.unModeloSinArrancarSeLibera: crea, dentro de un bloque, un modelo del partido de ejemplo a 1 ms por minuto y apúntalo con una referencia que no lo retenga; dentro del bloque sigue vivo, y al salir tiene que haberse liberado.
Lo que debes ver: las dos pruebas en verde.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Arriba del fichero, antes de `import Testing`:
import Observation
import Synchronization
// Delante del `struct` (si no lo tiene ya):
@MainActor
struct MarcadorAppTests {
// … lo que ya tienes
// `@Observable` avisa cuando cambia lo que se LEYÓ dentro de `withObservationTracking`.
@Test func observableAvisaSoloDeLoQueSeLee() {
let ajustes = AjustesModelo()
let avisos = Mutex(0) // el `onChange` puede correr en otro hilo: cerrojo
withObservationTracking {
_ = ajustes.duracion // se lee `duracion`
} onChange: {
avisos.withLock { $0 += 1 }
}
ajustes.duracion = 60 // cambia lo leído: avisa una vez
#expect(avisos.withLock { $0 } == 1)
#expect(ajustes.duracion == 60)
}
// Un modelo que nadie usa se libera: no queda nada que lo retenga.
@Test func unModeloSinArrancarSeLibera() {
weak var referencia: PartidoEnVivoModelo? // `weak`: no mantiene vivo el objeto
do {
let modelo = PartidoEnVivoModelo(partido: Ejemplo.rayoContraToros, msPorMinuto: 1)
referencia = modelo
#expect(referencia != nil) // dentro del bloque, sigue vivo
} // al salir del bloque, `modelo` deja de existir
#expect(referencia == nil) // y la referencia débil vale `nil`
}
}Si escribes la prueba con una var capturada en lugar de Mutex, sale este aviso: warning: mutation of captured var 'avisos' in concurrently-executing code; this is an error in the Swift 6 language mode.
Cmd+U lanza todas las pruebas: las 5 unitarias y las 8 de interfaz (las 7 de MarcadorAppUITests más testLaunch), 13 en total, y las de interfaz tardan. Para pasar solo las unitarias, desde la Terminal (-only-testing deja fuera las de interfaz):
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppTests testPunto de control
- Las pruebas pasan: el comando de arriba termina con
** TEST SUCCEEDED **(y Cmd+U, con sus 13 pruebas, también), e incluyeobservableAvisaSoloDeLoQueSeLeeyunModeloSinArrancarSeLibera. - La app compila sin
import Combinefuera de la carpetaEjercicios(los ejercicios de las lecciones anteriores sí lo llevan, y está bien). Búscalo con Find → Find in Project… (Cmd+Shift+F):import Combinesolo debe dar resultados dentro deEjercicios. Con la Terminal,grep -rn --exclude-dir=Ejercicios "import Combine" ~/Marcador-Swift/MarcadorApp/MarcadorAppno devuelve nada. - Ejecuta la app, abre un partido, pulsa «Empezar partido», deja pasar unos minutos y pulsa «Redibujar el padre»: el minuto sigue donde estaba. Cambia el selector de duración: sigue funcionando (es el
@Bindable).
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Un modelo
@ObservabletieneminutoyultimoAviso. Una vista solo leeminutoen subody. ¿Cuándo se recalcula esebody?2.Al escribir
Picker("Duración", selection: $ajustes.duracion)con@Environment(AjustesModelo.self) private var ajustes, el compilador dicecannot find '$ajustes' in scope. ¿Cuál es el arreglo?3.Al migrar de
ObservableObjecta@Observable, ¿en qué se convierte@EnvironmentObject var ajustes: AjustesModelo?4.¿Qué hace la macro
@Observableal escribirla delante de una clase?5.La vista guarda
_modelo = State(wrappedValue: PartidoEnVivoModelo(…))en suinit. Pulsas «Redibujar el padre» tres veces. ¿Qué ves en el registro?6.Vigilas con
withObservationTracking { _ = c.cuenta } onChange: { … }y cambiascuentados veces seguidas. ¿Cuántas veces se llama aonChange?