← Todos los articulos

Internos de @Observable: la macro, el registrar y lo que ObservableObject hizo mal

El framework Observation, introducido en iOS 17 y Swift 5.9, reemplazó el modelo ObservableObject basado en Combine con un sistema impulsado por macros que rastrea el acceso por propiedad1. El cambio parece pequeño en el sitio de la llamada (una macro @Observable en lugar de : ObservableObject más @Published en todas partes), pero el comportamiento en tiempo de ejecución es diferente de una manera que afecta el rendimiento, la corrección y la ruta de migración. El cambio, en una sola frase: las vistas que no leyeron una propiedad modificada ya no se vuelven a evaluar cuando esa propiedad cambia.

Esta publicación recorre los internos del framework contrastándolos con la documentación de Apple y la propuesta SE-03952. El enfoque es “qué genera realmente la macro y por qué”, porque la mayoría de los equipos adoptan @Observable por la sintaxis y pasan por alto el cambio estructural en la propagación de actualizaciones, que es donde reside la verdadera ganancia de rendimiento (y las trampas de migración).

TL;DR

  • @Observable es una macro de Swift que expande una clase a un tipo que conforma el protocolo marcador Observable, con una instancia _$observationRegistrar: ObservationRegistrar sintetizada como propiedad almacenada3.
  • El getter de cada propiedad envuelve _$observationRegistrar.access(self, keyPath:). El setter de cada propiedad envuelve _$observationRegistrar.withMutation(of:keyPath:_:). El registrar rastrea qué ámbitos accedieron a qué key paths.
  • El vocabulario de reemplazo: class Foo: ObservableObject se convierte en @Observable class Foo. @Published var name se convierte en var name. @StateObject var foo = Foo() se convierte en @State var foo = Foo(). @EnvironmentObject se convierte en @Environment(Foo.self). @ObservedObject var foo se convierte simplemente en usar la propiedad.
  • @Bindable es el nuevo property wrapper para crear bindings hacia las propiedades de una instancia observable (reemplaza algunos casos de uso de @ObservedObject para binding).
  • La trampa de migración: @State con un tipo de referencia se comporta de manera distinta a @StateObject en aspectos sutiles relacionados con la identidad de la vista. Las apps que los intercambian a ciegas pueden producir un comportamiento de inicialización confuso al reconstruir las vistas.

La expansión de la macro

Cuando el compilador ve @Observable, expande el tipo agregando tres cosas3:

@Observable
class UserProfile {
    var name: String = ""
    var email: String = ""
    var preferences: [String] = []
}

La expansión (simplificada) genera:

class UserProfile: Observable {
    @ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()

    private var _name: String = ""
    var name: String {
        get {
            access(keyPath: \.name)
            return _name
        }
        set {
            withMutation(keyPath: \.name) {
                _name = newValue
            }
        }
    }
    // ... mismo patrón para email y preferences

    func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }

    func withMutation<Member, T>(
        keyPath: KeyPath<UserProfile, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
}

Tres cambios estructurales:

El registrar. Una instancia privada de ObservationRegistrar posee el estado de seguimiento. El registrar es el puente entre las mutaciones del modelo y las re-evaluaciones de los ámbitos dependientes. La macro lo marca como @ObservationIgnored para que el propio registrar no sea rastreado.

Reescritura del almacenamiento de propiedades. Cada propiedad almacenada declarada se convierte en un campo de respaldo privado más una propiedad calculada cuyo getter y setter llaman al registrar. Los accesores generados por el compilador son lo que hace funcionar el seguimiento por propiedad.

Conformidad con Observable. El protocolo marcador que el API del registrar espera. El protocolo no tiene requisitos; es una verificación de conformidad, no un contrato de interfaz.

El trabajo del registrar

ObservationRegistrar hace dos cosas3:

Rastrear el acceso. Cuando withObservationTracking { ... } onChange: { ... } (el API de seguimiento subyacente que SwiftUI usa para los cuerpos de las vistas) ejecuta el closure, el registrar registra cada par (self, keyPath) que se lee. El conjunto de rutas accedidas es la “huella de dependencias” del ámbito.

