El debugger de vistas y por qué SwiftUI redibuja
Inspeccionar la jerarquía capturada en tres dimensiones, entender qué representan esas capas cuando el framework es declarativo, y las herramientas concretas para responder la pregunta difícil: qué dependencia cambió y provocó una nueva evaluación del cuerpo.
El depurador de jerarquía de vistas nació en la época de UIKit, cuando la pantalla era el árbol de objetos: si algo no se veía, el objeto estaba mal colocado, oculto o fuera de su padre, y bastaba con girar la escena en tres dimensiones para encontrarlo. En SwiftUI esa correspondencia se rompe. Lo que describes es un valor efímero, un árbol de estructuras que el framework compara con el anterior para decidir qué capas del sistema tocar. La captura sigue siendo utilísima —resuelve todos los problemas de geometría y muchos de accesibilidad— pero deja de responder la pregunta que más duele en una app declarativa, que no es dónde está esta vista sino por qué se ha vuelto a evaluar su cuerpo cuarenta veces por segundo. Son dos investigaciones distintas y necesitan herramientas distintas.
- Interpretar una captura de jerarquía y distinguir lo que es geometría de lo que es identidad.
- Diagnosticar problemas de layout, recorte y accesibilidad sobre la escena capturada.
- Explicar el modelo de invalidación de
SwiftUIy qué cuenta como dependencia. - Localizar la causa de un redibujado con
Self._printChangesy con el instrumento deSwiftUI.
Qué estás mirando cuando capturas la jerarquía
Al pulsar el botón de captura, Xcode congela el proceso, serializa el árbol de capas y controladores reales y lo reconstruye en una escena explorable. En una app de SwiftUI verás nombres del framework por todas partes y muy pocos tuyos, porque tus estructuras no existen en tiempo de ejecución como objetos: se han evaporado en una descripción que el framework tradujo a un conjunto mínimo de capas.
Aun así, la captura resuelve por sí sola una familia entera de problemas.
- Geometría: un elemento invisible porque su marco tiene altura cero, porque quedó fuera del área segura o porque un ancestro lo recorta. La vista lateral con la separación entre capas lo enseña sin ambigüedad.
- Orden y superposición: quién tapa a quién, qué fondo se dibuja encima del contenido, dónde hay una capa transparente interceptando toques.
- Coste de composición: capas con transparencia, sombras sin ruta explícita y máscaras que fuerzan renderizado fuera de pantalla aparecen listadas y son responsables de una parte enorme de los tirones.
- Accesibilidad: el inspector muestra etiqueta, valor, rasgos y si el elemento está agrupado, que es la única forma rápida de comprobar que
VoiceOverpercibirá lo que crees.
Activar la visualización de contenido recortado revela lo que se dibuja fuera de los límites del padre, y activar las restricciones muestra por qué un elemento no ocupa lo que esperabas. Casi todo no se ve se explica con uno de los dos.
Identidad, dependencias e invalidación
Para entender por qué algo se redibuja hay que asumir el modelo real. SwiftUI mantiene un árbol de identidades persistente, distinto del árbol de valores que tú devuelves. Cada nodo con identidad estable guarda su estado y sus dependencias. Cuando una dependencia cambia, el framework invalida ese nodo, vuelve a evaluar su cuerpo, compara el resultado con el anterior y actualiza únicamente lo que difiera.
De ahí salen las dos patologías del rendimiento declarativo, y son opuestas.
La invalidación excesiva ocurre cuando el cuerpo se evalúa muchas más veces de las necesarias. Sus causas habituales son un objeto observable demasiado grande del que se depende entero para leer un solo campo, estado colocado más arriba de donde se usa, o el paso de clausuras y valores recreados en cada evaluación que rompen la comparación.
La identidad rota ocurre cuando el framework cree que un nodo es nuevo aunque conceptualmente sea el mismo. Entonces no reutiliza: destruye estado, reinicia animaciones y reconstruye subárboles enteros. La causa clásica es un identificador inestable en una colección, algo como usar el índice o un valor recalculado en cada vuelta.
// Identidad inestable: el estado interno de cada fila se pierde al reordenar
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
Fila(item: item)
}
// Identidad estable: el nodo sobrevive a reordenaciones e inserciones
ForEach(items, id: \.id) { item in
Fila(item: item)
}
flowchart TD
A[Cambia una dependencia observada] --> B[Se invalida el nodo de identidad]
B --> C[Se evalua de nuevo el cuerpo]
C --> D{El resultado difiere del anterior}
D -- No --> E[No se toca ninguna capa]
D -- Si --> F[Se actualiza solo la parte distinta]
F --> G[Layout y despues dibujo]
G --> H{Cabe en el presupuesto del fotograma}
H -- No --> I[Fotograma perdido y tiron visible]Las herramientas que responden por qué
La primera es la más barata y la más infravalorada. Dentro del cuerpo de una vista, una llamada estática imprime en consola qué dependencia provocó la evaluación.
struct Fila: View {
let item: Item
var body: some View {
let _ = Self._printChanges()
Contenido(item: item)
}
}
La salida distingue tres casos que hay que saber leer. Si nombra una propiedad, esa propiedad cambió. Si dice @self, la propia estructura cambió porque algo que le pasaron es distinto, y ahí suele estar el valor recreado sin necesidad. Si dice @identity, el nodo se considera nuevo: el problema no es de coste sino de identidad, y ninguna optimización de cuerpo lo va a arreglar.
La segunda es el instrumento de SwiftUI en Instruments, que agrega lo mismo pero sin consola: cuántas veces se evaluó cada vista, cuánto costó cada evaluación, y cuáles de esas evaluaciones terminaron provocando trabajo real de layout o de dibujo. Es lo que permite ordenar por coste acumulado en lugar de por sospecha, y detectar la vista que se evalúa mil veces aunque cada evaluación sea barata.
La tercera es la línea de tiempo de Core Animation y los Hangs, que cierran el círculo: te dicen si toda esa actividad llegó a costar fotogramas o si el framework la absorbió sin consecuencias. No toda evaluación de cuerpo es un problema, y ese matiz evita optimizaciones inútiles.
Cuerpos pequeños
Dividir una vista grande en varias pequeñas reduce el radio de invalidación: cada trozo depende solo de lo suyo y el resto no se reevalúa.
Identidad primero
Antes de optimizar el coste de un cuerpo, comprueba que la identidad es estable. Un nodo que se recrea no se optimiza, se arregla.
Estado abajo
Colocar el estado en el nodo más profundo que lo necesita es la técnica que más invalidaciones elimina, y no cuesta una línea de código adicional.
Correcciones que de verdad reducen trabajo
Diagnosticado el porqué, las correcciones se repiten y conviene tenerlas ordenadas de mayor a menor rendimiento por línea escrita.
Reducir el radio de la dependencia. Si una vista lee un solo campo de un modelo observable grande, extrae una subvista que reciba ese campo y nada más. La invalidación deja de propagarse a todo lo que colgaba del modelo.
// La lista entera se reevalua cuando cambia cualquier campo del modelo
struct Mal: View {
@Bindable var modelo: Modelo
var body: some View {
VStack {
Cabecera(titulo: modelo.titulo)
Lista(items: modelo.items)
}
}
}
// Cada hijo depende solo de lo suyo
struct Bien: View {
let titulo: String
let items: [Item]
var body: some View {
VStack {
Cabecera(titulo: titulo)
Lista(items: items)
}
}
}
Evitar valores recreados. Una clausura, un array construido al vuelo o un objeto instanciado dentro del cuerpo son distintos en cada evaluación y hacen que la comparación falle siempre. Sacarlos fuera convierte muchas actualizaciones en ninguna.
Estabilizar la identidad. Identificadores persistentes en las colecciones, id explícito solo cuando quieras forzar una recreación, y cuidado con los modificadores condicionales, que cambian la estructura del árbol y por tanto la identidad de lo que envuelven.
Aplazar el trabajo caro. Cargar imágenes, formatear fechas o calcular derivados dentro del cuerpo multiplica el coste por el número de evaluaciones. Esos cálculos pertenecen al modelo o a una propiedad memorizada, no al body.
Dividir vistas tiene un coste de legibilidad. Hazlo cuando el instrumento de SwiftUI muestre evaluaciones que cuestan de verdad, no como norma estética aplicada a ciegas.
Hay una razón profunda por la que el depurador de jerarquía se vuelve insuficiente en un framework declarativo, y entenderla reorganiza toda la depuración de interfaces. En un sistema imperativo el modelo mental y el modelo de ejecución coinciden: escribes objetos, esos objetos existen, y por tanto una fotografía de los objetos es una fotografía del programa. En un sistema declarativo existen tres árboles distintos y solo uno de ellos es fotografiable. El primero es el árbol de valores, la estructura que devuelve tu body, que se construye y se destruye muchas veces por segundo y cuyo coste de creación es deliberadamente ínfimo. El segundo es el árbol de identidades, invisible y persistente, que el framework mantiene emparejando nodos entre evaluaciones consecutivas y que es el verdadero dueño del estado, de las animaciones y de las suscripciones. El tercero es el árbol de renderizado, las capas que el sistema de composición dibuja, y ese es justamente el que captura el depurador. La consecuencia es que la captura te enseña el resultado y te oculta la causa: un estado perdido al reordenar una lista, una animación que reinicia, un cuerpo que se evalúa sin parar son todos fenómenos del árbol intermedio, del que no hay fotografía posible porque no está hecho de objetos sino de decisiones de emparejamiento. Por eso la depuración declarativa cambia de método y no solo de herramienta: en lugar de inspeccionar un estado congelado, se observa una serie temporal de invalidaciones, y las preguntas correctas dejan de ser dónde está esto y pasan a ser qué dependencia disparó esta evaluación, si este nodo conservó su identidad entre dos actualizaciones y si la evaluación llegó a producir trabajo de dibujo o murió en la comparación. Quien interioriza esa distinción deja de perseguir vistas por la pantalla y empieza a razonar sobre el grafo de dependencias que las genera, que es donde viven de verdad tanto los bugs de estado como los de rendimiento.
- Captura la jerarquía de una pantalla real y localiza una capa con transparencia o sombra sin ruta explícita.
- Añade
Self._printChangesa tres vistas de una lista y clasifica cada salida en propiedad,@selfo@identity. - Provoca a propósito una identidad inestable en un
ForEachy comprueba qué estado interno se pierde al reordenar. - Mueve un
@Statedesde la vista contenedora hasta la fila que lo usa y mide la reducción de evaluaciones. - Correlaciona en
Instrumentsun pico de evaluaciones con la línea deHangsy decide si merecía la pena optimizarlo.