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.