Rendimiento con listas grandes: Equatable, observación y paginación
Cien filas no duelen; cincuenta mil sí, y duelen en sitios concretos que conviene conocer antes de que la app se arrastre. Esta lección mide los tres costes reales de una colección de features grande: la comparación de igualdad, que es lineal en el tamaño y se paga cada vez que alguien la pide; la observación, que con `@ObservableState` es por elemento salvo que el padre la estropee leyendo la colección entera en su cuerpo; y los efectos por fila, que no dependen de lo que se ve sino de lo que existe. Cierra con la paginación como estrategia estructural y con la deduplicación que la identidad regala.
Todo lo aprendido en este nivel es correcto a cualquier escala, pero no es igual de barato a cualquier escala. Una colección de features tiene tres costes que crecen con el número de elementos y que casi nadie mide hasta que la lista va a tirones: comparar el estado, invalidar vistas y sostener efectos. Los tres se pueden controlar, y ninguno se controla optimizando el framework, porque el framework ya hace lo que puede; se controlan cambiando la forma del estado. Esta lección cierra el nivel con la pregunta que separa un prototipo de un producto: qué exactamente se vuelve lento cuando la lista pasa de cien a cincuenta mil filas, y qué decisiones de modelado hacen que el número deje de importar.
- Medir el coste de
Equatablesobre una colección grande y sacar del estado del elemento todo lo que sea caro de comparar y copiar. - Aprovechar la observación por elemento y detectar el patrón del padre que la anula leyendo la colección entera en su cuerpo.
- Distinguir el coste de renderizar —que es perezoso— del coste de los efectos por fila, que no lo es.
- Diseñar paginación incremental con deduplicación por identidad y cancelación de la petición en vuelo.
El coste de Equatable
La igualdad sintetizada de una estructura compara cada campo en orden y corta en la primera diferencia. Eso convierte la comparación de dos colecciones iguales en el peor caso posible: hay que recorrer los n elementos y, dentro de cada uno, todos sus campos. Con estados de fila pequeños es ruido; con estados gordos es un desastre silencioso que aparece en el perfilador como un == sintetizado ocupando la mitad del cuadro.
// Caro de comparar y caro de copiar
struct State: Equatable, Identifiable {
let id: UUID
var miniatura: Data // cientos de KB por fila
var textoCompleto: String // el documento entero
}
// Barato: el estado guarda referencias, no cargas
struct State: Equatable, Identifiable {
let id: UUID
var miniaturaURL: URL
var resumen: String
}
La regla es que el estado de un elemento debe pesar lo que pesa una fila en pantalla, no lo que pesa el objeto de dominio completo. Los datos binarios, los documentos y los resultados decodificados grandes viven en una caché detrás de una dependencia y el estado guarda su identificador. La tentación contraria —escribir un == a mano que solo compare los ids para que la comparación sea barata— es peor que el problema: una igualdad que miente hace que SwiftUI y TestStore no vean cambios que sí ocurrieron, y los fallos resultantes son de los que cuestan una tarde por cada uno.
Conviene saber además dónde se paga. En el TCA anterior a la observación, WithViewStore deduplicaba comparando el estado de vista completo en cada acción, así que una colección grande pagaba su comparación lineal en cada tecla pulsada. Con @ObservableState esa comparación desaparece del camino caliente, porque la invalidación pasa a ser por propiedad accedida; pero Equatable sigue siendo necesario y sigue usándose en las aserciones de TestStore, en las animaciones y en los observadores de cambio. Barato de comparar no es opcional, solo dejó de ser urgente en todos los frames.
Observación por elemento, y cómo estropearla
Con la vista de fila observando su propio store rebanado, teclear en la fila cuarenta invalida la fila cuarenta y nada más. Es una propiedad que se obtiene gratis y se pierde con una sola línea distraída en el padre.
// Estropea la observación: el padre lee la colección entera
var body: some View {
VStack {
Text("\(store.filas.count) elementos") // toca toda la colección
Text("\(store.filas.filter(\.completada).count)") // y encima la recorre
List {
ForEach(store.scope(state: \.filas, action: \.filas)) { s in
FilaView(store: s)
}
}
}
}
Al leer store.filas en su cuerpo, el padre se suscribe a la colección completa: cualquier cambio en cualquier elemento reevalúa ese cuerpo, y con él la construcción del ForEach. La observación por fila sigue funcionando, pero has añadido encima un trabajo lineal por cada pulsación. El arreglo es de modelado, no de sintaxis: mantener los agregados como campos almacenados que el reducer actualiza cuando corresponde, o aislar la cabecera en una subvista que observe solo ese campo. Lo mismo vale para las proyecciones calculadas de la lección anterior: filasVisibles es lineal cada vez que se lee, y si el cuerpo la lee varias veces por frame con decenas de miles de elementos, ahí está tu cuello de botella.
Hay un segundo modo de estropearlo, esta vez desde el reducer. IdentifiedArrayOf es un tipo valor con copia sobre escritura, de modo que mutar un elemento por su id modifica el almacenamiento en el sitio cuando la referencia es única, y no toca a nadie más. Reconstruir la colección, en cambio, sustituye el almacenamiento entero y marca como cambiado todo lo que hubiera dentro.
// Rehace la estructura completa: lineal, y todos los elementos cuentan como cambiados
state.filas = IdentifiedArrayOf(uniqueElements: state.filas.map { fila in
var copia = fila
copia.visto = true
return copia
})
// Mutación dirigida: toca exactamente a quien hay que tocar
for id in state.pendientesDeMarcar {
state.filas[id: id]?.visto = true
}
Las dos versiones producen el mismo estado final y cuestan cosas muy distintas, y la diferencia se amplifica cuando la operación es frecuente. La heurística es la de siempre en este nivel, ahora en clave de rendimiento: si sabes a quién quieres tocar, nómbralo; recorrer la colección entera solo está justificado cuando de verdad cambian todos.
List y LazyVStack solo instancian las filas visibles, así que treinta mil elementos no cuestan treinta mil vistas. Pero el reducer de un elemento solo corre cuando llega una acción suya, y un efecto de larga vida corre porque existe, no porque se vea: treinta mil filas con un reloj cada una son treinta mil tareas concurrentes vivas aunque solo haya diez en pantalla. Si el efecto es común a todas, súbelo al padre —un solo reloj que reparte— y deja en la fila solo lo que de verdad es suyo.
Paginación: crecer sin reconstruir
La respuesta estructural a una colección enorme no es optimizar su manejo sino no tenerla entera. La paginación encaja con naturalidad porque la unicidad por identidad hace que las páginas solapadas —el caso normal cuando el servidor inserta mientras tú lees— no produzcan duplicados.
enum CancelID { case pagina }
case let .filaAparecio(id):
guard id == state.filas.last?.id, !state.cargando else { return .none }
state.cargando = true
return .run { [cursor = state.cursor] send in
await send(.paginaLlego(try await self.api.pagina(cursor)))
}
.cancellable(id: CancelID.pagina, cancelInFlight: true)
case let .paginaLlego(pagina):
state.cargando = false
state.cursor = pagina.siguienteCursor
for elemento in pagina.elementos {
state.filas.updateOrAppend(Fila.State(elemento)) // sin duplicados, por id
}
return .none
Tres detalles cargan todo el peso. El disparo por identidad —comparar el id de la fila que apareció con el de la última— evita la aritmética de índices que se rompe en cuanto hay filtro. El cancelInFlight garantiza que un usuario que hace scroll rápido no encadena cinco peticiones simultáneas: la nueva sustituye a la anterior. Y updateOrAppend convierte el solapamiento de páginas en actualización silenciosa en vez de en filas repetidas, sin que tengas que escribir una sola comprobación.
Estado ligero
El estado del elemento pesa lo que la fila muestra. Las cargas grandes viven en una caché tras una dependencia y el estado guarda su id.
Lectura mínima en el padre
Ningún cuerpo de vista lee la colección completa si puede leer un campo. Los agregados se almacenan, no se recalculan por frame.
Efectos compartidos
Un reloj en el padre que reparte tics vale por miles de relojes por fila. El efecto por elemento se reserva a lo que es genuinamente suyo.
Ventana en vez de todo
Paginar, y si hace falta podar lo que quedó muy atrás: la identidad permite recuperarlo después como si nunca se hubiera ido.
flowchart TD N[La lista crece] --> EQ[Coste de comparar es lineal] N --> OB[Coste de observar depende del padre] N --> EF[Coste de efectos depende de cuantos existen] EQ --> A[Estado del elemento ligero] OB --> B[Leer campos y no la coleccion] EF --> C[Subir el efecto comun al padre] A --> R[Escala estable] B --> R C --> R R --> P[Paginar con updateOrAppend y cancelInFlight] style R fill:#a6e3a1,color:#11111b
Cuando una lista de TCA va lenta, la reacción instintiva es sospechar del framework: demasiadas capas, demasiada composición, demasiada copia de valores. Casi siempre es falso, y lo interesante es por qué. En una arquitectura donde la interfaz es un reflejo del estado y las acciones son mensajes dirigidos por identidad, el trabajo total que hace el sistema por unidad de tiempo queda determinado por tres magnitudes que tú controlas por completo desde el modelado: cuánto cuesta comparar un elemento, a cuántos observadores alcanza cada cambio, y cuántos procesos existen simultáneamente. Ninguna de las tres es un parámetro del runtime; las tres son consecuencias de decisiones que tomaste al escribir un struct. Meter una imagen decodificada en el estado de la fila no es una imprudencia de memoria, es multiplicar por su tamaño el coste de cada igualdad que alguien pida. Leer la colección en el cuerpo del padre no es una fea costumbre de estilo, es convertir una invalidación puntual en una lineal. Poner un reloj en cada fila no es una decisión de comportamiento, es fijar el número de tareas vivas en función del número de filas. Vista así, la optimización deja de ser un arte de perfilador y trucos y pasa a ser lo que era en los otros niveles: elegir bien la forma de los datos. Y el mismo movimiento que resolvió el primer problema de este nivel resuelve también el último, porque la identidad —que empezó siendo la manera de entregar una acción tardía al destinatario correcto— es lo que permite paginar sin duplicar, podar sin perder y recuperar sin reconstruir. Una colección de features escala cuando cada elemento es pequeño, mira poco y hace lo justo; el resto lo pone la estructura que lleva cinco lecciones construyéndose.
- Genera una colección sintética de cincuenta mil elementos con identidades estables y ábrela en la app. Anota si el arranque, el scroll o el tecleo son lo primero que se degrada.
- Perfila una pulsación de tecla en una fila. Si ves comparaciones de igualdad ocupando tiempo, busca qué campo del estado del elemento es grande y sácalo a una caché tras una dependencia.
- Revisa el cuerpo de la vista padre línea a línea y marca cada lectura de la colección completa. Convierte una en un campo almacenado que mantenga el reducer y vuelve a medir.
- Cuenta cuántos efectos de larga vida existen: multiplica los que hay por fila por el número de filas. Si el producto te asusta, sube el efecto común al padre y reparte por identidad.
- Sustituye la carga completa por paginación con
updateOrAppendycancelInFlight, haz scroll rápido a propósito y comprueba dos cosas: que no aparecen filas duplicadas y que no hay más de una petición viva a la vez.