Posts

`withContinuousObservation`: observación continua para `@Observable` en Swift 6.4

Arturo Rivas Arias

El framework Observation dio un salto importante con @Observable, pero hasta ahora había una diferencia bastante grande entre lo que SwiftUI podía hacer automáticamente y lo que teníamos disponible fuera de una vista. En SwiftUI basta con leer una propiedad observable desde el body para que el sistema registre la dependencia y vuelva a evaluar la vista cuando cambie. En un controlador, un coordinador o una capa de infraestructura, en cambio, mantener una observación durante toda la vida de un objeto requería bastante más trabajo. Swift 6.4 y los SDK 27 incorporan withContinuousObservation precisamente para cubrir ese hueco.

El problema nace de la semántica de withObservationTracking, disponible desde Swift 5.9. Esta función evalúa qué propiedades @Observable se leen dentro de un bloque y ejecuta el bloque onChange cuando alguna de ellas cambia, pero esa observación es esencialmente de un solo uso. Después del primer cambio hay que registrar las dependencias de nuevo si queremos seguir recibiendo notificaciones.

@Observable
final class AudioPreferences {
    var volume = 0.8
}

final class AudioController {
    let preferences: AudioPreferences

    init(preferences: AudioPreferences) {
        self.preferences = preferences
        observeVolume()
    }

    private func observeVolume() {
        withObservationTracking {
            _ = preferences.volume
        } onChange: { [weak self] in
            guard let self else { return }

            self.observeVolume()
            self.applyVolume()
        }
    }

    private func applyVolume() {
        print("Nuevo volumen: \(preferences.volume)")
    }
}

Este patrón funciona, pero mezcla dos responsabilidades. El código que quiere reaccionar al cambio también tiene que encargarse de reconstruir la observación. Además, volver a registrar la observación demasiado pronto o demasiado tarde puede introducir comportamientos difíciles de predecir cuando varias propiedades cambian de forma seguida. withContinuousObservation encapsula ese ciclo: ejecuta el bloque, descubre las dependencias leídas y vuelve a establecer automáticamente el seguimiento después de cada evento.

import Observation

@Observable
final class AudioPreferences {
    var volume = 0.8
    var isMuted = false
}

@MainActor
final class AudioController {
    private let preferences: AudioPreferences
    private var observationToken: ObservationTracking.Token?

    init(preferences: AudioPreferences) {
        self.preferences = preferences

        observationToken = withContinuousObservation(options: [.didSet]) { [weak self] event in
            guard let self else {
                event.cancel()
                return
            }

            let volume = self.preferences.volume
            let isMuted = self.preferences.isMuted

            if event.kind == .initial {
                self.apply(volume: volume, muted: isMuted)
                return
            }

            if event.matches(\AudioPreferences.volume) ||
               event.matches(\AudioPreferences.isMuted) {
                self.apply(volume: volume, muted: isMuted)
            }
        }
    }

    private func apply(volume: Double, muted: Bool) {
        print(muted ? "Silenciado" : "Volumen: \(volume)")
    }
}

Hay un detalle especialmente interesante en este ejemplo: el bloque no espera al primer cambio para ejecutarse. La observación continua comienza con un evento de tipo .initial. Esa primera ejecución sirve para obtener el estado inicial y, al mismo tiempo, registrar las propiedades que se han leído. Después llegarán los eventos correspondientes a las mutaciones que encajen con las opciones seleccionadas. .initial no es una opción que tengamos que solicitar: forma parte de la semántica de la observación continua.

ObservationTracking.Options permite decidir qué tipo de modificaciones nos interesan. Las opciones públicas son .willSet, .didSet y .deinit, y se pueden combinar porque el tipo adopta SetAlgebra. .willSet identifica un cambio antes de que el nuevo valor sea almacenado, .didSet identifica el momento posterior a la escritura y .deinit permite saber que una de las instancias observadas está siendo destruida. Si registramos tanto .willSet como .didSet, una misma mutación puede producir ambos tipos de evento.

En withContinuousObservationTracking, el cambio observado y la ejecución del bloque no ocurren necesariamente al mismo tiempo. Según la propuesta SE-0506, el bloque se ejecuta después del cambio, cuando el contexto de aislamiento desde el que se inició la observación alcanza su siguiente punto de suspensión. Por ejemplo, si la observación se inicia desde MainActor, el bloque también se ejecutará en MainActor.

ObservationTracking.Event añade además información que antes no estaba disponible de esta forma. Su propiedad kind permite diferenciar .initial, .willSet, .didSet y .deinit, mientras que matches(_:) permite comprobar qué propiedad originó el evento mediante un KeyPath. Esto hace posible observar varias dependencias con un único token y reaccionar de manera distinta a cada una.

@Observable
final class ReaderSettings {
    var fontSize = 17.0
    var lineSpacing = 6.0
    var usesSerifFont = false
}

@MainActor
final class TextLayoutCoordinator {
    private let settings: ReaderSettings
    private var token: ObservationTracking.Token?

