El escaneo de 8 segundos escondido en cada refresco
Deep Cut Atlas tiene dos pestañas principales. Ambas a veces tardaban una eternidad en cargar, tanto que daba tiempo de cambiar a otra app mientras se esperaba. Esta semana se hizo una pasada de revisión de rendimiento para averiguar por qué.
El problema
La app rastrea qué álbumes ya se tienen para mostrar solo los lanzamientos nuevos. Para eso escanea toda la biblioteca de Apple Music y construye un conjunto de claves de álbum normalizadas. Ya se sabía que el escaneo no era gratis. Lo que no se sabía era cuánto costaba realmente en una biblioteca real, ni con qué frecuencia corría.
Entonces se midió. Se conectó un diagnóstico temporal a la app detrás de un argumento de lanzamiento, se compiló al teléfono, y se lanzó desde la terminal con captura de consola:
xcrun devicectl device process launch --console --terminate-existing \
<device-id> com.example.app --timing-diag
Los números en mi biblioteca (1,610 artistas, 5,054 álbumes):
fetchLibraryArtists cold 533ms
fetchLibraryAlbumKeys cold 8215ms
fetchPlaylistContents 73ms
Ocho segundos. Y la revisión de código encontró que la pestaña Discover corría ese escaneo en cada lote: en cada arranque en frío, cada pull-to-refresh, cada regreso desde segundo plano con datos obsoletos. La pestaña Playlist ya había aprendido esta lección meses atrás y guardaba el escaneo en caché por sesión. A la pestaña Discover nunca le llegó el memo.
La solución
Mover la caché hacia el servicio compartido, para que cada pestaña se beneficie y el escaneo corra una sola vez por sesión:
private var cachedLibraryAlbumKeys: Set<String>?
private var libraryAlbumKeysScan: Task<Set<String>, Error>?
func fetchLibraryAlbumKeys() async throws -> Set<String> {
if let cachedLibraryAlbumKeys { return cachedLibraryAlbumKeys }
let scan = libraryAlbumKeysScan ?? Task.detached(priority: .utility) { /* scan */ }
libraryAlbumKeysScan = scan
do {
let keys = try await scan.value
cachedLibraryAlbumKeys = keys
libraryAlbumKeysScan = nil
return keys
} catch {
if libraryAlbumKeysScan == scan { libraryAlbumKeysScan = nil }
throw error
}
}
El Task guardado importa tanto como el valor guardado. Al arrancar la app, dos pestañas piden el escaneo con milisegundos de diferencia entre sí. Con solo una caché de valor, ambas fallarían la búsqueda y ambas escanearían. Con la tarea en caché, el segundo llamador espera el escaneo del primero. Y en caso de falla la tarea se limpia para que la siguiente llamada reintente, en vez de repetir un error en caché para siempre. Task es Equatable en Swift, lo que permite que la ruta de falla limpie solo su propia tarea en vez de pisar un reintento que alguien más ya había iniciado.
Dos ganancias más salieron de la misma revisión. La búsqueda de catálogo por artista hacía dos solicitudes por artista: una para volver a resolver el artista por id, otra para cargar sus álbumes. Los objetos de artista del escaneo de sesión se pueden reutilizar directamente, así que ahora es una sola solicitud. Y la pestaña de playlist volvía a obtener su contenido en cada cambio de pestaña. Ahora revisa un contador de versión de escritura en el servicio, así que solo vuelve a obtener datos cuando algo realmente cambió. Una ventana de obsolescencia de 5 minutos detecta ediciones hechas en la propia app de Music.
La trampa de medición
La primera comparación de tiempos para la búsqueda de catálogo dijo que la ruta anterior era 16 veces más rápida que la nueva. Habría sido un mal día, salvo que era un sinsentido: ambas pasadas obtuvieron los mismos cinco artistas, y la segunda pasada se sirvió desde la caché HTTP de MusicKit. Diez solicitudes de red en 61ms no es algo que mi wifi pueda hacer.
Al repetir la medición con muestras de artistas disjuntas, y con la ruta anterior medida primero para sesgar en contra del cambio nuevo, dio el resultado honesto: 231ms por artista antes, 153ms después.
Resultados
El arranque en frío de Discover pasó de unos 18 segundos de trabajo a cerca de 6, y un pull-to-refresh en caliente ya no paga el escaneo de 8 segundos en absoluto. Los cambios de pestaña en Playlist son instantáneos a menos que algo realmente haya cambiado. Todo confirmado en el dispositivo, ya que MusicKit no devuelve nada en el simulador.
La lección que se sigue reaprendiendo: medir en el dispositivo con datos reales antes y después. El escaneo de biblioteca “se sentía lento” durante semanas, pero bastó un número impreso para ver que eran 8 segundos y que corría con mucha más frecuencia de la necesaria.
Lecturas relacionadas
El escaneo de biblioteca completa que creí haber corregido
Un patrón no se corrige una sola vez. Un escaneo más de biblioteca completa en el main actor, escondido en una ruta de escritura, y la API por lotes que solo estaba conectada en una dirección.
La ventana espejada mintió sobre que el teléfono estaba desbloqueado
Un arreglo de agrupación de una línea que ya estaba escrito, y tres muros entre eso y la prueba: aislamiento de actores, el alcance exclusivo a Mac de log stream, y una sesión espejada que parece desbloqueada cuando el dispositivo no lo está.
Álbumes de preestreno, y la solución que no pude verificar del todo
Los marcadores de posición 'Track N' de Apple se mostraban como si fueran datos reales. La única señal de detección que no pude confirmar se convirtió en la única señal de la que dejé de depender.