Disparar la invalidación. Cuando una propiedad muta, el registrar encuentra cada ámbito que accedió a ese keyPath específico y dispara su closure onChange. Los ámbitos que no accedieron al keyPath no se ven afectados.

El contraste con ObservableObject es el cambio estructural. El publisher objectWillChange de ObservableObject se dispara en cada mutación de @Published, y todos los suscriptores reciben la notificación. La maquinaria del cuerpo de vistas de SwiftUI usa el publisher para saber “algo cambió; vuelve a evaluar”. La re-evaluación se ejecuta sobre la vista completa; SwiftUI luego calcula qué vistas dependientes realmente cambiaron y solo actualiza esas, pero la re-evaluación del cuerpo ya ocurrió. Con @Observable, la re-evaluación del cuerpo en sí está controlada: si el cuerpo no leyó la propiedad cambiada, no se vuelve a ejecutar.

Para un UserProfile con tres propiedades y una vista que solo lee name, la diferencia es real: un modelo @ObservableObject también dispara la re-evaluación del cuerpo ante cambios en email y preferences; un modelo @Observable no lo hace. En una app compleja con muchos modelos y muchas vistas, los ahorros acumulados son significativos.

Mapeo de migración

El vocabulario de migración, lado a lado4:

ObservableObject @Observable
class Foo: ObservableObject @Observable class Foo
@Published var name: String var name: String
@StateObject var foo = Foo() @State var foo = Foo()
@ObservedObject var foo: Foo var foo: Foo (o @Bindable var foo: Foo para bindings)
@EnvironmentObject var foo: Foo @Environment(Foo.self) var foo
.environmentObject(foo) .environment(foo)

El wrapper @Bindable merece una nota aparte. Es la nueva forma de crear Bindings hacia las propiedades de una instancia @Observable:

@Bindable var profile: UserProfile

TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)

Sin @Bindable, la sintaxis $profile.name no funciona porque los tipos @Observable no proporcionan automáticamente valores proyectados. Con él, cada propiedad obtiene una forma de binding. Usa @Bindable cuando una vista hija necesite un binding bidireccional al modelo observable de un padre; usa una referencia simple (var profile: UserProfile) cuando la hija solo lee.

La trampa de @State vs @StateObject

La línea de migración que causa más bugs en producción: @StateObject var foo = Foo() se convierte en @State var foo = Foo(). El cambio compila. El comportamiento diverge a través de un mecanismo sutil: cómo se evalúa la expresión del valor por defecto5.

Tanto @State como @StateObject preservan la instancia a través de las reconstrucciones de vistas de SwiftUI cuando la identidad de la vista es estable; ambos almacenamientos de respaldo indexados por identidad descartan las re-inicializaciones impulsadas por el padre. La diferencia está en cuándo se ejecuta la expresión del inicializador.

@StateObject declara su parámetro mediante @autoclosure. La expresión del inicializador Foo() se envuelve y solo se evalúa cuando SwiftUI realmente necesita construir la instancia. En las reconstrucciones del padre donde se preserva la identidad de la vista y se reutiliza la instancia existente, la expresión nunca se invoca. El inicializador costoso nunca se ejecuta.

@State no está envuelto con autoclosure. La expresión del inicializador Foo() se evalúa de forma impaciente cada vez que se ejecuta el init de la vista (lo cual ocurre en cada reconstrucción del padre, incluso cuando se preserva la identidad de la vista y la instancia existente se mantiene en el almacenamiento). La asignación de Foo() ocurre; SwiftUI descarta la nueva instancia y continúa usando la almacenada. Para modelos con un init() barato, la asignación desperdiciada es invisible. Para modelos con un init() costoso (peticiones de red, carga de datos grandes, trabajo asíncrono iniciado en init), la diferencia es la diferencia entre una app que funciona y una app que se hace DDoS a su propio backend en cada reconstrucción del padre.

El patrón defensivo: mantén el init() del modelo barato para que la diferencia no importe, o inicializa el modelo costoso una sola vez a nivel de la app y pásalo hacia abajo mediante .environment(). Los modelos que necesitan trabajo de configuración costoso no deberían ejecutar ese trabajo en init independientemente de qué property wrapper los contenga; la inicialización perezosa o métodos de configuración explícitos son el patrón correcto tanto para los casos de @State como de @StateObject.

