Renderizado de SwiftUI
SwiftUI observa tres cosas en tu código: identidad (¿es este el mismo elemento que antes?), ciclo de vida (cuánto dura esa identidad — y con ella, @State), y dependencias (qué entradas, cuando cambian, hacen que se ejecute body de nuevo). Casi cualquier pregunta del tipo "¿por qué se re-renderizó / perdió estado / animó extrañamente / se atascó?" es una de estas.
El modelo
- Los valores de las vistas son efímeros; la identidad no lo es. Una estructura de vista se crea, se compara con el valor anterior y se descarta. Lo que persiste es su identidad, y el almacenamiento de
@State/@StateObjectestá vinculado a esa identidad: cuando la identidad cambia, el estado se reemplaza yonAppear/taskse ejecutan de nuevo, las imágenes se recargan. - Dos tipos de identidad. Explícita: IDs en
ForEach,Identifiable,.id(_:). Estructural: el tipo y la posición de una vista en la jerarquía — cada rama de unif/switches una identidad diferente. - El grafo de dependencias. Las dependencias de una vista son sus propiedades almacenadas (entradas de su padre),
@State,@Environment,@Binding, y las propiedades@Observableque subodylee. Cuando una cambia, solo las vistas que dependen de ella obtienen un nuevobody. SwiftUI luego compara los nuevos valores hijos con los antiguos y se detiene donde nada cambió. - Orden de actualización para una vista invalidada: nuevo valor → propiedades dinámicas actualizadas →
bodyse ejecuta → hijos comparados → recursión.
Reglas
Identidad
- Nunca identifiques por índice (
ForEach(Array(x.enumerated()), id: \.offset),0..<countsobre datos dinámicos). Cualquier cosa insertada arriba desplaza cada índice posterior: su estado se reinicia, sus imágenes se recargan, las animaciones van al lugar equivocado. Usa un ID estable y único. - Los IDs deben ser estables y únicos. No uses
UUID()en unidcalculado (nueva identidad en cada renderizado). No uses nombres que se puedan repetir (dos golosinas "Galleta de perro"). Desambiguiza repeticiones (clave,clave#2) si los datos no pueden garantizarlo — los IDs duplicados en unForEachse comportan mal. - Para contenido generado o transmitido, deriva la identidad de una estructura que no se mueva: dónde comienza el elemento en el texto fuente (la transmisión solo agrega), o posición de la plantilla + clave por iteración para la salida del programa (modelo de React:
plantilla:clave). Nombra elementos sin clave (texto suelto, párrafos) después del elemento clave anterior más un contador (e812+2), para que el nuevo contenido al final nunca renombre elementos anteriores. - Prefiere una vista con modificadores cambiantes sobre ramas.
if vencido { v.opacidad(0.3) } else { v }son dos identidades;v.opacidad(vencido ? 0.3 : 1)es una. Los valores "inertes" (opacidad 1, padding 0,framesin tamaños,layoutPriority(0),transformEnvironment) cuestan ~nada. Usa una rama solo para vistas genuinamente diferentes. - Cambia diseños con
AnyLayout, no conif envolver { FlowLayout { … } } else { HStack { … } }— los hijos mantienen su identidad y estado. - Evita
AnyView. Oculta la estructura de tipos a SwiftUI (peor comparación, y enList/Tableobliga a construir cada fila para contarlas). Usa funciones@ViewBuilder,switch, genéricos. - Modela los tipos de elementos como un enum y haz switch sobre él en la vista que despacha, sin
default: un nuevo caso no puede ser ignorado silenciosamente.
Dependencias
- Pasa a las vistas solo lo que usan. Una vista que toma un modelo completo ejecuta
bodyde nuevo cuando cualquier campo cambia; pasa la imagen, no el perro. Extrae subvistas para delimitar dependencias — con criterio, no en cada línea. @Observablerastrea lo quebodylee. Leer una propiedad en un cierre de acción no crea dependencia; leerla enbodysí. Pon el estado que la vista muestra en el modelo y léelo enbodysolo donde sea necesario.- Nunca pongas un nuevo cierre (u otro valor no comparable) en el entorno por cada renderizado — p. ej.
.environment(\.acciones, Acciones { … })en unbody. No se puede comparar, por lo que cada lector se invalida en cada actualización del padre. Pon un objeto estable en el entorno en su lugar (.environment(modelo)con@Observable, leído como@Environment(Modelo.self) var modelo: Modelo?), y llámalo desde las acciones. - No notifiques cuando nada cambió. Una propiedad
@Observablenotifica en cada asignación. Protege:if nuevo != valor { valor = nuevo }— importa cuando reconstruyes estado derivado en cada fragmento o tick. - Vistas
Equatablepara saltar subárboles sin cambios. Conforma la vista (solo posible cuando no tiene envoltorios de propiedad) y aplica.equatable()donde un padre se re-renderiza a menudo con hijos mayormente sin cambios (una lista de bloques mientras el contenido se transmite). Escribe un==personalizado que compare solo lo que la vista muestra. ⚠️.equatable()oculta ellayoutPrioritydel hijo de su contenedor. Dentro de unHStack/fila, un hijo conlayoutPriority(1)envuelto enEquatableViewdeja de ganar espacio sobrante y la fila se distribuye de manera diferente. Usa.equatable()en listas verticales de bloques, no en hijos de filas/pilas que dependan de la prioridad.
body barato
- Sin análisis, decodificación, filtrado, ordenamiento o trabajo pesado de asignación en
body. Se ejecuta en cada invalidación. Precalcula en el modelo, almacena en caché (unNSCachecon clave por entrada), o muévelo a la vista cuyas entradas realmente cambian. - Separa las entradas costosas del estado que cambia rápidamente. Si una vista decodifica datos y mantiene
@Stateque cambia a menudo (arrastrar, desplazar, pasar el cursor), la decodificación se vuelve a ejecutar en cada cambio. Decodifica en un padre, pasa el resultado a un hijo que posea el@State. - El trabajo lento va en
.task, no eninitobody. Hazloasync; nunca bloquees en el valor inicial de una propiedad dinámica. - Almacena en caché el formateo costoso —
AttributedString(markdown:), interpolación de cadenas en rutas calientes, búsquedas en paquetes.
Listas
List/Tablerecopila todos los IDs por adelantado. Mantén la generación de IDs barata, y dale a cada elemento deForEachun número constante de vistas — sin filtrosifoAnyViewdentro del cierre de la fila (filtra los datos, en el modelo, no en línea enbody).ForEachanidado está bien para secciones.
SwiftUI en celdas UIKit (UIHostingConfiguration)
- Las celdas se reutilizan, y el estado de la vista SwiftUI puede trasladarse al siguiente elemento mostrado en esa celda. Dale a la raíz
.id(item.id)para que un elemento diferente obtenga un árbol nuevo — y mantén el estado visible para el usuario (marcado, expandido, selección) en el modelo, con clave por IDs estables, no en@State, o se pierde/aplica incorrectamente en la reutilización y el desplazamiento. - Reconfigurar una celda reconstruye el valor de la vista raíz. Si el contenido está impulsado por un modelo observado, haz que la vista raíz sea
Equatableen lo que muestra (no en entradas sin procesar que solo alimentan el modelo) y aplica.equatable(), para que la reconfiguración por actualización no vuelva a ejecutar todo el árbol — la observación hace la actualización real. - Auto-dimensionado se mide con una altura abierta. Las vistas sin un tamaño ideal colapsan a cero allí:
Canvas, formas,GeometryReader..aspectRatio(r, contentMode: .fit)no es suficiente — dales un tamaño con un pequeñoLayoutpersonalizado (ancho de la propuesta, altura = ancho / relación; ignora alturas de prueba). .aspectRatio(nil, contentMode:)fija una vista al tamaño ideal de su contenido — las tarjetas dejan de llenar su fila. Aplica.aspectRatiosolo cuando tengas una relación.
Lista de verificación para revisión
- Cada
ForEachtiene IDs estables y únicos — sin\.offset, sinUUID()calculado. - Sin
AnyView; ramas solo para vistas genuinamente diferentes;AnyLayoutpara cambios de diseño. - Las vistas reciben solo los datos que renderizan.
- No se crea nada no comparable por renderizado en el entorno.
-
bodyno realiza análisis/decodificación/filtrado; entradas costosas separadas del@Staterápido. - Las propiedades observables no se reasignan a valores iguales.
-
.equatable()donde ahorra trabajo — y no en hijos que usenlayoutPriority. - Celdas alojadas: raíz
.id(item.id), estado visible en el modelo, raíz Equatable. - A las vistas sin un tamaño ideal se les da uno antes de ponerlas en celdas de auto-dimensionado.
Verificación
let _ = Self._printChanges()al inicio de unbodyregistra por qué se ejecutó (@self,@identity, o la propiedad que cambió). Solo para depuración — elimínalo antes de enviar.- Instruments → plantilla SwiftUI para conteos de body y actualizaciones lentas; Hangs/Hitches para los efectos.
- Prueba de identidad: renderiza cada prefijo del contenido transmitido y afirma que cada ID de elemento completado aparece sin cambios en el renderizado final; después de interacciones, afirma que los elementos supervivientes mantuvieron sus IDs; afirma que no hay IDs de hermanos duplicados.
- Diferencia de píxeles antes/después de una refactorización de renderizado: renderiza vistas fuera de pantalla en macOS con
ImageRenderer(mismo código,.frame(width:),proposedSizecon altura nil) y compara los PNG. Detecta regresiones de diseño como la deEquatableView/layoutPriority. (Map,ScrollViewe imágenes remotas no se renderizan fuera de pantalla; compara todo lo demás). Divide una diferencia alternando un archivo cambiado a la vez.
Referencias
references/case-study.md— el renderizador de respuestas AddToChatMenu (marcado generado por modelo transmitido en celdas de collection view): cada problema encontrado contra estas reglas y su solución.- WWDC21 Demystify SwiftUI (sesión 10022); WWDC23 Demystify SwiftUI performance (sesión 10160).