← Todos los articulos

SwiftData en iOS 27: Observación e historial

SwiftData llegó en iOS 17 con dos formas de observar tus datos: @Query dentro de una vista de SwiftUI, o la conexión manual de notificaciones en ModelContext para todo lo demás. Ninguna cubría el caso que más importa en una app sincronizada, que es saber cuándo otro dispositivo cambió el store. iOS 27 cierra ambas brechas en una sola versión. ResultsObserver convierte el seguimiento de cambios en un objeto de primera clase que puedes mantener fuera de una vista, y HistoryObserver observa el historial persistente de SwiftData e incrementa un contador observable cuando llegan nuevas transacciones, de modo que tu código de sincronización puede traer solo los últimos cambios. iOS 27 añade la observación como una primitiva, no como un efecto secundario de SwiftUI.1

El planteamiento coincide con el resto de este clúster: SwiftData era barato de empezar y caro de coordinar. Observar un fetch fuera de la jerarquía de vistas significaba reconstruir a mano lo que hace @Query. Mantener un store sincronizado con un servidor externo, o reaccionar a escrituras desde una extensión de la app, significaba recorrer el historial persistente y reconciliar las transacciones tú mismo. iOS 27 le da a ambas tareas un tipo con nombre que se conforma a Observable, así que la misma maquinaria de actualización de SwiftUI que ya impulsa tus vistas impulsa también tu capa de sincronización.

TL;DR / Puntos clave

  • ResultsObserver observa y rastrea los cambios en una colección de modelos persistentes dentro de un model context, proporcionando actualizaciones en tiempo real cuando los datos subyacentes cambian. Es Observable, así que una vista de SwiftUI se actualiza automáticamente, y funciona fuera de una vista donde @Query no puede llegar.2
  • HistoryObserver observa el historial persistente de SwiftData y expone una única propiedad observable, eventCounter, que se incrementa cuando llegan nuevas transacciones. Filtras por tipo de modelo y por autor de la transacción, y luego llamas a ModelContext.fetchHistory para leer los cambios. Es la respuesta estructurada para sincronizar con un servidor externo o reaccionar a escrituras de una extensión de la app.3
  • @Attribute(.codable) usa la representación codable de la propiedad para almacenarla, lo que te da una forma declarativa de persistir un tipo de valor Codable sin un ValueTransformer.4
  • Las tres llegan en iOS, iPadOS, macOS, Mac Catalyst, tvOS, visionOS y watchOS en la beta 27.0.234

Observar un fetch fuera de la vista: ResultsObserver

@Query es excelente y está acotado. Vive dentro de una vista de SwiftUI, se vuelve a ejecutar cuando cambia su predicado y le entrega a la vista un arreglo. La restricción es la ubicación. Un view model, un coordinador de sincronización, una tarea de exportación o un reconciliador en segundo plano tienen datos que cambian y no cuentan con un @Query en el que apoyarse. Antes de iOS 27, esos llamadores se suscribían a las notificaciones de guardado de ModelContext y volvían a hacer fetch a mano, que es la reconstrucción manual de exactamente lo que @Query ya hace internamente.

ResultsObserver es la respuesta con nombre de iOS 27. La declaración indica lo que es:2

final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable

La clase monitorea automáticamente los cambios en los modelos que coinciden con los criterios de fetch especificados y mantiene una colección de resultados obtenidos, lo que la convierte en la herramienta para mantener cualquier consumidor sincronizado con los datos persistentes, no solo una vista.2 La configuras de dos maneras: con un FetchDescriptor completo, o con predicados de filtro y sort descriptors individuales.2 Las dos rutas de SectionName importan para las listas agrupadas; cuando no necesitas seccionar, pasas Never como parámetro de tipo SectionName.2

