设计一个简易图片缓存组件

在 UI 界面加载一张图片时很简单,然而如果需要加载多张较大的图像,事情就会变得更加复杂。在许多情况下(如 ListView、RecyclerView 或 ViewPager 等的组件),屏幕上的图片的总数伴随屏幕的滚动会大大增加,且基本上是无限的。为了使内存使用保持在稳定范围内,防止出现 OOM,这些组件会在子 View 划出屏幕后,对其进行资源回收,并重新显示新出现的图片,垃圾回收机制会释放掉不再显示的图片的内存空间。但是这样频繁地处理图片的加载和回收不利于操作的流畅性,而内存或者磁盘的 Cache 就会帮助解决这个问题,实现快速加载已加载的图片。在缓存上,主要有两种级别的 Cache:LruCache 和 DiskLruCache,即内存缓存与磁盘缓存。我们就来借助内存缓存和磁盘缓存来实现一个简易的图片缓存框架。

概要设计

代码实现

BitMapLruCache.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.util.Log;
import android.util.LruCache;
import android.widget.ImageView;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class BitMapLruCache {
private static MemoryCacheUtils memoryCacheUtils;
private static ExecutorService executor;

private static final String TAG = "BitMapLruCache";

private BitMapLruCache(){}

/**
* 外部私有缓存文件夹
*/
private static File cacheDir;

/**
* 初始化 BitMapLruCache,必须在 App 启动完成后调用!
* @param context context
*/
public static void init(Context context){
cacheDir = context.getExternalCacheDir ();
memoryCacheUtils = new MemoryCacheUtils();
executor = new ThreadPoolExecutor(
4, 8,
10, TimeUnit.SECONDS, new LinkedBlockingDeque<>(128));
}

/**
* 根据 URL 获取一张图片
* @param url URL
* @param imageView {@link ImageView}
* @param listener {@link LoadBitMapListener} 加载 BitMap 的监听器
*/
public static void getBitMap(String url, ImageView imageView, LoadBitMapListener listener) {
// 先从内存找
Bitmap fromMemory = memoryCacheUtils.getBitmapFromMemory (url);
if(fromMemory != null){
listener.success (fromMemory, imageView);
}else {
// 找不到就从磁盘找
Bitmap bitmap = getBitMapFromDisk (url);
if(bitmap != null){
listener.success (bitmap, imageView);
// 找到就保存到内存
memoryCacheUtils.setBitmapToMemory (url, bitmap);
}else{
// 找不到从网络加载 & 保存到内存
GetFromNetWorkTask task = new GetFromNetWorkTask(url, imageView, listener);
executor.submit (task);
}
}
}

/**
* 根据 URL 从磁盘加载 BitMap
* @param url 图片 URL
* @return bitmap {@link Bitmap}
*/
private static Bitmap getBitMapFromDisk(String url) {
File file = new File(cacheDir, calcMD5 (url));
if(file.exists ()){
try {
return BitmapFactory.decodeStream (new FileInputStream(file));
} catch (IOException e) {
Log.e (TAG, "getBitMapFromDisk: ", e);
}
}
return null;
}

/**
* 保存 BitMap 到磁盘
* @param url 图片 URL
* @param bitmap {@link Bitmap}
*/
private static void saveBitMapToDisk(String url, Bitmap bitmap) {
//url convert
try {
File file = new File(cacheDir, calcMD5 (url));
Log.i (TAG, "saveBitMapToDisk: file' path = " + file.getAbsolutePath ());
FileOutputStream out = new FileOutputStream(file);
bitmap.compress (Bitmap.CompressFormat.JPEG, 100, out);
} catch (IOException e) {
Log.e (TAG, "saveBitMapToDisk: ", e);
}
}

/**
* 加载 BitMap 过程中的监听器
*/
public interface LoadBitMapListener{
void success(Bitmap bitmap, ImageView imageView);
void failed();
}

/**
* 从网络加载的 Task
*/
static private class GetFromNetWorkTask implements Runnable {
String urlStr;
ImageView imageView;
LoadBitMapListener listener;

public GetFromNetWorkTask(String urlStr, ImageView imageView, LoadBitMapListener listener) {
this.urlStr = urlStr;
this.imageView = imageView;
this.listener = listener;
}

@Override
public void run() {
try {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection ();
conn.setRequestMethod ("GET");
InputStream connInputStream = conn.getInputStream ();
Bitmap baseBitmap = BitmapFactory.decodeStream (connInputStream);
// 比例压缩
Bitmap bitmap = imageCompressL (baseBitmap);
listener.success (bitmap, imageView);
memoryCacheUtils.setBitmapToMemory (urlStr, bitmap);
saveBitMapToDisk (urlStr, bitmap);
} catch (IOException e) {
listener.failed ();
}
}
}

/**
* 将 URL 转换为 MD5
* @param str URL
* @return MD5
*/
private static String calcMD5(String str) {
if (str == null || str.length () == 0) {
throw new IllegalArgumentException("String cannot be null or zero length");
}
StringBuilder hexString = new StringBuilder();
try {
MessageDigest md = MessageDigest.getInstance ("MD5");
md.update (str.getBytes ());
byte[] hash = md.digest ();
for (byte b : hash) {
if ((0xff & b) < 0x10) {
hexString.append ("0").append (Integer.toHexString ((0xFF & b)));
} else {
hexString.append (Integer.toHexString (0xFF & b));
}
}
} catch (NoSuchAlgorithmException e) {
e.printStackTrace ();
}
return hexString.toString ();
}


/**
* 计算 Bitmap 大小,如果超过 64kb,则进行压缩
* @param bitmap 源 BitMap {@link Bitmap}
* @return 压缩后的 Bitmap {@link Bitmap}
*/
private static Bitmap imageCompressL(Bitmap bitmap) {
double targetWidth = Math.sqrt (64.00 * 1000);
if (bitmap.getWidth () > targetWidth || bitmap.getHeight () > targetWidth) {
// 创建操作图片用的 matrix 对象
Matrix matrix = new Matrix();
// 计算宽高缩放率
float x = (float) Math.max (targetWidth /bitmap.getWidth (), targetWidth /bitmap.getHeight ());
// 缩放图片动作
matrix.postScale (x, x);
bitmap = Bitmap.createBitmap (bitmap, 0, 0, bitmap.getWidth (),
bitmap.getHeight (), matrix, true);
}
return bitmap;
}

/**
* LRUCache 操作工具类
*/
private static class MemoryCacheUtils {
private static final String TAG = "MemoryCacheUtils";
private final LruCache<String, Bitmap> mMemoryCache;

public MemoryCacheUtils(){
// 得到手机最大允许内存的 1/8,即超过指定内存,则开始回收
long maxMemory = Runtime.getRuntime ().maxMemory ()/8;
Log.i (TAG, "MemoryCacheUtils: maxMemory = " + maxMemory);
// 需要传入允许的内存最大值,虚拟机默认内存 24M
mMemoryCache = new LruCache<String,Bitmap>((int) maxMemory){
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount ();
}
};
}

/**
* 从内存中读图片
* @param url 图片 URL
* @return {@link Bitmap}
*/
public Bitmap getBitmapFromMemory(String url) {
return mMemoryCache.get (url);
}

/**
* 往内存中写图片
* @param url 图片 URL
* @param bitmap BitMap
*/
public void setBitmapToMemory(String url, Bitmap bitmap) {
mMemoryCache.put (url,bitmap);
}
}
}

