Lưu các Bitmap vào bộ nhớ đệm

Lưu ý: Trong hầu hết các trường hợp, bạn nên sử dụng thư viện Glide để tìm nạp, giải mã và hiển thị bitmap trong ứng dụng của mình. Glide tóm tắt hầu hết độ phức tạp trong việc xử lý các tác vụ này và những tác vụ khác liên quan đến việc sử dụng bitmap và các hình ảnh khác trên Android. Để biết thông tin về cách sử dụng và tải Glide, vui lòng truy cập vào Kho lưu trữ Glide trên GitHub.

Việc tải một bitmap vào giao diện người dùng (UI) của bạn rất đơn giản, tuy nhiên, mọi thứ sẽ phức tạp hơn nếu bạn cần tải một nhóm hình ảnh lớn hơn cùng lúc. Trong nhiều trường hợp (chẳng hạn như với các thành phần như ListView, GridView hoặc ViewPager), tổng số hình ảnh trên màn hình kết hợp với hình ảnh có thể sớm cuộn lên màn hình về cơ bản là không giới hạn.

Việc sử dụng bộ nhớ được duy trì với các thành phần như thế này bằng cách tái chế các chế độ xem con khi di chuyển khỏi màn hình. Trình thu thập rác cũng sẽ giải phóng các bitmap đã tải của bạn, giả sử bạn không giữ bất kỳ tệp tham chiếu nào tồn tại trong thời gian dài. Mọi thứ đều ổn và tốt nhưng để giữ được giao diện người dùng linh hoạt và tải nhanh, bạn muốn tránh liên tục xử lý những hình ảnh này mỗi khi chúng trở lại màn hình. Bộ nhớ và bộ nhớ đệm của ổ đĩa thường có thể giúp ích trong trường hợp này, cho phép các thành phần tải lại nhanh các hình ảnh đã xử lý.

Bài này sẽ hướng dẫn bạn từng bước sử dụng bộ nhớ đệm trên đĩa và bộ nhớ để cải thiện khả năng phản hồi và tính linh hoạt của giao diện người dùng khi tải nhiều bitmap.

Sử dụng Bộ nhớ đệm của bộ nhớ

Bộ nhớ đệm cho phép truy cập nhanh vào các bitmap với chi phí chiếm bộ nhớ ứng dụng có giá trị. Lớp LruCache (cũng có trong Thư viện hỗ trợ để sử dụng lại cho API Cấp 4) đặc biệt phù hợp với nhiệm vụ lưu vào bộ nhớ đệm bitmap, giữ các đối tượng đã tham chiếu gần đây trong một LinkedHashMap được tham chiếu mạnh mẽ và loại bỏ thành viên ít được sử dụng nhất gần đây trước khi bộ nhớ đệm vượt quá kích thước đã chỉ định.

Lưu ý: Trước đây, cách triển khai bộ nhớ đệm phổ biến là bộ nhớ đệm bitmap SoftReference hoặc WeakReference, tuy nhiên, bạn không nên làm như vậy. Bắt đầu từ Android 2.3 (API cấp 9), trình thu thập dữ liệu sẽ linh hoạt hơn bằng cách thu thập các tệp tham chiếu mềm/yếu, khiến các tệp này khá kém hiệu quả. Ngoài ra, trước Android 3.0 (API cấp 11), dữ liệu sao lưu của bitmap được lưu trữ trong bộ nhớ gốc mà không được phát hành theo cách có thể dự đoán, điều này có thể khiến ứng dụng nhanh chóng vượt quá giới hạn này giới hạn bộ nhớ và sự cố.

