wandres.dev
@OBSERVABLESTATE · observación fina

El framework Observation: del objeto que avisa al campo que se lee

Antes de entender `@ObservableState` hay que entender la maquinaria que imita. En 2023 Swift incorporó `Observation`, un módulo que sustituyó el modelo de Combine —un objeto que emite `objectWillChange` y arrastra consigo a todas las vistas suscritas— por otro radicalmente distinto: cada lectura de una propiedad se registra en tiempo de ejecución y cada mutación notifica solo a quien de verdad leyó ese campo. Esta lección disecciona la macro `@Observable`, el `ObservationRegistrar` que inyecta, el contrato de `withObservationTracking` que SwiftUI abre alrededor de cada `body`, y las tres consecuencias que de ahí se siguen: la observación deja de declararse y pasa a descubrirse, se vuelve condicional al camino que tomó el código, y queda atada a los tipos de referencia. Ese último límite es exactamente el hueco donde TCA tuvo que inventar algo.

⏱ 18 min

Durante casi una década, observar estado en el mundo de Apple significó una cosa: conformar una clase a ObservableObject, decorar sus campos con @Published y confiar en que SwiftUI hiciera lo correcto. Hacía lo correcto en el sentido de que nunca perdía un cambio, y lo hacía mal en el sentido de que notificaba de más: la unidad de observación era el objeto entero, no el campo. Una vista que solo leía el nombre del usuario se redibujaba cuando cambiaba el contador de notificaciones no leídas, porque ambos colgaban del mismo objectWillChange. Observation, el módulo que Swift 5.9 trajo consigo, invierte la pregunta: en vez de que el modelo declare qué publica, la vista revela qué lee, y el sistema apunta esa lectura. Entender ese giro es el prerrequisito de todo lo demás, porque @ObservableState no es más que la traducción de ese mecanismo al reino de los valores.

🎯 Al terminar esta lección sabrás
  • Explicar por qué ObservableObject invalidaba a granularidad de objeto y qué paliativos manuales exigía.
  • Leer la expansión de la macro @Observable y reconocer el papel de ObservationRegistrar, access y withMutation.
  • Describir el contrato de withObservationTracking y cómo SwiftUI lo abre alrededor de cada evaluación de body.
  • Identificar los límites del framework —tipos de referencia, semántica de aviso previo, disponibilidad— y anticipar por qué TCA necesitó una variante propia.

El pecado original de ObservableObject

El modelo antiguo se apoyaba en Combine. Un ObservableObject expone un objectWillChange, un publicador sin carga útil, y el envoltorio @Published se limita a emitir por él en el willSet de la propiedad que envuelve. Del lado de la vista, @ObservedObject y @StateObject se suscriben a ese publicador y, cuando llega un aviso, invalidan la vista completa. El diseño es honesto y tiene una virtud: es imposible perderse un cambio. Su defecto es de granularidad, y es estructural: el publicador no lleva información sobre qué cambió, de modo que ninguna vista suscrita puede decidir que el aviso no le concierne.

final class Sesion: ObservableObject {
  @Published var usuario = ""
  @Published var noLeidos = 0
}
// Cualquier vista con @ObservedObject var sesion: Sesion se invalida
// cuando cambia noLeidos, aunque solo dibuje sesion.usuario.

Hay un agravante que la práctica revela pronto. Como el aviso llega sin carga útil, tampoco puede deduplicarse: escribir el mismo valor que ya había en un campo publicado emite igualmente, y en un sistema donde el estado se recalcula a menudo eso significa oleadas de invalidaciones que no corresponden a ningún cambio real. Sumado a que el modelo empuja hacia objetos grandes —un view model por pantalla— el resultado es que la pantalla más compleja de la app es también la que peor se comporta, justo al revés de lo que uno querría.

La comunidad desarrolló paliativos, todos manuales y todos silenciosos cuando fallaban: partir el modelo en objetos pequeños para que cada vista se suscribiera al mínimo; renunciar a @Published y publicar a mano con removeDuplicates; o, en la TCA anterior a esta era, envolver la vista en WithViewStore(store, observe: ViewState.init) para proyectar una estructura diminuta y Equatable con solo los campos que la vista dibujaba. Ese observe: funcionaba, pero cargaba al programador con la obligación de mantener sincronizadas dos definiciones —lo que la vista lee y lo que la vista declara que lee— y el castigo por desincronizarlas no era un error de compilación, sino un redibujado de más o, peor, una vista congelada.

Anatomía de @Observable: lecturas y escrituras instrumentadas

Observation sustituye el publicador por un registro. La macro @Observable es una macro de adjunción que reescribe la clase: la conforma al protocolo Observable, le inyecta un ObservationRegistrar privado y convierte cada propiedad almacenada en una propiedad computada con almacenamiento subyacente, de modo que el get anuncia una lectura y el set anuncia una mutación.