    init(settings: ReaderSettings) {
        self.settings = settings

        token = withContinuousObservation(options: [.didSet]) { [weak self] event in
            guard let self else { return }

            // Leer las propiedades es lo que las registra como dependencias.
            let fontSize = self.settings.fontSize
            let lineSpacing = self.settings.lineSpacing
            let usesSerifFont = self.settings.usesSerifFont

            if event.kind == .initial ||
               event.matches(\ReaderSettings.fontSize) ||
               event.matches(\ReaderSettings.lineSpacing) ||
               event.matches(\ReaderSettings.usesSerifFont) {
                self.rebuildLayout(
                    fontSize: fontSize,
                    lineSpacing: lineSpacing,
                    usesSerifFont: usesSerifFont
                )
            }
        }
    }

    private func rebuildLayout(
        fontSize: Double,
        lineSpacing: Double,
        usesSerifFont: Bool
    ) {
        // Recalcular el layout del lector.
    }
}

Como ocurre con el resto de Observation, las dependencias no se declaran en una lista separada: se descubren a partir de las propiedades que realmente se leen durante la ejecución del bloque. Esto también permite dependencias dinámicas. Si una propiedad solo se consulta cuando se cumple una determinada condición, únicamente formará parte del seguimiento en las ejecuciones en las que se haya leído.

@Observable
final class DashboardConfiguration {
    var showsNetworkDetails = false
    var refreshInterval = 30
}

@Observable
final class NetworkMetrics {
    var latency = 0.0
}

let token = withContinuousObservation(options: [.didSet]) { _ in
    if configuration.showsNetworkDetails {
        print("Latencia: \(metrics.latency)")
    }

    print("Intervalo: \(configuration.refreshInterval)")
}

En el ejemplo anterior, refreshInterval siempre se observa porque siempre se lee. showsNetworkDetails también se registra porque decide qué camino toma el bloque. Sin embargo, latency solo se convierte en dependencia cuando showsNetworkDetails es true. La siguiente ejecución vuelve a descubrir el conjunto de propiedades utilizadas, por lo que el grafo observado puede cambiar con el propio estado. Esto evita tener que mantener manualmente una lista de dependencias, pero obliga a prestar atención a los guard y return: una propiedad que no se lee antes de abandonar el bloque no queda registrada en esa ejecución.

La otra pieza fundamental es ObservationTracking.Token. withContinuousObservation devuelve el token y la observación solo continúa mientras ese token siga vivo. No almacenarlo equivale, en la práctica, a crear una observación cuyo ciclo de vida termina inmediatamente. El token es además un tipo no copiable (~Copyable), una decisión que hace explícito que representa la propiedad de un recurso y no un valor que debamos duplicar libremente.

@MainActor
final class ConnectivityCoordinator {
    private var token: ObservationTracking.Token?

    func startObserving(_ state: ConnectivityState) {
        token = withContinuousObservation(options: [.didSet]) { event in
            let isOnline = state.isOnline

            if event.kind == .initial || event.matches(\ConnectivityState.isOnline) {
                print(isOnline ? "Con conexión" : "Sin conexión")
            }
        }
    }

    func stopObserving() {
        token = nil
    }
}

También es posible cancelar desde el propio ObservationTracking.Event llamando a event.cancel(). Esto resulta útil cuando la condición para detener la observación se evalúa dentro del bloque, por ejemplo porque el objeto propietario ya no existe o porque se ha alcanzado un estado terminal. Una vez cancelada, no se reciben nuevos eventos asociados a esa observación.

La llegada de esta API no sustituye a Observations, introducida en Swift 6.2. Ambas cubren necesidades diferentes. Observations es un AsyncSequence pensado para consumir estados de forma asíncrona con for await y trabaja con cambios transaccionales, agrupando las modificaciones síncronas hasta el siguiente punto de suspensión. withContinuousObservation, en cambio, encaja mejor cuando necesitamos mantener una reacción ligada a un objeto existente —un controlador de UIKit o AppKit, un coordinador, una caché o una capa de sincronización— sin crear una tarea dedicada únicamente a consumir una secuencia.

Apple está utilizando esta nueva pieza también en SwiftData. En la WWDC26 se introdujo ResultsObserver, un nuevo tipo observable para seguir resultados de consultas fuera de SwiftUI, y HistoryObserver, destinado a detectar nuevas transacciones del historial persistente. Ambos pueden combinarse con withContinuousObservation para reaccionar a cambios sin depender de @Query ni de que exista una vista en pantalla. Es una señal bastante clara del objetivo de la API: llevar el mismo modelo de dependencias de Observation a capas donde SwiftUI no participa.

withContinuousObservation forma parte de SE-0506, Advanced Observation Tracking, implementada en Swift 6.4. Junto a ella llega también una nueva sobrecarga de withObservationTracking(options:_:onChange:), que permite usar ObservationTracking.Options y recibir un Event incluso cuando solo necesitamos observar el siguiente cambio. En los SDK 27 estas APIs aparecen todavía como beta, por lo que su forma final debe comprobarse con la versión del SDK con la que se vaya a compilar.

La distinción práctica queda bastante clara: dentro de SwiftUI, lo normal sigue siendo dejar que el framework observe automáticamente las propiedades usadas por body; para flujos asíncronos y transaccionales, Observations es la herramienta natural; para reaccionar una sola vez con control sobre willSet, didSet o deinit, está la nueva variante de withObservationTracking; y para mantener esa reacción durante la vida de un controlador o servicio, withContinuousObservation elimina por fin la necesidad de rearmar la observación manualmente.