wandres.dev
LISTAS Y COLECCIONES · rendimiento y edición

Rendimiento en listas grandes

Qué se reevalúa realmente al desplazar una lista, cómo abaratar el cuerpo de una fila, cómo paginar sin disparos duplicados ni saltos de posición, y cómo medir con Instruments en lugar de suponer: presupuesto de fotograma, cuerpos de vista, invalidaciones y microcortes de desplazamiento.

⏱ 20 min

Una lista lenta casi nunca lo es por la cantidad de datos: lo es porque algo caro ocurre una vez por fila y por fotograma sin que nadie lo haya pedido. El desplazamiento es el escenario más exigente de una app porque impone un plazo duro y repetido —dieciséis milisegundos por fotograma, ocho en pantallas de frecuencia alta— y cualquier trabajo que se cuele dentro del cuerpo de una fila se multiplica por todas las que entran en pantalla. Optimizar aquí no consiste en aplicar trucos sino en saber exactamente qué vuelve a ejecutarse, reducirlo, y comprobar con instrumentos que la reducción existió.

🎯 Al terminar esta lección sabrás
  • Identificar qué se reevalúa y qué se recrea cuando una lista se desplaza o se invalida.
  • Abaratar el cuerpo de la fila desplazando trabajo fuera del camino caliente.
  • Implementar paginación estable, sin disparos duplicados ni desplazamientos que saltan.
  • Medir con Instruments contra un presupuesto de fotograma en lugar de estimar a ojo.

Qué se reevalúa al desplazar

Hay que separar tres cosas que suelen confundirse. Evaluar el cuerpo de una vista es ejecutar su propiedad de contenido para producir una descripción nueva; es barato salvo que hayas metido trabajo dentro. Recrear la identidad es descartar la vista anterior y construir otra, perdiendo su estado. Redibujar es lo que hace la capa de composición cuando el resultado realmente cambió en pantalla.

Al desplazarse, una lista reciclada evalúa el cuerpo de las filas que entran y destruye las que salen; las que ya estaban visibles no se tocan si nada de lo que leen ha cambiado. Al invalidarse por un cambio de estado, la historia es distinta: se recorre la colección completa para recalcular identidades y se reevalúan los cuerpos de las filas visibles cuyas dependencias hayan cambiado. Ese recorrido completo es proporcional al número de elementos aunque solo diez estén en pantalla, y es la razón por la que una colección enorme en memoria pesa incluso cuando el reciclaje funciona bien.

La granularidad de la invalidación depende de cómo observes los datos. Con el sistema de observación moderno, la vista solo se invalida cuando cambia una propiedad que esa vista leyó, así que modificar un campo que la fila no usa no le cuesta nada. Con el mecanismo antiguo basado en publicaciones, cualquier cambio notifica a todos los observadores y una lista de quinientas filas suscritas al mismo objeto se reevalúa entera por un cambio irrelevante. Migrar la observación suele producir más mejora que cualquier optimización local.

⚠️
Medir en depuración es medir otra app

Las compilaciones sin optimizar añaden comprobaciones, impiden la especialización de genéricos y ejecutan mucho más código del que llegará al usuario, y el simulador usa la potencia y la memoria de un ordenador de escritorio. Cualquier número obtenido en el simulador o en modo de depuración es, en el mejor de los casos, ruido correlacionado. Se mide en dispositivo, en configuración de publicación y preferiblemente en el modelo más modesto que la app soporte.

Abaratar el cuerpo de la fila

La regla general es que el cuerpo de una fila debe leer datos ya preparados y no prepararlos. Todo lo que se pueda calcular una vez, se calcula fuera; todo lo que dependa del elemento y no de la pantalla, vive en el modelo.

🧮

Precalcular lo derivado

Texto formateado, fechas relativas, totales y filtros pertenecen al modelo o a una capa de presentación, no al cuerpo de la fila.

🏭

Reutilizar formateadores

Crear un formateador por fila es de los costes ocultos más caros que existen. Uno compartido, o valores ya formateados.

🧊

Evitar borrado de tipos

Envolver el contenido en una vista de tipo borrado impide comparar y obliga a reconstruir. Usa condicionales y grupos.

📏

Alturas predecibles

Un lector de geometría dentro de cada fila introduce una ronda extra de medida por fila. Fija alturas cuando el diseño lo permita.

