wandres.dev
RENDIMIENTO · observación y coste

Observación granular: qué invalida qué y cómo comprobarlo

La macro `@ObservableState` no acelera nada: cambia quién decide que una vista está obsoleta, y lo hace pasando de la suscripción por objeto a la suscripción por ruta de clave. Esta lección desmonta el mecanismo —accesos registrados en el getter, mutaciones anunciadas en el setter, identidad como eje separado— para poder predecir con exactitud qué lectura suscribe a qué, enumera las cuatro formas de perder la granularidad sin que el compilador diga nada, y establece un protocolo de verificación con contadores, rastreo de cambios y ámbitos para dejar de suponer que la observación funciona y empezar a demostrarlo.

⏱ 20 min

Hay una frase que circula por todos los equipos que adoptan la observación fina y que es casi verdad, que es la peor categoría de frase: «con @ObservableState solo se redibuja lo que cambia». Lo correcto sería decir que solo se invalida lo que leyó lo que cambió, y esa diferencia de una palabra contiene todo lo que hay que aprender. Porque la suscripción no la fija el estado, la fija el código de la vista en el momento exacto en que accede a una propiedad; porque leer no es dibujar, y una vista puede leer diez campos para pintar uno; y porque el mecanismo es dinámico, de modo que la misma vista tiene dependencias distintas según qué rama tomó su última evaluación. La consecuencia práctica es incómoda y liberadora a la vez: la granularidad de tu app no está escrita en ningún fichero, se produce en tiempo de ejecución, y por tanto no se razona, se observa. Esta lección enseña primero el mecanismo con precisión suficiente para predecirlo, y después las tres maneras de comprobar si tu predicción era cierta.

🎯 Al terminar esta lección sabrás
  • Describir el mecanismo de acceso y mutación que la macro instala en cada propiedad almacenada del State.
  • Predecir, dada una vista, el conjunto exacto de rutas de clave a las que queda suscrita tras una evaluación.
  • Distinguir invalidación de evaluación y de dibujo, y saber qué medida corresponde a cada una.
  • Aplicar un protocolo de comprobación con rastreo de cambios, contadores y ámbitos antes y después de cada cambio.

De suscribirse a un objeto a suscribirse a una ruta

La macro reescribe cada propiedad almacenada del State en una propiedad computada sobre un almacenamiento privado, y le añade dos gestos simétricos: en el getter registra un acceso a esa ruta de clave, y en el setter envuelve la escritura en una mutación anunciada. El getter dice «alguien me está leyendo, apúntalo»; el setter dice «he cambiado, avisa a quien me leyera». Todo el sistema es eso, y de esa simetría se deducen sus dos propiedades más importantes.

La primera es que el registro es dinámico. SwiftUI evalúa un cuerpo dentro de un ámbito de seguimiento, recoge las rutas accedidas durante esa evaluación concreta y descarta las anteriores. Si el cuerpo tiene un condicional, las lecturas de la rama no tomada sencillamente no existen para el sistema, y mutar esos campos no invalida nada hasta que la rama vuelva a visitarse. La segunda es que la suscripción es por ruta y no por valor: no hay ninguna comparación de igualdad implicada. Asignar a un campo el mismo valor que ya tenía anuncia una mutación e invalida a sus lectores igualmente, lo cual es sorprendente la primera vez y casi siempre inofensivo, salvo cuando el cuerpo afectado es caro.

@ObservableState
struct State: Equatable {
  var titulo = ""
  var elementos: IdentifiedArrayOf<Elemento> = []
  @ObservationStateIgnored var cacheDeMedidas: [UUID: CGSize] = [:]
}

El atributo @ObservationStateIgnored desactiva ambos gestos para un campo: ni registra accesos ni anuncia mutaciones. Es la herramienta correcta para datos que viven en el State por comodidad de acceso pero que ninguna vista dibuja —cachés, memorias auxiliares, identificadores internos— y es una herramienta peligrosa por la misma razón: si algún día alguien dibuja ese campo, la interfaz se quedará desincronizada sin ningún aviso del compilador. La regla que hace segura su presencia es no leerlo nunca desde un cuerpo, y dejarlo escrito en un comentario junto a la declaración.

Qué cuenta como lectura

La granularidad se decide en el punto de acceso, así que la pregunta operativa es siempre la misma: ¿qué ruta se registró aquí? La tabla resume los casos que aparecen a diario y el conjunto al que suscriben.

Forma de acceder Rutas registradas Cuándo invalida
store.titulo La ruta de ese campo, y solo esa Al mutar titulo
store.state La raíz del estado Ante cualquier mutación de cualquier campo
Propiedad computada del State Todas las rutas que toque su getter Al mutar cualquiera de sus ingredientes
store.scope a un hijo Solo lo que la subvista lea del hijo Al mutar lo leído dentro del hijo
Campo con @ObservationStateIgnored Ninguna Nunca

