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

Memoria e imágenes: downsampling antes de mostrar

La aritmética real de una foto de 12 megapíxeles, por qué resizable no ahorra un solo byte, cómo reducir con ImageIO antes de decodificar, y los límites de memoria que provocan el crash que no aparece en tu simulador.

⏱ 17 min

Hay una clase de crash que solo ocurre en dispositivos que no tienes, con fotos que no hiciste tú, y que en el informe aparece sin stack trace útil. Es la app terminada por el sistema por exceso de memoria, y su causa número uno son las imágenes. La aritmética que lo explica cabe en una servilleta, pero casi nadie la hace: el peso del archivo y el peso en RAM no tienen casi nada que ver.

🎯 Al terminar esta lección sabrás
  • Calcular el coste real en RAM de una imagen decodificada.
  • Entender por qué .resizable() no ahorra memoria.
  • Reducir con ImageIO antes de que el bitmap exista.
  • Reconocer los límites de memoria del proceso y de las extensiones.

La aritmética que nadie hace

Una foto de la cámara del iPhone en 12 megapíxeles mide 4032 por 3024 píxeles. Eso son 12.192.768 píxeles. En el formato de trabajo habitual, cada píxel ocupa 4 bytes: rojo, verde, azul y alfa, un byte cada canal.

4032 x 3024 = 12.192.768 pixeles
12.192.768 x 4 bytes = 48.771.072 bytes
                     = 46,5 MiB de RAM por foto

El mismo archivo en disco, comprimido en HEIC, pesa entre 1,5 y 3 MB. El factor de expansión ronda veinte veces. Y no es un caso extremo: una foto de 48 megapíxeles del modo alta resolución llega a 8064 por 6048, es decir 48,7 millones de píxeles y unos 186 MiB en RAM. Una sola.

Ahora multiplica. Una galería que mantenga diez miniaturas decodificadas a tamaño completo son 465 MiB. En un iPhone con 4 GB de RAM eso no es “un poco de memoria”: es territorio de terminación por parte del sistema.

El framework no puede adivinar el tamaño que necesitas

Aquí está el malentendido que cuesta más caro en todo el desarrollo de apps con imágenes: creer que .resizable().frame(width: 120, height: 120) reduce el consumo. No reduce nada. La secuencia real es: los bytes comprimidos llegan, alguien los decodifica a un bitmap completo de 46 MiB, y solo entonces SwiftUI dibuja ese bitmap escalado a 120 puntos. El escalado ocurre en el dibujado, aguas abajo de la decodificación, y la decodificación ya pagó el precio completo. El frame es una instrucción de layout, no de memoria.

La consecuencia arquitectónica es profunda y va más allá de las imágenes: el punto donde reduces determina lo que gastas, y en un pipeline la reducción tiene que ocurrir lo más arriba posible. Si reduces al final, has pagado todo lo anterior. Por eso la solución correcta no es un modificador de vista sino una operación de ImageIO que produce directamente el bitmap pequeño, sin materializar nunca el grande. La capa de presentación no puede arreglar un problema de la capa de datos: cuando la vista recibe la imagen, el daño ya está hecho y el sistema ya te tiene fichado. Interiorizar esto cambia cómo diseñas cualquier flujo de medios: la pregunta deja de ser “cómo lo muestro más pequeño” y pasa a ser “quién es el primero que puede saber el tamaño de destino, y cómo se lo digo”.

flowchart LR
A[Archivo HEIC 2 MB] --> B[Decodificacion]
B --> C[Bitmap 46 MiB]
C --> D[Dibujado a 120 puntos]
A --> E[ImageIO thumbnail]
E --> F[Bitmap 0,25 MiB]
F --> D
style C fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b

Downsampling con ImageIO

CGImageSource sabe leer la cabecera de un archivo y producir una versión reducida sin decodificar nunca el original completo. Esta es la función que faltaba en la lección anterior:

import ImageIO
import UIKit

func reducir(datos: Data, lado: CGFloat, escala: CGFloat) -> UIImage? {
    // No cachear la imagen completa al abrir la fuente.
    let opcionesFuente = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let fuente = CGImageSourceCreateWithData(datos as CFData, opcionesFuente) else {
        return nil
    }

    let maxPixeles = Int(lado * escala)
    let opciones = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,   // ignora la miniatura embebida si es peor
        kCGImageSourceCreateThumbnailWithTransform: true,     // respeta la orientacion EXIF
        kCGImageSourceShouldCacheImmediately: true,           // decodifica aqui, no en el hilo principal
        kCGImageSourceThumbnailMaxPixelSize: maxPixeles
    ] as CFDictionary

    guard let cg = CGImageSourceCreateThumbnailAtIndex(fuente, 0, opciones) else { return nil }
    return UIImage(cgImage: cg, scale: escala, orientation: .up)
}

Cada opción está ahí por una razón. kCGImageSourceShouldCache: false evita que abrir la fuente ya reserve el bitmap completo. CreateThumbnailFromImageAlways fuerza a generar desde el original en vez de devolver la miniatura embebida del EXIF, que suele ser diminuta y borrosa. WithTransform aplica la orientación de la cámara, sin lo cual la mitad de las fotos verticales salen tumbadas. Y ShouldCacheImmediately es el más sutil: obliga a que la decodificación ocurra ahora, en el hilo donde estás, en vez de diferirse hasta el primer dibujado, que ocurriría en el hilo principal y provocaría el tirón.

