wandres.dev
IMÁGENES Y MULTIMEDIA · fotos, audio, vídeo

Imágenes remotas: AsyncImage y su techo

Las fases de AsyncImage, lo que URLCache sí guarda y lo que no, los cuatro límites que hacen que toda app seria acabe escribiendo su propia cache de imágenes, y la anatomía mínima de esa cache.

⏱ 16 min

AsyncImage es una de esas APIs que enamoran en la demo y decepcionan en producción. Una línea y ya tienes una imagen de red con su spinner. Pero en cuanto la metes en una lista que el usuario desplaza, descubres que redescarga, que decodifica en tamaño completo y que no puedes decirle nada. Entender exactamente dónde está su techo es lo que te permite decidir, con criterio y no por moda, cuándo escribir tu propio cargador.

🎯 Al terminar esta lección sabrás
  • Las fases de AsyncImage y cómo controlarlas.
  • Qué guarda URLCache de verdad y por qué no basta.
  • Los cuatro límites estructurales de la API.
  • La anatomía mínima de un cargador propio con deduplicación.

Las fases y su control

La forma corta de AsyncImage acepta una URL y poco más. La forma útil te entrega una fase y te deja decidir qué dibujar en cada una:

AsyncImage(url: url, transaction: Transaction(animation: .easeIn(duration: 0.2))) { fase in
    switch fase {
    case .empty:
        ProgressView()
    case .success(let imagen):
        imagen
            .resizable()
            .scaledToFill()
    case .failure:
        Image(systemName: "photo.badge.exclamationmark")
            .foregroundStyle(.secondary)
    @unknown default:
        Color.clear
    }
}
.frame(width: 120, height: 120)
.clipped()

Tres detalles que casi nadie aplica. El transaction es la única manera de animar la transición entre fases: sin él, la imagen aparece de golpe. El .frame debe ir fuera del cierre y la imagen debe ser .resizable(), porque si no la vista adopta el tamaño en píxeles del recurso descargado y te rompe el layout. Y el caso @unknown default no es burocracia: la enumeración de fases es no exhaustiva por contrato, y Apple ya la amplió una vez.

Lo importante es entender la semántica de vida: AsyncImage arranca su descarga cuando la vista aparece y la cancela cuando desaparece. En una lista larga esto significa que desplazar hacia arriba y volver a bajar puede lanzar la misma petición dos, tres o diez veces.

Lo que URLCache guarda de verdad

La objeción inmediata es: pero URLSession ya tiene cache. Es cierto y es insuficiente, y la distinción es la clave de toda esta lección.

URLCache es una cache de bytes de respuesta HTTP, no de imágenes. Guarda el cuerpo comprimido tal cual llegó, gobernado por las cabeceras del servidor: Cache-Control, ETag, Expires. Eso implica tres cosas incómodas:

  • Si el servidor manda Cache-Control: no-store o simplemente no manda nada útil, tu cache no existe. Dependes de la infraestructura de otro.
  • Aunque acierte, te devuelve un JPEG comprimido. El trabajo caro —decodificar esos bytes a un bitmap— se repite cada vez. Y decodificar es lo que cuesta milisegundos de CPU y decenas de megabytes de RAM.
  • La cache de memoria por defecto de URLCache.shared es pequeña y compartida con todas las peticiones de tu app, incluidas las de JSON.

Una cache de imágenes de verdad guarda dos cosas en dos sitios: bitmaps decodificados y ya reducidos en memoria, y bytes originales en disco. Son niveles distintos con políticas de expulsión distintas.

flowchart LR
V[Vista pide URL y tamano] --> M{Memoria bitmaps}
M -->|acierto| R[Listo para dibujar]
M -->|fallo| D{Disco bytes}
D -->|acierto| DE[Decodificar y reducir]
D -->|fallo| N[Red]
N --> DE
DE --> R
style R fill:#a6e3a1,color:#11111b
style N fill:#f38ba8,color:#11111b

Los cuatro límites

🧠

No decide el tamaño

AsyncImage decodifica el recurso completo. Una miniatura de 120 puntos alimentada por una foto de 4000 píxeles ocupa en RAM lo mismo que a pantalla completa. La lección siguiente vive entera en este problema.

🔁

No deduplica

Dos vistas con la misma URL visibles a la vez lanzan dos peticiones. Un avatar repetido en veinte celdas puede ser veinte descargas.

⏱️

No hay prefetch ni prioridad

No puedes pedir la imagen antes de que la celda aparezca, ni marcar unas como urgentes y otras como oportunistas. La descarga empieza cuando la vista ya está en pantalla.

🚫

No hay política propia

Ni límite de memoria, ni caducidad, ni invalidación, ni reintentos, ni transformaciones. No hay puntos de extensión: la API no expone nada configurable.