El tercer caso es el que más daño hace en silencio. Una propiedad computada parece un ahorro —un solo nombre en lugar de cinco— y es en realidad un reparto de suscripciones: cuantas vistas la lean quedan atadas al ritmo de cambio de todos sus ingredientes. Un resumen que concatena nombre, apellido, ciudad y estado de verificación suscribe al lector a los cuatro, aunque en pantalla solo se vea el nombre el noventa por ciento del tiempo.

El cuarto merece un matiz que la mayoría de la gente descubre tarde: pasar el Store de un hijo a una subvista no registra ninguna lectura por sí mismo. El Store es una referencia, y llevarlo de un sitio a otro no toca ningún getter observado. Pasar en cambio un valor extraído del estado —DetalleView(datos: store.detalle)— sí lo registra, y lo registra en el padre, que a partir de ahí se reevaluará entero cada vez que cambie cualquier cosa dentro de detalle. Es la diferencia entre entregar una llave y entregar una fotocopia del contenido.

// El padre no lee nada del hijo: pasa un store.
FilaView(store: store.scope(state: \.fila, action: \.fila))

// El padre lee el hijo entero: queda atado a todas sus mutaciones.
FilaView(datos: store.fila)
⚠️
En sistemas anteriores a iOS 17 manda el bloque, no la lectura

Con la capa de compatibilidad, la unidad de invalidación deja de ser la ruta leída y pasa a ser el WithPerceptionTracking más pequeño que envuelve la lectura. Una vista con higiene impecable se invalida en bloque si alguien colocó el rastreo demasiado arriba en la jerarquía, y el aviso en tiempo de ejecución solo aparece cuando falta por completo, no cuando está mal colocado. Si soportas versiones antiguas, la comprobación de esta lección hay que hacerla en ellas, porque el resultado no se transfiere.

Comprobar cuáles ocurren de verdad

Predecir está bien; comprobar es obligatorio. Hay tres instrumentos y cada uno responde a una pregunta distinta, así que usarlos como sinónimos es una fuente habitual de conclusiones falsas.

Self._printChanges(), colocado como primera sentencia del cuerpo, responde por qué se está evaluando esta vista: distingue un cambio de sus propiedades almacenadas, una pérdida de identidad y la mutación de una propiedad dinámica concreta. Es atribución de causa, no cuantificación. Un contador propio responde cuántas veces se evalúa un cuerpo en un escenario dado, que es la métrica más estable de todas porque no depende de la carga de la máquina. Y el instrumento SwiftUI de Instruments responde cuánto tiempo cuesta el conjunto, incluida la diferenciación y la confirmación de capas, que es lo único que el usuario percibe.

struct FilaView: View {
  let store: StoreOf<Fila>
  @State private var evaluaciones = 0

  var body: some View {
    let _ = Self._printChanges()
    let _ = evaluaciones += 1
    HStack {
      Text(store.titulo)
      Spacer()
      Text("\(evaluaciones)")
        .font(.caption2.monospacedDigit())
        .foregroundStyle(.secondary)
    }
  }
}

Hay una cuarta comprobación, todavía más burda y a menudo la más reveladora de todas: pintar el fondo de la vista con un color aleatorio generado en cada evaluación. Lo que antes era una lista de números en la consola se convierte en un parpadeo que señala sin ambigüedad qué subárbol se está rehaciendo. Sirve especialmente para detectar el caso en que la observación funciona pero el padre reconstruye igualmente, porque entonces se ve cambiar el contenedor entero en lugar de una fila.

El contador visible en pantalla es una técnica tosca y extraordinariamente eficaz: al teclear en un filtro se ve de un vistazo si suben todas las filas o solo la afectada, sin abrir ninguna herramienta y sin salir del simulador. Ojo con el detalle metodológico: mutar @State dentro del cuerpo es exactamente lo que SwiftUI desaconseja, así que este código es andamiaje de diagnóstico y no puede quedarse en la rama principal ni un minuto más de lo necesario.

flowchart TD
L[La vista lee store punto campo] --> G[Getter registra acceso a la ruta]
G --> S[SwiftUI guarda el conjunto de rutas leidas]
M[El reducer muta ese campo] --> W[Setter anuncia la mutacion]
W --> C[El registrador busca lectores de esa ruta]
C -->|hay lectores| I[Invalida y reevalua el cuerpo]
C -->|no hay lectores| N[No ocurre nada]
I --> S
style I fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b

Colecciones, presentaciones y el eje de la identidad

Hay un segundo eje de invalidación que no depende de las rutas leídas y que domina cuando aparece: la identidad. Si SwiftUI concluye que la vista antigua ya no es la misma vista, no la actualiza, la sustituye, con montaje completo, pérdida del estado local y animaciones reiniciadas. Ninguna cantidad de observación fina interviene ahí, porque el trabajo se paga por vía estructural.