Sobre la escala: usa el displayScale del entorno de SwiftUI en lugar de una constante. Un lado de 120 puntos necesita 360 píxeles en una pantalla de escala 3, y 240 en una de escala 2. Pedir más es desperdicio; pedir menos es una imagen borrosa.

💡
Si el origen es un archivo, no lo cargues en Data

CGImageSourceCreateWithData obliga a tener todos los bytes comprimidos en memoria. Cuando la imagen está en disco —la fototeca, tu cache, el bundle— usa CGImageSourceCreateWithURL(url as CFURL, opciones). ImageIO lee entonces por partes, con memoria mapeada, y ni siquiera los bytes comprimidos se materializan enteros. En un lote de fotos grandes esta diferencia sola ya evita picos de cientos de megabytes.

Cuándo se decodifica y con cuántos bytes

Los 4 bytes por píxel del cálculo inicial son un suelo, no una ley. El formato del bitmap depende del espacio de color y de la profundidad, y hay dos direcciones en las que se desvía:

  • Hacia abajo: una imagen en escala de grises de 8 bits ocupa 1 byte por píxel, y una sin canal alfa puede almacenarse en 3. Rara vez te salva, porque el sistema suele alinear a 4 por eficiencia de acceso.
  • Hacia arriba, y esto sí importa: una imagen de gama amplia en Display P3 o en HDR se decodifica con 16 bits por canal en coma flotante media, es decir 8 bytes por píxel. La foto de 46,5 MiB pasa a 93 MiB sin que hayas cambiado una línea de código, simplemente porque el usuario tiene un iPhone que dispara en P3.

Cuando generas imágenes tú —recortes, composiciones, miniaturas de mapas— puedes fijar el rango explícitamente:

let formato = UIGraphicsImageRendererFormat.default()
formato.preferredRange = .standard    // evita el bitmap de gama amplia si no lo necesitas
formato.scale = escala

let renderizador = UIGraphicsImageRenderer(size: destino, format: formato)
let miniatura = renderizador.image { _ in
    original.draw(in: CGRect(origin: .zero, size: destino))
}

El segundo eje es cuándo ocurre la decodificación. Un UIImage construido desde Data o desde un archivo no decodifica en ese momento: guarda los bytes comprimidos y difiere el trabajo hasta el primer dibujado, que ocurre en el hilo principal, en mitad de un desplazamiento, con el resultado clásico del tirón que aparece justo cuando la celda entra en pantalla. Por eso existe:

let listaParaDibujar = await imagen.byPreparingForDisplay()

Fuerza la decodificación fuera del hilo principal y devuelve un UIImage cuyo dibujado ya es una copia de memoria. Es el equivalente en UIKit de la opción kCGImageSourceShouldCacheImmediately de ImageIO, y su hermana byPreparingThumbnail(ofSize:) hace reducción y decodificación en un solo paso cuando ya tienes un UIImage en la mano.

Los límites reales del proceso

El presupuesto de memoria no es “la RAM del dispositivo”. El sistema aplica límites por proceso y mata sin contemplaciones al que los supera. Los órdenes de magnitud que conviene tener en la cabeza:

📱

App en primer plano

Del orden de la mitad a dos tercios de la RAM física, según modelo y presión del momento. No es un número fijo ni documentado: depende de lo que más haya en marcha.

🧩

Widget

Extremadamente restringido, del orden de decenas de megabytes. Decodificar una foto de la cámara dentro de un widget lo mata casi con seguridad.

🔔

Extensión de notificación

Aún más estrecha. Descargar y adjuntar una imagen rica exige reducirla antes de tocarla.

📤

Extensión de compartir

Más holgada que las anteriores pero muy por debajo de la app. Es el sitio clásico donde el mismo código que funcionaba en la app se cae.

Dos consecuencias prácticas. La primera: el simulador no aplica estos límites, así que un código que revienta en un iPhone real puede funcionar perfectamente en tu Mac con 64 GB. La segunda: applicationDidReceiveMemoryWarning y su equivalente en SwiftUI, la notificación UIApplication.didReceiveMemoryWarningNotification, llegan tarde y no siempre llegan. Vaciar la cache al recibir el aviso es higiene, no estrategia; NSCache ya lo hace por ti.

Para medir de verdad: el gauge de memoria de Xcode te da el rumbo, pero el instrumento que responde a la pregunta correcta es Allocations filtrado por IOSurface y CGImage, o directamente VM Tracker, porque los bitmaps grandes no viven en el heap de Swift sino en regiones de memoria gráfica que el gauge simple agrupa de forma poco reveladora.

⚔️ Haz la aritmética y luego rómpela
  1. Calcula a mano el consumo en RAM de la foto de mayor resolución de tu propia fototeca. Compáralo con el peso del archivo.
  2. Carga esa foto con UIImage(contentsOfFile:) en una vista con .resizable().frame(width: 100, height: 100) y observa el pico de memoria en el gauge de Xcode.
  3. Sustitúyela por la función reducir de esta lección y repite la medición. Anota la diferencia.
  4. Cambia CGImageSourceCreateWithData por CreateWithURL y mide otra vez con un lote de veinte fotos.
  5. Mete el mismo código en una extensión de widget y observa qué ocurre. Explica por qué, con los límites de proceso en la mano.