Hay un coste que casi nunca se busca donde está: el número de vistas por fila. Cada modificador y cada contenedor añade nodos al árbol que hay que crear, medir y disponer, y una fila con siete niveles de anidamiento y quince modificadores cuesta varias veces lo que una fila plana con el mismo aspecto. Aplanar la jerarquía —menos contenedores intermedios, menos capas de fondo apiladas, formas simples en lugar de recortes compuestos— produce mejoras medibles precisamente porque el trabajo se repite por cada fila que entra en pantalla.

Las imágenes merecen atención propia porque son la causa más frecuente de microcortes. Decodificar una imagen es trabajo de procesador y redimensionarla también; hacerlo en el hilo principal mientras el dedo se mueve produce un salto perfectamente visible. La descarga asíncrona resuelve la red pero no la decodificación ni el redimensionado, así que en listas con muchas miniaturas conviene una capa que entregue la imagen ya del tamaño final y en caché.

// El modelo entrega lo que la fila necesita ya resuelto
struct FilaArticuloModelo: Identifiable {
    let id: Articulo.ID
    let titulo: String
    let fechaTexto: String        // formateado una sola vez, fuera del cuerpo
    let miniatura: URL?
}

struct FilaArticulo: View, Equatable {
    let datos: FilaArticuloModelo

    var body: some View {
        HStack(spacing: 12) {
            Miniatura(url: datos.miniatura)     // decodificacion en cache y fuera del hilo principal
            VStack(alignment: .leading, spacing: 2) {
                Text(datos.titulo).font(.headline).lineLimit(2)
                Text(datos.fechaTexto).font(.caption).foregroundStyle(.secondary)
            }
        }
    }

    static func == (a: FilaArticulo, b: FilaArticulo) -> Bool { a.datos.id == b.datos.id }
}

Pasar el elemento entero a la fila cuando esta solo usa tres campos la ata a cambios que no le importan. La alternativa es pasar un valor de presentación reducido, como en el ejemplo, o pasar solo el identificador y leer del almacén; la primera opción es más fácil de probar y la segunda evita duplicar datos. Ambas son mejores que arrastrar el modelo completo por costumbre.

Paginar sin disparos duplicados

La técnica habitual —pedir la página siguiente cuando aparece el último elemento— es frágil por dos motivos. El primero es que la aparición se produce con antelación y puede repetirse varias veces mientras el usuario oscila cerca del final, lo que dispara peticiones duplicadas. El segundo es que si la fuente pagina por desplazamiento numérico y alguien inserta un elemento entre dos peticiones, la página siguiente repetirá o se saltará elementos.

flowchart TB
A[El centinela final se acerca al area visible] --> B{Hay una carga en curso}
B -- Si --> C[Ignorar el evento]
B -- No --> D{Existe cursor de continuacion}
D -- No --> E[Fin de la coleccion]
D -- Si --> F[Marcar carga en curso y pedir la pagina]
F --> G[Anadir resultados y guardar el cursor nuevo]
G --> H[Liberar la marca de carga]
H --> A
style C fill:#f9e2af,color:#11111b
style E fill:#89b4fa,color:#11111b
style G fill:#a6e3a1,color:#11111b
@MainActor
final class PaginaArticulos {
    private(set) var articulos: [Articulo] = []
    private var cursor: String?
    private var cargando = false
    private(set) var hayMas = true

    func cargarSiguienteSiHaceFalta() async {
        guard !cargando, hayMas else { return }   // idempotente por construccion
        cargando = true
        defer { cargando = false }
        let pagina = try? await api.articulos(desde: cursor, limite: 40)
        guard let pagina else { return }
        articulos.append(contentsOf: pagina.elementos)
        cursor = pagina.cursorSiguiente
        hayMas = pagina.cursorSiguiente != nil
    }
}

Las dos reparaciones son independientes y ambas necesarias. Contra los disparos duplicados, un indicador de carga en curso que se consulta antes de lanzar nada y una única tarea viva por página; el evento de aparición se convierte así en una sugerencia idempotente en lugar de en una orden. Contra el desplazamiento inestable, paginación por cursor: el servidor devuelve una referencia al último elemento entregado y la petición siguiente parte de ahí, de modo que las inserciones intermedias no descolocan nada.