LRUCache 本身就是基于 LinkHashMap 实现的,关于更多 LRUCache 的内容可以看我的另一篇博客:《LinkHashMap 与 LRU》

需要注意的是使用此组件需要外部存储的权限与网络访问权限(如果是 Android6.0 之后的设备还需要动态权限申请):

1
2
3
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

关于图片压缩

我们在在展示高分辨率图片的时候,最好先将图片进行压缩。压缩后的图片大小应该和用来展示它的控件大小相近,因为在一个很小的 ImageView 上显示一张超大的图片不会带来任何视觉上的好处,但却会占用我们相当多宝贵的内存,而且在性能上还可能会带来负面影响。下面我们就来看一看,如何对一张大图片进行适当的压缩,让它能够以最佳大小显示的同时,还能防止 OOM 的出现。

1
W/System.err: java.lang.RuntimeException: Canvas: trying to draw too large (552960000bytes) bitmap.

BitmapFactory 这个类提供了多个解析方法 (decodeByteArray, decodeFile, decodeResource 等) 用于创建 Bitmap 对象,我们应该根据图片的来源选择合适的方法。比如 SD 卡中的图片可以使用 decodeFile 方法,网络上的图片可以使用 decodeStream 方法,资源文件中的图片可以使用 decodeResource 方法。这些方法会尝试为已经构建的 bitmap 分配内存,这时就会很容易导致 OOM 出现。为此每一种解析方法都提供了一个可选的 BitmapFactory.Options 参数,将这个参数的 inJustDecodeBounds 属性设置为 true 就可以让解析方法禁止为 bitmap 分配内存,返回值也不再是一个 Bitmap 对象,而是 null。虽然 Bitmap 是 null 了,但是 BitmapFactory.Options 的 outWidth、outHeight 和 outMimeType 属性都会被赋值。这个技巧让我们可以在加载图片之前就获取到图片的长宽值和 MIME 类型,从而根据情况对图片进行压缩。如下代码所示:

