Cómo almacenar mapas de bits en la memoria caché

Nota: En la mayoría de los casos, te recomendamos que uses la biblioteca de Glide para obtener, decodificar y mostrar mapas de bits en tu app. Glide facilita el manejo de estas y otras tareas relacionadas con el trabajo con mapas de bits y otras imágenes en Android. Para obtener información sobre el uso y la descarga de Glide, visita el repositorio de Glide en GitHub.

Es sencillo subir un solo mapa de bits en la interfaz de usuario (IU); sin embargo, es más complicado si necesitas subir más imágenes a la vez. En muchos casos (por ejemplo, con componentes como ListView, GridView o ViewPager), el número total de imágenes en pantalla combinado con imágenes que pronto podrían desplazarse a la pantalla es esencialmente ilimitado.

El uso de memoria se mantiene con componentes como este al reciclar las vistas secundarias a medida que se desplazan hacia afuera de la pantalla. La recolección de elementos no utilizados también libera los mapas de bits cargados y supone que no guardas ninguna referencia de larga duración. Todo esto está en orden, pero para mantener una interfaz de usuario fluida y de carga rápida, debes evitar el procesamiento continuo de estas imágenes cada vez que vuelven a la pantalla. Una memoria y una caché de disco a menudo pueden ayudar permitiendo que los componentes recarguen rápidamente las imágenes procesadas.

En esta lección, se muestra cómo usar una caché de mapa de bits de disco y una memoria para mejorar la capacidad de respuesta y la fluidez de la IU cuando se cargan varios mapas de bits.

Cómo usar una memoria caché

Una memoria caché ofrece acceso rápido a mapas de bits a costa de usar una valiosa memoria de la aplicación. La clase LruCache (también disponible en la biblioteca de compatibilidad para usar la API nivel 4) es adecuada para almacenar mapas de bits, mantener los objetos recientemente mencionados en un LinkedHashMap de referencia eficaz y quitar el miembro que menos se usó recientemente antes de que la caché exceda el tamaño designado.

Nota: En el pasado, una implementación de memoria caché popular era una memoria caché de mapa de bits SoftReference o WeakReference; sin embargo, esto no se recomienda. A partir de la versión Android 2.3 (API nivel 9), la recolección de elementos no utilizados es más intensa en la recolección referencias débiles, lo que la hace poco eficaz. Además, en las versiones anteriores a Android 3.0 (API nivel 11), los datos de copia de seguridad de un mapa de bits se almacenaban en la memoria nativa, que no se libera de manera predecible, lo cual puede causar que una aplicación exceda brevemente sus límites de memoria y se bloquee.

Con el fin de elegir un tamaño adecuado para un LruCache, se deben tener en cuenta varios factores, por ejemplo:

  • ¿Cuán intensivo es el uso de memoria por parte de la actividad o la aplicación?
  • ¿Cuántas imágenes se mostrarán en pantalla a la vez? ¿Cuántas deben estar disponibles para mostrarse en la pantalla?
  • ¿Cuál es el tamaño de la pantalla y la densidad del dispositivo? Un dispositivo de pantalla extra de alta densidad (xhdpi), como Galaxy Nexus, necesitará una memoria caché más grande para contener la misma cantidad de imágenes en la memoria, en comparación con un dispositivo como Nexus S (hdpi).
  • ¿Qué dimensiones y configuración tienen los mapas de bits y, por lo tanto, cuánta memoria ocupará cada uno?
  • ¿Con qué frecuencia se accederá a las imágenes? ¿Se accederá a algunas con más frecuencia que a otras? Si es así, tal vez quieras mantener ciertos elementos siempre en la memoria o incluso tener varios objetos LruCache para diferentes grupos de mapas de bits.
  • ¿Puedes equilibrar calidad con cantidad? A veces, puede ser más útil almacenar una mayor cantidad de mapas de bits de menor calidad, y cargar una versión de mayor calidad en otra tarea en segundo plano.

No hay un tamaño o una fórmula específicos que se adapten a todas las aplicaciones; debes analizar el uso y encontrar una solución adecuada. Una memoria caché demasiado pequeña causa una sobrecarga adicional sin beneficio, mientras que una memoria caché demasiado grande puede causar nuevamente excepciones java.lang.OutOfMemory y dejar al resto de la app con poca memoria para trabajar.

Aquí se muestra cómo configurar un LruCache para mapas de bits:

Kotlin

    private lateinit var memoryCache: LruCache<String, Bitmap>

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        // Get max available VM memory, exceeding this amount will throw an
        // OutOfMemory exception. Stored in kilobytes as LruCache takes an
        // int in its constructor.
        val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()

        // Use 1/8th of the available memory for this memory cache.
        val cacheSize = maxMemory / 8

        memoryCache = object : LruCache<String, Bitmap>(cacheSize) {

            override fun sizeOf(key: String, bitmap: Bitmap): Int {
                // The cache size will be measured in kilobytes rather than
                // number of items.
                return bitmap.byteCount / 1024
            }
        }
        ...
    }
    

Java

    private LruCache<String, Bitmap> memoryCache;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        // Get max available VM memory, exceeding this amount will throw an
        // OutOfMemory exception. Stored in kilobytes as LruCache takes an
        // int in its constructor.
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

        // Use 1/8th of the available memory for this memory cache.
        final int cacheSize = maxMemory / 8;

        memoryCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap bitmap) {
                // The cache size will be measured in kilobytes rather than
                // number of items.
                return bitmap.getByteCount() / 1024;
            }
        };
        ...
    }

    public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
        if (getBitmapFromMemCache(key) == null) {
            memoryCache.put(key, bitmap);
        }
    }

    public Bitmap getBitmapFromMemCache(String key) {
        return memoryCache.get(key);
    }
    

Nota: En este ejemplo, una octava parte de la memoria de la aplicación se asigna a nuestra memoria caché. En un dispositivo normal/hdpi, esto es un mínimo de aproximadamente 4 MB (32/8). Un GridView de pantalla completa con imágenes en un dispositivo con una resolución de 800 x 480 usaría aproximadamente 1.5 MB (800*480*4 bytes), por lo que almacenaría en la memoria caché un mínimo de 2.5 páginas.

Al cargar un mapa de bits en un ImageView, se comprueba primero LruCache. Si se encuentra una entrada, se usa de inmediato para actualizar ImageView; de lo contrario, se genera un subproceso en segundo plano con el fin de procesar la imagen:

Kotlin

    fun loadBitmap(resId: Int, imageView: ImageView) {
        val imageKey: String = resId.toString()

        val bitmap: Bitmap? = getBitmapFromMemCache(imageKey)?.also {
            mImageView.setImageBitmap(it)
        } ?: run {
            mImageView.setImageResource(R.drawable.image_placeholder)
            val task = BitmapWorkerTask()
            task.execute(resId)
            null
        }
    }
    

Java

    public void loadBitmap(int resId, ImageView imageView) {
        final String imageKey = String.valueOf(resId);

        final Bitmap bitmap = getBitmapFromMemCache(imageKey);
        if (bitmap != null) {
            mImageView.setImageBitmap(bitmap);
        } else {
            mImageView.setImageResource(R.drawable.image_placeholder);
            BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
            task.execute(resId);
        }
    }
    

También es necesario actualizar BitmapWorkerTask para agregar entradas a la memoria caché:

Kotlin

    private inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
        ...
        // Decode image in background.
        override fun doInBackground(vararg params: Int?): Bitmap? {
            return params[0]?.let { imageId ->
                decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
                    addBitmapToMemoryCache(imageId.toString(), bitmap)
                }
            }
        }
        ...
    }
    

Java

    class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
        ...
        // Decode image in background.
        @Override
        protected Bitmap doInBackground(Integer... params) {
            final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources(), params[0], 100, 100));
            addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
            return bitmap;
        }
        ...
    }
    

Cómo usar una caché de disco

La caché de memoria es útil para acelerar el acceso a mapas de bits vistos recientemente; sin embargo, no puedes confiar en que las imágenes estarán disponibles en la caché. Los componentes como GridView, con conjuntos de datos más grandes, pueden llenar fácilmente una memoria caché. Otra tarea podría interrumpir la aplicación, como una llamada telefónica, y es posible que se detenga la aplicación mientras está en segundo plano y que se destruya la memoria caché. Una vez que el usuario reanude la aplicación, ésta deberá volver a procesar cada imagen.

En estos casos, se puede usar una caché de disco para conservar los mapas de bits procesados y disminuir los tiempos de carga en los que las imágenes ya no están disponibles en la memoria caché. Por supuesto, recuperar imágenes del disco es más lento que cargarlas desde la memoria y se debe hacer en un subproceso en segundo plano, ya que los tiempos de lectura del disco pueden ser impredecibles.

Nota: Es posible que ContentProvider sea el lugar más apropiado para almacenar imágenes en caché si se accede a ellas con mayor frecuencia, por ejemplo, en una aplicación de galería de imágenes.

El código de muestra de esta clase utiliza una implementación de DiskLruCache que se extrae de la fuente de Android. Este es un código de ejemplo actualizado que agrega un caché de disco, además de la caché de memoria existente:

Kotlin

    private const val DISK_CACHE_SIZE = 1024 * 1024 * 10 // 10MB
    private const val DISK_CACHE_SUBDIR = "thumbnails"
    ...
    private var diskLruCache: DiskLruCache? = null
    private val diskCacheLock = ReentrantLock()
    private val diskCacheLockCondition: Condition = diskCacheLock.newCondition()
    private var diskCacheStarting = true

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        // Initialize memory cache
        ...
        // Initialize disk cache on background thread
        val cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR)
        InitDiskCacheTask().execute(cacheDir)
        ...
    }

    internal inner class InitDiskCacheTask : AsyncTask<File, Void, Void>() {
        override fun doInBackground(vararg params: File): Void? {
            diskCacheLock.withLock {
                val cacheDir = params[0]
                diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE)
                diskCacheStarting = false // Finished initialization
                diskCacheLockCondition.signalAll() // Wake any waiting threads
            }
            return null
        }
    }

    internal inner class  BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
        ...

        // Decode image in background.
        override fun doInBackground(vararg params: Int?): Bitmap? {
            val imageKey = params[0].toString()

            // Check disk cache in background thread
            return getBitmapFromDiskCache(imageKey) ?:
                    // Not found in disk cache
                    decodeSampledBitmapFromResource(resources, params[0], 100, 100)
                            ?.also {
                                // Add final bitmap to caches
                                addBitmapToCache(imageKey, it)
                            }
        }
    }

    fun addBitmapToCache(key: String, bitmap: Bitmap) {
        // Add to memory cache as before
        if (getBitmapFromMemCache(key) == null) {
            memoryCache.put(key, bitmap)
        }

        // Also add to disk cache
        synchronized(diskCacheLock) {
            diskLruCache?.apply {
                if (!containsKey(key)) {
                    put(key, bitmap)
                }
            }
        }
    }

    fun getBitmapFromDiskCache(key: String): Bitmap? =
            diskCacheLock.withLock {
                // Wait while disk cache is started from background thread
                while (diskCacheStarting) {
                    try {
                        diskCacheLockCondition.await()
                    } catch (e: InterruptedException) {
                    }

                }
                return diskLruCache?.get(key)
            }

    // Creates a unique subdirectory of the designated app cache directory. Tries to use external
    // but if not mounted, falls back on internal storage.
    fun getDiskCacheDir(context: Context, uniqueName: String): File {
        // Check if media is mounted or storage is built-in, if so, try and use external cache dir
        // otherwise use internal cache dir
        val cachePath =
                if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
                        || !isExternalStorageRemovable()) {
                    context.externalCacheDir.path
                } else {
                    context.cacheDir.path
                }

        return File(cachePath + File.separator + uniqueName)
    }
    

Java

    private DiskLruCache diskLruCache;
    private final Object diskCacheLock = new Object();
    private boolean diskCacheStarting = true;
    private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
    private static final String DISK_CACHE_SUBDIR = "thumbnails";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        // Initialize memory cache
        ...
        // Initialize disk cache on background thread
        File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
        new InitDiskCacheTask().execute(cacheDir);
        ...
    }

    class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
        @Override
        protected Void doInBackground(File... params) {
            synchronized (diskCacheLock) {
                File cacheDir = params[0];
                diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
                diskCacheStarting = false; // Finished initialization
                diskCacheLock.notifyAll(); // Wake any waiting threads
            }
            return null;
        }
    }

    class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
        ...
        // Decode image in background.
        @Override
        protected Bitmap doInBackground(Integer... params) {
            final String imageKey = String.valueOf(params[0]);

            // Check disk cache in background thread
            Bitmap bitmap = getBitmapFromDiskCache(imageKey);

            if (bitmap == null) { // Not found in disk cache
                // Process as normal
                final Bitmap bitmap = decodeSampledBitmapFromResource(
                        getResources(), params[0], 100, 100));
            }

            // Add final bitmap to caches
            addBitmapToCache(imageKey, bitmap);

            return bitmap;
        }
        ...
    }

    public void addBitmapToCache(String key, Bitmap bitmap) {
        // Add to memory cache as before
        if (getBitmapFromMemCache(key) == null) {
            memoryCache.put(key, bitmap);
        }

        // Also add to disk cache
        synchronized (diskCacheLock) {
            if (diskLruCache != null && diskLruCache.get(key) == null) {
                diskLruCache.put(key, bitmap);
            }
        }
    }

    public Bitmap getBitmapFromDiskCache(String key) {
        synchronized (diskCacheLock) {
            // Wait while disk cache is started from background thread
            while (diskCacheStarting) {
                try {
                    diskCacheLock.wait();
                } catch (InterruptedException e) {}
            }
            if (diskLruCache != null) {
                return diskLruCache.get(key);
            }
        }
        return null;
    }

    // Creates a unique subdirectory of the designated app cache directory. Tries to use external
    // but if not mounted, falls back on internal storage.
    public static File getDiskCacheDir(Context context, String uniqueName) {
        // Check if media is mounted or storage is built-in, if so, try and use external cache dir
        // otherwise use internal cache dir
        final String cachePath =
                Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                        !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                                context.getCacheDir().getPath();

        return new File(cachePath + File.separator + uniqueName);
    }
    