Để chọn kích thước phù hợp cho LruCache, bạn cần xem xét một số yếu tố, chẳng hạn như:

  • Mức độ chuyên sâu của bộ nhớ đối với hoạt động và/hoặc ứng dụng của bạn.
  • Có bao nhiêu hình ảnh sẽ xuất hiện trên màn hình cùng lúc? Có bao nhiêu khung hình sẵn sàng xuất hiện trên màn hình?
  • Kích thước và độ phân giải màn hình của thiết bị là bao nhiêu? Màn hình có độ phân giải siêu cao (xhdpi) nhưGalaxy Nexus sẽ cần một bộ nhớ đệm lớn hơn để lưu giữ cùng số lượng hình ảnh trong bộ nhớ so với một thiết bị nhưNexus S (hdpi).
  • Kích thước và cấu hình bitmap là gì và do đó mỗi bitmap sẽ chiếm bao nhiêu bộ nhớ?
  • Hình ảnh sẽ được truy cập bao lâu một lần? Một số người dùng có truy cập thường xuyên hơn những người dùng khác không? Nếu vậy, có thể bạn muốn lưu giữ một số mục nhất định trong bộ nhớ hoặc thậm chí có nhiều đối tượng LruCache cho các nhóm bitmap khác nhau.
  • Bạn có thể cân bằng chất lượng với số lượng không? Đôi khi, bạn có thể lưu trữ nhiều bitmap chất lượng thấp hơn, và có thể tải một phiên bản có chất lượng cao hơn trong một tác vụ khác.

Không có kích thước hoặc công thức cụ thể phù hợp với tất cả các ứng dụng, việc quyết định sử dụng và đưa ra giải pháp phù hợp là tùy thuộc vào bạn. Bộ nhớ đệm quá nhỏ làm tốn thêm chi phí nhưng không mang lại lợi ích, bộ nhớ đệm quá lớn có thể một lần nữa dẫn tới ngoại lệ java.lang.OutOfMemory, ngoài ra còn chiếm hết dung lượng của các bộ nhớ nhỏ khác trong ứng dụng khiến nó không thể hoạt động được.

Dưới đây là một ví dụ về cách thiết lập LruCache cho 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);
}

Lưu ý: Trong ví dụ này, 1/8 bộ nhớ ứng dụng được phân bổ cho bộ nhớ đệm. Trên thiết bị thông thường/hdpi, tối thiểu là khoảng 4MB (32/8). Một GridView toàn màn hình chứa đầy hình ảnh trên thiết bị có độ phân giải 800x480 sẽ sử dụng khoảng 1,5MB (800*480*4 byte), do đó sẽ lưu vào bộ nhớ đệm tối thiểu khoảng 2,5 trang hình ảnh.

Khi tải bitmap vào ImageView, LruCache sẽ được kiểm tra trước tiên. Nếu tìm thấy một mục nhập, mục đó sẽ được sử dụng ngay để cập nhật ImageView, nếu không thì luồng trong nền sẽ được tạo để xử lý hình ảnh:

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

Bạn cũng cần cập nhật BitmapWorkerTask để thêm các mục vào bộ nhớ đệm:

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

Sử dụng Bộ nhớ đệm của ổ đĩa

Bộ nhớ đệm rất hữu ích trong việc tăng tốc độ truy cập vào các bitmap đã xem gần đây. Tuy nhiên, bạn không thể dựa vào những hình ảnh có trong bộ nhớ đệm này. Các thành phần như GridView có tập dữ liệu lớn hơn có thể dễ dàng lấp đầy bộ nhớ đệm. Ứng dụng của bạn có thể bị gián đoạn do một tác vụ khác chẳng hạn như gọi điện thoại, và khi ở chế độ nền, ứng dụng đó có thể bị loại bỏ và bộ nhớ đệm sẽ bị ngừng hoạt động. Sau khi người dùng tiếp tục, ứng dụng của bạn phải xử lý lại từng hình ảnh.