En TCA hay dos generadores clásicos de sustituciones. El primero es reasignar un estado hijo entero —state.detalle = Detalle.State(...)— en lugar de mutar sus campos: para el sistema es un valor nuevo en una posición, y la vista asociada se reconstruye. El segundo son las colecciones con identidad inestable, ya sea por iterar sobre índices o por regenerar identificadores en cada recarga; IdentifiedArray existe precisamente para hacer difícil ese error, pero no lo hace imposible si el identificador se sintetiza al vuelo.

// Sustituye la vista entera: identidad nueva en la misma posicion.
state.detalle = Detalle.State(articulo: articulo, modo: .lectura)

// Actualiza en sitio: misma identidad, invalidacion solo de lo mutado.
state.detalle?.articulo = articulo
state.detalle?.modo = .lectura

Hay un tercer generador menos evidente y específico de esta arquitectura: el ciclo de presentación. Cuando un estado marcado con @Presents pasa de nulo a un valor, no hay actualización posible porque antes no existía nada, así que el montaje completo es correcto y no es un defecto. Lo que sí es un defecto es reasignar ese valor mientras la pantalla está presentada —para refrescar un dato, por ejemplo— porque entonces sustituyes una vista viva, con la consiguiente pérdida del desplazamiento, del foco del teclado y de cualquier @State local. La distinción operativa es sencilla: nulo a valor y valor a nulo son transiciones de ciclo de vida; valor a valor debería ser siempre una mutación de campos.

🔑

Suscribe la lectura, no el estado

La granularidad la fija el punto de acceso. Mover una lectura un nivel hacia abajo es la corrección más potente y la más barata.

🧾

Las computadas reparten suscripciones

Una propiedad derivada ata a sus lectores a todos sus ingredientes. Úsalas cerca de donde se dibujan, no en la raíz.

🪪

La identidad es otro eje

Reasignar un estado hijo entero sustituye la vista. No es un problema de observación y no se arregla observando mejor.

🔢

Cuenta antes de cronometrar

El número de evaluaciones es más estable que los milisegundos y señala la causa. Cronometra solo cuando la cuenta ya no baje.

La observación fina convierte el rendimiento en una propiedad revisable, no en una propiedad garantizada

Conviene entender qué clase de promesa hace realmente @ObservableState, porque se malinterpreta en las dos direcciones. No promete velocidad: no toca la evaluación de un cuerpo, no acelera la diferenciación, no reduce la confirmación de capas. Y tampoco es un mero detalle de implementación: lo que hace es reemplazar un modelo de invalidación en el que el coste era proporcional al número de suscriptores de un objeto por otro en el que es proporcional al número de lectores de una ruta, es decir, sustituye una cota superior gruesa por una que puede ser óptima. La palabra clave es puede. El modelo antiguo era malo pero era estable: la granularidad no dependía de cómo escribieras las vistas, así que tampoco se degradaba cuando el equipo crecía. El modelo nuevo puede ser cien veces mejor y puede ser exactamente igual de malo, y qué caso te toca depende de una propiedad emergente del código de la vista que ningún tipo captura, ningún compilador comprueba y ninguna revisión de código detecta con fiabilidad, porque el defecto característico —leer un campo que no se dibuja— no parece un defecto en el diff donde se introduce. Lo que has ganado, entonces, no es rendimiento sino la posibilidad del rendimiento, más una herramienta para auditar si la estás conservando. Es la misma estructura de compromiso que ya aceptaste al elegir esta arquitectura en otros terrenos: TCA no garantiza que tu lógica sea correcta, garantiza que puedes escribir un TestStore que lo demuestre; no garantiza que tus dependencias sean sustituibles, garantiza que el sistema de tipos te obliga a declararlas. Aquí ocurre lo mismo con la invalidación, y la conclusión operativa es idéntica: una propiedad que solo existe si alguien la comprueba periódicamente necesita un ritual de comprobación con la misma dignidad que una suite de tests. Un equipo que mide sus evaluaciones de cuerpo cada vez que toca una pantalla conserva la granularidad durante años; uno que confía en que la macro trabaja sola la pierde campo a campo, y cuando por fin lo nota tiene cincuenta vistas glotonas y ninguna pista de cuál fue la primera.

⚔️ Audita la granularidad de una pantalla real
  1. Escoge una pantalla con al menos un contenedor y una lista. Añade el contador visible de esta lección a la fila, al contenedor y a la vista raíz.
  2. Muta un solo campo de un solo elemento y anota las tres cifras. Si el contenedor sube, tienes una lectura de más en el padre: encuéntrala.
  3. Sustituye cualquier PantallaHija(datos: store.hijo) por su equivalente con scope y repite la medición. Documenta la diferencia en la cuenta, no en milisegundos.
  4. Busca una propiedad computada del State que se lea desde varias vistas y enumera todos los campos que toca su getter. Decide para cada lector si necesita esa suscripción completa.
  5. Provoca una pérdida de identidad reasignando un estado hijo entero y comprueba con Self._printChanges() que aparece la marca correspondiente. Conviértela en mutaciones de campos y verifica que la marca desaparece.