1
2
3
4
5
6
BitmapFactory.Options options = new BitmapFactory.Options ();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource (getResources (), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;

为了避免 OOM 异常,最好在解析每张图片的时候都先检查一下图片的大小,除非你非常信任图片的来源,保证这些图片都不会超出你程序的可用内存。

现在图片的大小已经知道了,我们就可以决定是把整张图片加载到内存中还是加载一个压缩版的图片到内存中。以下几个因素是我们需要考虑的:

1、预估一下加载整张图片所需占用的内存
2、为了加载这一张图片你所愿意提供多少内存
3、用于展示这张图片的控件的实际大小
4、当前设备的屏幕尺寸和分辨率

比如,你的 ImageView 只有 128×96 像素的大小,只是为了显示一张缩略图,这时候把一张 1024×768 像素的图片完全加载到内存中显然是不值得的。通过设置 BitmapFactory.Options 中 inSampleSize 的值就可以实现。比如我们有一张 2048×1536 像素的图片,将 inSampleSize 的值设置为 4,就可以把这张图片压缩成 512×384 像素。原本加载这张图片需要占用 13M 的内存,压缩后就只需要占用 0.75M 了 (假设图片是 ARGB_8888 类型,即每个像素点占用 4 个字节)。下面的方法可以根据传入的宽和高,计算出合适的 inSampleSize 值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
// 源图片的高度和宽度
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
// 计算出实际宽高和目标宽高的比率
final int heightRatio = Math.round ((float) height / (float) reqHeight);
final int widthRatio = Math.round ((float) width / (float) reqWidth);
// 选择宽和高中最小的比率作为 inSampleSize 的值,这样可以保证最终图片的宽和高
// 一定都会大于等于目标的宽和高。
inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
}
return inSampleSize;
}

使用这个方法,首先你要将 BitmapFactory.Options 的 inJustDecodeBounds 属性设置为 true,解析一次图片。然后将 BitmapFactory.Options 连同期望的宽度和高度一起传递到到 calculateInSampleSize 方法中,就可以得到合适的 inSampleSize 值了。之后再解析一次图片,使用新获取到的 inSampleSize 值,并把 inJustDecodeBounds 设置为 false,就可以得到压缩后的图片了:

1
2
3
4
5
6
7
8
9
10
11
12
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
int reqWidth, int reqHeight) {
// 第一次解析将 inJustDecodeBounds 设置为 true,来获取图片大小
final BitmapFactory.Options options = new BitmapFactory.Options ();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource (res, resId, options);
// 调用上面定义的方法计算 inSampleSize 值
options.inSampleSize = calculateInSampleSize (options, reqWidth, reqHeight);
// 使用获取到的 inSampleSize 值再次解析图片
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource (res, resId, options);
}

下面的代码非常简单地将任意一张图片压缩成 100×100 的缩略图,并在 ImageView 上展示:

1
mImageView.setImageBitmap (decodeSampledBitmapFromResource (getResources (), R.id.myimage, 100, 100));

解决 RecyclerView 加载图片错乱

因为有 ViewHolder 的重用机制,每一个 Item 在移出屏幕后都会被重新使用以节省资源,避免滑动卡顿。所以当使用异步任务加载图片的时候就可能会发生图片加载错乱的问题,既然弄清楚了原因,那么下面就给出解决方式:

  1. 先将图片预设为本地一个占位图片。(重要!很多错位情况在于复用了其他位置的图片缓存,而当前图片迟迟加载不出来,导致当前图片错位。所以解决之道是先用本地占位图片,快速刷新当前图片。等加载完毕之后,就可以替换掉占位图);

  2. 通过 ImageView.setTag,把 url 记录在图片内部;

  3. 把 url 放进一个请求队列,(这是避免图片很大,请求耗时很长,重复发起 url 请求);

  4. 如果请求队列存在 url,则将老的 url 对应图片控件,替换为本次请求图片控件,返回;

  5. 如果当前 url 和第一步 ImageView.getTag 一致,则用新的数据源更新图片,否则返回;

  6. 如果是重复使用的图片,最好就是访问之后,写入到本地存储上,避免耗时网络请求。

下面给出具体代码实现:(里面的 BitMapLruCache 正是用的上面的 BitMapLruCache)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class PictureAdapter extends RecyclerView.Adapter<PictureAdapter.MyViewHolder>{
private Context context;
private List<PictureItem> array;

public PictureAdapter(Context context, List<PictureItem> array) {
this.context = context;
this.array = array;
}

@NonNull
@Override
public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from (context).inflate (R.layout.picture_item, parent, false);
return new MyViewHolder(view);
}

@Override
public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
PictureItem item = array.get (position);
// 图片准备之前设置 Tag
holder.pictureImageView.setTag (item.getImgUrl ());
BitMapLruCache.getBitMap (item.getImgUrl (), holder.pictureImageView, new BitMapLruCache.LoadBitMapListener () {
@Override
public void success(Bitmap bitmap, ImageView imageView) {
String tag = (String) imageView.getTag ();
// 加载图片的时候判断 Tag
if(item.getImgUrl ().equals (tag)){
holder.pictureImageView.setImageBitmap (bitmap);
}
}

@Override
public void failed() {
holder.pictureImageView.setImageResource (R.drawable.ic_launcher_background);
}
});
}

@Override
public int getItemCount() {
return array.size ();
}

static class MyViewHolder extends RecyclerView.ViewHolder{
ImageView pictureImageView;
public MyViewHolder(@NonNull View itemView) {
super(itemView);
pictureImageView = itemView.findViewById (R.id.item_picture_iv);
pictureImageView.setImageResource (R.drawable.ic_launcher_background);
}
}
}