La ganancia frente al viejo enfoque manual es la conformidad con Observable. ResultsObserver es Observable, lo que permite que las vistas de SwiftUI se actualicen automáticamente cuando cambian los resultados, de la misma forma en que los objetos de modelo @Observable impulsan la invalidación de vistas (cubierto en los interiores de @Observable).2 Un coordinador de sincronización que mantiene un ResultsObserver recibe notificaciones de cambios sin escribir una sola línea de NotificationCenter, y cualquier vista que lea los resultados del observer se vuelve a renderizar gratis.

En la sesión 274, Apple presenta ResultsObserver para el caso que @Query no puede atender: hacer fetch y observar el store desde cualquier parte de tu app a través de Swift Observation, incluido un state object o un juego que nunca toca SwiftUI.5

Watch on Apple Developer ↗
ResultsObserver lleva la observación estilo query al código fuera de las vistas de SwiftUI.

import SwiftData
import Observation

@Observable
final class ShoppingListModel {
    let observer: ResultsObserver<ShoppingItem, Never>

    init(context: ModelContext) {
        let descriptor = FetchDescriptor<ShoppingItem>(
            sortBy: [SortDescriptor(\.sortOrder)]
        )
        // No sectioning, so SectionName is Never.
        observer = ResultsObserver(context: context, fetchDescriptor: descriptor)
    }
}

La referencia publicada por Apple confirma las declaraciones de clase y la superficie de configuración (un FetchDescriptor o predicados de filtro y sort descriptors, Never para el nombre de sección cuando no seccionas), pero omite las firmas exactas de los inicializadores al momento de escribir esto, así que trata las formas de llamada en estos ejemplos como ilustrativas y confirma las etiquetas de los parámetros contra el SDK.

El cambio mental es que el fetch se convierte en un objeto que posees y pasas de un lado a otro, en lugar de un property wrapper atrapado en el body de una vista. Un @Query responde “¿qué muestra esta vista?”. Un ResultsObserver responde “¿cuál es el estado actual de este fetch, donde sea que lo esté manteniendo?”. La segunda pregunta es la que de verdad hace un llamador que no es una vista.

Reaccionar a los cambios del historial: HistoryObserver

La brecha que tanto @Query como ResultsObserver dejan abierta es el cambio que se origina fuera de tu fetch en proceso. Cada vez que se guarda tu store, SwiftData registra una transacción de historial que describe qué cambió, de dónde provino el cambio y un token que lo identifica. Mantener un store sincronizado con un servidor externo, o reaccionar a escrituras desde una extensión de la app, significaba recorrer ese historial persistente tú mismo. iOS 27 le da a la tarea un observer con nombre.3

HistoryObserver es la respuesta estructurada:3

final class HistoryObserver

El observer observa el historial persistente de SwiftData y permite que tu código reaccione cuando se añaden nuevas transacciones.3 Si solo necesitas ciertos tipos de cambios, filtras por tipo de modelo y por autor de la transacción, de modo que reaccionas a las escrituras que te importan en lugar de a cada mutación del store.3

Toda la superficie es una sola propiedad observable, eventCounter. Cuando llegan nuevas transacciones al historial persistente, el contador se incrementa; lo observas, y en cada incremento llamas a la API ModelContext.fetchHistory para leer solo los últimos cambios.3 Los tokens del historial viven en las transacciones mismas, así que fetchHistory devuelve únicamente lo nuevo en lugar de volver a escanear el store. Eso mantiene rápido a un manejador de sincronización a medida que el historial crece.

import SwiftData
import Observation

// Illustrative call shapes; confirm parameter labels against the SDK.
let historyObserver = HistoryObserver(container: modelContainer, authors: "App")

// Observe eventCounter; on each increment, fetch and process the new history.
let token = withContinuousObservation(of: historyObserver.eventCounter) {
    Task { await processChanges() }
}

