Làm việc hiệu quả với Bitmap trong Android [Part 1]
Thuật ngữ Load Bitmap Efficiently Chúng ta biết rằng trong lập trình Mobile nói chung và Android nói riêng, Bitmap luôn được sử dụng rất nhiều để tạo Graphic Design cho ứng dụng bởi lẽ chúng sẽ tạo nên sự trực quan cho người dùng hơn là những dòng chữ khô khan. Một ứng dụng đẹp là một ứng dụng ...
Thuật ngữ Load Bitmap Efficiently
- Chúng ta biết rằng trong lập trình Mobile nói chung và Android nói riêng, Bitmap luôn được sử dụng rất nhiều để tạo Graphic Design cho ứng dụng bởi lẽ chúng sẽ tạo nên sự trực quan cho người dùng hơn là những dòng chữ khô khan. Một ứng dụng đẹp là một ứng dụng có Graphic Design đẹp và dĩ nhiên, đó là ứng dụng thành công về mặt thu hút người dùng mặc dù nội dung có thể chưa thực sự nổi bật.
- Một ứng dụng Mobile thường sẽ phải làm việc rất nhiều với Bitmap như load ảnh từ server, từ resource hay tạo những icon, những custom view … Thường thì những công việc này sẽ làm cho ứng dụng trở nên ì ạch, chậm chạp nếu chúng ta không xử lý đúng cách. Bài toán đặt ra là làm thế nào để xử lý hiệu quả chúng? Thuật ngữ Load Bitmap Efficiently ra đời dựa trên những yêu cầu đó.
Tại sao phải Load Bitmap Efficiently
- Hình ảnh thì luôn có những kích cỡ khác nhau. Tuy nhiên trong một số trường hợp, ta chỉ cần hiển thị vừa đủ theo kích thước đã định sẵn. Mặt khác, một ứng dụng luôn được cấp phát một mức bộ nhớ (RAM) xác định cho việc xử lý các công việc trong cả ứng dụng, do vậy việc sử dụng chúng phải thật hợp lý để không ảnh hưởng đến hiệu suất của toàn bộ ứng dụng. Trong trường hợp này, một hình ảnh với độ phân giải thấp hơn vừa đủ sẽ là lựa chọn hợp lý hơn cả.
- Chắc hẳn trong chúng ta, ai cũng mong muốn ứng dụng mình làm ra phải thật đẹp những phải luôn đảm bảo performance và trải nghiệm người dùng thật tốt. Việc phải làm việc với nhiều Graphic Resource nói chung hay image nói riêng luôn đòi hỏi một ứng dụng làm việc với hiệu suất cao, vậy nên chúng ta luôn tìm cách để tối ưu hóa chúng bằng những kỹ thuật, những thủ thuật cần thiết để ứng dụng luôn được mượt mà.
Các kỹ thuật Load Bitmap Efficently
Load Scale Down Bitmap
Như đã dẫn nhập ở trên, trong một số trường hợp cụ thể, ví dụ như load ảnh ở dạng Thumbnail, bạn chỉ cần một bức ảnh với kích thước 64x64 để có thể hiển thị ở dạng grid view. Tuy nhiên, server lưu ảnh ở một độ phân giải cao gấp nhiều lần, ví dụ như 1024x1024. Vấn đề ở đây, bạn chỉ cần hiển thị một ảnh ở độ phân giải thấp, không lẽ bạn phải decode một cái ảnh với độ phân giải cao gấp nhiều lần, điều đó chỉ làm tốn nhiều tài nguyên hệ thống và dễ dàng làm overload bộ nhớ của bạn ( chúng ta hay biết đến với cái tên OutOfMemory Exception) mà kết quả mang lại cho bạn thì không có gì thay đổi. Kỹ thuật Load Scale Down Bitmap ra đời để giải bài toán này. Chúng ta cùng tìm hiểu xem nhé.
Chúng ta cần xem xét một số khía cạnh sau đây.
- Memory Usage sẽ được sử dụng để load toàn bộ ảnh gốc.
- Memory Usage sẽ được sử dụng để load ảnh với độ phân giải vừa đủ theo yêu cầu.
- Dimension của component sẽ sử dụng Bitmap này. Ví dụ ImageView …
- Screen size và density của màn hình sẽ hiển thị.
Ví dụ nhé, bạn cần load một ảnh với kích thước 1024x1024 từ server về và decode nó thì lượng memory usage bạn cần để decode ảnh này là khoảng 4MB. Tuy nhiên, nếu bạn chỉ cần một ảnh vừa đủ với độ phân giải là 96x96 để load vào một ImageView có kích thước 48dp x 48dp ở màn hình xhdpi, lương memory usage bạn cần chỉ khoảng 36KB. Rõ ràng, sự chênh lệch là rất lớn.
Android cung cấp cho chúng ta một thuộc tính trong BitmapFactory.Options gọi là inSampleSize. Để hiểu rõ hơn về thuộc tính này, chúng ta có một ví dụ. Một bức ảnh có độ phân giải 1024x1024 nếu được decode với inSampleSize = 8 thì sẽ tạo ra một Bitmap có size sấp xỉ bằng 128x128. Như vậy, bạn đã hình dung ra thuộc tính inSampleSize này làm gì rồi chứ?? ;)
Dưới đây là method để tính toán giá trị của thuộc tính inSampleSize dựa vào size của ảnh thật và size của ảnh được giảm độ phân giải.
public static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and awidth of image
final int height = options.outHeight;
final int awidth = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || awidth > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = awidth / 2;
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and awidth larger than the requested height and awidth.
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
Sau khi tính toán được thuộc tính inSampleSize cần thiết, chúng ta tiến hành decode ảnh.
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
int reqWidth, int reqHeight) {
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
Chúng ta nhận thấy có một thuộc tính trong BitmapFactoryOptions gọi là inJustDecodeBounds. Thuộc tính này rất hữu dụng khi ta không muốn allocate memory khi decode Bitmap. Ở ví dụ trên, khi set thuộc tính inJustDecodeBounds = true, method BitmapFactory.decodeResource sẽ trả về null, tuy nhiên out fields( Bitmap height, Bitmap awidth ...) sẽ vẫn được tính toán. Bước này thực chất giống như một bước decode giả lập để tính toán các thuộc tính cần thiết và sử dụng nó sau này.
Với kỹ thuật Load Scale Down Bitmap, bạn sẽ dễ dàng decode được hình ảnh với kích thước mong muốn từ ảnh gốc với kích thước lớn mà không làm ngốn nhiều tài nguyên hệ thống.
Caching Bitmap
Trong lập trình úng dụng Mobile, đôi khi bạn phải load một số lượng ảnh rất lớn một lúc, ví dụ như load ảnh vào RecyclerView, ListView hay GridView. Điều đó luôn làm tốn tài nguyên hệ thống rất nhiều và bạn phải luôn tìm cách để giảm thiểu số lần phải load hay decode ảnh. Chưa kể đến việc khi ta phải tái sử dụng ảnh đó ở nhiều view khác nhau hoặc list ảnh đó được sử dụng nhiều lần. Trong Android có một kỹ thuật để giải quyết những vấn đề trên gọi là Caching Bitmap. Ta sẽ tìm hiểu xem nhé.
Sử dụng Memory Cache
Memory cache cho phép truy cập bộ nhớ được allocate cho Bitmap một cách nhanh chóng và hiệu quả. Android cung cấp một class thực hiện điều đó gọi là LruCache, nó sẽ giữ tham chiếu đến các object bằng một tham chiếu mạnh (Strong referenced) LinkedHashMap và sẽ tự động giải phóng những object được sử dụng gần đây nhất trước khi kích thước bộ nhớ cache vượt quá ngưỡng cho phép.
Để khỏi tạo 1 LruCache, chúng ta cần phải xem xét các yếu tố sau đây để xác định được cache size cần thiết và phù hợp nhất.
- Lượng bộ nhớ (memory) còn trống trong ứng dụng của bạn?
- Có bao nhiêu hình ảnh sẽ được hiển thị trên màn hình cùng một lúc ? Có bao nhiêu hình ảnh đã sẵn sàng cho việc hiển thị?
- Kích thước của màn hình?
- Kích thước của mỗi bitmap và lượng bộ nhớ sẽ được allocate cho nó.
- Tần số những hình ảnh này sẽ được truy cập đến.
- Sự cân bằng giữa chất lượng và số lượng hình ảnh.
Dưới đây là ví dụ về cài đặt 1 LruCache.
private LruCache<String, Bitmap> mMemoryCache;
@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;
mMemoryCache = 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) {
mMemoryCache.put(key, bitmap);
}
}
public Bitmap getBitmapFromMemCache(String key) {
return mMemoryCache.get(key);
}
Chú ý rằng cache size sẽ được bạn tính toán và ước chừng sao cho phù hợp nhất.
Để load bitmap lên một ImageView, chúng ta sẽ sủ dụng method dưới đây.
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);
}
}
Trong một số trường hợp cần cache bitmap tải về từ server, chúng ta sẽ sử dụng chính URL của ảnh làm key cho bitmap cache.
Sử dụng Disk cache
Lợi thế của việc sử dụng Memory Cache trên là việc truy cập đến cache object rất nhanh chóng khi phải làm việc với một số lượng lớn hình ảnh. Tuy nhiên, việc sử dụng bộ nhớ (memory) để lưu object sẽ làm tốn nhiều tài nguyên hệ thống khi số lượng ảnh quá lớn buộc ta phải allocate cache size lớn hơn để có thể đáp ứng được yêu cầu. Ngoài ra, ứng dụng có thể bị ngắt khi có một task khác với độ ưu tiên cao hơn, ví dụ như cuộc gọi đến … và trong khi ở background, nó có thể bị killed và dĩ nhiên, memory cache sẽ bị destroyed cùng ứng dụng. Khi user quay lại ứng dụng, memory cache phải khởi tạo lại từ đầu. Để giải quyết các vấn đề này, Android cung cấp 1 kỹ thuật cache khác, gọi là Disk Cache.
private DiskLruCache mDiskLruCache;
private final Object mDiskCacheLock = new Object();
private boolean mDiskCacheStarting = 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 (mDiskCacheLock) {
File cacheDir = params[0];
mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
mDiskCacheStarting = false; // Finished initialization
mDiskCacheLock.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) {
mMemoryCache.put(key, bitmap);
}
// Also add to disk cache
synchronized (mDiskCacheLock) {
if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
mDiskLruCache.put(key, bitmap);
}
}
}
public Bitmap getBitmapFromDiskCache(String key) {
synchronized (mDiskCacheLock) {
// Wait while disk cache is started from background thread
while (mDiskCacheStarting) {
try {
mDiskCacheLock.wait();
} catch (InterruptedException e) {}
}
if (mDiskLruCache != null) {
return mDiskLruCache.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);
}
Khác với Memory Cache, Disk Cache truy cập và sử dụng Disk để lưu trữ object, vì vậy, tốc độ truy cập đến cache object sẽ chậm hơn so với khi sử dụng Memory Cache. Việc khởi tạo Disk Cache cũng không nên thực hiện trên UI Thread vì nó có nhiều khả năng làm block UI của bạn, thay vào đó chúng ta sẽ sử dụng một Background Thread để khởi tạo Disk Cache.
Việc truy cập Disk cache có thể thực hiện từ nhiều thread khác nhau, do đó có thể xảy ra trường hợp truy vấn object từ Disk Cache trước khi nó được khởi tạo xong (Multi thread). Để tránh trường hợp này, ta sẽ lock method getBitmapFromDiskCache bằng từ khóa synchronized.
Kết luận
Việc Load Bitmap Efficiently còn có nhiều kỹ thuật khác nhau, tuy nhiên trong bài viết này, mình chỉ giới thiệu các kỹ thuật phổ biến và được áp dụng hiệu quả nhất. Việc sử dụng thành thạo các kỹ thuật trên đây sẽ giúp cho ứng dụng của bạn được mượt mà và hoạt động hiệu quả hơn rất nhiều.