Saltar al contenido
Development

Los botones no estaban rotos: el hilo principal estaba ocupado

Por Victor Da Luz
iosswiftswiftuiperformancedev-logdeep-cut-atlas

Esta app se renombró más adelante a Deep Cut Atlas. Acá abajo se la llama “Discoverer” todo el tiempo, porque así se llamaba el día en que pasó esto.

Rediseñé la pestaña de Historial en mi app de Apple Music para que abriera una sheet por canción: reproducir la canción, agregar su álbum a una playlist, marcarla como “no me interesa,” y ver más del mismo artista. Pasó 118 pruebas unitarias. Pasó una corrida de automatización de interfaz en el simulador donde toqué cada control. Después la corrí en mi teléfono y los botones de la sheet no respondieron durante uno o dos segundos después de abrirse.

Pasé demasiado tiempo adivinando. Mi primera teoría fue un bug de paginación. La segunda, que mapear unos cientos de modelos era lento. Las dos estaban mal, y un revisor me repetía lo mismo: dejar de adivinar, medir. Había investigado las herramientas exactas para esto (el instrumento Hangs de Instruments, el interruptor de Hang Detection en el dispositivo, en Ajustes → Desarrollador) y después traté de razonar para esquivar la necesidad de usarlas.

El arreglo salió de una sola frase de feedback en un dispositivo real: “los botones de reproducir, agregar e ignorar no responden hasta que carga la sección de más.”

Ese es todo el bug. La sección “más de este artista” carga de forma asíncrona. Para mostrar solo los álbumes que todavía no se tienen, el servicio traía toda la biblioteca de Apple Music y la mapeaba a claves de comparación. El servicio está anotado @MainActor, así que ese mapeo corría en el hilo principal. SwiftUI también procesa los toques de botón en el hilo principal. Mientras corría el mapeo de la biblioteca, cada toque simplemente se ponía en cola. Los botones estaban bien. El hilo estaba ocupado.

Tanto .task de SwiftUI como las acciones de botón de una vista corren en el main actor por defecto. Un await sobre una llamada de red libera el hilo, pero el trabajo síncrono entre awaits, como mapear miles de álbumes, no lo hace. Con una biblioteca chica no se notaría nunca. Con una real es un congelamiento visible.

El arreglo fue no hacer nada de ese trabajo en ese camino. La pantalla de Historial ya había calculado las claves de biblioteca al cargar. Así que pasé esas claves a la sheet y filtré las sugerencias del lado del cliente, y le dije a la consulta del catálogo que se saltara su pasada por toda la biblioteca. Los botones responden al instante ahora porque el hilo principal no hace nada pesado mientras la sheet está abierta.

La misma forma explicaba un segundo congelamiento: volver a la lista releía toda la biblioteca y corría una consulta síncrona a la base de datos en cada cierre. En realidad solo necesitaba revisar de nuevo la única playlist que podía haber cambiado, y solo cuando algo realmente había cambiado.

La lección que realmente me quedó no fue técnica. Las pruebas unitarias y la automatización en el simulador verifican lógica y renderizado. No verifican si un toque se siente instantáneo, y corren sobre datos simulados que nunca reproducen el rendimiento de una biblioteca real. Así que agregué una regla al proyecto: ninguna funcionalidad de interfaz está “terminada” hasta que una corrida en dispositivo con Hang Detection activado confirma que cada toque responde rápido y que la acción realmente persistió. Las herramientas para detectar esto existieron todo el tiempo. Solo tenía que obligarme a usarlas antes de dar algo por terminado.

Lecturas relacionadas