Mise en cache des bitmaps

Remarque : Dans la plupart des cas, nous vous recommandons d'utiliser la bibliothèque Glide pour récupérer, décoder et afficher les bitmaps dans votre application. Glide enlève la complexité liée à la gestion de ces tâches et d'autres tâches liées à l'utilisation de bitmaps et d'autres images sur Android. Pour plus d'informations sur l'utilisation et le téléchargement de Glide, consultez le dépôt Glide sur GitHub.

Le chargement d'un seul bitmap dans votre interface utilisateur est simple à effectuer, mais il est plus compliqué de charger simultanément un plus grand nombre d'images. Dans de nombreux cas (comme avec des composants tels que ListView, GridView ou ViewPager), le nombre total d'images à l'écran associées à des images susceptibles de faire défiler l'écran est illimité.

L'utilisation de la mémoire est limitée à l'aide de composants de ce type en recyclant les vues enfants lorsqu'elles disparaissent de l'écran. Le récupérateur de mémoire libère également vos bitmaps chargés, en supposant que vous ne conservez aucune référence durable. C'est tout à fait normal, mais pour que l'interface utilisateur reste fluide et se charge rapidement, vous devez éviter de traiter ces images en continu chaque fois qu'elles s'affichent à l'écran. Le cache de la mémoire et le cache du disque aident souvent les composants à recharger rapidement les images traitées.

Cette leçon explique comment utiliser un cache de mémoire et de disque bitmap pour améliorer la réactivité et la fluidité de votre interface utilisateur lors du chargement de plusieurs bitmaps.

Utiliser un cache de mémoire

Un cache de mémoire offre un accès rapide aux bitmaps, au prix de consommer une quantité précieuse de mémoire d'application. La classe LruCache (également disponible dans la bibliothèque Support pour une utilisation avec le niveau d'API 4) est particulièrement bien adaptée à la tâche de mise en cache des bitmaps, en conservant les objets référencés récemment dans une LinkedHashMap fortement référencée et en expulsant le membre le moins récemment utilisé avant que le cache ne dépasse sa taille spécifiée.

Remarque : Auparavant, le cache bitmap SoftReference ou WeakReference était couramment utilisé en guise de cache de mémoire, mais cela n'est pas recommandé. À partir d'Android 2.3 (niveau d'API 9), le récupérateur de mémoire est plus agressif dans la collecte de références douces/faibles, ce qui les rend peu efficaces. En outre, avant Android 3.0 (niveau d'API 11), les données de sauvegarde d'un bitmap étaient stockées dans la mémoire native et n'étaient pas publiées de manière prévisible, ce qui peut amener une application à dépasser momentanément les limites de la mémoire et à planter.

Afin de choisir une taille appropriée pour un LruCache, plusieurs facteurs doivent être pris en compte, par exemple :

  • Le reste de votre activité et/ou application utilise-t-il beaucoup de mémoire ?
  • Combien d'images s'afficheront simultanément à l'écran ? Combien d'éléments doivent être disponibles et prêts à s'afficher à l'écran ?
  • Quelles sont la taille et la densité de l'écran de l'appareil ? Un appareil à écran haute densité (xhdpi) tel que Galaxy Nexus aura besoin d'un cache plus important pour stocker le même nombre d'images en mémoire qu'un appareil tel que Nexus S (hdpi).
  • Quelles sont les dimensions et la configuration des bitmaps, et donc quelle quantité de mémoire consommeront-ils chacun ?
  • À quelle fréquence les images seront-elles consultées ? Certaines seront-elles consultées plus souvent que d'autres ? Si tel est le cas, vous souhaiterez peut-être conserver certains éléments en mémoire ou même disposer de plusieurs objets LruCache pour différents groupes de bitmaps.
  • Pouvez-vous trouver un juste milieu entre la qualité et la quantité ? Il peut parfois être plus utile de stocker un plus grand nombre de bitmaps de qualité inférieure, ce qui peut entraîner le chargement d'une version de qualité supérieure dans une autre tâche en arrière-plan.

Il n'existe pas de taille ni de formule spécifiques adaptées à toutes les applications. C'est à vous d'analyser votre utilisation et de trouver une solution adaptée. Un cache trop petit entraîne des frais supplémentaires sans avantage, tandis qu'un cache trop volumineux peut à nouveau entraîner des exceptions java.lang.OutOfMemory et laisser peu de mémoire au reste de votre application.