Queda un detalle de experiencia que se nota mucho: añadir elementos al final mientras el usuario se desplaza no debe alterar la posición visible. Si la inserción provoca un salto, casi siempre es porque el indicador de carga desapareció y reapareció cambiando la altura del contenido, o porque la colección se reordenó al llegar los datos nuevos. Mantener una fila de carga de altura constante y añadir siempre al final resuelve los dos casos.

Cuando los datos vienen de almacenamiento local, el problema cambia de forma pero no desaparece: una consulta que trae la tabla entera para mostrar veinte filas gasta memoria y tiempo de arranque. Limitar el número de resultados, ordenar por un campo indexado y traer más bajo demanda es la versión local de la misma disciplina.

Medir: Instruments y el presupuesto de fotograma

El instrumento específico de SwiftUI da tres pistas decisivas: cuántas veces se evalúa cada cuerpo de vista, cuánto tardan esas evaluaciones y cuántas confirmaciones de animación se producen. Una fila cuyo cuerpo se evalúa cientos de veces por segundo tiene un problema de dependencias, no de rendimiento bruto. El instrumento de microcortes de animación mide lo que el usuario realmente percibe: el tiempo perdido por fotogramas que llegaron tarde durante el desplazamiento, expresado como milisegundos perdidos por segundo de interacción. El perfilador de tiempo localiza el trabajo concreto, y suele apuntar a formateadores, decodificación de imágenes o cálculos derivados que deberían estar precalculados.

Antes de abrir ninguna herramienta hay que fijar el presupuesto, porque sin él no hay forma de saber si un número es bueno. En una pantalla de sesenta hercios cada fotograma dispone de unos dieciséis milisegundos y medio para todo: evaluar cuerpos, medir, disponer y confirmar la animación. En una de frecuencia alta, la mitad. Ese es el listón, y la métrica que decide si lo superas no es la media sino el peor caso, porque un único fotograma tardío ya se percibe como un tirón.

Para el diagnóstico de invalidaciones existe además una utilidad de depuración que imprime, en cada reevaluación de una vista, qué la provocó: un cambio de estado, una propiedad concreta o un cambio de identidad. Ver aparecer una identidad cambiada donde esperabas un cambio de propiedad es la confirmación inmediata de un problema de identificadores como los del segundo apartado de este nivel.

El rendimiento de una lista es una propiedad del grafo de dependencias, no del código de dibujo

La intuición heredada de las interfaces imperativas dice que una lista va lenta porque dibujar cuesta, y lleva a optimizar el dibujo: menos capas, menos transparencias, menos sombras. En un sistema declarativo esa intuición apunta al sitio equivocado casi siempre, porque el dibujo lo hace una máquina muy buena y el verdadero coste está antes, en cuántas veces alguien decide que hay que volver a describir la pantalla. Cada lectura de una propiedad observable dentro de un cuerpo instala una arista en un grafo de dependencias invisible, y el rendimiento del desplazamiento es exactamente una función de la forma de ese grafo: cuántas vistas dependen de cuántas fuentes, con qué granularidad y con qué estabilidad de identidad. Por eso las dos intervenciones que más mejoran una lista lenta no se parecen en nada a optimizar: pasar a la fila solo lo que la fila lee, y garantizar que su identidad no cambia cuando el elemento no ha cambiado. La primera poda aristas; la segunda evita que ramas enteras se corten y se replanten. Y por eso también medir es imprescindible en lugar de recomendable: el grafo no está escrito en ninguna parte del código fuente, se forma en tiempo de ejecución a partir de qué leyó cada vista, así que la única manera de conocer su forma real es observarla mientras corre. Programar por corazonada en este terreno es programar contra una estructura que nunca has visto.

⚔️ Del síntoma a la causa, con números
  1. Perfila una lista de diez mil filas en dispositivo y en configuración de publicación, y anota los milisegundos perdidos por segundo al desplazar.
  2. Instrumenta el cuerpo de la fila para contar evaluaciones durante un recorrido fijo y establece esa cifra como línea base.
  3. Sustituye el modelo completo por un valor de presentación reducido y vuelve a medir las dos cifras anteriores.
  4. Mueve la creación de formateadores y el redimensionado de imágenes fuera del cuerpo y compara de nuevo.
  5. Implementa paginación por cursor con marca de carga en curso y verifica que ninguna página se pide dos veces.
  6. Escribe una nota de tres líneas con la causa real que encontraste; en la mayoría de los casos no será la que esperabas.