Compatibilidad hacia atrás: WithPerceptionTracking y la librería Perception
`Observation` viaja dentro del sistema operativo y su enganche decisivo —que SwiftUI abra una sesión de rastreo alrededor de cada cuerpo de vista— vive dentro de SwiftUI, así que ningún truco de disponibilidad lo hace retroceder a iOS 16. Point-Free resolvió el problema reimplementando el framework entero en la librería `Perception`, con una correspondencia deliberadamente literal de nombres y semántica, y añadiendo la única pieza que el sistema antiguo no puede aportar: una vista, `WithPerceptionTracking`, que abre a mano el ámbito de rastreo que SwiftUI abriría por su cuenta en versiones modernas. Esta lección explica por qué hacía falta, dónde exactamente hay que colocar la envoltura, qué significa el aviso de ejecución que aparece cuando falta, y cómo se retira todo el andamiaje el día que subes el objetivo de despliegue.
Toda mejora del sistema operativo plantea la misma pregunta incómoda a quien mantiene una app real: qué hago con los usuarios que aún no la tienen. Con Observation la pregunta es más aguda de lo normal, porque no se trata de una API aislada que se pueda envolver en una comprobación de disponibilidad, sino de un mecanismo que atraviesa dos capas: el registrador que vive en el módulo Observation y el enganche que SwiftUI abre alrededor de cada evaluación de body. La segunda mitad es la que no se puede replicar por fuera, y sin ella el registrador más perfecto del mundo no se entera de nada, porque nadie ha abierto el ámbito donde apuntar las lecturas. La respuesta de Point-Free fue reconstruir el framework completo en una librería aparte y, en el punto donde el sistema antiguo no colabora, pedirte una sola línea de código: envolver el cuerpo en WithPerceptionTracking. Entender por qué esa línea existe, y por qué debe ir donde debe ir, evita la clase de fallo más desconcertante de TCA en versiones antiguas: una interfaz que sencillamente deja de actualizarse.
- Explicar por qué
Observationno se puede retroportar con una simple comprobación de disponibilidad. - Situar la correspondencia de nombres entre
ObservationyPerceptiony saber cuál usar según el objetivo de despliegue. - Colocar
WithPerceptionTrackingen los sitios correctos, en particular dentro de cada closure que produce vistas de forma diferida. - Interpretar el aviso de ejecución sobre estado perceptible no rastreado y planificar la retirada del andamiaje.
Por qué no basta con comprobar la disponibilidad
El mecanismo de la lección 1 tiene dos mitades. La primera es el registrador: una pieza de biblioteca que apunta lecturas y anuncia mutaciones, y que en principio cualquiera podría reescribir en Swift puro. La segunda es el consumidor: alguien tiene que abrir la sesión de rastreo alrededor del código que lee, y en SwiftUI moderno ese alguien es el propio framework, que envuelve cada evaluación de body sin que tú lo pidas. Esa segunda mitad es código compilado dentro de SwiftUI y es exclusiva de iOS 17, macOS 14 y equivalentes. No hay API pública que la active en un sistema anterior, ni forma de inyectarla desde fuera.
De ahí la asimetría del retroporte: la parte difícil de escribir —el registrador— es la que sí se puede replicar, y la parte trivial en apariencia —abrir el ámbito— es la que hay que sustituir por una intervención manual. Perception hace ambas cosas: reimplementa el framework para funcionar desde iOS 13 y equivalentes, y expone una vista contenedora que abre el ámbito de rastreo alrededor del contenido que envuelve, ocupando el lugar de lo que SwiftUI haría solo en las versiones nuevas.
flowchart TD A[La vista lee store punto campo] --> B[Version del sistema] B -->|iOS 17 o superior| C[SwiftUI abre el rastreo alrededor de body] C --> D[Perception delega en Observation nativo] D --> E[Observacion fina automatica] B -->|iOS 16 o anterior| F[SwiftUI no abre ningun rastreo] F --> G[Hace falta WithPerceptionTracking en el cuerpo] G -->|presente| E G -->|ausente| H[Aviso de ejecucion y vista que no se actualiza] style E fill:#a6e3a1,color:#11111b style H fill:#f38ba8,color:#11111b
Un reflejo deliberadamente literal
La correspondencia entre ambos mundos es uno a uno, y no por casualidad: se diseñó así para que la migración final consista en renombrar y borrar, nunca en repensar.
| Framework del sistema | Librería Perception |
Papel |
|---|---|---|
@Observable |
@Perceptible |
Instrumenta una clase |
ObservationRegistrar |
PerceptionRegistrar |
Apunta lecturas y anuncia mutaciones |
withObservationTracking |
withPerceptionTracking |
Abre un ámbito de rastreo imperativo |
| Rastreo implícito del cuerpo | WithPerceptionTracking |
Abre el ámbito dentro de una vista |
@Bindable |
@Perception.Bindable |
Produce enlaces de dos vías |
Hay un detalle de implementación que conviene conocer porque explica por qué no se paga peaje en sistemas modernos: cuando la app corre en iOS 17 o superior, PerceptionRegistrar delega en el ObservationRegistrar nativo en lugar de usar su propia maquinaria, y WithPerceptionTracking se vuelve prácticamente un contenedor transparente. Es decir, el mismo binario usa el mecanismo del sistema donde existe y el sustituto donde no, sin bifurcaciones en tu código. En TCA no tienes que añadir nada: la dependencia viene incluida y @ObservableState ya está construida sobre este esquema.
Del lado de los enlaces, la regla es sencilla: si tu objetivo de despliegue incluye versiones anteriores a iOS 17, escribe @Perception.Bindable var store en lugar de @Bindable var store. Está disponible en todas las versiones y, en las modernas, se comporta exactamente igual que el original.
struct FormularioView: View {
@Perception.Bindable var store: StoreOf<Formulario>
var body: some View {
WithPerceptionTracking {
Form {
TextField("Nombre", text: $store.nombre)
Toggle("Avisos", isOn: $store.avisos)
}
}
}
}
Ese es, literalmente, todo el impuesto que se paga por soportar sistemas antiguos: un prefijo en el envoltorio de propiedad y una vista contenedora alrededor del cuerpo. No hay dos rutas de código que mantener, ni un protocolo intermedio que inventar, ni pruebas duplicadas.
Dónde va la envoltura y por qué justo ahí
La regla enunciada de forma perezosa —envuelve el cuerpo de la vista— es cierta pero insuficiente, y la insuficiencia es la fuente de casi todos los fallos. La regla correcta es: el ámbito de rastreo debe estar abierto en el momento y en el lugar en que se lee el estado. Y hay muchísimo código de SwiftUI cuyo contenido no se evalúa dentro del cuerpo que lo escribe, sino más tarde y desde otro sitio: las filas de un ForEach, el contenido de un List perezoso, el contenido de una hoja o de una alerta, los destinos de navegación, el contenido de una barra de herramientas, los hijos de un LazyVStack. Todas esas closures escapan del cuerpo del padre y se ejecutan fuera de su ámbito, de modo que la envoltura del padre no las cubre.
struct ListaView: View {
let store: StoreOf<Lista>
var body: some View {
WithPerceptionTracking {
List {
ForEach(store.scope(state: \.filas, action: \.filas)) { filaStore in
WithPerceptionTracking { // imprescindible: closure diferida
FilaView(store: filaStore)
}
}
}
.sheet(item: $store.scope(state: \.destino, action: \.destino)) { destinoStore in
WithPerceptionTracking { // imprescindible: closure diferida
DetalleView(store: destinoStore)
}
}
}
}
}
La lista de sospechosos habituales conviene tenerla presente al revisar código, porque no siempre salta a la vista qué closure escapa y cuál no.
Filas de colecciones
El constructor de contenido de ForEach, de List y de las pilas perezosas se evalúa fila a fila y bajo demanda, fuera del cuerpo que lo declaró.
Presentaciones
El contenido de hojas, ventanas emergentes, alertas y diálogos de confirmación se construye en el momento de presentar, no al escribir el modificador.
Destinos de navegación
Los destinos de NavigationStack y de NavigationLink se materializan al navegar, con el ámbito del padre ya cerrado hace rato.
Barras y accesorios
El contenido de barras de herramientas, menús contextuales y accesorios se evalúa en contextos propios que el ámbito del padre no cubre.
Cuando falta una de esas envolturas, el fallo no es un bloqueo ni un error de compilación: la vista se dibuja bien la primera vez y luego se queda muda ante los cambios. Para que ese síntoma no se convierta en una tarde perdida, la librería emite un aviso de ejecución en cuanto detecta una lectura de estado perceptible fuera de todo ámbito de rastreo:
Perceptible state was accessed but is not being tracked.
Track changes to state by wrapping your view in a
'WithPerceptionTracking' view.
La comprobación está activa en sistemas anteriores a iOS 17 y en compilaciones de depuración; en iOS 17 y superiores no se emite porque el rastreo nativo cubre el cuerpo por su cuenta. La consecuencia práctica es incómoda y hay que tenerla presente: si desarrollas siempre contra el simulador más moderno, no verás ni un aviso aunque tu app tenga docenas de envolturas ausentes, y el fallo aparecerá solo en los dispositivos antiguos de tus usuarios. Si soportas iOS 16, incluye un destino antiguo en tu rutina de pruebas.
Algunas pruebas —de instantáneas, o cualquiera que evalúe cuerpos de vista fuera del ciclo de SwiftUI— disparan el aviso sin que haya nada que corregir. Para esos casos existe un interruptor global, Perception.isPerceptionCheckingEnabled, que se puede poner a false en la preparación de la prueba. Úsalo con parquedad y nunca en el código de la app: es una forma de decir aquí sé lo que hago, no una manera de callar avisos legítimos.
El plan de retirada
Todo este andamiaje está diseñado para desaparecer, y el diseño incluye que desaparezca sin drama. El día que el objetivo mínimo de despliegue alcanza iOS 17 y equivalentes, la retirada es puramente textual: borrar las envolturas WithPerceptionTracking —que a esas alturas ya no hacen nada— y sustituir @Perception.Bindable por @Bindable. No hay lógica que revisar, ni comportamiento que cambie, ni pruebas que reescribir, porque la semántica de ambas rutas era idéntica por construcción. Puede hacerse de golpe o de forma incremental, y dejar envolturas de más mientras tanto no rompe nada; solo añade ruido.
Merece la pena señalar lo que esa propiedad dice de la librería. Un retroporte que exigiera escribir el código de otra manera habría fragmentado el ecosistema y habría condenado a cada proyecto a una migración futura con riesgo. Al construir un reflejo literal y concentrar toda la deuda en una envoltura sintácticamente aislable, la deuda se vuelve localizable con una búsqueda de texto y saldable en una tarde.
Lo que hay que aprender de Perception no es su API, que se olvidará en cuanto iOS 16 desaparezca de los objetivos de despliegue, sino la estrategia de ingeniería que representa, porque es reutilizable y escasea. Ante una capacidad del sistema que no está disponible en todas las versiones soportadas, la reacción habitual del gremio es una de tres, y las tres son malas: renunciar a la capacidad hasta que el parque de dispositivos se renueve, lo que regala años de mejoras; bifurcar el código en dos caminos con comprobaciones de disponibilidad, lo que duplica la superficie de prueba y garantiza que uno de los dos caminos se pudra sin que nadie lo note; o inventar una abstracción propia que envuelva ambos mundos con nombres nuevos, lo que crea una capa que sobrevivirá décadas a la restricción que la motivó y que habrá que mantener y enseñar para siempre. Point-Free tomó una cuarta vía que exige más disciplina y paga mucho mejor: replicar la API oficial con fidelidad literal —mismos nombres salvo el prefijo, misma semántica, mismos modos de fallo—, delegar en la implementación nativa allí donde exista para no arrastrar dos runtimes, y aislar la única diferencia irreducible en una anotación local, visible y buscable. El resultado tiene tres propiedades que conviene saber nombrar. Es migrable: la retirada futura es una operación textual, no un rediseño, porque nunca hubo un modelo alternativo que desaprender. Es enseñable: quien aprende Observation sabe ya Perception, y el conocimiento no se bifurca en dos dialectos. Y es auditable: la deuda no está diluida por el código en forma de comprobaciones dispersas, sino concentrada en un símbolo que puedes contar, y una deuda que se puede contar es una deuda que se puede pagar. Cuando te toque a ti sostener una capacidad nueva sobre una base antigua —y te tocará, porque es la condición permanente del desarrollo en plataformas—, el patrón a imitar es este: reflejo literal, delegación donde se pueda, y una sola cicatriz local que el compilador o el runtime te recuerden a gritos hasta que puedas borrarla.
- Localiza el objetivo mínimo de despliegue de tu proyecto y decide si
WithPerceptionTrackingte concierne. Si no, replica el ejercicio bajando temporalmente ese objetivo en una rama. - Busca en el proyecto todas las closures que producen vistas de forma diferida —filas de
ForEach, contenido de hojas y alertas, destinos de navegación— y comprueba una por una si están cubiertas por un ámbito de rastreo propio. - Quita a propósito una envoltura de una fila y ejecuta en un simulador antiguo: observa el aviso de ejecución y confirma que la fila deja de reflejar los cambios.
- Sustituye
@Bindablepor@Perception.Bindableen un formulario y verifica que el comportamiento es idéntico en un simulador moderno. - Escribe el plan de retirada en tres líneas: qué símbolos hay que borrar, cuál renombrar y qué pruebas deberían seguir pasando sin tocarse.