El efecto en el rendimiento: menos redibujados y cómo demostrarlo
La observación fina cambia el modelo de coste de una app: la invalidación deja de ser proporcional al número de vistas suscritas y pasa a serlo al número de vistas que leyeron el campo mutado. Esta lección precisa qué se ahorra de verdad —invalidaciones, no evaluaciones de cuerpo— y, sobre todo, enseña a demostrarlo en lugar de creerlo: `_printChanges` del lado del reducer para ver qué acción cambió qué, `Self._printChanges` del lado de la vista para leer si la reevaluación vino del estado, de una propiedad concreta o de una pérdida de identidad, y el instrumento SwiftUI para medir cuerpos y confirmaciones en un dispositivo real. Termina con el catálogo de patrones que devuelven la granularidad a la era antigua sin producir ni un aviso.
Es fácil recitar que @ObservableState mejora el rendimiento y difícil decir cuánto, dónde y por qué. La afirmación honesta es más estrecha y más útil que el eslogan: la observación fina no acelera la evaluación de un body, no reduce el trabajo de diferenciación de SwiftUI ni hace más rápido el reducer; lo único que hace —y no es poco— es reducir el número de veces que un body se marca como inválido. Eso convierte un coste que antes crecía con el tamaño del árbol de vistas suscritas en otro que crece con el número de vistas que de verdad leyeron lo que cambió. En una pantalla pequeña la diferencia es imperceptible; en una lista de doscientas filas donde cambia un campo de una, la diferencia es de dos órdenes de magnitud. Esta lección enseña a distinguir esos dos casos con datos y no con fe, porque la trampa del rendimiento no es no mejorar: es creer que has mejorado cuando un detalle del código ha devuelto la granularidad al punto de partida.
- Formular con precisión qué coste elimina la observación fina y cuál deja intacto.
- Usar
_printChangesen el reducer ySelf._printChangesen la vista para atribuir cada reevaluación a su causa. - Interpretar las marcas de cambio de SwiftUI, en especial la diferencia entre cambio de estado y pérdida de identidad.
- Reconocer los patrones que degradan la granularidad y corregirlos empujando las lecturas hacia abajo.
Qué se ahorra exactamente
Conviene escribir el modelo de coste de las dos épocas. Con ObservableObject, cada mutación de un campo publicado invalidaba todas las vistas suscritas al objeto: el coste de un cambio era proporcional al número de suscriptores, con independencia de lo que cada uno leyera. Con observación fina, el coste es proporcional al número de vistas cuya última evaluación de body tocó la ruta de clave mutada. La constante multiplicativa —lo que cuesta evaluar un body y diferenciar el resultado— no cambia en absoluto.
De ahí se siguen tres consecuencias prácticas. La primera: la ganancia es grande donde el numerador antiguo era grande, es decir, en listas, en pantallas con muchas subvistas colgando de un mismo estado y en árboles profundos de features. La segunda: si tu body es caro por sí mismo —cálculos en línea, formateadores creados en cada pasada, imágenes redimensionadas— la observación fina no te salvará, porque el problema no está en cuántas veces se ejecuta sino en lo que hace cada vez. La tercera, la más incómoda: la granularidad real de tu app no la fija la macro, la fija la vista con peor higiene de lectura, y basta una para arrastrar a un subárbol entero.
Instrumentar la causa en los dos lados del ciclo
El ciclo tiene dos mitades y cada una tiene su instrumento. Del lado del reducer, _printChanges() se apila sobre el reducer y registra cada acción recibida junto con un diferencial del estado resultante, así que responde a la pregunta qué cambió y por culpa de qué acción.
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
return .none
}
._printChanges()
}
Del lado de la vista, SwiftUI ofrece Self._printChanges(), que se llama como primera sentencia del body y describe por qué esa evaluación está ocurriendo. Es una API no documentada pero estable en la práctica y hay una variante, Self._logChanges(), que escribe en el sistema de registro unificado en lugar de en la consola, más cómoda cuando el volumen es alto.
struct FilaView: View {
let store: StoreOf<Fila>
var body: some View {
let _ = Self._printChanges()
HStack {
Text(store.titulo)
Spacer()
if store.destacada { Image(systemName: "star.fill") }
}
}
}
Lo que imprime se lee así:
| Marca | Qué indica | Lectura práctica |
|---|---|---|
@self |
El valor de la vista cambió: alguna de sus propiedades almacenadas es distinta | Alguien reconstruyó la vista desde arriba; mira al padre |
@identity |
La identidad de la vista cambió: no es una actualización, es una sustitución | Estado reemplazado entero o rama estructural distinta; se pierde el @State interno |
_nombre |
Cambió esa propiedad dinámica concreta | Caso normal y deseable de observación fina |
La marca @identity es la que más información aporta y la que más se malinterpreta. No significa que algo se haya actualizado mucho: significa que SwiftUI ha decidido que la vista antigua ya no existe y ha creado otra en su lugar, con el coste completo de montaje, la pérdida del estado local y las animaciones reiniciadas. En TCA, la causa habitual es una reasignación completa de un State hijo —state.detalle = Detalle.State(...)— en lugar de una mutación de sus campos, o un ForEach cuya identidad de elementos no es estable. Verla aparecer repetidamente en un flujo normal de uso es casi siempre un defecto.
flowchart TD P[Una vista se redibuja demasiado] --> SPC[Self printChanges en su body] SPC -->|marca de propiedad| FINA[Observacion fina funcionando] FINA --> Q[Lee esa vista un campo que no dibuja] Q -->|si| BAJA[Empuja la lectura a una subvista] Q -->|no| CARO[El cuerpo es caro en si mismo, perfila el tiempo] SPC -->|marca self| PADRE[El padre reconstruye, revisa sus lecturas] SPC -->|marca identity| ID[Estado reasignado entero o identidad inestable] style FINA fill:#a6e3a1,color:#11111b style ID fill:#f38ba8,color:#11111b
Medir con método
Los print sirven para atribuir causas, no para cuantificar. Para medir hace falta el instrumento SwiftUI de Instruments, que expone tres pistas útiles: las evaluaciones de cuerpo, las actualizaciones de propiedades de vista y las confirmaciones de Core Animation. Las versiones recientes añaden el agrupamiento de actualizaciones y una vista de causa y efecto que permite seleccionar un cambio y ver qué cascada de cuerpos provocó, que es exactamente la pregunta de esta lección con nombres de Apple.
El método importa tanto como la herramienta. Mide en compilación de release, no de debug, porque las comprobaciones internas y la ausencia de optimizaciones distorsionan los tiempos. Mide en un dispositivo real, y preferiblemente en el más modesto que soportes, no en el simulador. Define un escenario reproducible con principio y fin —teclear veinte caracteres en un campo de texto de una pantalla con una lista larga, desplazar mil puntos, entrar y salir de un detalle diez veces— y compara el mismo escenario antes y después del cambio, contando evaluaciones de cuerpo además de milisegundos: el conteo es una medida más estable que el tiempo y apunta directamente a la causa. Y no perfiles con _printChanges activo, porque la impresión en consola es cara y contamina la medida.
La observación fina es un asunto de la vista. Si el perfil señala al reducer —efectos que se disparan en cascada, un Equatable costoso sobre colecciones grandes, trabajo pesado hecho de forma síncrona dentro de Reduce— el instrumento correcto es el perfilador de tiempo y la solución no pasa por la observación, sino por mover el trabajo a un Effect, por revisar la cancelación o por reconsiderar la estructura de datos del State.
Patrones que anulan la ganancia
Casi todas las regresiones de granularidad caben en una frase: alguien leyó más de lo que dibuja. Las formas concretas son cuatro y todas son silenciosas, en el sentido de que compilan, funcionan y solo se manifiestan como lentitud difusa.
Leer el estado entero
store.state dentro de un cuerpo registra la raíz, y a partir de ahí cualquier mutación invalida. Pasar el State completo a una subvista como parámetro es la misma falta con otro disfraz: quien lo construye tuvo que leerlo entero.
El padre curioso
Una vista contenedora que lee un campo del hijo para pintar un título o una insignia queda atada al ritmo de cambio del hijo, y suele ser el nodo más caro de reevaluar del subárbol.
La computada glotona
Una propiedad computada del State registra todo lo que su getter toca y reparte esa suscripción a cuantas vistas la lean. Un resumen que recorre diez campos suscribe a los diez.
Identidad inestable
Un ForEach sobre índices o sobre identificadores que se regeneran sustituye filas en vez de actualizarlas. La observación fina no interviene: el trabajo ya se paga por vía estructural.
El segundo caso es el más frecuente y también el que mejor ilustra que la solución rara vez está en el estado, sino en dónde se coloca la lectura. Compara estas dos versiones del mismo contenedor:
// Antes: el contenedor entero se reevalua cada vez que cambia el contador.
struct PanelView: View {
let store: StoreOf<Panel>
var body: some View {
VStack {
Text("Pendientes: \(store.bandeja.sinLeer)") // lectura del hijo
GraficoCaro(datos: store.serie)
}
}
}
// Despues: solo la etiqueta minuscula depende del contador.
struct PanelView: View {
let store: StoreOf<Panel>
var body: some View {
VStack {
ContadorEtiqueta(store: store.scope(state: \.bandeja, action: \.bandeja))
GraficoCaro(datos: store.serie)
}
}
}
El cambio no toca el reducer, no toca el State y no altera un solo píxel de la interfaz; solo mueve una lectura un nivel hacia abajo. En una pantalla con un gráfico costoso, esa mudanza puede ser la diferencia entre una app fluida y otra que se atasca al llegar cada notificación. Es el ejemplo canónico de que, con observación fina, el rendimiento se edita en la estructura de vistas y no en la lógica.
Un quinto caso merece mención aparte porque no es un error tuyo: en versiones de sistema anteriores a iOS 17, la granularidad real no la fija la lectura sino el bloque WithPerceptionTracking más pequeño que la envuelve, por lo que una vista impecable puede invalidarse en bloque si el rastreo está abierto demasiado arriba. Es el tema de la lección siguiente.
La conclusión que hay que llevarse de esta lección es que, con observación fina, el rendimiento de una interfaz deja de ser una consecuencia de cómo está escrito el código y pasa a ser una consecuencia de la forma del grafo de dependencias que el código induce en tiempo de ejecución. Son cosas distintas y la diferencia tiene consecuencias metodológicas serias. Un grafo no se lee en el editor: dos vistas idénticas en apariencia pueden tener dependencias radicalmente distintas según qué rama de un condicional se tomó, qué propiedad computada se evaluó o dónde se colocó una lectura aparentemente inocente; y a la inversa, un cambio de una línea —extraer una subvista, mover una condición, marcar un campo como ignorado— puede recortar un subárbol entero de invalidaciones sin tocar nada de la lógica. De ahí se sigue que la intuición sirve de poco y que el único protocolo defendible es empírico: instrumentar la causa antes de tocar nada, medir con un escenario reproducible en release y en un dispositivo real, cambiar una cosa, volver a medir. Es exactamente el mismo rigor que ya aceptas en otros terrenos —no afirmas que un reducer es correcto sin un TestStore que lo demuestre— aplicado al renderizado. Y hay una simetría más profunda entre ambos rigores que conviene ver: tanto el TestStore como Self._printChanges sirven para lo mismo, convertir una propiedad que antes solo podías esperar en una que puedes observar. El TestStore hace visible cada mutación de estado y cada efecto pendiente; el rastreo de cambios hace visible cada invalidación y su causa. En ambos casos la arquitectura no te regala la propiedad deseada, te regala la observabilidad de esa propiedad, que es lo único que permite mantenerla viva cuando el proyecto crece y cambian las manos que lo escriben. Un equipo que mide sus invalidaciones conserva la granularidad fina durante años; uno que confía en que la macro trabaja sola la pierde en el primer trimestre, campo a campo, sin que ningún compilador se queje jamás.
- Elige una pantalla con una lista de al menos cincuenta filas y coloca
Self._printChanges()al principio del cuerpo de la fila y del contenedor. Anota cuántas líneas se imprimen al mutar un campo de una sola fila. - Apila
._printChanges()en el reducer y correlaciona cada acción con el conjunto de vistas que se reevaluaron. Identifica alguna reevaluación que no debería estar ahí. - Localiza en el contenedor una lectura de un campo que no dibuja y extráela a una subvista mínima. Repite la cuenta y documenta la diferencia.
- Provoca a propósito una marca
@identityreasignando unStatehijo entero y observa qué se pierde: estado local, animación, posición de desplazamiento. Convierte esa reasignación en una mutación de campos. - Perfila el mismo escenario con el instrumento SwiftUI en release y en dispositivo, antes y después, y escribe dos cifras: evaluaciones de cuerpo y confirmaciones. Si no cambian, tu problema no era de observación.