Jank en el scroll: causas, medida y baseline profiles
Un scroll a tirones es siempre lo mismo: un fotograma que no llegó a tiempo a su plazo. Esta lección enumera las causas reales de que eso ocurra en una lista —trabajo en el cuerpo del elemento, tipos que impiden saltar la recomposición, medidas en cascada, imágenes sin dimensionar, anticipación demasiado cara—, distingue el jank de arranque del jank permanente porque tienen causas y remedios distintos, presenta el instrumental para medirlo con pruebas de rendimiento y trazas del sistema, y explica qué son las baseline profiles, por qué existen, qué problema resuelven exactamente y cómo comprobar que están haciendo efecto.
El jank tiene una definición incómodamente precisa y por eso se puede razonar sobre él sin recurrir a impresiones. La pantalla pide un fotograma nuevo cada dieciséis milisegundos y medio a sesenta hercios, cada ocho y pico a ciento veinte; si el trabajo de ese fotograma no ha terminado cuando llega el plazo, la pantalla repite el anterior y el usuario percibe un salto. No hay grados: o llegaste o no llegaste. Todo lo que sigue es la investigación de por qué no llegaste, y en una lista las respuestas posibles son sorprendentemente pocas y bastante catalogables. La complicación es que existen dos jank distintos que se confunden constantemente porque se ven igual: el de las primeras pasadas, causado por código que aún no está compilado a máquina, y el permanente, causado por trabajo que sobra en cada fotograma. Tratar el primero con las técnicas del segundo es la forma más habitual de perder una semana sin mover ninguna aguja.
- Definir el jank en términos de presupuesto de fotograma y separar el de arranque del permanente.
- Catalogar las causas habituales de trabajo excesivo dentro de una lista.
- Medir con pruebas de rendimiento y trazas en lugar de opinar sobre la fluidez.
- Explicar qué resuelve una baseline profile y cómo verificar su efecto con datos.
De dónde sale el jank en una lista
El presupuesto de un fotograma se gasta en tres fases sucesivas —composición, medida y colocación, dibujo— y una lista puede arruinarlo en cualquiera de ellas. Vale la pena tener el catálogo memorizado, porque un diagnóstico casi siempre termina en uno de estos casos.
La causa más frecuente es trabajo en el cuerpo del elemento: formatear fechas, construir expresiones regulares, ordenar o filtrar colecciones, leer preferencias, decodificar cadenas. Cada una de esas operaciones es trivial en aislamiento y se ejecuta veinte veces por pantallazo, en el hilo principal, mientras el dedo se mueve. Todo eso pertenece al modelo, precalculado, y debe llegar al elemento ya masticado.
La segunda es la pérdida de la capacidad de saltar la recomposición por culpa de un tipo que el compilador no puede considerar estable. Una lista es el lugar donde ese defecto se multiplica: lo que en una pantalla estática cuesta una recomposición sobrante, aquí cuesta una por elemento visible y por fotograma.
La tercera son las medidas en cascada. Cualquier construcción que necesite conocer las restricciones antes de componer obliga a una subcomposición adicional por elemento; los pesos dentro de una fila obligan a medir dos veces; una jerarquía innecesariamente profunda multiplica el número de nodos que hay que recorrer. Ninguna de estas cosas está prohibida, pero dentro de un elemento de lista su coste se multiplica por el número de elementos visibles.
La cuarta son las imágenes: decodificar a resolución completa una fotografía que se mostrará en ciento cuarenta puntos, no declarar el tamaño de destino, o no reservar el espacio antes de que llegue, lo que además provoca un reajuste de layout justo mientras se desplaza.
La quinta es la anticipación cara. La lista compone el siguiente elemento por adelantado aprovechando huecos entre fotogramas; si ese elemento es muy costoso, la anticipación deja de ser un regalo y pasa a ser lo que rompe el fotograma en curso. Un elemento anidado que a su vez contiene otra lista es el caso típico, y por eso las versiones modernas del entorno anticipan también hacia dentro de las listas anidadas en lugar de descubrir su contenido de golpe al aparecer.
Hay una sexta que no es de composición sino de memoria y conviene no olvidarla: las asignaciones. Crear objetos dentro del cuerpo de un elemento —listas intermedias, cadenas formateadas, lambdas no recordadas, instancias de formateadores— multiplica la presión sobre el recolector de basura por el número de elementos y por la frecuencia del gesto. Una pausa de recolección durante un desplazamiento produce exactamente el mismo síntoma que un fotograma lento, con el agravante de que su causa no aparece en ninguna traza de composición.
Antes de instrumentar nada, sustituye el contenido de tus filas por una caja vacía del mismo tamaño y desplaza. Si el jank desaparece, el problema está dentro del elemento y el catálogo anterior lo cubre. Si persiste, el problema está en la lista misma —número de elementos, anidamiento, estimación de tamaños— o fuera de ella, en algo que ocupa el hilo principal mientras se desplaza. Esa bisección de treinta segundos ahorra a menudo una tarde de perfilado.
Una compilación de depuración desactiva optimizaciones, añade instrumentación al código componible y ejecuta sin compilación anticipada. Las cifras que produce no describen el producto y suelen ser varias veces peores por motivos que desaparecen en producción. Cualquier conclusión sobre fluidez tomada en depuración es, literalmente, una medición de otro programa.
Dos jank distintos
Separar estas dos poblaciones es la decisión de diagnóstico más rentable de toda la lección, porque sus remedios no se parecen en nada.
El jank permanente se reparte por toda la sesión: cada pocos segundos de scroll aparecen fotogramas largos, y siguen apareciendo igual en el minuto diez que en el primero. Su causa está en el trabajo por fotograma y se ataca con el catálogo anterior.
La prueba diagnóstica que los separa es trivial y casi nadie la hace: desplázate durante un minuto seguido y compara los primeros diez segundos con los últimos diez. Si el perfil de fotogramas es indistinguible, el jank es permanente. Si la cola de valores altos se concentra al principio y luego se aplana, es de arranque.
El jank de arranque se concentra en las primeras pasadas y desaparece solo. La primera vez que se desplaza una lista se ejecuta código que nunca se había ejecutado, y ese código está en formato intermedio: la máquina virtual lo interpreta o lo compila justo a tiempo, y ambas cosas cuestan tiempo dentro del fotograma. Al cabo de unos segundos el compilador dinámico ha traducido las rutas calientes y la lista va fina. Ningún cambio en tu código de composición arregla eso, porque tu código no era el problema: el problema era que todavía no estaba compilado.
flowchart TD
SINT[Sintoma: la lista va a tirones] --> Q{Se mantiene tras varios minutos de uso}
Q -->|Si| PERM[Jank permanente: trabajo sobrante por fotograma]
Q -->|No, solo al principio| ARR[Jank de arranque: codigo aun no compilado]
PERM --> TRAZA[Traza del sistema: que fase consume el fotograma]
TRAZA --> CAUSA[Cuerpo del elemento, estabilidad, medidas, imagenes]
ARR --> PERFIL[Baseline profile: compilacion anticipada de rutas calientes]
style CAUSA fill:#a6e3a1,color:#11111b
style PERFIL fill:#89b4fa,color:#11111bMedirlo con instrumentos
La prueba de rendimiento sobre dispositivo real es el instrumento principal. Se escribe como un test que lanza la aplicación, ejecuta un gesto de desplazamiento reproducible y recoge métricas de tiempo de fotograma, repitiendo la sesión varias veces para poder hablar de percentiles en lugar de anécdotas.
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test fun scrollDeLaLista() = benchmarkRule.measureRepeated(
packageName = "com.ejemplo.app",
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.DEFAULT,
startupMode = StartupMode.WARM,
iterations = 10,
) {
startActivityAndWait()
val lista = device.findObject(By.res("lista_articulos"))
lista.setGestureMargin(device.displayWidth / 5)
repeat(3) { lista.fling(Direction.DOWN) }
}
Las dos cifras que importan son la duración del fotograma y, sobre todo, el exceso sobre el plazo: cuántos milisegundos tarde llegó. La segunda es mejor indicador porque se normaliza sola frente a pantallas de distinta frecuencia. Y hay que mirarlas por percentiles: la mediana describe la sensación general, pero el jank vive en la cola, así que el percentil noventa y nueve es el que se corresponde con lo que el usuario recuerda.
El gesto de la prueba merece más cuidado del que suele recibir, porque de él depende que la medida sea comparable entre ejecuciones. Un margen de gesto suficiente evita que el sistema interprete el arrastre como una navegación por bordes; un número fijo de impulsos y una espera hasta el reposo garantizan que siempre se recorre lo mismo; y varias iteraciones permiten distinguir una mejora real del ruido térmico del dispositivo, que en un teléfono caliente puede ser mayor que el efecto que intentas medir.
Una vez que existe esa prueba, lo más rentable que se puede hacer con ella es meterla en la integración continua y guardar la serie. Una regresión de fluidez introducida por un cambio inocente se detecta el día que ocurre, con el autor delante y el contexto fresco, en lugar de tres versiones después a partir de reseñas que dicen “va a tirones” sin más detalle.
Cuando la prueba confirma que hay problema pero no dice dónde, el siguiente instrumento es la traza del sistema, que muestra el fotograma descompuesto en sus fases y permite ver si el tiempo se fue en composición, en medida o en dibujo. Añadiendo la dependencia de trazado de la interfaz declarativa, las marcas incluyen el nombre de cada función componible, con lo que la traza deja de ser un muro de bloques anónimos.
La lectura de la traza tiene un orden que ahorra tiempo: mira primero si el fotograma largo tiene una fase claramente dominante o si el reparto es uniforme. Una fase dominante señala una causa concreta y localizable. Un reparto uniforme suele significar que simplemente hay demasiados nodos en pantalla, y ese problema no se arregla optimizando nada sino simplificando la jerarquía del elemento.
En producción, por último, existe una librería que registra los fotogramas descartados en dispositivos reales de usuarios reales y permite atribuirlos a un estado de la aplicación. Es la única fuente que responde a la pregunta de si el problema le ocurre a alguien más que a ti con tu teléfono de gama alta.
Baseline profiles
Una baseline profile es una lista de clases y métodos que la aplicación declara como calientes y que el sistema compila anticipadamente al instalarla, en lugar de esperar a que el compilador dinámico los descubra en ejecución. Se genera automáticamente ejecutando un recorrido representativo con una regla de prueba, se empaqueta con la aplicación y el sistema la aplica en la instalación.
@get:Rule val baselineRule = BaselineProfileRule()
@Test fun generarPerfil() = baselineRule.collect(packageName = "com.ejemplo.app") {
startActivityAndWait()
val lista = device.findObject(By.res("lista_articulos"))
repeat(3) { lista.fling(Direction.DOWN) }
device.waitForIdle()
}
Lo importante es entender qué resuelve y qué no. Resuelve exactamente el jank de arranque y el de primer uso de cada pantalla, que en una lista suele ser el primer desplazamiento, justo el que forma la primera impresión del producto. No resuelve nada del jank permanente: si tu elemento hace trabajo de más, lo seguirá haciendo, solo que compilado a máquina. Confundir ambas cosas lleva a añadir un perfil y concluir que “no sirve para nada” tras medirlo sobre un problema que nunca estuvo en su alcance.
Verificar el efecto exige comparar la misma prueba en dos modos de compilación: uno sin compilación anticipada y otro con el perfil aplicado. Esa comparación produce un número defendible; sin ella, cualquier afirmación sobre la mejora es folclore.
Dos advertencias sobre la generación. La primera es que el perfil solo cubre lo que el recorrido de generación ejecutó: si tu prueba abre la pantalla principal y no toca el detalle, el detalle seguirá arrancando en frío. Conviene que el recorrido represente los caminos que hacen la mayoría de los usuarios, no todos los caminos posibles, porque un perfil desmesurado alarga la instalación sin beneficio proporcional. La segunda es que el perfil debe regenerarse cuando el código cambia de forma sustancial; un perfil viejo no daña, simplemente deja de cubrir lo que se movió, y esa degradación es silenciosa.
Conviene además situar la baseline profile junto a su pariente cercano, el perfil de arranque, que reorganiza el orden de las clases dentro del ejecutable para que las que se necesitan al iniciar queden contiguas y se lean del disco de una vez. Son mecanismos distintos —uno decide qué se compila antes, el otro dónde se coloca— pero se generan del mismo recorrido y se despliegan juntos, y ambos atacan la misma población de fotogramas: los primeros, que son los que forman la impresión inicial del producto.
Prueba de rendimiento
Responde a si hay problema y cuánto, con percentiles de duración y exceso de fotograma sobre un gesto reproducible.
Traza del sistema
Responde a dónde está: en qué fase del fotograma y, con las marcas de composición, en qué función concreta.
Baseline profile
Ataca el jank de arranque y de primer uso compilando por adelantado las rutas que tu recorrido demostró calientes.
Metricas de campo
Responden a si le pasa a los usuarios, en sus dispositivos, que casi nunca se parecen al tuyo.
Hay una intuición heredada de otras disciplinas que sabotea sistemáticamente el trabajo sobre listas, y conviene desarmarla explícitamente. Estamos entrenados para optimizar medias: reducir el tiempo total, bajar el coste por operación, mejorar el rendimiento agregado. En la percepción visual esa métrica es casi irrelevante. Un scroll en el que todos los fotogramas tardan doce milisegundos y uno solo tarda sesenta se percibe como defectuoso; otro en el que todos tardan quince y ninguno se pasa se percibe como perfecto, aunque su tiempo medio sea peor. El ojo no integra: detecta discontinuidades. Y eso tiene una consecuencia metodológica dura, porque significa que la optimización correcta no consiste en hacer que el caso común vaya más rápido sino en eliminar el caso raro, que casi siempre es el caso raro estructural: la primera vez que se compone un tipo de elemento, la vez que la anticipación coincidió con una decodificación de imagen, la fila que resulta contener un vídeo, el instante en que el recolector de basura decidió pasar. Por eso la disciplina se organiza como se organiza: percentiles altos en lugar de medias, porque ahí viven los atípicos; separación entre jank de arranque y permanente, porque son dos poblaciones de atípicos con causas distintas; y una compilación anticipada que existe únicamente para borrar la primera ejecución de la distribución. Quien interioriza esto deja de preguntar cómo hacer su lista más rápida y empieza a preguntar cuál es su peor fotograma y qué lo produjo, que es la única pregunta cuya respuesta el usuario llega a notar.
- Escribe una prueba de rendimiento que desplace tu lista y anota la mediana y el percentil noventa y nueve del exceso de fotograma.
- Ejecuta la misma prueba sin compilación anticipada y con el perfil aplicado, y calcula la diferencia entre ambas.
- Usa el mismo gesto durante un minuto seguido y determina si tu jank es de arranque o permanente.
- Captura una traza con las marcas de composición y localiza qué función consume más tiempo dentro de un elemento.
- Mueve al modelo un único cálculo que estuviera en el cuerpo del elemento, vuelve a medir y defiende la mejora con las dos cifras.