Como armazenar bitmaps em cache

Observação: na maioria dos casos, recomendamos que você use a biblioteca Glide para buscar, decodificar e exibir bitmaps no seu app. A Glide abstrai a maior parte da complexidade para processar essas e outras tarefas relacionadas ao trabalho com bitmaps e outras imagens no Android. Para saber mais sobre como usar e fazer o download do Glide, visite o repositório Glide no GitHub.

Carregar um único bitmap na sua interface do usuário (IU) é simples. No entanto, as coisas ficam mais complicadas se você precisa carregar um conjunto maior de imagens de uma só vez. Em muitos casos (como em componentes como ListView, GridView ou ViewPager), o número total de imagens na tela, combinado com imagens que podem em breve rolar para a tela, é essencialmente ilimitado.

Com componentes como esse, o uso de memória é mantido baixo por meio de reciclagem de exibições filhas à medida que saem da tela. O coletor de lixo também libera os bitmaps carregados e supõe que você não tenha mantido referências de longa duração. Tudo isso é muito bom, mas, para manter um carregamento de IU fluido e rápido, é melhor evitar o processamento contínuo dessas imagens toda vez que voltam à tela. Os caches de disco e memória podem ajudar com isso, permitindo que componentes recarreguem rapidamente as imagens processadas.

Esta lição mostra o uso de um cache de bitmap de memória e de disco para melhorar a capacidade de resposta e a fluidez da IU ao carregar vários bitmaps.

Usar um cache de memória

Um cache de memória oferece acesso rápido a bitmaps, mas ocupa um espaço valioso na memória do app. A classe LruCache (também disponível no Biblioteca de Suporte para uso na API de nível 4) é particularmente adequada para a tarefa de armazenamento em cache de bitmaps, mantendo os objetos referenciados recentemente em uma referência LinkedHashMap e removendo o membro usado menos recentemente antes que o cache exceda o tamanho designado.

Observação: no passado, uma implementação de cache de memória conhecida era um cache de bitmap SoftReference ou WeakReference. No entanto, isso não é recomendado. A partir do Android 2.3 (API de nível 9), o coletor de lixo é mais agressivo na coleta de referências suaves/fracas, o que as torna bem ineficazes. Além disso, antes do Android 3.0 (API de nível 11), os dados de backup de um bitmap eram armazenados na memória nativa, que não é liberada de forma previsível e pode fazer com que um app exceda temporariamente os limites da memória e falhe.

Para escolher um tamanho adequado para um LruCache, é necessário considerar vários fatores, por exemplo:

  • Qual é a intensidade do resto da sua atividade e/ou do seu app em relação à memória?
  • Quantas imagens aparecerão na tela ao mesmo tempo? Quantas precisarão estar disponíveis e prontas para serem exibidas?
  • Qual é o tamanho e a densidade da tela do dispositivo? Um dispositivo com tela de densidade extra-alta (xhdpi), como o Galaxy Nexus, precisará de um cache maior para armazenar o mesmo número de imagens que um dispositivo como o Nexus S (hdpi).
  • Quais são as dimensões e configurações dos bitmaps e, portanto, quanta memória cada um ocupa?
  • Com que frequência as imagens serão acessadas? Algumas serão acessadas com mais frequência do que outras? Nesse caso, talvez você queira manter determinados itens sempre na memória ou até ter vários objetos LruCache para diferentes grupos de bitmaps.
  • Você pode equilibrar a qualidade e a quantidade? Às vezes, pode ser mais útil armazenar um número maior de bitmaps de baixa qualidade, carregando uma versão de qualidade superior em outra tarefa em segundo plano.

Não existe tamanho ou fórmula que atenda a todos os apps. Cabe a você analisar o uso e encontrar uma solução adequada. Um cache muito pequeno causa sobrecarga adicional sem nenhum benefício; um cache muito grande pode mais uma vez causar exceções java.lang.OutOfMemory e deixar pouca memória para o restante do seu app funcionar.

Veja um exemplo de como configurar um LruCache para bitmaps:

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);
}

