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

@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):

ejemplo mínimo: solo avisa de lo que se leyó (con comentarios)
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: 1

Tres 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 = 0var x = 0
import Combineimport 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:

  • @ObservationIgnored excluye una propiedad de la observación: cambiarla no avisa a nadie. Tiene sentido en una var que ninguna pantalla pinta (una Task guardada, por ejemplo). En un let no hace falta, porque una constante no cambia y no se observa.
  • Los private(set) siguen valiendo: la vista lee minuto, 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 cambiaUn body que lee minuto se recalcula solo si cambia
remember { Modelo() }@State private var modelo = Modelo()
CompositionLocal@Environment(Modelo.self)
StateFlow + collectAsState(): hay que suscribirseNo hay suscripción explícita: leer la propiedad es suscribirse

Dos ejemplos para leer

El temporizador de la lección anterior, migrado, con @ObservationIgnored:

ejemplo mínimo: una clase @Observable con una propiedad ignorada (con comentarios)
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: T con _modelo = State(wrappedValue: …) en el init: el valor inicial depende de un parámetro, así que se crea en el init. 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 del body, 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.
ejemplo mínimo: @State con State(wrappedValue:), @Environment y @Bindable (con comentarios)
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:

  • CasaView es la dueña del termostato y lo deja en el entorno de una pantalla y un mando, en columna.
  • PantallaDelTermostato lo recoge del entorno y enseña «20 °C · calefacción encendida» (o «apagada»).
  • MandoDelTermostato lo 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 apagada
Ver solución
solución: Termostato.swift
import Foundation
import Observation
 
// El termostato de casa: una clase @Observable, sin @Published.
@Observable
final class Termostato {
    var grados = 20
    var calefaccion = true
}
solución: CasaView.swift
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.

MarcadorApp
MarcadorApp
Marcador
Data
AjustesModelo.swiftse modifica
UI
PartidoEnVivoModelo.swiftse modifica
PartidoEnDirectoView.swiftse modifica
MarcadorApp.swiftse modifica
MarcadorAppTests
MarcadorAppTests.swiftse modifica
Cinco ficheros que ya existen y que se modifican.

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
AjustesModelo.swift
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
PartidoEnVivoModelo.swift: lo que cambia
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 cambia

Paso 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
PartidoEnDirectoView.swift: lo que cambia
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 cambia

MarcadorApp.swift. La raíz pasa de @StateObject a @State y de .environmentObject a .environment:

MarcadorApp.swift: dos cambios
// …
    // 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: 1 hace que un minuto de partido dure 1 ms. Ejemplo.rayoContraToros es un partido de ejemplo que trae el paquete Torneo.
  • weak var referencia: una referencia que no mantiene vivo el objeto y pasa a nil cuando se libera (de ARC).
  • do { … } a secas: crea un ámbito; al salir, lo que se declaró dentro deja de existir.
  • @MainActor delante del struct de 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 .task frente 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ú:

  1. observableAvisaSoloDeLoQueSeLee: como en el ejemplo del Playground, con unos ajustes nuevos, usa withObservationTracking leyendo 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.
  2. 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
MarcadorAppTests.swift: lo que se añade
// 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 test

Punto de control

  • Las pruebas pasan: el comando de arriba termina con ** TEST SUCCEEDED ** (y Cmd+U, con sus 13 pruebas, también), e incluye observableAvisaSoloDeLoQueSeLee y unModeloSinArrancarSeLibera.
  • La app compila sin import Combine fuera de la carpeta Ejercicios (los ejercicios de las lecciones anteriores sí lo llevan, y está bien). Búscalo con Find → Find in Project… (Cmd+Shift+F): import Combine solo debe dar resultados dentro de Ejercicios. Con la Terminal, grep -rn --exclude-dir=Ejercicios "import Combine" ~/Marcador-Swift/MarcadorApp/MarcadorApp no 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. 1.Un modelo @Observable tiene minuto y ultimoAviso. Una vista solo lee minuto en su body. ¿Cuándo se recalcula ese body?

  2. 2.Al escribir Picker("Duración", selection: $ajustes.duracion) con @Environment(AjustesModelo.self) private var ajustes, el compilador dice cannot find '$ajustes' in scope. ¿Cuál es el arreglo?

  3. 3.Al migrar de ObservableObject a @Observable, ¿en qué se convierte @EnvironmentObject var ajustes: AjustesModelo?

  4. 4.¿Qué hace la macro @Observable al escribirla delante de una clase?

  5. 5.La vista guarda _modelo = State(wrappedValue: PartidoEnVivoModelo(…)) en su init. Pulsas «Redibujar el padre» tres veces. ¿Qué ves en el registro?

  6. 6.Vigilas con withObservationTracking { _ = c.cuenta } onChange: { … } y cambias cuenta dos veces seguidas. ¿Cuántas veces se llama a onChange?

Faltan 6 de 6