Кеширование растровых изображений

Примечание. В большинстве случаев мы рекомендуем использовать библиотеку Glide для получения, декодирования и отображения растровых изображений в приложении. Glide абстрагирует большую часть сложности при обработке этих и других задач, связанных с работой с растровыми и другими изображениями на Android. Информацию о том, как использовать и скачать Glide, можно найти в хранилище Glide на GitHub.

Загрузить одно растровое изображение в пользовательский интерфейс несложно, но если вам нужно загрузить сразу несколько изображений, все становится сложнее. Во многих случаях (например, при использовании компонентов ListView, GridView или ViewPager) общее количество изображений на экране и тех, которые могут появиться на нем в ближайшее время, практически не ограничено.

Использование памяти снижается за счет таких компонентов, как этот, путем повторного использования дочерних представлений по мере их перемещения за пределы экрана. Сборщик мусора также освобождает загруженные растровые изображения, если вы не сохраняете долгоживущие ссылки. Это все хорошо, но чтобы сохранить плавный и быстро загружаемый интерфейс, вы должны избегать постоянной обработки этих изображений каждый раз, когда они возвращаются на экран. В этом случае может помочь кеш памяти и кеш диска, позволяющий компонентам быстро перезагружать обработанные изображения.

В этом уроке вы узнаете, как использовать кэш битовых карт в памяти и на диске, чтобы повысить скорость отклика и плавность пользовательского интерфейса при загрузке нескольких битовых карт.

Используйте кеш памяти

Кеш памяти обеспечивает быстрый доступ к битовым картам, но занимает ценную память приложения. Класс LruCache (также доступен в библиотеке поддержки для использования с уровнем API 4) особенно хорошо подходит для кеширования растровых изображений, сохранения объектов, на которые недавно ссылались, в LinkedHashMap со строгой ссылкой и удаления наименее часто используемого элемента до того, как кеш превысит назначенный размер.

Примечание. Раньше для кеша памяти часто использовался кеш битовых карт SoftReference или WeakReference, но сейчас мы не рекомендуем его. Начиная с Android 2.3 (уровень API 9) сборщик мусора более агрессивно собирает мягкие/слабые ссылки, что делает их довольно неэффективными. Кроме того, до выхода Android 3.0 (уровень API 11) резервные данные растрового изображения хранились в нативной памяти, которая не освобождается предсказуемым образом, что могло привести к кратковременному превышению лимитов памяти приложения и его сбою.

Чтобы выбрать подходящий размер для LruCache, необходимо учесть ряд факторов, например:

  • Насколько требовательны к памяти другие действия и/или приложения?
  • Сколько изображений будет на экране одновременно? Сколько из них должны быть готовы к показу на экране?
  • Каковы размер и плотность экрана устройства? Устройству с экраном сверхвысокой плотности (xhdpi), например Galaxy Nexus, потребуется кеш большего размера, чтобы хранить в памяти то же количество изображений, что и устройство с экраном высокой плотности (hdpi), например Nexus S.
  • Каковы размеры и конфигурация растровых изображений и, следовательно, сколько памяти займет каждое из них?
  • Как часто будет осуществляться доступ к изображениям? Будут ли некоторые из них использоваться чаще других? Если да, то, возможно, вы захотите постоянно хранить определенные элементы в памяти или даже иметь несколько объектов LruCache для разных групп растровых изображений.
  • Можете ли вы найти баланс между качеством и количеством? Иногда может быть полезнее хранить большее количество растровых изображений более низкого качества, потенциально загружая версию более высокого качества в другой фоновой задаче.

Универсального решения нет, поэтому вам нужно проанализировать, как используется приложение, и выбрать подходящий вариант. Слишком маленький кеш приведет к дополнительным накладным расходам без какой-либо пользы, а слишком большой – к исключениям java.lang.OutOfMemory и нехватке памяти для работы остальных компонентов приложения.

Вот пример настройки LruCache для битовых карт:

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

Примечание. В этом примере для кеша выделена одна восьмая часть памяти приложения. На устройстве с обычным разрешением или HDPI это значение составляет около 4 МБ (32/8). Для заполнения изображениями экрана GridView с разрешением 800 x 480 пикселей требуется около 1,5 МБ (800 x 480 x 4 байта). Таким образом, в памяти будет кэшироваться не менее 2,5 страниц с изображениями.

При загрузке битовой карты в ImageView сначала проверяется LruCache. Если запись найдена, она сразу используется для обновления ImageView. В противном случае для обработки изображения создается фоновый поток:

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

Чтобы добавить записи в кеш памяти, также необходимо обновить BitmapWorkerTask:

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

Используйте кеш диска

Кеш памяти позволяет ускорить доступ к недавно просмотренным битовым картам, однако не стоит полагаться на то, что изображения будут доступны в этом кеше. Компоненты, такие как GridView, с большими наборами данных могут легко заполнить кеш памяти. Ваше приложение может быть прервано другой задачей, например телефонным звонком, и, находясь в фоновом режиме, оно может быть уничтожено, а кеш памяти уничтожен. Когда пользователь возобновляет работу, приложение должно снова обработать каждое изображение.

В таких случаях можно использовать кеш диска, чтобы сохранять обработанные битовые карты и сокращать время загрузки, когда изображения больше недоступны в кеше памяти. Конечно, получение изображений с диска происходит медленнее, чем загрузка из памяти, и должно выполняться в фоновом потоке, поскольку время чтения с диска может быть непредсказуемым.

Примечание. Если к кешированным изображениям часто обращаются, например в приложении "Галерея", то для их хранения лучше использовать ContentProvider.

В образце кода этого класса используется реализация DiskLruCache, взятая из исходного кода Android. Вот обновленный пример кода, в котором помимо кеша памяти добавляется кеш диска:

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

Примечание. Даже инициализация кеша диска требует операций с диском, поэтому ее не следует выполнять в основном потоке. Однако в этом случае есть вероятность, что к кешу обратятся до инициализации. Чтобы решить эту проблему, в приведенной выше реализации объект блокировки гарантирует, что приложение не будет считывать данные из кеша диска до тех пор, пока кеш не будет инициализирован.

Кеш памяти проверяется в потоке UI, а кеш диска – в фоновом потоке. Операции с диском никогда не должны выполняться в потоке UI. После завершения обработки изображений окончательное растровое изображение добавляется в кеш памяти и диска для дальнейшего использования.

Обработка изменений конфигурации

Изменения конфигурации во время выполнения, например изменение ориентации экрана, приводят к тому, что Android уничтожает и перезапускает текущее действие с новой конфигурацией. Подробнее об обработке изменений во время выполнения… Вы хотите избежать повторной обработки всех изображений, чтобы пользователь не заметил задержек при изменении конфигурации.

К счастью, у вас есть кеш памяти для битовых карт, который вы создали в разделе Использование кеша памяти. Этот кеш можно передать новому экземпляру действия с помощью Fragment, который сохраняется при вызове setRetainInstance(true). После повторного создания действия сохраненный объект Fragment снова прикрепляется, и вы получаете доступ к существующему объекту кеша, что позволяет быстро извлекать изображения и повторно заполнять объекты ImageView.

Ниже приведен пример сохранения объекта LruCache при изменении конфигурации с помощью 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);
    }
}

Чтобы проверить это, попробуйте повернуть устройство, сохраняя и не сохраняя Fragment. Вы должны заметить небольшую задержку или ее отсутствие, поскольку изображения заполняют активность почти мгновенно из памяти, когда вы сохраняете кэш. Если изображения не найдены в кеше памяти, они могут быть доступны в кеше диска. Если нет, они обрабатываются как обычно.