Observação: nesse exemplo, um oitavo da memória do app está alocado para nosso cache. Em um dispositivo normal/hdpi, esse é um mínimo de cerca de 4 MB (32/8). Uma tela inteira GridView preenchida com imagens em um dispositivo com resolução 800x480 usaria cerca de 1,5 MB (800*480*4 bytes), armazenando em cache um mínimo de cerca de 2,5 páginas de imagens na memória.

Ao carregar um bitmap em ImageView, o LruCache é verificado primeiro. Se uma entrada for encontrada, ela será usada imediatamente para atualizar o ImageView. Caso contrário, uma linha de execução em segundo plano será gerada para processar a imagem:

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);
    }
}

O BitmapWorkerTask também precisa ser atualizado para adicionar entradas ao cache de memória:

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;
    }
    ...
}

Usar um cache de disco

Um cache de memória é útil para acelerar o acesso a bitmaps Acesso recente. No entanto, não espere que as imagens estejam disponíveis nesse cache. Componentes como GridView com conjuntos de dados maiores podem facilmente preencher um cache de memória. Seu app pode ser interrompido por outra tarefa, como uma ligação telefônica, e, no segundo plano, ele pode ser interrompido e o cache de memória pode ser destruído. Quando for retomado, seu app precisará processar cada imagem novamente.

Um cache de disco pode ser usado nesses casos para manter os bitmaps processados e ajudar a diminuir tempos de carregamento em que imagens não estão mais disponíveis em um cache de memória. Obviamente, buscar imagens no disco leva mais tempo do que carregá-las da memória e isso só deve ser feito em uma linha de execução em segundo plano, já que os tempos de leitura do disco podem ser imprevisíveis.

Observação: um ContentProvider pode ser um local mais apropriado para armazenar imagens em cache se elas forem acessadas com mais frequência, por exemplo, em um app de galeria de imagens.

O código de exemplo desta classe usa uma implementação de DiskLruCache extraída da origem do Android. Veja um código de exemplo atualizado que adiciona um cache de disco ao cache de memória 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);
}

Observação: até mesmo a inicialização do cache de disco precisa de operações de disco e, portanto, não deve ocorrer na linha de execução principal. No entanto, isso significa que há uma chance de o cache ser acessado antes da inicialização. Para resolver isso, um objeto de bloqueio na implementação acima garante que o app não seja lido do cache de disco até que o cache tenha sido inicializado.

Enquanto o cache de memória é verificado na linha de execução de interface, o cache em disco é verificado na linha de execução em segundo plano. Operações de disco nunca devem ocorrer na linha de execução de interface. Quando o processamento de imagem for concluído, o bitmap final será adicionado aos caches de disco e memória para uso posterior.

Processar mudanças de configuração

Alterações na configuração no momento da execução, como uma mudança na orientação da tela, fazem com que o Android destrua e reinicie a atividade com a nova configuração. Para saber mais sobre esse comportamento, consulte Como gerenciar alterações no momento da execução (link em inglês). Evitar o processamento de todas as suas imagens de novo garante uma experiência simples e rápida para o usuário durante uma mudança na configuração.

Felizmente, você tem um bom cache de memória de bitmaps criado na seção Usar um cache de memória. Esse cache pode ser transmitido para a nova instância de atividade usando um Fragment, que é preservado chamando setRetainInstance(true). Depois que a atividade for recriada, o Fragment retido será reanexado e você terá acesso ao objeto de cache já existente, permitindo que as imagens sejam rapidamente buscadas e preenchidas novamente em objetos ImageView.

Veja um exemplo de como reter um objeto LruCache nas mudanças de configuração usando um 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 testar isso, tente girar um dispositivo com e sem Fragment. Quando retiver o cache, você observará pouco ou nenhum atraso no preenchimento quase instantâneo das imagens da memória. Espera-se que as imagens que não forem encontradas no cache de memória estejam disponíveis no cache de disco. Se não estiverem, elas serão processadas como de costume.