withObservationTracking para seguimiento explícito

Fuera de SwiftUI, la primitiva de seguimiento es withObservationTracking { ... } onChange: { ... }6:

import Observation

let profile = UserProfile()

withObservationTracking {
    print("Name: \(profile.name)")
} onChange: {
    print("Something we read changed")
}

profile.name = "Alice"  // Triggers onChange
profile.email = "..."   // Does NOT trigger onChange (we didn't read it)

El closure se ejecuta una vez y registra cada acceso observable. Cuando cualquiera de las propiedades fuente de esos accesos cambia, onChange se dispara exactamente una vez (es un callback de un solo disparo). Para volver a rastrear, el closure debe configurarse de nuevo. Es el patrón que SwiftUI usa internamente para rastrear las dependencias del cuerpo de la vista; para código fuera de SwiftUI (NSWindowController, apps de Cocoa, herramientas de línea de comandos), withObservationTracking es la primitiva correcta.

Cuándo ObservableObject sigue siendo la elección correcta

Tres casos donde ObservableObject mantiene su lugar:

Apps que apuntan a iOS 16 y anteriores. El framework Observation es iOS 17+. Las apps con objetivos de despliegue más antiguos necesitan ObservableObject. Una vez que el objetivo de despliegue pasa a 17+, la migración es segura.

Modelos que necesitan publicar notificaciones fuera del grafo de valores. El objectWillChange de ObservableObject es un publisher de Combine; el código que quiere suscribirse a “cualquier cambio” a través de pipelines de Combine (debouncing, throttling, transformación del flujo de eventos) lo obtiene gratis con ObservableObject y tendría que reconstruir el equivalente con @Observable. El framework Observation prioriza la eficiencia de la re-evaluación de vistas sobre las suscripciones arbitrarias de publishers.

Bases de código existentes donde el costo de migración supera el beneficio. Una base de código ObservableObject que funciona y que no ha medido un problema de rendimiento no gana lo suficiente con la migración como para justificar la auditoría. Migra cuando ya estés tocando el archivo o cuando el profiling identifique un punto caliente.

Para código nuevo, en objetivos iOS 17+, @Observable es el predeterminado moderno y la ruta de migración es clara.

Lo que este patrón significa para apps iOS 26+

Tres puntos clave.

  1. Usa @Observable por defecto para código nuevo. La macro es concisa, el seguimiento por propiedad mejora el rendimiento para casos comunes, y el vocabulario de migración es claro. Los modelos nuevos en bases de código iOS 17+ deberían ser @Observable.

  2. Audita las migraciones de @StateObject@State por la identidad de la vista. El intercambio compila limpiamente pero puede producir re-inicializaciones sorprendentes en vistas con estructura condicional. Los modelos que hacen trabajo costoso en init() necesitan migración cuidadosa; los que no, son seguros.

  3. Usa @Bindable deliberadamente. Es el nuevo patrón para bindings bidireccionales hacia modelos observables. Recurre a él en vistas hijas que necesitan mutar el modelo del padre; mantén la referencia simple (var foo: Foo) para vistas de solo lectura.

El cluster completo del Ecosistema Apple: App Intents tipados; servidores MCP; la pregunta de enrutamiento; Foundation Models; la distinción runtime vs LLM de tooling; tres superficies; el patrón de fuente única de verdad; Two MCP Servers; hooks para desarrollo Apple; Live Activities; el contrato de runtime de watchOS; internos de SwiftUI; el modelo mental espacial de RealityKit; disciplina de esquemas SwiftData; patrones de Liquid Glass; envío multi-plataforma; la matriz de plataformas; framework Vision; Symbol Effects; inferencia Core ML; API de Writing Tools; Swift Testing; Privacy Manifest; Accesibilidad como plataforma; tipografía SF Pro; patrones espaciales de visionOS; framework Speech; migraciones SwiftData; motor de foco tvOS; sobre lo que me niego a escribir. El hub está en la Serie del Ecosistema Apple. Para contexto más amplio sobre iOS con agentes de IA, consulta la guía iOS Agent Development.