No te falta una cache: te falta un pipeline

El error de encuadre más común es pensar que el problema de las imágenes remotas es “guardar el archivo para no bajarlo otra vez”. Si fuera eso, URLCache bastaría. El problema real es que una imagen atraviesa tres costes independientes antes de llegar al ojo: la transferencia por red (latencia y datos), la decodificación (CPU, y sobre todo memoria, con un factor de expansión de diez o veinte veces respecto al archivo), y el dibujado (GPU, más cualquier reescalado que hagas en el momento equivocado). AsyncImage gestiona el primero de forma rudimentaria e ignora los otros dos. Por eso ninguna app seria “elige” escribir su cache: llega a ella empujada por síntomas que parecen inconexos —desplazamiento con tirones, picos de memoria al abrir una galería, consumo de datos absurdo, avisos de memoria en dispositivos viejos— y que resultan ser el mismo pipeline sin gobernar. Cuando entiendes que estás construyendo un pipeline y no un diccionario de URLs, las decisiones se ordenan solas: dónde reducir (antes de guardar en memoria, nunca en la vista), qué guardar en cada nivel (bitmaps arriba, bytes abajo), qué clave usar (la URL más el tamaño de destino, porque la misma foto en miniatura y a pantalla completa son dos entradas distintas), y cuándo cancelar. La cache es el resultado visible de haber pensado el pipeline, no al revés.

Un cargador propio, mínimo pero correcto

No necesitas una biblioteca de miles de líneas. Necesitas un actor que resuelva los dos problemas que AsyncImage deja abiertos: memoria acotada y peticiones en vuelo deduplicadas.

actor CargadorDeImagenes {
    static let compartido = CargadorDeImagenes()

    private let memoria: NSCache<NSString, UIImage> = {
        let c = NSCache<NSString, UIImage>()
        c.totalCostLimit = 80 * 1024 * 1024   // presupuesto explicito, en bytes
        return c
    }()
    private var enVuelo: [String: Task<UIImage, Error>] = [:]

    func imagen(de url: URL, lado: CGFloat) async throws -> UIImage {
        let clave = "\(url.absoluteString)|\(Int(lado))" as NSString   // URL + tamano

        if let lista = memoria.object(forKey: clave) { return lista }
        if let tarea = enVuelo[clave as String] { return try await tarea.value }

        let tarea = Task<UIImage, Error> {
            let (datos, _) = try await URLSession.shared.data(from: url)
            guard let reducida = reducir(datos: datos, lado: lado) else {
                throw URLError(.cannotDecodeContentData)
            }
            return reducida
        }
        enVuelo[clave as String] = tarea
        defer { enVuelo[clave as String] = nil }

        let imagen = try await tarea.value
        let coste = Int(imagen.size.width * imagen.size.height * imagen.scale * imagen.scale * 4)
        memoria.setObject(imagen, forKey: clave, cost: coste)
        return imagen
    }
}

Cuatro decisiones deliberadas. El actor serializa el acceso al diccionario enVuelo sin un solo lock a mano. La clave incluye el tamaño, porque la miniatura y la versión grande no son intercambiables. El cost es el tamaño real del bitmap, no del archivo, para que NSCache expulse con información veraz. Y NSCache —en vez de un Dictionary— porque libera automáticamente bajo presión de memoria del sistema.

Falta la función reducir(datos:lado:), que es exactamente el tema de la lección siguiente, y falta el nivel de disco. Pero con estas treinta líneas ya has eliminado la redescarga y la duplicación de peticiones.

⚠️
No escribas la cache antes de medir

Este cargador es una respuesta a un problema demostrado, no un rito de iniciación. Si tu app muestra cuatro imágenes en una pantalla de ajustes, AsyncImage es la decisión correcta y añadir un actor propio es complejidad sin retorno. El umbral práctico es la lista larga con desplazamiento: en cuanto tengas una colección desplazable de imágenes remotas, medir con Instruments te dará la justificación o te la quitará.

⚔️ Del atajo al pipeline
  1. Monta una lista de cincuenta imágenes remotas con AsyncImage y observa en el Network de Instruments cuántas peticiones se lanzan al desplazar arriba y abajo tres veces.
  2. Inspecciona las cabeceras de respuesta de tu servidor de imágenes: ¿manda Cache-Control? Deduce qué está haciendo URLCache en tu caso concreto.
  3. Implementa el CargadorDeImagenes de arriba y sustituye AsyncImage por una vista propia que lo use. Repite la medición de red.
  4. Añade una segunda vista con la misma URL pero otro lado y comprueba que la clave compuesta genera dos entradas y una sola descarga si compartes el nivel de disco.
  5. Argumenta en cinco líneas, para tu app concreta, si este cargador se justifica o si AsyncImage era suficiente.