Menyimpan Cache Bitmap

Catatan: Untuk sebagian besar kasus, sebaiknya gunakan library Glide untuk mengambil, mendekode, dan menampilkan bitmap dalam aplikasi. Glide menyederhanakan sebagian besar kompleksitas dalam menangani tugas ini dan tugas lain yang terkait dengan penggunaan bitmap dan gambar lain di Android. Untuk informasi cara menggunakan dan mendownload Glide, buka repositori Glide di GitHub.

Memuat satu bitmap ke antarmuka pengguna (UI) sangat mudah, tetapi situasinya akan menjadi lebih rumit jika Anda harus memuat satu set gambar yang lebih besar sekaligus. Dalam banyak kasus (misalnya pada komponen seperti ListView, GridView, atau ViewPager), jumlah total gambar di layar jika digabungkan dengan gambar yang mungkin akan segera di-scroll ke layar, pada dasarnya tidak terbatas.

Penggunaan memori diturunkan dengan komponen seperti ini dengan mendaur ulang tampilan turunan saat berpindah keluar layar. Pembersih sampah memori juga mengosongkan bitmap yang dimuat, dengan asumsi Anda tidak menyimpan referensi jangka lama. Semua ini bagus, tetapi untuk mempertahankan UI yang lancar dan dimuat dengan cepat, sebaiknya hindari memproses gambar ini secara terus-menerus tiap kali gambar ini muncul kembali di layar. Memori dan cache disk sering kali berguna di sini, sehingga komponen dapat dengan cepat memuat ulang gambar yang diproses.

Pelajaran ini akan memandu Anda menggunakan memori dan cache bitmap disk untuk meningkatkan daya respons dan kelancaran UI saat memuat beberapa bitmap.

Menggunakan Cache Memori

Cache memori menawarkan akses yang cepat ke bitmap, tetapi dapat menghabiskan memori aplikasi yang berharga. Class LruCache (juga tersedia di Support Library untuk digunakan kembali ke API Level 4) sangat sesuai dengan tugas menyimpan cache bitmap, dengan mempertahankan objek yang baru-baru ini direferensikan dalam LinkedHashMap, yang direferensikan dengan tegas dan mengeluarkan anggota yang paling lama digunakan sebelum cache melebihi ukuran yang ditentukan.

Catatan: Sebelumnya, implementasi cache memori yang populer digunakan adalah cache bitmap SoftReference atau WeakReference, tetapi tidak direkomendasikan. Mulai dari Android 2.3 (API Level 9) pembersih sampah memori lebih agresif mengumpulkan referensi lembut/lemah yang membuatnya agak tidak efektif. Selain itu, sebelum Android 3.0 (API Level 11), data pendukung bitmap disimpan di memori native yang tidak dirilis dengan cara yang dapat diprediksi, yang berpotensi menyebabkan aplikasi melewati batas memorinya dalam waktu singkat dan kemudian error.

Untuk memilih ukuran yang sesuai bagi LruCache, sejumlah faktor harus dipertimbangkan, misalnya:

  • Seberapa intensif aktivitas dan/atau aplikasi Anda dalam menggunakan memori?
  • Berapa banyak gambar yang akan ditampilkan sekaligus di layar? Berapa banyak gambar yang harus tersedia dan siap untuk ditampilkan di layar?
  • Berapa ukuran dan kepadatan layar perangkat? Perangkat dengan layar berkepadatan sangat tinggi (xhdpi) seperti Galaxy Nexus memerlukan cache yang lebih besar untuk menampung jumlah gambar yang sama dalam memori dibandingkan dengan perangkat seperti Nexus S (hdpi).
  • Apa saja dimensi dan konfigurasi bitmap tersebut dan, oleh karena itu, berapa banyak memori yang akan digunakan?
  • Seberapa sering gambar akan diakses? Apakah beberapa gambar yang akan lebih sering diakses daripada yang lain? Jika demikian, mungkin Anda dapat selalu menyimpan item tertentu dalam memori, atau bahkan memiliki beberapa objek LruCache untuk kelompok bitmap yang berbeda.
  • Dapatkah Anda menyeimbangkan kualitas dengan kuantitas? Terkadang, menyimpan bitmap berkualitas lebih rendah dalam jumlah lebih besar akan lebih baik, yang berpotensi memuat versi kualitas yang lebih tinggi dalam tugas latar belakang lainnya.

Tidak ada ukuran atau formula tertentu yang cocok untuk semua aplikasi, semua terserah pada Anda untuk menganalisis penggunaan dan membuat solusi yang sesuai. Cache yang terlalu kecil menyebabkan overhead tambahan tanpa manfaat apa pun, dan cache yang terlalu besar dapat menyebabkan pengecualian java.lang.OutOfMemory yang membuat aplikasi hanya memiliki sedikit memori untuk digunakan.

