Saltar al contenido
Development

El escaneo de 8 segundos escondido en cada refresco

Por Victor Da Luz
iosswiftperformancemusickitdev-logdeep-cut-atlas

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