Memorizzazione nella cache delle bitmap

Nota: nella maggior parte dei casi, consigliamo di utilizzare la libreria Glide per recuperare, decodificare e visualizzare le bitmap nella tua app. Scorrimento elimina gran parte della complessità della gestione di queste e altre attività correlate all'utilizzo di bitmap e altre immagini su Android. Per informazioni sull'utilizzo e sul download di Glide, visita il repository Glide su GitHub.

Il caricamento di una singola bitmap nell'interfaccia utente (UI) è semplice, ma le operazioni si complicano se devi caricare contemporaneamente un insieme più grande di immagini. In molti casi (ad esempio con componenti come ListView, GridView o ViewPager), il numero totale di immagini sullo schermo combinato con immagini che presto potrebbero scorrere sullo schermo è praticamente illimitato.

L'utilizzo della memoria viene ridotto con componenti come questo riciclando le viste secondarie mentre si spostano fuori dallo schermo. Il garbage collector libera anche le bitmap caricate, supponendo che tu non conservi riferimenti di lunga durata. Non c'è niente di meglio, ma per mantenere un'interfaccia utente fluida e con caricamento rapido, è consigliabile evitare di elaborare continuamente queste immagini ogni volta che queste immagini vengono visualizzate di nuovo sullo schermo. In questo caso, spesso la cache di memoria e di disco può essere utile, consentendo ai componenti di ricaricare rapidamente le immagini elaborate.

Questa lezione illustra l'utilizzo di una cache bitmap di memoria e disco per migliorare la reattività e la fluidità della UI durante il caricamento di più bitmap.

Usa una cache di memoria

Una cache di memoria offre un accesso rapido ai bitmap, consumando memoria preziosa dell'applicazione. La classe LruCache (disponibile anche nella Support Library per tornare al livello API 4) è particolarmente adatta per memorizzare le bitmap nella cache, mantenere gli oggetti a cui si fa riferimento di recente in un LinkedHashMap di riferimento efficace ed eliminare il membro meno utilizzato di recente prima che la cache superi le dimensioni indicate.

Nota: in passato, un'implementazione molto utilizzata della cache di memoria era la cache bitmap SoftReference o WeakReference, tuttavia questa operazione non è consigliata. A partire da Android 2.3 (livello API 9), il garbage collector è più aggressivo nella raccolta di riferimenti flessibili/deboli, il che li rende abbastanza inefficaci. Inoltre, prima di Android 3.0 (livello API 11), i dati di supporto di una bitmap venivano archiviati in una memoria nativa che non viene rilasciata in modo prevedibile, causando potenzialmente il superamento breve dei limiti di memoria da parte di un'applicazione e un arresto anomalo.

Per scegliere una dimensione adatta per un LruCache, è necessario prendere in considerazione una serie di fattori, ad esempio:

  • Quanto è in uso la memoria rimanente della tua attività e/o applicazione?
  • Quante immagini saranno contemporaneamente sullo schermo? Quante devono essere disponibili per apparire sullo schermo?
  • Quali sono le dimensioni e la densità dello schermo del dispositivo? Un dispositivo con schermo ad altissima densità (xhdpi) come Galaxy Nexus necessita di una cache più grande per contenere lo stesso numero di immagini in memoria rispetto a un dispositivo come Nexus S (hdpi).
  • Quali dimensioni e configurazione sono le bitmap e, di conseguenza, quanta memoria verrà utilizzata?
  • Con quale frequenza verrà eseguito l'accesso alle immagini? Ad alcuni utenti si accede più spesso di altri? Se sì, potresti voler mantenere sempre determinati elementi in memoria o persino avere più oggetti LruCache per diversi gruppi di bitmap.
  • Riesci a trovare il giusto equilibrio tra qualità e quantità? A volte può essere più utile archiviare un numero maggiore di bitmap di qualità inferiore, caricando potenzialmente una versione di qualità superiore in un'altra attività in background.

Non esiste una dimensione o una formula specifica adatta a tutte le applicazioni, spetta a te analizzare l'utilizzo e trovare una soluzione adatta. Una cache troppo piccola causa un overhead aggiuntivo senza vantaggi; una cache troppo grande può causare nuovamente java.lang.OutOfMemory eccezioni e lasciare lavorare poca memoria rimanente dell'app.

