¿Qué provoca realmente que una lista de SwiftUI se redibuje más de lo esperado, y cómo se soluciona?
Claves Identifiable inestables, un ObservableObject que publica de más y trabajo inestable dentro de body amplían el alcance del redibujado de formas distintas — y cada una tiene una solución específica y diferente.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué provoca realmente que una lista de SwiftUI se redibuje más de lo esperado, y cómo se soluciona?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.Esta es una de las mejores preguntas avanzadas de SwiftUI porque no se puede responder solo con documentación: exige entender que volver a invocar body no es lo mismo que la pantalla realmente volviendo a pintarse, y que la brecha entre ambas cosas depende enteramente de la identidad y la igualdad. SwiftUI decide qué cambió mediante un proceso de diffing, y ese diffing es tan bueno como la información de identidad e igualdad que tú le des. Si te equivocas en cualquiera de las dos, o vuelves a renderizar mucho más de lo necesario, o, peor aún, mantienes en pantalla contenido obsoleto sin darte cuenta.
#Causa 1: identidad de fila inestable
ForEach y List necesitan saber qué fila es cuál entre un render y el siguiente, y por defecto eso viene de Identifiable o de un id: explícito con un key path. Usar los índices de un array como identidad es el error más común con diferencia:
struct BadListView: View {let titles: [String]var body: some View {List {ForEach(titles.indices, id: \.self) { index inText(titles[index])}}}}
Inserta o elimina un elemento en cualquier posición que no sea el final, y cada índice a partir de ese punto ahora apunta a un string distinto del que apuntaba en el render anterior — SwiftUI no tiene forma de saber si "la fila 3 es la misma fila lógica, solo que su contenido cambió" o si "todo desde la fila 3 hacia abajo es una fila completamente nueva," así que asume lo segundo y vuelve a renderizar (y a menudo anima) toda la cola de la lista. La solución es una identidad estable que viaje con los datos, no con su posición:
struct Item: Identifiable {let id: UUIDlet title: String}struct GoodListView: View {let items: [Item]var body: some View {List(items) { item inText(item.title)}}}
Eso solo funciona si el id es realmente estable entre renders. Generar un identificador nuevo dentro de body anula por completo la idea:
// Mal: un UUID nuevo cada vez que se ejecuta body — nada le parece "igual" a SwiftUI nunca.List(titles.map { Item(id: UUID(), title: $0) }) { item inText(item.title)}
El id tiene que venir del modelo — un identificador asignado por el servidor, un UUID guardado que se creó una sola vez al crear el elemento — nunca fabricado sobre la marcha durante el renderizado.
#Causa 2: un ObservableObject que publica de más
El seguimiento de @Published en ObservableObject es grueso: dispara objectWillChange cuando cualquier propiedad publicada cambia, y cada vista que sostenga ese objeto como @ObservedObject/@StateObject/@EnvironmentObject vuelve a evaluar su body, lea o no esa vista realmente la propiedad que cambió.
final class ListViewModel: ObservableObject {@Published var items: [Item] = []@Published var selectedID: Item.ID?}struct RowView: View {@ObservedObject var viewModel: ListViewModellet item: Itemvar body: some View {// Solo le importan item y selectedID, pero se vuelve a ejecutar ante// CUALQUIER cambio publicado en viewModel — incluyendo cambios de// `items` que no le afectan.Text(item.title).background(viewModel.selectedID == item.id ? Color.blue : .clear)}}
Hay dos soluciones independientes para esto. Primero, deja de hacer que cada fila sostenga el objeto entero — pásale solo los valores derivados que necesita, y haz la fila Equatable para que SwiftUI pueda saltarse su re-renderizado cuando esos valores no cambiaron de verdad:
struct RowView: View, Equatable {let item: Itemlet isSelected: Boolstatic func == (lhs: RowView, rhs: RowView) -> Bool {lhs.item.id == rhs.item.id && lhs.isSelected == rhs.isSelected}var body: some View {Text(item.title).background(isSelected ? Color.blue : .clear)}}// Uso:ForEach(items) { item inRowView(item: item, isSelected: item.id == viewModel.selectedID).equatable()}
.equatable() le dice a SwiftUI que compruebe tu == antes de volver a renderizar — si el valor nuevo es igual al anterior, se salta por completo el diffing del body de ese subárbol, en lugar de depender solo del diffing estructural.
Segundo, desde iOS 17, la macro @Observable sustituye el seguimiento a nivel de objeto de ObservableObject por un seguimiento a nivel de propiedad: una vista solo se vuelve a renderizar cuando cambia una propiedad que realmente leyó durante la última evaluación de body, de forma automática, sin necesidad de conformar manualmente a Equatable.
@Observablefinal class ListViewModel {var items: [Item] = []var selectedID: Item.ID?}
Una RowView que solo lee item y nunca toca selectedID sencillamente no se invalidará cuando selectedID cambie — eso es una solución estructural al problema de la sobrepublicación, no un parche para sortearlo.
#Causa 3: trabajo inestable dentro de body
Recalcular valores derivados, ordenar o filtrar directamente dentro de body significa que ese trabajo se repite en cada invalidación sin importar si sus entradas cambiaron, y los closures que capturan un objeto de modelo entero (en lugar de los campos concretos que necesitan) pueden dejar a una vista dependiendo, sin que te des cuenta, de mucho más estado del que realmente usa. Ninguna de las dos cosas genera trabajo extra por sí sola, pero ambas amplían el radio de impacto de un cambio no relacionado en algún otro punto del mismo objeto.