Berikut ini contoh cara menyiapkan LruCache untuk 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);
}

Catatan: Pada contoh ini, seperdelapan memori aplikasi dialokasikan untuk cache. Pada perangkat normal/hdpi, jumlah minimum memori yang diperlukan adalah sekitar 4 MB (32/8). Layar penuh GridView yang diisi dengan gambar di perangkat dengan resolusi 800 x 480 piksel akan menggunakan sekitar 1,5 MB (800*480*4 byte), sehingga akan menyimpan cache minimum sekitar 2,5 halaman gambar dalam memori.

Saat memuat bitmap ke ImageView, LruCache akan diperiksa terlebih dahulu. Jika ditemukan, entri tersebut akan segera digunakan untuk mengupdate ImageView. Jika tidak, thread latar belakang akan digunakan untuk memproses gambar:

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 juga perlu diupdate untuk menambahkan entri ke cache memori:

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

Menggunakan Cache Disk

Cache memori bermanfaat dalam mempercepat akses ke bitmap yang baru-baru ini dilihat, tetapi Anda tidak dapat mengandalkan gambar yang tersedia dalam cache ini. Komponen seperti GridView dengan set data yang lebih besar dapat memenuhi cache memori dalam waktu singkat. Aplikasi dapat terganggu oleh tugas lain seperti panggilan telepon, dan saat berada di latar belakang, aplikasi mungkin akan dimatikan dan cache memori dihancurkan. Setelah pengguna melanjutkan, aplikasi harus memproses kembali setiap gambar.

Cache disk dapat digunakan dalam kasus ini untuk mempertahankan bitmap yang diproses dan membantu mengurangi waktu pemuatan jika gambar tidak tersedia lagi dalam cache memori. Tentu saja, pengambilan gambar dari disk akan lebih lambat daripada pemuatan dari memori dan harus dilakukan di thread latar belakang, karena waktu baca disk tidak dapat diprediksi.

Catatan: ContentProvider mungkin menjadi tempat yang lebih cocok untuk menyimpan gambar cache jika lebih sering diakses, misalnya dalam aplikasi galeri gambar.

Kode contoh dari class ini menggunakan implementasi DiskLruCache yang diambil dari sumber Android. Berikut ini kode contoh yang diupdate yang menambahkan cache disk bersama dengan cache memori yang sudah ada:

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

Catatan: Bahkan tindakan seperti melakukan inisialisasi cache disk juga memerlukan operasi disk dan, oleh karena itu, tidak boleh dilakukan di thread utama. Akan tetapi, ini artinya ada kemungkinan cache diakses sebelum diinisialisasi. Untuk mengatasinya, pada implementasi di atas, objek kunci memastikan bahwa aplikasi tidak membaca dari cache disk hingga cache telah diinisialisasi.

Saat cache memori diperiksa di thread UI, cache disk diperiksa di thread latar belakang. Operasi disk tidak boleh dilakukan di UI thread. Saat pemrosesan gambar selesai, bitmap akhir akan ditambahkan ke cache memori dan cache disk untuk digunakan di masa mendatang.

Menangani Perubahan Konfigurasi

Perubahan konfigurasi runtime, seperti perubahan orientasi layar, menyebabkan Android menghancurkan dan memulai ulang aktivitas yang sedang berjalan dengan konfigurasi baru (Untuk informasi selengkapnya tentang perilaku ini, lihat Menangani Perubahan Runtime). Sebaiknya hindari memproses ulang semua gambar agar pengguna memiliki pengalaman yang lancar dan cepat saat terjadi perubahan konfigurasi.

Untungnya, Anda memiliki cache memori bitmap yang baik, yang telah Anda bangun di bagian Menggunakan Cache Memori. Cache ini dapat diteruskan ke instance aktivitas baru menggunakan Fragment yang dipertahankan dengan memanggil setRetainInstance(true). Setelah aktivitas dibuat ulang, Fragment yang dipertahankan ini dilampirkan ulang dan Anda dapat memperoleh akses ke objek cache yang sudah ada sehingga gambar dapat diambil dengan cepat dan diisi ulang ke objek ImageView.

Berikut contoh cara mempertahankan objek LruCache saat terjadi perubahan konfigurasi menggunakan 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);
    }
}

Untuk mengujinya, coba putar perangkat dengan dan tanpa mempertahankan Fragment. Jika mempertahankan cache, Anda akan merasakan sedikit atau tidak ada jeda sama sekali saat gambar mengisi aktivitas hampir seketika dari memori. Gambar yang tidak ditemukan di cache memori mungkin ada di cache disk. Jika tidak, gambar tersebut akan diproses seperti biasa.