Filtrar por autor de la transacción es el detalle que hace correcta la sincronización con un servidor. En el ejemplo de Apple, el observer pasa "App" como autor para reaccionar solo a los cambios que hizo la app, y no reproducir hacia el servidor los cambios que vinieron del servidor.3 En cada incremento, tu paso processChanges llama a ModelContext.fetchHistory para leer las nuevas transacciones y subirlas. Los patrones multiproceso y de sincronización externa que las apps de este clúster resolvían con una conexión de historial hecha a mano (ver disciplina de esquema en SwiftData) obtienen una costura del framework de la cual colgarse.

Lecturas ligeras que combinan con el historial

Una vez que HistoryObserver despierta tu manejador de sincronización, la siguiente pregunta es cuánto trabajo hacer. Un manejador ingenuo vuelve a hacer fetch de los modelos afectados en cada incremento, lo que hidrata objetos de modelo completos que quizá no necesites. SwiftData te da dos lecturas que responden preguntas más acotadas sin materializar nada, y ambas combinan de forma natural con la observación del historial.

ModelContext.fetchCount(_:) devuelve la cantidad de modelos que coinciden con un fetch descriptor como un simple Int, sin cargar los objetos coincidentes:6

func fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel

ModelContext.fetchIdentifiers(_:) devuelve las coincidencias como [PersistentIdentifier], de nuevo sin cargar los modelos detrás de ellas. Una sobrecarga con batchSize transmite esos identificadores en fragmentos para conjuntos de resultados grandes:6

func fetchIdentifiers<T>(_ descriptor: FetchDescriptor<T>) throws -> [PersistentIdentifier] where T : PersistentModel

El SwiftData Group Lab sugirió un patrón que ata estas lecturas directamente a la observación del historial. Cuando llega un cambio de historial, haz fetch de los identificadores afectados y compáralos con lo que la vista realmente muestra antes de decidir si recargar, así evitas hidratar objetos que no necesitas.6 Un cambio en una fila que el usuario no está mirando no mueve ningún píxel, y una comparación de identificadores te lo dice al costo de una búsqueda por clave en lugar de un fetch completo. fetchCount responde la pregunta aún más barata de si algo coincidió siquiera, lo cual basta para decidir si un badge o un estado vacío necesitan cambiar.

Persistir un tipo de valor de forma limpia: @Attribute(.codable)

La tercera incorporación es pequeña y práctica. SwiftData almacena de forma nativa los tipos primitivos de Swift y las relaciones @Model, pero una propiedad cuyo tipo es un valor Codable personalizado (un struct que contiene unos pocos campos, un enum con valores asociados) requería un ValueTransformer y el atributo .transformable(by:), que es ceremonia de Core Data filtrándose de regreso a través de la superficie de la macro.

iOS 27 añade la opción de almacenamiento codable:4

static var codable: Schema.Attribute.Option { get }

La opción usa la representación codable de la propiedad para almacenarla, así que un tipo de valor Codable persiste a través de su propia conformidad Encodable/Decodable sin un transformer que registrar.4 La aplicas igual que cualquier otra opción de atributo:

import SwiftData

struct Coordinate: Codable {
    var latitude: Double
    var longitude: Double
}

@Model
final class Place {
    var name: String

    // Persisted via Coordinate's own Codable conformance.
    @Attribute(.codable) var location: Coordinate
}

La regla práctica es recurrir a .codable cuando una propiedad es un valor Codable autocontenido que no merece su propia tabla @Model. Un par de coordenadas, un pequeño struct de configuración, un enum con payload: estos son datos, no entidades, y .codable los almacena en línea a través de la representación que ya definen, en lugar de forzar un transformer o una relación artificial.

Apple es explícito sobre las concesiones. El contenido de un atributo codable es opaco para SwiftData, así que no puedes usarlo en predicados para filtrar resultados ni en sort descriptors, y un cambio en la forma del tipo codable (agregar o quitar propiedades) no disparará una migración, así que su implementación de Codable tiene que mantenerse compatible hacia adelante y hacia atrás.5 Apple plantea .codable como una vía de escape para tipos que no posees; para los tipos que tú defines, modelarlos como modelos de SwiftData o tipos de valor soportados mantiene el ordenamiento, el filtrado y la indexación en la tabla.5