@Observable
final class Sesion {
  var usuario = ""
  var noLeidos = 0
  @ObservationIgnored var cacheDeDisco: Cache?
}

Esto se expande, en esencia, a lo siguiente. Merece la pena leerlo despacio porque es todo el truco:

final class Sesion: Observable {
  private let _$observationRegistrar = ObservationRegistrar()
  private var _usuario = ""

  var usuario: String {
    get {
      _$observationRegistrar.access(self, keyPath: \.usuario)
      return _usuario
    }
    set {
      _$observationRegistrar.withMutation(of: self, keyPath: \.usuario) {
        _usuario = newValue
      }
    }
  }
}

Tres detalles que suelen pasar desapercibidos. Primero: access no hace nada si no hay una sesión de rastreo abierta; fuera de ese contexto el coste es una comprobación y poco más. Segundo: withMutation avisa antes de escribir, no después, y por eso quien recibe la notificación todavía puede leer el valor viejo. Tercero: @ObservationIgnored desconecta una propiedad del mecanismo, y es la herramienta correcta para cachés, temporizadores y todo lo que la vista no debe observar jamás. No hay conformidades que escribir a mano, no hay @Published, y las propiedades computadas quedan rastreadas por contagio: si var saludo: String { "Hola, \(usuario)" } se lee desde una vista, quien se registra es usuario, porque es lo que el getter tocó.

withObservationTracking: el contrato entre modelo y vista

El registro por sí solo no sirve de nada; hace falta alguien que abra el ámbito donde las lecturas se apuntan. Esa es la función de withObservationTracking(_:onChange:): ejecuta el bloque apply, recolecta el conjunto de rutas de clave que se accedieron durante su ejecución y promete llamar a onChange una sola vez, justo antes de la primera mutación de cualquiera de ellas.

withObservationTracking {
  _ = sesion.usuario          // se apunta \.usuario
} onChange: {
  print("algo que leimos va a cambiar")  // se dispara como maximo una vez
}

SwiftUI hace exactamente esto por ti: envuelve cada evaluación de body en una sesión de rastreo y, cuando el onChange se dispara, marca la vista como inválida y programa una nueva evaluación, que a su vez abre una sesión nueva y recalcula el conjunto de rutas observadas. De ahí se siguen las dos propiedades que cambian el modelo mental por completo. La observación ya no se declara, se descubre: nadie escribe en ningún sitio qué campos importan, se deducen de lo que el código leyó. Y la observación es condicional: si un body solo lee noLeidos dentro de una rama if que esta vez no se tomó, esa vista no quedará suscrita a noLeidos hasta que la rama se ejecute.

El efecto en la vista es una simplificación notable de los envoltorios de propiedad. Ya no hace falta anunciar la relación con el modelo: @State sirve para poseer el objeto y mantenerlo vivo entre evaluaciones, una propiedad constante basta para recibirlo prestado, y @Bindable solo aparece cuando de verdad se necesita un enlace de dos vías. @ObservedObject, @StateObject y @EnvironmentObject quedan reducidos al papel de piezas heredadas.

struct PanelView: View {
  @State private var sesion = Sesion()   // posee el modelo

  var body: some View {
    VStack {
      NombreView(sesion: sesion)         // lo recibe prestado, sin envoltorio
      EditorView(sesion: sesion)
    }
  }
}

struct NombreView: View {
  let sesion: Sesion
  var body: some View { Text(sesion.usuario) }   // solo depende de usuario
}

struct EditorView: View {
  @Bindable var sesion: Sesion
  var body: some View { TextField("Usuario", text: $sesion.usuario) }
}

Un matiz que confunde a mucha gente: que NombreView reciba el objeto con una simple constante no significa que no observe. La observación no la establece el envoltorio de propiedad, la establece la lectura del getter dentro del cuerpo; el envoltorio solo decide quién es el dueño del objeto y quién puede escribir en él. Es la misma inversión de la lección: el sistema no pregunta qué declaras, mira qué lees.

flowchart TD
V[body de la vista] -->|lee usuario| ACC[registrar access keyPath usuario]
ACC --> SET[conjunto de rutas leidas en esta pasada]
MUT[mutacion de noLeidos] --> WM[registrar withMutation willSet]
WM --> CMP[esta noLeidos en el conjunto]
CMP -->|no| NADA[la vista no se invalida]
CMP -->|si| INV[onChange invalida y reprograma body]
INV --> V
style SET fill:#89b4fa,color:#11111b
style NADA fill:#a6e3a1,color:#11111b
💡
El rastreo es de un solo disparo

