Scroll fluido: el presupuesto por fotograma y los hitches
Un desplazamiento se percibe fluido o no por una razón binaria: si cada fotograma llega a tiempo. Esta lección deduce el presupuesto real a 60 y a 120 Hz, define con precisión qué es un `hitch` y cómo se mide con la razón de tiempo de tirón, separa los tirones de confección de los de renderizado y recorre las causas típicas en SwiftUI, desde la decodificación síncrona hasta el cuerpo de vista que recalcula el mundo.
La fluidez es una de las pocas propiedades del software que el usuario evalúa sin poder explicarla y sin margen de negociación. Nadie dice que una lista tardó cuarenta y dos milisegundos en un fotograma; dice que la app va rara, o que se siente barata, y esa sensación se forma en menos de un segundo de desplazamiento. Detrás de esa impresión difusa hay un mecanismo estrictamente determinista: la pantalla se refresca a intervalos fijos y en cada intervalo pregunta si hay un fotograma nuevo. Si lo hay, lo muestra; si no lo hay, repite el anterior. El movimiento continuo que percibimos es una ilusión que depende de que la respuesta sea siempre afirmativa, y basta con que falle una vez cada tanto para que el sistema visual humano —extraordinariamente sensible a las discontinuidades de movimiento— detecte el salto. Optimizar el desplazamiento no consiste, por tanto, en hacer que el código sea rápido en promedio, sino en garantizar que ningún fotograma concreto exceda su plazo.
- Deducir el presupuesto real por fotograma a 60 Hz y a 120 Hz y qué lo consume.
- Definir un
hitchcon precisión y calcular la razón de tiempo de tirón. - Separar los tirones de la fase de confección de los de la fase de renderizado.
- Reconocer las causas habituales de tirón en listas de SwiftUI y su remedio.
El presupuesto por fotograma
A 60 Hz la pantalla se refresca cada 16,67 milisegundos; a 120 Hz, cada 8,33. Ese número es el plazo absoluto para producir un fotograma completo, pero no es el presupuesto disponible para tu código, y confundir ambas cosas es el primer error de cálculo. Dentro de ese intervalo caben dos fases consecutivas que no se solapan para un mismo fotograma: la fase de confección, en la que tu proceso calcula la distribución, actualiza el árbol de capas y envía la transacción al servidor de renderizado, y la fase de renderizado, en la que la GPU compone las capas y el resultado se entrega al planificador de pantalla. El sistema encadena estas fases en una tubería, de modo que mientras la GPU renderiza el fotograma actual tu aplicación ya está confeccionando el siguiente, pero cada una debe caber en un intervalo de refresco.
La aritmética honesta, entonces, es que a 120 Hz tu trabajo en el hilo principal dispone de algo menos de 8,33 milisegundos, y una parte de ese margen la consumen el propio sistema de eventos, la gestión de gestos y el trabajo de composición que no controlas. Un presupuesto operativo de cinco milisegundos por fotograma para todo lo que ocurre en el hilo principal es una aproximación conservadora y utilizable. Cinco milisegundos son muy poco: no caben una decodificación de imagen, ni una consulta a base de datos, ni un ordenamiento de diez mil elementos, ni una lectura de disco.
En un dispositivo con refresco adaptativo la frecuencia varía de forma dinámica entre 10 y 120 Hz según el contenido, el estado térmico y el nivel de batería. Un desplazamiento activo sube el ritmo; una pantalla estática lo baja para ahorrar energía. La consecuencia contraintuitiva es que una app que solo cumple el presupuesto de 60 Hz puede sentirse peor en un dispositivo caro que en uno barato, porque el sistema le concede 120 Hz y ella no llega. Y hay una trampa de configuración clásica: si tu proyecto no declara la clave de refresco máximo apropiada, las animaciones dirigidas por temporizador quedan limitadas a 60 Hz aunque el hardware pueda más.
Qué es exactamente un hitch
Un hitch es un fotograma que se muestra más tiempo del previsto porque el siguiente no estuvo listo a tiempo. La definición operativa que utiliza el sistema no cuenta fotogramas perdidos sino tiempo de retraso: para cada fotograma se mide cuántos milisegundos se ha excedido su instante previsto de aparición, y esos excesos se acumulan. La métrica derivada, y la única que permite comparar entre versiones, es la razón de tiempo de tirón: milisegundos de retraso acumulado por cada segundo de desplazamiento.
Por debajo de 5
Cinco milisegundos de retraso por segundo o menos se considera un desplazamiento bueno. La inmensa mayoría de los usuarios no percibe nada.
Entre 5 y 10
Zona de aviso. Hay tirones aislados que algunas personas notan, sobre todo en dispositivos con refresco alto donde el plazo es la mitad.
Por encima de 10
Desplazamiento defectuoso. A partir de aquí la sensación de baja calidad es generalizada y la métrica aparece marcada en el organizador de Xcode.
La razón por la que esta métrica es superior a los fotogramas por segundo merece un momento de atención, porque cambia la forma de razonar. Una media de fotogramas por segundo promedia sobre el tiempo y por tanto oculta exactamente lo que duele: cincuenta y nueve fotogramas perfectos y uno que tarda cien milisegundos dan una media excelente y una experiencia pésima, mientras que un ritmo uniformemente bajo pero constante se percibe mucho mejor de lo que su media sugiere. El sistema visual no integra, detecta discontinuidades. La razón de tiempo de tirón mide justo eso, la irregularidad, y por eso correlaciona con la percepción de calidad mucho mejor que cualquier promedio.
flowchart TD
A[Evento de desplazamiento] --> B[Fase de confeccion en el hilo principal]
B --> C{Cabe en el plazo}
C -- Si --> D[Transaccion enviada al render server]
C -- No --> E[Hitch de confeccion]
D --> F[Fase de renderizado en la GPU]
F --> G{Cabe en el plazo}
G -- Si --> H[Fotograma presentado a tiempo]
G -- No --> I[Hitch de renderizado]Esa bifurcación es diagnóstica y conviene usarla antes de tocar una línea de código. Un tirón de confección significa que el hilo principal tardó demasiado: el culpable está en tu código Swift, en el cálculo de distribución, en la construcción de vistas o en algo bloqueante. Un tirón de renderizado significa que la GPU no llegó: el culpable es la complejidad visual, y la lista de sospechosos es completamente distinta —desenfoques, sombras sobre fondos transparentes, máscaras, superposiciones translúcidas apiladas, mezclas de color en áreas grandes. Atacar el segundo con técnicas del primero es una pérdida de tiempo garantizada.
Las causas típicas en una lista de SwiftUI
La primera y más frecuente es la decodificación síncrona de imágenes en el hilo principal. Una imagen construida desde datos o desde un fichero difiere la decodificación hasta el primer dibujado, y ese primer dibujado ocurre justo cuando la celda entra en pantalla, en mitad del desplazamiento, en el hilo principal. El resultado es un tirón perfectamente sincronizado con la aparición de cada elemento nuevo. El remedio es decodificar fuera del hilo principal y a tamaño de destino, nunca a tamaño original.
La segunda es el trabajo dentro del cuerpo de la vista. El cuerpo se evalúa muchas veces y debe ser barato hasta lo trivial: leer propiedades, componer subvistas y poco más. Filtrar, ordenar, formatear fechas, construir un formateador o resolver un cálculo agregado dentro del cuerpo significa repetir ese trabajo en cada evaluación, y en una lista con cincuenta filas visibles eso son cincuenta repeticiones por fotograma.
// Mal: se ordena y se construye el formateador en cada evaluacion del cuerpo
var body: some View {
List(tareas.sorted { $0.fecha < $1.fecha }) { tarea in
Text(tarea.fecha.formatted(.dateTime.day().month().year()))
}
}
// Bien: el orden vive en el modelo y el formateador es un valor estatico
private static let formato = Date.FormatStyle.dateTime.day().month().year()
var body: some View {
List(modelo.tareasOrdenadas) { tarea in
Text(tarea.fecha, format: Self.formato)
}
}
Hay una variante especialmente escurridiza de esta segunda causa: la dependencia oculta. Un cuerpo de vista que lee una propiedad observada muy general —el modelo entero en lugar de la porción que dibuja— se reevalúa cada vez que cambia cualquier cosa del modelo, aunque esa cosa no aparezca en pantalla. En una lista con filas que observan el contenedor completo, actualizar un solo elemento invalida las cincuenta filas visibles. El remedio no es optimizar el cuerpo sino estrechar la dependencia: que cada fila observe su propio elemento y nada más.
La tercera es la identidad inestable. Cuando los elementos de una colección no tienen un identificador persistente, o cuando se usa la posición como identidad, cualquier cambio en la colección obliga a reconstruir vistas que podrían haberse reutilizado y a recalcular su distribución completa. En listas largas la diferencia entre una identidad estable y una que no lo es se mide en un orden de magnitud.
La cuarta es la construcción no perezosa. Un contenedor apilado ordinario dentro de un área desplazable construye todos sus hijos de inmediato, incluso los que están a diez pantallas de distancia; su variante perezosa solo construye los que se acercan al área visible. En una colección de mil elementos esa diferencia decide si el desplazamiento existe o no.
La quinta pertenece al otro lado de la bifurcación y por eso se resiste a todas las técnicas anteriores: la complejidad de composición. Una sombra proyectada sobre una vista sin ruta opaca declarada obliga al sistema a calcular la silueta del contenido en cada fotograma; un desenfoque de material sobre un área grande consume ancho de banda de memoria gráfica proporcional al área; varias capas translúcidas apiladas multiplican las operaciones de mezcla por píxel. Ninguno de esos costes aparece en una traza de llamadas de Swift, porque no ocurren en tu código, y por eso un equipo puede pasar días optimizando el hilo principal sin mover la aguja.
// Sombra cara: el sistema deduce la silueta del contenido en cada fotograma
.shadow(radius: 8)
// Sombra barata: la geometria es conocida de antemano
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.background)
.shadow(radius: 8)
)
Las intuiciones no se transfieren entre las dos fases y conviene tener presente el catálogo. Encarecen el renderizado los desenfoques y materiales sobre áreas extensas, las máscaras y recortes con esquinas redondeadas aplicados a jerarquías completas, las sombras sin geometría declarada, las superposiciones translúcidas apiladas y cualquier efecto que obligue a renderizar fuera de pantalla para componer después. Abaratan: declarar opacidad cuando la hay, limitar el área de los efectos al fragmento que de verdad lo necesita y sustituir recortes generales por formas dibujadas directamente.
Casi toda la cultura de optimización que heredamos viene de un mundo donde la métrica objetivo es el rendimiento agregado: transacciones por segundo, tiempo total de un lote, coste medio por petición. En ese mundo la estrategia correcta es la que enseña el perfilado clásico, atacar por coste acumulado, porque reducir un diez por ciento en la función que consume el treinta por ciento del tiempo mejora el agregado más que eliminar por completo una función que consume el uno. La interfaz de usuario invierte ese razonamiento por completo, y no por matiz sino por naturaleza del criterio: aquí la métrica objetivo no es una suma sino un máximo. La calidad percibida la fija el peor fotograma, no el promedio de los fotogramas, porque el ojo detecta la discontinuidad y no la integra. La consecuencia estratégica es profunda. Una función que consume el cero coma cinco por ciento del tiempo total pero se ejecuta una sola vez, durante ciento veinte milisegundos, cuando el usuario abre un menú, es un defecto de fluidez grave e invisible para el perfilado por coste acumulado, que la enterrará al final de la lista. A la inversa, una función que aparece la primera en el perfil porque se ejecuta un millón de veces a dos microsegundos cada una es probablemente irrelevante para la fluidez. Optimizar la interfaz exige por tanto cambiar de instrumento y de pregunta: no cuánto tiempo total gasta cada función, sino qué ocurrió durante los fotogramas que se pasaron de plazo. Es la misma diferencia que en sistemas distribuidos separa la optimización del rendimiento total de la optimización de la latencia de cola, y quien ha interiorizado por qué el percentil noventa y nueve manda en un servicio ya entiende por qué el peor fotograma manda en una lista.
Medir antes de tocar
El instrumento que responde a la pregunta correcta es la plantilla de tirones de animación de Instruments, que marca cada fotograma retrasado, indica si el retraso fue de confección o de renderizado y permite bajar desde el fotograma culpable hasta la pila de llamadas responsable. Trabajar así invierte el flujo habitual: en lugar de buscar código lento y esperar que sea el relevante, se parte del síntoma observable y se desciende hasta la causa.
import MetricKit
func didReceive(_ payloads: [MXMetricPayload]) {
for carga in payloads {
guard let animacion = carga.animationMetrics else { continue }
// Porcentaje de tiempo de desplazamiento con tirones, agregado por dia
registrar(razon: animacion.scrollHitchTimeRatio)
}
}
Ese valor agregado, entregado a diario por el sistema con la distribución real de dispositivos de tus usuarios, es lo que convierte la fluidez en una propiedad gobernable en lugar de una impresión. Una regla de trabajo que funciona: fijar el umbral en cinco milisegundos por segundo, vigilarlo por versión y tratar cualquier salto entre dos versiones consecutivas como una regresión que hay que bisecar, exactamente igual que se trata un fallo funcional nuevo.
Queda una advertencia sobre el entorno de medición que invalida buena parte del trabajo cuando se ignora. Una compilación de depuración no dice nada sobre fluidez: las comprobaciones de la biblioteca estándar, la ausencia de optimización y la instrumentación del depurador cambian el coste del hilo principal en un factor que puede superar el orden de magnitud. Y el simulador miente todavía más, porque compone con la unidad gráfica del ordenador, no aplica límites térmicos y ejecuta código compilado para otra arquitectura. Cualquier afirmación sobre desplazamiento debe hacerse con compilación de publicación y en hardware, preferiblemente en el modelo más modesto que la app soporte y con el dispositivo ya templado, no recién sacado de la nevera del reinicio.
La pantalla se refresca cada 16,67 milisegundos a 60 Hz y cada 8,33 a 120 Hz, y de ese plazo tu hilo principal dispone realmente de unos cinco. Un hitch es un fotograma que aparece tarde, y la métrica útil es la razón de tiempo de tirón en milisegundos por segundo: por debajo de cinco es bueno, por encima de diez es defectuoso. Antes de optimizar, clasifica el tirón en de confección o de renderizado, porque las causas y los remedios de cada uno no se solapan. Y recuerda que aquí manda el peor fotograma, no el promedio.
- Perfila un desplazamiento largo de tu lista más pesada con la plantilla de tirones y clasifica cada tirón en de confección o de renderizado antes de proponer ninguna solución.
- Busca en tus cuerpos de vista todo ordenamiento, filtrado o construcción de formateadores y muévelo al modelo o a un valor estático; vuelve a medir la razón de tiempo de tirón.
- Sustituye un contenedor apilado ordinario por su variante perezosa en una colección larga y compara el tiempo hasta el primer fotograma de la pantalla.
- Fuerza la decodificación de imágenes fuera del hilo principal y a tamaño de destino, y comprueba si desaparecen los tirones sincronizados con la aparición de celdas.
- Recoge la razón de tiempo de tirón de
MetricKitdurante dos versiones consecutivas y establece el umbral que tu equipo defenderá a partir de ahora.