FAQ

¿Por qué Apple reemplazó ObservableObject?

Dos razones. Primera, rendimiento: el publisher objectWillChange de ObservableObject se dispara en cada mutación de @Published, disparando la re-evaluación del cuerpo en cada vista dependiente sin importar si la vista realmente lee la propiedad cambiada. El seguimiento por propiedad de @Observable controla la re-evaluación del cuerpo según la propiedad a la que la vista realmente accede. Segunda, sintaxis: la anotación @Published por propiedad y la escalera @StateObject/@ObservedObject/@EnvironmentObject eran verbosas para lo que conceptualmente es una sola idea (“esto es estado mutable compartido”). @Observable más @State más @Environment es más corto.

¿@Observable funciona con structs?

No. @Observable requiere semántica de referencia; los structs no califican. La macro es para clases que mantienen estado mutable a través de vistas. Para estado de tipo valor en una sola vista, usa @State directamente con el tipo valor.

¿Puedo usar @Observable y ObservableObject en la misma app?

Sí. Coexisten sin conflicto. Una migración puede proceder archivo por archivo. El límite es por tipo: una clase es ObservableObject o @Observable, no ambas, pero clases distintas en la misma app pueden usar enfoques distintos.

¿Qué hay de las propiedades @Published que disparan pipelines de Combine?

@Observable no proporciona un equivalente de publisher de Combine para propiedades individuales. El código que usa patrones $foo.publisher desde propiedades @Published necesita reconstruir esa suscripción de manera diferente con @Observable (por ejemplo, envolver la propiedad en un modelo de tipo valor y observar a través del ciclo de actualización de SwiftUI, o usar withObservationTracking repetidamente). Para rutas de código intensivas en Combine, la migración es trabajo de ingeniería real.

¿Cómo interactúa @Observable con @Model de SwiftData?

Los tipos @Model (SwiftData) son automáticamente @Observable. El framework de persistencia agrega la conformidad con Observable como parte de su codegen, por lo que los modelos SwiftData participan en el mismo seguimiento por propiedad que los tipos @Observable simples. Las vistas que observan las propiedades de un tipo @Model obtienen el mismo comportamiento de re-evaluación de grano fino. Las publicaciones del cluster sobre migraciones SwiftData y disciplina de esquemas SwiftData cubren el lado de la persistencia de la misma superficie de observación.

¿Para qué sirve @ObservationIgnored?

Excluye una propiedad almacenada del seguimiento de observación. La macro normalmente reescribe cada propiedad almacenada para que pase por el registrar; las propiedades marcadas con @ObservationIgnored mantienen almacenamiento directo sin seguimiento. Úsalo para propiedades que no deberían disparar la re-evaluación de vistas: cachés, file handles, contadores de métricas, el propio registrar.

Referencias


  1. Documentación para Desarrolladores de Apple: framework Observation. La referencia del framework que cubre el protocolo Observable y la macro @Observable. Disponible en iOS 17+, macOS 14+, Swift 5.9+. 

  2. Swift Evolution: SE-0395 Observability. La propuesta de Swift aceptada con el razonamiento de diseño, los requisitos semánticos y el contrato del protocolo del registrar. 

  3. Documentación para Desarrolladores de Apple: ObservationRegistrar y Observable. Los tipos de tiempo de ejecución a los que la macro genera conformidad y el API del registrar que llaman los accesores sintetizados. 

  4. Documentación para Desarrolladores de Apple: Migrar del protocolo Observable Object a la macro Observable. La guía oficial de migración de Apple que cubre la tabla de mapeo de property wrappers y los cambios de integración con SwiftUI. 

  5. Documentación para Desarrolladores de Apple: State y StateObject. La semántica de inicialización documentada de los dos property wrappers en torno a la identidad de la vista y el ciclo de vida de reconstrucción. 

  6. Documentación para Desarrolladores de Apple: withObservationTracking(_:onChange:). La primitiva de seguimiento explícito utilizada fuera del seguimiento automático del cuerpo de vistas de SwiftUI. 

Artículos relacionados

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min de lectura

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min de lectura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de lectura