Cuándo recurrir a cada uno

Las tres incorporaciones responden tres preguntas distintas, y la pregunta te dice cuál usar.

  • Recurre a ResultsObserver cuando un llamador que no es una vista necesita un fetch en vivo. Un view model, un coordinador, una tarea de exportación, cualquier cosa que tenga datos que cambian y no sea un body de SwiftUI. Dentro de una vista, @Query sigue siendo la herramienta más ligera; el observer se gana su lugar en cuanto el consumidor no es una vista.2
  • Recurre a HistoryObserver cuando sincronizas el store con algo fuera de tu app. Un servidor externo, o una extensión de la app que escribe en el mismo store. Observa eventCounter, filtra por tipo de modelo y por autor de la transacción, y llama a ModelContext.fetchHistory en cada incremento para leer solo las nuevas transacciones.3
  • Recurre a @Attribute(.codable) cuando una propiedad es un valor Codable, no una entidad. Structs y enums pequeños que viajan con su propietario. Si el tipo necesita su propia identidad, relaciones o consultas, lo que quiere es @Model; si es solo datos en línea, .codable se salta el transformer.4

Los dos observers se componen. Un ResultsObserver mantiene en vivo tu fetch en proceso; un HistoryObserver te avisa cuándo un push remoto amerita actuar sobre lo que cambió. Una app que hace verdadera sincronización multidispositivo usa ambos, y usa .codable para mantener honestas sus columnas de tipo de valor por el camino.

Preguntas frecuentes

¿En qué se diferencia ResultsObserver de @Query?

@Query es un property wrapper de SwiftUI que vive dentro de una vista y le entrega a esa vista un arreglo. ResultsObserver es una clase independiente que creas y mantienes en cualquier parte, incluso fuera de la jerarquía de vistas, que observa y rastrea los cambios en una colección de modelos persistentes dentro de un model context.2 Como el observer es Observable, una vista de SwiftUI que lo lee se sigue actualizando automáticamente, así que cubre tanto el caso dentro de la vista como el caso del view model o el coordinador que @Query no puede alcanzar.2

¿Qué observa realmente HistoryObserver?

Observa el historial persistente de SwiftData, el registro de transacciones que SwiftData escribe cada vez que se guarda el store.3 Expone una única propiedad observable, eventCounter, que se incrementa cuando hay nuevas transacciones disponibles; puedes filtrar por tipo de modelo y por autor de la transacción para que solo los cambios que te importan muevan el contador.3 En cada incremento, tu código llama a la API ModelContext.fetchHistory para leer las nuevas transacciones, lo que la convierte en el manejador estructurado para sincronizar con un servidor externo o reaccionar a escrituras de una extensión de la app.3

¿Puedo usar ResultsObserver y HistoryObserver juntos?

Sí, y una app sincronizada normalmente debería. ResultsObserver mantiene actualizado un fetch en proceso a medida que cambia el contexto local; HistoryObserver saca a la superficie los cambios registrados en el historial persistente, filtrados por tipo de modelo y por autor de la transacción.23 Ambos son objetos observables a los que puedes reaccionar desde una vista de SwiftUI u otro observer, así que encajan en el mismo flujo reactivo sin un manejo de notificaciones aparte.23

¿Cuándo debo usar @Attribute(.codable) en lugar de una relación?

Usa .codable cuando la propiedad es un tipo de valor Codable autocontenido que no tiene identidad independiente, ya que la opción almacena la propiedad a través de su propia representación codable.4 Usa una relación @Model cuando el valor es una entidad real con su propio ciclo de vida, identidad o consultas. La línea divisoria es si la cosa son datos que pertenecen a su propietario, o una entidad que otras filas referencian.

