Cómo almacenar mapas de bits en caché

Nota: En la mayoría de los casos, te recomendamos que uses la biblioteca de Glide para recuperar, 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 cómo descargar y usar 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), la cantidad total de imágenes en pantalla combinada con imágenes que podrían desplazarse con rapidez a la pantalla es esencialmente ilimitada.

El uso de memoria se mantiene con componentes como este, con el reciclaje de 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 IU 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 hasta el nivel de API 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, esta no es una práctica recomendada. A partir de Android 2.3 (nivel de API 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 (nivel de API 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 que puede causar que una aplicación exceda brevemente sus límites de memoria y falle.

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

  • ¿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 ni 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 tu app con poca memoria.

Este es un ejemplo de 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.

Cuando se carga un mapa de bits en un ImageView, se verifica primero el 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.

En el siguiente ejemplo, se retiene un objeto LruCache en todos los cambios de configuración mediante 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, gira un dispositivo reteniendo el Fragment y sin retenerlo. 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.