Nota: Incluso la inicialización de la memoria caché de disco requiere operaciones de disco y, por lo tanto, no debe realizarse en el subproceso principal. Sin embargo, esto significa que existe la posibilidad de acceder a la caché antes de la inicialización. Para solucionar esto, en la implementación anterior, un objeto de bloqueo garantiza que la app no leerá desde la caché del disco hasta que la caché se haya inicializado.

Mientras se comprueba la memoria caché en el subproceso de la IU, la memoria caché del disco se comprueba en el subproceso en segundo plano. Las operaciones de disco nunca tienen lugar en el subproceso de la interfaz del usuario. Cuando se completa el procesamiento de la imagen, el mapa de bits final se agrega a la memoria y a la caché del disco para uso futuro.

Cómo controlar los cambios de configuración

Los cambios en la configuración del tiempo de ejecución, como un cambio en la orientación de la pantalla, hacen que Android destruya y reinicie la actividad en ejecución con la nueva configuración (para obtener más información sobre este comportamiento, consulta Cómo controlar los cambios en el tiempo de ejecución). Debes evitar tener que volver a procesar todas las imágenes para que el usuario tenga una experiencia fluida y rápida cuando se produzca un cambio en la configuración.

Afortunadamente, tienes una buena memoria caché de mapas de bits que creaste en la sección Cómo usar una memoria caché. Esta caché se puede pasar a la nueva instancia de la actividad utilizando un Fragment que se preserva llamando a setRetainInstance(true). Después de recrear la actividad, se vuelve a conectar este Fragment retenido y obtienes acceso al objeto de caché existente, lo que permite que las imágenes se recuperen rápidamente y se vuelvan a propagar en los objetos ImageView.

Este es un ejemplo de retención de un objeto LruCache a través de cambios de configuración usando un Fragment:

Kotlin

    private const val TAG = "RetainFragment"
    ...
    private lateinit var mMemoryCache: LruCache<String, Bitmap>

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val retainFragment = RetainFragment.findOrCreateRetainFragment(supportFragmentManager)
        mMemoryCache = retainFragment.retainedCache ?: run {
            LruCache<String, Bitmap>(cacheSize).also { memoryCache ->
                ... // Initialize cache here as usual
                retainFragment.retainedCache = memoryCache
            }
        }
        ...
    }

    class RetainFragment : Fragment() {
        var retainedCache: LruCache<String, Bitmap>? = null

        companion object {
            fun findOrCreateRetainFragment(fm: FragmentManager): RetainFragment {
                return (fm.findFragmentByTag(TAG) as? RetainFragment) ?: run {
                    RetainFragment().also {
                        fm.beginTransaction().add(it, TAG).commit()
                    }
                }
            }
        }

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            retainInstance = true
        }
    }

    

Java

    private LruCache<String, Bitmap> memoryCache;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        RetainFragment retainFragment =
                RetainFragment.findOrCreateRetainFragment(getFragmentManager());
        memoryCache = retainFragment.retainedCache;
        if (memoryCache == null) {
            memoryCache = new LruCache<String, Bitmap>(cacheSize) {
                ... // Initialize cache here as usual
            }
            retainFragment.retainedCache = memoryCache;
        }
        ...
    }

    class RetainFragment extends Fragment {
        private static final String TAG = "RetainFragment";
        public LruCache<String, Bitmap> retainedCache;

        public RetainFragment() {}

        public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) {
            RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG);
            if (fragment == null) {
                fragment = new RetainFragment();
                fm.beginTransaction().add(fragment, TAG).commit();
            }
            return fragment;
        }

        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setRetainInstance(true);
        }
    }
    

Para probar esto, intenta girar un dispositivo con y sin retener el Fragment. Deberás notar que no hay retraso o que hay muy poco, pues, cuando retienes la caché, las imágenes propagan la actividad casi instantáneamente desde la memoria. Cualquier imagen que no se encuentre en la memoria caché estará disponible en la memoria caché de disco; de lo contrario, se procesará normalmente.