Bộ nhớ đệm của ổ đĩa có thể được dùng trong những trường hợp này để duy trì bitmap đã xử lý, đồng thời giúp giảm thời gian tải trong trường hợp hình ảnh không còn tồn tại trong bộ nhớ đệm. Dĩ nhiên, việc tìm nạp hình ảnh từ ổ đĩa sẽ chậm hơn tải từ bộ nhớ và phải được thực hiện ở một luồng trong nền, vì thời gian đọc ổ đĩa có thể không thể dự đoán được.

Lưu ý: ContentProvider có thể là vị trí thích hợp hơn để lưu trữ hình ảnh đã lưu trong bộ nhớ đệm nếu truy cập thường xuyên hơn, ví dụ như trong ứng dụng thư viện hình ảnh.

Mã mẫu của lớp này sử dụng cách triển khai DiskLruCache được lấy từ nguồn Android. Dưới đây là mã mẫu được cập nhật sẽ thêm bộ nhớ đệm của ổ đĩa vào bộ nhớ đệm hiện tại:

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

Lưu ý: Ngay cả khi khởi chạy bộ nhớ đệm của ổ đĩa, bạn phải thực hiện các thao tác trên đĩa. Do đó, bạn không nên thực hiện thao tác này trên luồng chính. Tuy nhiên, điều này có nghĩa là có khả năng bộ nhớ đệm được truy cập trước khi khởi tạo. Để giải quyết vấn đề này, trong cách triển khai ở trên, một đối tượng khóa sẽ đảm bảo ứng dụng không đọc từ bộ nhớ đệm trên ổ đĩa cho đến khi bộ nhớ đệm được khởi tạo.

Mặc dù bộ nhớ đệm của bộ nhớ được kiểm tra trong luồng giao diện người dùng, nhưng bộ nhớ đệm của ổ đĩa đã được kiểm tra ở luồng trong nền. Hoạt động của ổ đĩa không bao giờ được diễn ra trên luồng giao diện người dùng. Khi quá trình xử lý hình ảnh hoàn tất, bitmap cuối cùng sẽ được thêm vào cả bộ nhớ và bộ nhớ đệm trên ổ đĩa để dùng trong tương lai.

Xử lý thay đổi về cấu hình

Các thay đổi về cấu hình thời gian chạy, chẳng hạn như thay đổi hướng màn hình, khiến Android hủy bỏ và bắt đầu lại hoạt động chạy bằng cấu hình mới (Để biết thêm thông tin về hành vi này, vui lòng xem phần Xử lý các thay đổi trong thời gian chạy). Bạn muốn tránh phải xử lý lại tất cả hình ảnh để người dùng có trải nghiệm mượt mà và nhanh chóng khi thay đổi cấu hình xảy ra.

May mắn thay, bạn đã có một bộ nhớ đệm tuyệt vời cho bộ nhớ mà bạn đã tạo trong phần Sử dụng bộ nhớ đệm của bộ nhớ. Bộ nhớ đệm này có thể được truyền qua thực thể hoạt động mới bằng cách sử dụng Fragment được giữ nguyên bằng cách gọi setRetainInstance(true). Sau khi hoạt động được tạo lại, Fragment đã giữ lại này được đính kèm lại và bạn sẽ có quyền truy cập vào đối tượng bộ nhớ đệm hiện tại, cho phép tìm nạp nhanh các hình ảnh và điền lại vào các đối tượng ImageView.

Dưới đây là ví dụ về cách giữ lại đối tượng LruCache trên các thay đổi về cấu hình bằng cách sử dụng 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);
    }
}

Để thử nghiệm điều này, hãy thử xoay ngang một thiết bị mà không giữ lại Fragment. Bạn sẽ nhận thấy rất ít hoặc không có độ trễ vì các hình ảnh sẽ điền hoạt động gần như ngay lập tức từ bộ nhớ khi bạn giữ lại bộ nhớ đệm. Những hình ảnh không có trong bộ nhớ đệm hy vọng sẽ có trong bộ nhớ đệm của ổ đĩa. Nếu không, những hình ảnh đó sẽ được xử lý như bình thường.