Ecco un esempio di configurazione di un LruCache per le bitmap:

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: in questo esempio, un ottavo della memoria dell'applicazione è allocato per la nostra cache. Su un dispositivo normale/hdpi questo è un minimo di circa 4 MB (32/8). Un elemento GridView a schermo intero pieno di immagini su un dispositivo con risoluzione 800 x 480 richiede circa 1,5 MB (800 x 480 x 4 byte), pertanto in questo modo vengono memorizzate nella cache almeno 2,5 pagine di immagini.

Quando carichi una bitmap in un file ImageView, viene prima controllato LruCache. Se viene trovata una voce, questa viene utilizzata immediatamente per aggiornare ImageView, altrimenti viene creato un thread sullo sfondo per elaborare l'immagine:

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

Inoltre, BitmapWorkerTask deve essere aggiornato per aggiungere voci alla cache di memoria:

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

Usa cache su disco

La cache di memoria è utile per velocizzare l'accesso alle bitmap visualizzate di recente, tuttavia non puoi affidarti alle immagini disponibili in questa cache. I componenti come GridView con set di dati più grandi possono riempire facilmente la cache di memoria. L'applicazione potrebbe essere interrotta da un'altra attività come una telefonata e, mentre l'applicazione è in background, l'applicazione potrebbe essere interrotta e la cache di memoria distrutta. Quando l'utente riprende, l'applicazione deve elaborare di nuovo ogni immagine.

In questi casi può essere utilizzata una cache su disco per rendere persistenti le bitmap elaborate e contribuire a ridurre i tempi di caricamento in cui le immagini non sono più disponibili in una cache di memoria. Ovviamente, il recupero delle immagini dal disco è più lento del caricamento dalla memoria e deve essere eseguito in un thread in background, poiché i tempi di lettura del disco possono essere imprevedibili.

Nota: un ContentProvider potrebbe essere una posizione più appropriata per archiviare le immagini memorizzate nella cache se vi si accede più spesso, ad esempio in un'applicazione galleria di immagini.

Il codice campione di questa classe utilizza un'implementazione DiskLruCache estratta dall'origine Android. Di seguito è riportato un codice di esempio aggiornato che aggiunge una cache su disco oltre a quella di memoria esistente:

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: anche l'inizializzazione della cache del disco richiede operazioni sul disco e, pertanto, non dovrebbe avvenire nel thread principale. Tuttavia, esiste la possibilità di accedere alla cache prima dell'inizializzazione. Per risolvere il problema, nell'implementazione precedente, un oggetto di blocco garantisce che l'app non legga dalla cache su disco fino a quando la cache non viene inizializzata.

Mentre la cache della memoria viene controllata nel thread dell'interfaccia utente, la cache su disco viene controllata nel thread in background. Le operazioni del disco non devono mai avvenire nel thread dell'interfaccia utente. Quando l'elaborazione dell'immagine è completata, la bitmap finale viene aggiunta sia alla cache di memoria che alla cache del disco per poterla usare in futuro.

Gestire le modifiche alla configurazione

Le modifiche alla configurazione del runtime, ad esempio una modifica dell'orientamento dello schermo, causano l'eliminazione e il riavvio dell'attività in esecuzione con la nuova configurazione (per ulteriori informazioni su questo comportamento, consulta la sezione Gestione delle modifiche al runtime). Vuoi evitare di dover elaborare di nuovo tutte le immagini in modo che l'utente possa avere un'esperienza fluida e veloce quando si verifica una modifica alla configurazione.

Fortunatamente hai una bella cache di memoria di bitmap che hai creato nella sezione Usa cache di memoria. Questa cache può essere trasmessa alla nuova istanza dell'attività utilizzando un Fragment, che viene conservato chiamando setRetainInstance(true). Dopo aver ricreato l'attività, questo Fragment conservato viene ricollegato e puoi accedere all'oggetto della cache esistente, consentendo un rapido recupero delle immagini e un nuovo completamento negli oggetti ImageView.

Ecco un esempio di conservazione di un oggetto LruCache nelle modifiche alla configurazione utilizzando 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);
    }
}

Per fare un test, prova a ruotare un dispositivo con e senza tenere Fragment. Dovresti notare un ritardo minimo o nullo, poiché le immagini popolano l'attività quasi istantaneamente dalla memoria quando conservi la cache. Le eventuali immagini non trovate nella cache di memoria sono disponibili nella cache su disco. In caso contrario, vengono elaborate come di consueto.