¿Cómo gestionan librerías de carga de imágenes como Glide o Coil el caché en memoria frente al caché en disco, y cómo configurarías una para una app con un feed pesado en imágenes?
Glide y Coil combinan un LRU acotado en memoria, un caché en disco y un downsampling agresivo — si te equivocas en cualquiera de los tres, un feed de scroll infinito termina en un OutOfMemoryError.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo gestionan librerías de carga de imágenes como Glide o Coil el caché en memoria frente al caché en disco, y cómo configurarías una para una app con un feed pesado en imágenes?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dos cachés, dos trabajos distintos
Conviene empezar por explicar por qué existen dos cachés. Un bitmap ya decodificado y listo para dibujarse en pantalla es enorme comparado con los bytes comprimidos de los que proviene: un JPEG de 1080×1080 que pesa 150 KB en disco se decodifica en un bitmap ARGB_8888 de aproximadamente 1080 × 1080 × 4 bytes, unos 4.6 MB, viviendo en RAM. Una pantalla de feed que muestra una docena de imágenes visibles más las del buffer ya representa decenas de megabytes de píxeles decodificados, y el heap por app de Android se mide, en el mejor de los casos, en cientos de megabytes. Ese único hecho explica por qué tanto Glide como Coil dividen el cacheo en dos niveles con compromisos muy distintos: un caché en memoria pequeño, rápido y volátil que guarda bitmaps listos para dibujar, y un caché en disco más grande, más lento y persistente que guarda algo más cercano a los bytes originales.
#El caché en memoria: acotado, rápido, el primero en desaparecer
El caché en memoria es un LRU (least-recently-used) indexado por la firma de la solicitud — URL más cualquier transformación, tamaño objetivo y formato — que mapea a un Bitmap ya decodificado. Ambas librerías lo dimensionan como una fracción de la memoria disponible de la app en lugar de un número fijo: el MemorySizeCalculator de Glide consulta ActivityManager.getMemoryClass() y la resolución de pantalla para elegir un valor por defecto razonable (aproximadamente entre 1/8 y 1/4 de la memoria disponible, configurable), y el MemoryCache.Builder de Coil expone maxSizePercent() para la misma idea. Al ser un LRU, hacer scroll continuo en un feed va desalojando las imágenes que quedaron más lejos fuera de pantalla para hacer espacio a las nuevas — eso es esperado y correcto, es un caché, no almacenamiento. Lo que vale la pena recordar en una entrevista: este caché se vacía por completo cuando el proceso muere, y ambas librerías se enganchan a ComponentCallbacks2.onTrimMemory() para reducirlo o vaciarlo proactivamente bajo presión de memoria del sistema (app en segundo plano, TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_COMPLETE), que es exactamente el comportamiento correcto — mejor volver a decodificar desde disco que ser la app que el low-memory killer elige primero.
Glide agrega una segunda estructura en memoria que casi todos olvidan: el BitmapPool. En lugar de cachear solo bitmaps terminados, Glide mantiene un pool de arreglos de bytes de Bitmap "muertos" que puede devolverle a BitmapFactory vía inBitmap, permitiendo que el decodificador reutilice la misma memoria subyacente para una decodificación de mismo tamaño y misma configuración en lugar de asignar memoria nueva y generar basura. Solo ayuda cuando las dimensiones y la configuración coinciden casi exactamente, por lo que un feed en grilla que mezcla relaciones de aspecto muy distintas se beneficia mucho menos del pooling que un feed de miniaturas uniformes.
#El caché en disco: lo que realmente se escribe importa
El caché en disco es donde más divergen las dos librerías, y es una pregunta de seguimiento clásica. Glide expone DiskCacheStrategy por solicitud: DATA cachea los bytes originales sin transformar (barato de escribir, pero cada carga tiene que rehacer centerCrop/redimensionado/decodificación); RESOURCE cachea el bitmap final ya transformado, re-codificado a disco (más espacio en disco por imagen, pero un acierto de caché se salta por completo la transformación — la opción por defecto para imágenes remotas con transformaciones costosas); ALL cachea ambos; AUTOMATIC, el valor por defecto, elige RESOURCE para datos remotos y DATA para datos locales, bajo la teoría de que volver a leer un archivo local es casi gratis pero volver a descargar uno remoto no lo es. Coil toma un enfoque más simple, de un solo nivel: su DiskCache guarda los bytes crudos obtenidos (lo que llegó de la red o del Fetcher), nunca los píxeles post-transformación — un acierto en el caché de disco igual vuelve a aplicar tus Transformation y a decodificar al tamaño objetivo cada vez, cambiando algo de CPU por una historia de invalidación de caché mucho más simple.
#El downsampling es la palanca que realmente evita el OOM
Ningún nivel de caché importa si decodificas a resolución completa. Ambas librerías usan BitmapFactory.Options.inSampleSize (o el equivalente moderno con ImageDecoder) para decodificar directamente a un tamaño cercano al que realmente se va a dibujar, y por eso darles un tamaño objetivo explícito es lo de mayor impacto que puedes hacer en un feed: el override(width, height) de Glide, el size() de Coil (o su ViewSizeResolver por defecto, que espera a que el ImageView objetivo termine su layout y lee sus dimensiones medidas). Si te saltas esto — por ejemplo, cargando en un ImageView con wrap_content y sin tamaño explícito — corres el riesgo de decodificar una foto de cámara de 4000×3000 en memoria solo para achicarla a una miniatura de 300 dp, que es la causa clásica de OOM en pantallas de feed.
#Configurándolo para un feed
Un feed es el peor de los casos para el cacheo de imágenes: un flujo de scroll prácticamente ilimitado de imágenes distintas, la mayoría vistas una sola vez y nunca revisitadas en la misma sesión. Eso cambia los valores por defecto que elegirías en una pantalla más pequeña. Dimensiona el caché en disco de forma generosa — el disco es barato, y un caché en disco "caliente" significa que un usuario que vuelve a abrir la app no vuelve a descargar el feed de la sesión anterior — pero mantén el caché en memoria modesto, ya que se comparte con todo lo demás que el proceso está haciendo, y las imágenes de un feed son inherentemente más desechables que, digamos, un avatar de perfil que se muestra en cada pantalla. Fija tu tamaño de decodificación objetivo a las dimensiones exactas en píxeles de la celda de la grilla (no al máximo al que el layout podría crecer) para que toda solicitud de la misma celda converja en el mismo bucket del bitmap pool. Precarga una pantalla o dos por delante del viewport visible usando RecyclerViewPreloader (Glide) para que la decodificación ocurra durante frames de scroll ociosos en lugar de generar jank justo cuando la imagen se hace visible; Coil logra el mismo efecto precargando URLs con ImageLoader.enqueue() por delante de la posición de scroll. Y sáltate el caché en memoria para cualquier imagen que se muestre exactamente una vez y sea poco probable que se revisite (una imagen a pantalla completa abierta desde el detalle) con skipMemoryCache(true) — de lo contrario, un puñado de vistas grandes y de uso único puede desalojar las miniaturas pequeñas a las que un usuario probablemente vaya a volver.
// Glide: un único módulo a nivel de Application que configura ambos niveles de caché.@GlideModuleclass FeedGlideModule : AppGlideModule() {override fun applyOptions(context: Context, builder: GlideBuilder) {val calculator = MemorySizeCalculator.Builder(context).setMemoryCacheScreens(2f) // ~2 pantallas de bitmaps decodificados en RAM.build()builder.setMemoryCache(LruResourceCache(calculator.memoryCacheSize.toLong())).setBitmapPool(LruBitmapPool(calculator.bitmapPoolSize.toLong())).setDiskCache(InternalCacheDiskCacheFactory(context, "feed_image_cache", 250L * 1024 * 1024)).setDefaultRequestOptions(RequestOptions().diskCacheStrategy(DiskCacheStrategy.AUTOMATIC))}override fun isManifestParsingEnabled(): Boolean = false}// Solicitud por ítem dentro del onBindViewHolder del adapter del feed.Glide.with(imageView).load(post.thumbnailUrl).override(feedCellWidthPx, feedCellHeightPx) // fuerza el downsampling al tamaño exacto de la celda.centerCrop().placeholder(R.drawable.placeholder_shimmer).into(imageView)// Precarga una o dos pantallas por delante mientras el usuario sigue haciendo scroll.val preloader = RecyclerViewPreloader(Glide.with(this),feedPreloadModelProvider,ViewPreloadSizeProvider(),/* maxPreload = */ 12,)recyclerView.addOnScrollListener(preloader)
// Coil: un único ImageLoader compartido en toda la app, dimensionado explícitamente para un feed.val feedImageLoader = ImageLoader.Builder(context).memoryCache {MemoryCache.Builder(context).maxSizePercent(0.20) // 20% de la memoria disponible de la app.build()}.diskCache {DiskCache.Builder().directory(context.cacheDir.resolve("coil_feed_cache")).maxSizeBytes(250L * 1024 * 1024).build()}.bitmapConfig(Bitmap.Config.HARDWARE) // bitmaps solo de visualización, casi no tocan el heap de la JVM.build()Coil.setImageLoader(feedImageLoader)// Solicitud por ítem; size() fuerza el downsampling al tamaño exacto de la celda.imageView.load(post.thumbnailUrl) {size(feedCellWidthPx, feedCellHeightPx)scale(Scale.FILL)crossfade(150)placeholder(R.drawable.placeholder_shimmer)if (isOneShotDetailImage) memoryCachePolicy(CachePolicy.WRITE_ONLY)}
🧠 Repaso rápido de caché de imágenes
0/3 · 0/3 answered1. Un feed muestra una foto de 4000x3000 dentro de una celda de grilla de 300dp, cargada sin llamar a override()/size(). ¿Cuál es la consecuencia más probable?
2. ¿Por qué DiskCacheStrategy.RESOURCE de Glide podría rendir mejor que DATA para una imagen remota de feed con una transformación pesada como un recorte de esquinas redondeadas?
3. ¿Por qué Coil reaplica las transformaciones en cada carga incluso cuando hay acierto en el caché de disco?