这篇文章主要和大家一起动手编写Android图片加载框架,从内部原理到具体实现来详细介绍如何开发一个简洁而实用的Android图片加载缓存框架,感兴趣的小伙伴们可以参考一下
开发一个简洁而实用的Android图片加载缓存框架,并在内存占用与加载图片所需时间这两个方面与主流图片加载框架之一Universal Image Loader做出比较,来帮助我们量化这个框架的性能。通过开发这个框架,我们可以进一步深入了解Android中的Bitmap操作、LruCache、LruDiskCache,让我们以后与Bitmap打交道能够更加得心应手。若对Bitmap的大小计算及inSampleSize计算还不太熟悉,请参考这里:高效加载Bitmap。由于个人水平有限,叙述中必然存在不准确或是不清晰的地方,希望大家能够指出,谢谢大家。
一、图片加载框架需求描述
在着手进行实际开发工作之前,我们先来明确以下我们的需求。通常来说,一个实用的图片加载框架应该具备以下2个功能:
图片的加载:包括从不同来源(网络、文件系统、内存等),支持同步及异步方式,支持对图片的压缩等等;
图片的缓存:包括内存缓存和磁盘缓存。
下面我们来具体描述下这些需求。
1. 图片的加载
(1)同步加载与异步加载
我们先来简单的复习下同步与异步的概念:
同步:发出了一个“调用”后,需要等到该调用返回才能继续执行;
异步:发出了一个“调用”后,无需等待该调用返回就能继续执行。
同步加载就是我们发出加载图片这个调用后,直到完成加载我们才继续干别的活,否则就一直等着;异步加载也就是发出加载图片这个调用后我们可以直接去干别的活。
(2)从不同的来源加载
我们的应用有时候需要从网络上加载图片,有时候需要从磁盘加载,有时候又希望从内存中直接获取。因此一个合格的图片加载框架应该支持从不同的来源来加载一个图片。对于网络上的图片,我们可以使用HttpURLConnection来下载并解析;对于磁盘中的图片,我们可以使用BitmapFactory的decodeFile方法;对于内存中的Bitmap,我们直接就可以获取。
(3)图片的压缩
关于对图片的压缩,主要的工作是计算出inSampleSize,剩下的细节在下面实现部分我们会介绍。
2. 图片的缓存
缓存功能对于一个图片加载框架来说是十分必要的,因为从网络上加载图片既耗时耗电又费流量。通常我们希望把已经加载过的图片缓存在内存或磁盘中,这样当我们再次需要加载相同的图片时可以直接从内存缓存或磁盘缓存中获取。
(1)内存缓存
访问内存的速度要比访问磁盘快得多,因此我们倾向于把更加常用的图片直接缓存在内存中,这样加载速度更快,内存缓存的不足在于由于内存空间有限,能够缓存的图片也比较少。我们可以选择使用SDK提供的LruCache类来实现内存缓存,这个类使用了LRU算法来管理缓存对象,LRU算法即Least Recently Used(最近最少使用),它的主要思想是当缓存空间已满时,移除最近最少使用的缓存对象。关于LruCache类的具体使用我们下面会进行详细介绍。
(2)磁盘缓存
磁盘缓存的优势在于能够缓存的图片数量比较多,不足就是磁盘IO的速度比较慢。磁盘缓存我们可以用DiskLruCache来实现,这个类不属于Android SDK,文末给出的本文示例代码的地址,其中包含了DiskLruCache。
DisLruCache同样使用了LRU算法来管理缓存,关于它的具体使用我们会在后文进行介绍。
二、缓存类使用介绍
1. LruCache的使用
首先我们来看一下LruCache类的定义:
public
class
LruCache {
private
final
LinkedHashMap map;
...
public
LruCache(
int
maxSize) {
if
(maxSize <=
0
) {
throw
new
IllegalArgumentException(
"maxSize <= 0"
);
}
this
.maxSize = maxSize;
this
.map =
new
LinkedHashMap(
0
,
0
.75f,
true
);
}
...
}
由以上代码我们可以知道,LruCache是个泛型类,它的内部使用一个LinkedHashMap来管理缓存对象。
(1)初始化LruCache
初始化LruCache的惯用代码如下所示:
1
2
3
4
5
6
7
8
9
|
//获取当前进程的可用内存(单位KB)
int
maxMemory = (
int
) (Runtime.getRuntime().maxMemory() /
1024
);
int
memoryCacheSize = maxMemory /
8
;
mMemoryCache =
new
LruCache
@Override
protected
int
sizeOf(String key, Bitmap bitmap) {
return
bitmap.getByteCount() /
1024
;
}
};
|
在以上代码中,我们创建了一个LruCache实例,并指定它的maxSize为当前进程可用内存的1/8。我们使用String作为key,value自然是Bitmap。第6行到第8行我们重写了sizeOf方法,这个方法被LruCache用来计算一个缓存对象的大小。我们使用了getByteCount方法返回Bitmap对象以字节为单位的方法,又除以了1024,转换为KB为单位的大小,以达到与cacheSize的单位统一。
(2)获取缓存对象
LruCache类通过get方法来获取缓存对象,get方法的源码如下:
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
|
public
final
V get(K key) {
if
(key ==
null
) {
throw
new
NullPointerException(
"key == null"
);
}
V mapValue;
synchronized
(
this
) {
mapValue = map.get(key);
if
(mapValue !=
null
) {
hitCount++;
return
mapValue;
}
missCount++;
}
/*
* Attempt to create a value. This may take a long time, and the map
* may be different when create() returns. If a conflicting value was
* added to the map while create() was working, we leave that value in
* the map and release the created value.
*/
V createdValue = create(key);
if
(createdValue ==
null
) {
return
null
;
}
synchronized
(
this
) {
createCount++;
mapValue = map.put(key, createdValue);
if
(mapValue !=
null
) {
// There was a conflict so undo that last put
map.put(key, mapValue);
}
else
{
size += safeSizeOf(key, createdValue);
}
}
if
(mapValue !=
null
) {
entryRemoved(
false
, key, createdValue, mapValue);
return
mapValue;
}
else
{
trimToSize(maxSize);
return
createdValue;
}
}
|
通过以上代码我们了解到,首先会尝试根据key获取相应value(第8行),若不存在则会新建一个key-value对,并将它放入到LinkedHashMap中。从get方法的实现我们可以看到,它用synchronized关键字作了同步,因此这个方法是线程安全的。实际上,LruCache类对所有可能涉及并发数据访问的方法都作了同步。
(3)添加缓存对象
在添加缓存对象之前,我们先得确定用什么作为被缓存的Bitmap对象的key,一种很直接的做法便是使用Bitmap的URL作为key,然而由于URL中存在一些特殊字符,所以可能会产生一些问题。基于以上原因,我们可以考虑使用URL的md5值作为key,这能够很好的保证不同的url具有不同的key,而且相同的url得到的key相同。我们自定义一个getKeyFromUrl方法来通过URI获取key,该方法的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
private
String getKeyFromUrl(String url) {
String key;
try
{
MessageDigest messageDigest = MessageDigest.getInstance(
"MD5"
);
messageDigest.update(url.getBytes());
byte
[] m = messageDigest.digest();
return
getString(m);
}
catch
(NoSuchAlgorithmException e) {
key = String.valueOf(url.hashCode());
}
return
key;
}
private
static
String getString(
byte
[] b){
StringBuffer sb =
new
StringBuffer();
for
(
int
i =
0
; i < b.length; i ++){
sb.append(b[i]);
}
return
sb.toString();
}
|
得到了key后,我们可以使用put方法向LruCache内部的LinkedHashMap中添加缓存对象,这个方法的源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public
final
V put(K key, V value) {
if
(key ==
null
|| value ==
null
) {
throw
new
NullPointerException(
"key == null || value == null"
);
}
V previous;
synchronized
(
this
) {
putCount++;
size += safeSizeOf(key, value);
previous = map.put(key, value);
if
(previous !=
null
) {
size -= safeSizeOf(key, previous);
}
}
if
(previous !=
null
) {
entryRemoved(
false
, key, previous, value);
}
trimToSize(maxSize);
return
previous;
}
|
从以上代码我们可以看到这个方法确实也作了同步,它将新的key-value对放入LinkedHashMap后会返回相应key原来对应的value。
(4)删除缓存对象
我们可以通过remove方法来删除缓存对象,这个方法的源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public
final
V remove(K key) {
if
(key ==
null
) {
throw
new
NullPointerException(
"key == null"
);
}
V previous;
synchronized
(
this
) {
previous = map.remove(key);
if
(previous !=
null
) {
size -= safeSizeOf(key, previous);
}
}
if
(previous !=
null
) {
entryRemoved(
false
, key, previous,
null
);
}
return
previous;
}
|
这个方法会从LinkedHashMap中移除指定key对应的value并返回这个value,我们可以看到它的内部还调用了entryRemoved方法,如果有需要的话,我们可以重写entryRemoved方法来做一些资源回收的工作。
2. DiskLruCache的使用
(1)初始化DiskLruCache
通过查看DiskLruCache的源码我们可以发现,DiskLruCache就存在如下一个私有构造方法:
1
2
3
4
5
6
7
8
|
private
DiskLruCache(File directory,
int
appVersion,
int
valueCount,
long
maxSize) {
this
.directory = directory;
this
.appVersion = appVersion;
this
.journalFile =
new
File(directory, JOURNAL_FILE);
this
.journalFileTmp =
new
File(directory, JOURNAL_FILE_TMP);
this
.valueCount = valueCount;
this
.maxSize = maxSize;
}
|
因此我们不能直接调用构造方法来创建DiskLruCache的实例。实际上DiskLruCache为我们提供了open静态方法来创建一个DiskLruCache实例,我们来看一下这个方法的实现:
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
|
public
static
DiskLruCache open(File directory,
int
appVersion,
int
valueCount,
long
maxSize)
throws
IOException {
if
(maxSize <=
0
) {
throw
new
IllegalArgumentException(
"maxSize <= 0"
);
}
if
(valueCount <=
0
) {
throw
new
IllegalArgumentException(
"valueCount <= 0"
);
}
// prefer to pick up where we left off
DiskLruCache cache =
new
DiskLruCache(directory, appVersion, valueCount, maxSize);
if
(cache.journalFile.exists()) {
try
{
cache.readJournal();
cache.processJournal();
cache.journalWriter =
new
BufferedWriter(
new
FileWriter(cache.journalFile,
true
),
IO_BUFFER_SIZE);
return
cache;
}
catch
(IOException journalIsCorrupt) {
// System.logW("DiskLruCache " + directory + " is corrupt: "
// + journalIsCorrupt.getMessage() + ", removing");
cache.delete();
}
}
// create a new empty cache
directory.mkdirs();
cache =
new
DiskLruCache(directory, appVersion, valueCount, maxSize);
cache.rebuildJournal();
return
cache;
}
|
从以上代码中我们可以看到,open方法内部调用了DiskLruCache的构造方法,并传入了我们传入open方法的4个参数,这4个参数的含义分别如下:
directory:代表缓存文件在文件系统的存储路径;
appVersion:代表应用版本号,通常设为1即可;
valueCount:代表LinkedHashMap中每个节点上的缓存对象数目,通常设为1即可;
maxSize:代表了缓存的总大小,若缓存对象的总大小超过了maxSize,DiskLruCache会自动删去最近最少使用的一些缓存对象。
以下代码展示了初始化DiskLruCache的惯用代码:
1
2
3
4
5
|
File diskCacheDir= getAppCacheDir(mContext,
"images"
);
if
(!diskCacheDir.exists()) {
diskCacheDir.mkdirs();
}
mDiskLruCache = DiskLruCache.open(diskCacheDir,
1
,
1
, DISK_CACHE_SIZE);
|
以上代码中的getAppCacheDir是我们自定义的用来获取磁盘缓存目录的方法,它的定义如下:
1
2
3
4
5
6
7
8
9
|
public
static
File getAppCacheDir(Context context, String dirName) {
String cacheDirString;
if
(Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
cacheDirString = context.getExternalCacheDir().getPath();
}
else
{
cacheDirString = context.getCacheDir().getPath();
}
return
new
File(cacheDirString + File.separator + dirName);
}
|
接下来我们介绍如何添加、获取和删除缓存对象。
(2)添加缓存对象
先通过以上介绍的getKeyFromUrl获取Bitmap对象对应的key,接下来我们就可以把这个Bitmap存入磁盘缓存中了。我们通过Editor来向DiskLruCache添加缓存对象。首先我们要通过edit方法获取一个Editor对象:
String key = getKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
获取到Editor对象后,通过调用Editor对象的newOutputStream我们就可以获取key对应的Bitmap的输出流,需要注意的是,若我们想通过edit方法获取的那个缓存对象正在被“编辑”,那么edit方法会返回null。相关的代码如下:
1
2
3
|
if
(editor !=
null
) {
OutputStream outputStream = editor.newOutputStream(
0
);
//参数为索引,由于我们创建时指定一个节点只有一个缓存对象,所以传入0即可
}
|
获取了输出流后,我们就可以向这个输出流中写入图片数据,成功写入后调用commit方法即可,若写入失败则调用abort方法进行回退。相关的代码如下:
1
2
3
4
5
6
7
8
|
//getStream为我们自定义的方法,它通过URL获取输入流并写入outputStream,具体实现后文会给出
if
(getStreamFromUrl(url, outputStream)) {
editor.commit();
}
else
{
//返回false表示写入outputStream未成功,因此调用abort方法回退整个操作
editor.abort();
}
mDiskLruCache.flush();
//将内存中的操作记录同步到日志文件中
|
下面我们来看一下getStream方法的实现,这个方法实现很直接简单,就是创建一个HttpURLConnection,然后获取InputStream再写入outputStream,为了提高效率,使用了包装流。该方法的代码如下:
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
|
public
boolean
getStreamFromUrl(String urlString, OutputStream outputStream) {
HttpURLConnection urlCOnnection =
null
;
BufferedInputStream bis =
null
;
BufferedOutputStream bos =
null
;
try
{
final
URL url =
new
URL(urlString);
urlConnection = (HttpURLConnection) url.openConnection();
bis =
new
BufferedInputStream(urlConnection.getInputStream(), BUF_SIZE);
int
byteRead;
while
((byteRead = bis.read()) != -
1
) {
bos.write(byteRead);
}
return
true
;
}
catch
(IOException e) {
e.printStackTrace();
}
finally
{
if
(urlConnection !=
null
) {
urlConnection.disconnect();
}
//HttpUtils为一个自定义工具类
HttpUtils.close(bis);
HttpUtils.close(bos);
}
return
false
;
}
|
经过以上的步骤,我们已经成功地将图片写入了文件系统。
(3)获取缓存对象
我们使用DiskLruCache的get方法从中获取缓存对象,这个方法的大致源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public
synchronized
Snapshot get(String key)
throws
IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if
(entry ==
null
) {
return
null
;
}
if
(!entry.readable) {
return
null
;
}
/*
* Open all streams eagerly to guarantee that we see a single published
* snapshot. If we opened streams lazily then the streams could come
* from different edits.
*/
InputStream[] ins =
new
InputStream[valueCount];
19
...
return
new
Snapshot(key, entry.sequenceNumber, ins);
}
|
我们可以看到,这个方法最终返回了一个Snapshot对象,并以我们要获取的缓存对象的key作为构造参数之一。Snapshot是DiskLruCache的内部类,它包含一个getInputStream方法,通过这个方法可以获取相应缓存对象的输入流,得到了这个输入流,我们就可以进一步获取到Bitmap对象了。在获取缓存的Bitmap时,我们通常都要对它进行一些预处理,主要就是通过设置inSampleSize来适当的缩放图片,以防止出现OOM。我们之前已经介绍过如何高效加载Bitmap,在那篇文章里我们的图片来源于Resources。尽管现在我们的图片来源是流对象,但是计算inSampleSize的方法是一样的,只不过我们不再使用decodeResource方法而是使用decodeFileDescriptor方法。
相关的代码如下:
1
2
3
4
5
6
7
8
9
10
11
|
Bitmap bitmap =
null
;
String key = getKeyFromUrl(url);
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
if
(snapShot !=
null
) {
FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(
0
);
//参数表示索引,同之前的newOutputStream一样
FileDescriptor fileDescriptor = fileInputStream.getFD();
bitmap = decodeSampledBitmapFromFD(fileDescriptor, dstWidth, dstHeight);
if
(bitmap !=
null
) {
addBitmapToMemoryCache(key, bitmap);
}
}
|
第7行我们调用了decodeSampledBitmapFromFD来从fileInputStream的文件描述符中解析出Bitmap,decodeSampledBitmapFromFD方法的定义如下:
1
2
3
4
5
6
7
8
9
|
public
Bitmap decodeSampledBitmapFromFD(FileDescriptor fd,
int
dstWidth,
int
dstHeight) {
final
BitmapFactory.Options options =
new
BitmapFactory.Options();
options.inJustDecodeBounds =
true
;
BitmapFactory.decodeFileDescriptor(fd,
null
, options);
//calInSampleSize方法的实现请见“Android开发之高效加载Bitmap”这篇博文
options.inSampleSize = calInSampleSize(options, dstWidth, dstHeight);
options.inJustDecodeBounds =
false
;
return
BitmapFactory.decodeFileDescriptor(fd,
null
, options);
}
|
第9行我们调用了addBitmapToMemoryCache方法把获取到的Bitmap加入到内存缓存中,关于这一方法的具体实现下文会进行介绍。
三、图片加载框架的具体实现
1. 图片的加载
(1)同步加载
同步加载的相关代码需要在工作者线程中执行,因为其中涉及到对网络的访问,并且可能是耗时操作。同步加载的大致步骤如下:首先尝试从内存缓存中加载Bitmap,若不存在再从磁盘缓存中加载,若不存在则从网络中获取并添加到磁盘缓存中。同步加载的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public
Bitmap loadBitmap(String url,
int
dstWidth,
int
dstHeight) {
Bitmap bitmap = loadFromMemory(url);
if
(bitmap !=
null
) {
return
bitmap;
}
//内存缓存中不存在相应图片
try
{
|