El clúster completo de Apple Ecosystem: disciplina de esquema en SwiftData para el costo de migración sobre el que se asienta la observación; la guía de migraciones de SwiftData para la maquinaria de VersionedSchema y MigrationPlan; los interiores de @Observable para el modelo de observación al que se conectan estas clases; los interiores de SwiftUI para el sustrato del framework por debajo. El hub está en la Serie Apple Ecosystem. Para un contexto más amplio de iOS con agentes de IA, ver la guía de desarrollo de agentes en iOS.

Referencias


  1. Documentación para desarrolladores de Apple: SwiftData. La referencia del framework que cubre @Model, ModelContext, ModelContainer, las consultas y las incorporaciones de observación de iOS 27. 

  2. Documentación para desarrolladores de Apple: ResultsObserver (beta de iOS 27.0). “Observa y rastrea los cambios en una colección de modelos persistentes dentro de un model context.” Declarada como final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable; configurable con un FetchDescriptor o con predicados de filtro y sort descriptors; Observable, así que las vistas de SwiftUI se actualizan automáticamente; pasa Never como SectionName cuando no se necesita seccionar. 

  3. Apple, sesión 274 de WWDC26, Novedades en SwiftData, y Documentación para desarrolladores de Apple: HistoryObserver (beta de iOS 27.0). Declarada como final class HistoryObserver. Según la sesión 274, observa el historial persistente de SwiftData y “tiene una única propiedad observable” (eventCounter); “cuando hay nuevas transacciones disponibles en el historial persistente, el eventCounter se incrementa”, y “tu código puede observar el eventCounter y, cuando se incrementa, usar la API ModelContext.fetchHistory para obtener los últimos cambios.” “Te permite filtrar por tipo de modelo y por autor de la transacción”; el ejemplo de la sesión pasa "App" como autor para que los cambios originados en la app no se reproduzcan de vuelta hacia un servidor externo. 

  4. Documentación para desarrolladores de Apple: codable (beta de iOS 27.0). “Usa la representación codable de la propiedad para almacenarla.” Declarada como static var codable: Schema.Attribute.Option { get }

  5. Apple, sesión 274 de WWDC26, Novedades en SwiftData. Apple presenta ResultsObserver, que “obtiene datos de tu store de SwiftData y luego observa tu store en busca de cambios”, pero “funciona en cualquier parte de tu app (independiente de las vistas de SwiftUI) usando Swift Observation”, y nombra un state object o un juego escrito en SceneKit como casos que @Query no puede alcanzar. 

  6. Documentación para desarrolladores de Apple: fetchCount(_:) y fetchIdentifiers(_:) en ModelContext. fetchCount se declara func fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel y “devuelve la cantidad de modelos que coinciden con los criterios del fetch descriptor especificado.” fetchIdentifiers se declara func fetchIdentifiers<T>(_ descriptor: FetchDescriptor<T>) throws -> [PersistentIdentifier] where T : PersistentModel y devuelve “un arreglo de identificadores persistentes, donde cada identificador representa un único modelo que satisface los criterios”; una sobrecarga fetchIdentifiers(_:batchSize:) devuelve los identificadores en lotes como un FetchResultsCollection<PersistentIdentifier>. Fuente del patrón de hacer-fetch-de-identificadores-afectados-y-luego-comparar-con-la-vista: parafraseado de una grabación transcrita localmente del SwiftData Group Lab de la WWDC 2026; Apple no publica subtítulos oficiales para los labs. 

Artículos relacionados

Migraciones de SwiftData: lightweight vs. personalizadas, y cuándo no necesitas un V2

El modelo de migración de SwiftData usa VersionedSchema, MigrationStage y SchemaMigrationPlan. La mayoría de los cambios…

14 min de lectura

El verdadero costo de SwiftData es la disciplina de esquema

La API de SwiftData son dos macros. El costo es lo que pasa después de publicar. Los campos opcionales son la migración …

17 min de lectura

The Robots Are Taking Exams in My Search Console

First-party GSC data: 91% of 3.8M impressions fail a human-query filter. Exam questions, pasted errors, and agent sweeps…

10 min de lectura