Voici un exemple de configuration d'un LruCache pour les 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);
}

Remarque : Dans cet exemple, un huitième de la mémoire de l'application est alloué à notre cache. Sur un appareil normal/hdpi, la taille minimale est d'environ 4 Mo (32/8). Un GridView en plein écran rempli d'images sur un appareil avec une résolution de 800 x 480 utiliserait environ 1,5 Mo (800 x 480 x 4 octets), ce qui mettrait en cache environ 2,5 pages d'images en mémoire.

Lors du chargement d'un bitmap dans une ImageView, LruCache est d'abord vérifié. Si une entrée est trouvée, elle est utilisée immédiatement pour mettre à jour l'ImageView, sinon un thread d'arrière-plan est généré pour traiter l'image :

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

La BitmapWorkerTask doit également être mise à jour pour ajouter des entrées au cache de la mémoire :

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

Utiliser un cache de disque

Un cache de mémoire permet d'accélérer l'accès aux bitmaps récemment consultés, mais vous ne pouvez pas compter sur la disponibilité des images dans ce cache. Des composants tels que GridView avec des ensembles de données plus volumineux peuvent facilement remplir un cache de mémoire. Votre application peut être interrompue par une autre tâche telle qu'un appel téléphonique. En arrière-plan, elle peut être fermée et le cache de la mémoire détruit. Une fois que l'utilisateur reprend l'activité, votre application doit traiter à nouveau chaque image.

Dans ces cas-là, un cache de disque permet de conserver les bitmaps traités et de réduire les temps de chargement pendant lesquels les images ne sont plus disponibles dans un cache de mémoire. Bien sûr, la récupération d'images à partir d'un disque est plus lente que le chargement à partir de la mémoire et doit être effectuée dans un thread en arrière-plan, car les temps de lecture du disque peuvent être imprévisibles.

Remarque : Un ContentProvider peut être un emplacement plus approprié pour stocker des images mises en cache si elles sont consultées plus fréquemment, par exemple dans une application de galerie d'images.

L'exemple de code de cette classe utilise une mise en œuvre DiskLruCache extraite de la source Android. Voici un exemple de code qui ajoute un cache de disque en plus du cache existant de la mémoire :

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

Remarque : L'initialisation du cache du disque nécessite des opérations sur le disque et ne doit donc pas avoir lieu sur le thread principal. Toutefois, cela signifie qu'il est possible d'accéder au cache avant l'initialisation. Pour résoudre ce problème, dans l'implémentation ci-dessus, un objet de verrouillage garantit que l'application ne lit pas à partir du cache du disque tant qu'il n'a pas été initialisé.

Lorsque le cache de la mémoire est vérifié dans le thread UI, le cache du disque est vérifié dans le thread d'arrière-plan. Les opérations du disque ne doivent jamais avoir lieu sur le thread UI. Une fois le traitement des images terminé, le bitmap final est ajouté à la fois au cache de la mémoire et au cache du disque pour une utilisation ultérieure.

Gérer les modifications de configuration

Les modifications apportées à la configuration de l'environnement d'exécution, telles que le changement d'orientation de l'écran, entraînent la destruction et le redémarrage de l'activité en cours par Android avec la nouvelle configuration. Pour en savoir plus sur ce comportement, consultez Gérer les modifications apportées à l'environnement d'exécution. Il faut éviter de devoir traiter à nouveau toutes les images pour que l'utilisateur bénéficie d'une expérience fluide et rapide en cas de modification de la configuration.

Heureusement, vous avez créé un bon cache de mémoire dans la section Utiliser un cache de mémoire. Ce cache peut être transmis à la nouvelle instance d'activité à l'aide d'un Fragment qui est conservé en appelant setRetainInstance(true). Une fois l'activité créée, ce Fragment conservé est réassocié et vous pouvez accéder à l'objet mis en cache, ce qui permet de récupérer rapidement les images et de les insérer à nouveau dans les objets ImageView.

Voici un exemple de conservation d'un objet LruCache lors de modifications de configuration à l'aide d'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);
    }
}

Pour tester cette méthode, essayez de faire pivoter un appareil avec et sans Fragment. Vous devriez constater un décalage faible ou inexistant tandis que les images renseignent l'activité presque instantanément à partir de la mémoire lorsque vous conservez le cache. Toutes les images introuvables dans le cache de la mémoire devraient normalement être disponibles dans le cache du disque. Si ce n'est pas le cas, elles sont traitées comme d'habitude.