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.
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.
- Las fases de
AsyncImagey cómo controlarlas. - Qué guarda
URLCachede 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-storeo 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.sharedes 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:#11111bLos 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.
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.
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á.
- Monta una lista de cincuenta imágenes remotas con
AsyncImagey observa en el Network de Instruments cuántas peticiones se lanzan al desplazar arriba y abajo tres veces. - Inspecciona las cabeceras de respuesta de tu servidor de imágenes: ¿manda
Cache-Control? Deduce qué está haciendoURLCacheen tu caso concreto. - Implementa el
CargadorDeImagenesde arriba y sustituyeAsyncImagepor una vista propia que lo use. Repite la medición de red. - Añade una segunda vista con la misma URL pero otro
ladoy comprueba que la clave compuesta genera dos entradas y una sola descarga si compartes el nivel de disco. - Argumenta en cinco líneas, para tu app concreta, si este cargador se justifica o si
AsyncImageera suficiente.