onChange se invoca como máximo una vez por sesión: después, el ámbito queda agotado. SwiftUI lo disimula porque vuelve a armarlo en cada evaluación de body, pero si usas withObservationTracking a mano —para reaccionar a cambios fuera de una vista— tendrás que rearmarlo tú, típicamente llamándote a ti mismo desde dentro del onChange. Swift 6.2 alivia ese patrón con Observations, una secuencia asíncrona que emite valores derivados del estado observado y evita escribir la recursión a mano.

Los tres límites que heredan las capas de arriba

El primero es el que interesa a esta guía: Observable exige una clase. El registrador debe sobrevivir a las lecturas y ser compartido por todos los observadores, y eso pide identidad de referencia. Una struct copiada por valor no puede, sin ayuda, mantener ese contrato. Como el State de TCA es un valor por convicción arquitectónica —y no por casualidad—, adoptar Observation tal cual habría significado renunciar a la pieza central del modelo. La lección siguiente muestra la salida: no una clase disfrazada, sino un registrador embebido en el valor y un Store de referencia que actúa de puente.

El segundo es la semántica de aviso previo. withMutation notifica antes de escribir, de modo que dentro de onChange todavía se lee el valor antiguo. Para SwiftUI es irrelevante, porque lo único que hace es programar una invalidación que se resolverá después; para código propio que intente leer el valor nuevo desde el onChange es una trampa clásica.

El tercero es de disponibilidad. Observation viaja con el sistema operativo, no con tu binario: iOS 17, macOS 14, tvOS 17, watchOS 10. Y no basta con tener el módulo, porque la integración que importa —que SwiftUI abra la sesión de rastreo alrededor de body— vive dentro de SwiftUI. Un proyecto que aún dé soporte a iOS 16 no puede simplemente comprobar la disponibilidad y seguir: le falta el enganche. Ese agujero es el que rellena la librería Perception, y ocupa la lección 4 de este nivel.

Se pasó de un modelo declarado a un modelo descubierto, y esa es la ruptura

Lo que Observation cambia no es la eficiencia, es la epistemología del sistema: cambia quién sabe qué se observa y cuándo lo sabe. En el modelo de Combine, la relación entre modelo y vista era un contrato declarado por adelantado y por partida doble —el modelo declaraba @Published, la vista declaraba @ObservedObject— y el sistema no tenía forma de saber si ese contrato correspondía al uso real; solo podía tomarlo al pie de la letra y, ante la duda, invalidar de más. La invalidación de más es el precio universal de la ignorancia: cuando no sabes quién depende de qué, avisas a todo el mundo. Todo el trabajo manual de aquella época —partir modelos, proyectar ViewState, deduplicar publicadores— era una forma de comprarle información al sistema pagándola con disciplina humana, y la disciplina humana se degrada con el tamaño del equipo y con el paso de los meses. Observation sustituye la declaración por la observación empírica: el conjunto de dependencias no se escribe, se mide, y se mide exactamente donde ocurre —en el getter, en el instante de la lectura, dentro de la pasada de renderizado concreta que la hizo—. Por eso la granularidad resultante no es solo más fina, es correcta por construcción: no puede desincronizarse del código porque es el código ejecutándose. Y por eso mismo la ganancia de rendimiento, con ser real, es lo menos interesante del asunto. Lo interesante es que desaparece toda una clase de errores que no producían fallos ruidosos sino degradación silenciosa: la vista que se refresca de más y hace que la app parezca lenta sin que nadie sepa por qué, y la que se refresca de menos porque alguien olvidó añadir un campo al ViewState y muestra un dato viejo. Ninguno de los dos era detectable por el compilador; ambos dejan de existir cuando la dependencia se deduce en lugar de declararse. Guarda esa idea, porque es la misma que gobierna el resto del nivel: la observación fina no es una optimización que TCA adoptó, es un cambio en dónde vive la verdad sobre las dependencias de tu interfaz.

⚔️ Mide la diferencia de granularidad con tus propias manos
  1. Escribe una clase ObservableObject con dos campos @Published y dos vistas hermanas, cada una leyendo solo uno. Añade un print en cada body y cambia un solo campo: comprueba que se imprimen los dos.
  2. Convierte la clase a @Observable, borra los envoltorios de la vista y repite el experimento. Explica por qué ahora solo se imprime uno.
  3. Mueve una de las lecturas dentro de una rama if que empiece desactivada y razona, antes de ejecutarlo, si la vista se invalidará al cambiar ese campo. Comprueba tu predicción.
  4. Usa withObservationTracking a mano fuera de SwiftUI y verifica que onChange se dispara una sola vez; reármalo desde dentro y observa cómo se convierte en un flujo continuo.
  5. Anota en una frase por qué nada de lo anterior se puede aplicar directamente a una struct, y guarda esa frase: es la pregunta que responde la lección siguiente.