浅析Cube SDK里的ImageLoader组件是如何加载图片的(一)

为什么使用轻量级的库?

通常我会选择一些成熟的、功能丰富的框架,而这些框架通常是一些重量级的库,也许很多功能你根本用不到,而这些需求之外的功能直接导致了代码体积的增长。也许使用Proguard能移除没有使用的代码,但是类之间如果存在复杂的依赖耦合关系,Proguard还能起到多大作用,Who knows. 有人担心以后可能需要用到那些功能,现在的库没法满足需求. OK, 在使用这个库的时候了解是如何设计和实现的,优秀的库都是对扩展开放的,面向接口编程,那么需要新功能的话就扩展它吧,学习其他人造轮子也会提高自己的编程能力。

曾经一个朋友拿着他们公司大约200kb的类似小米游戏下载软件给我看,我简直无法相信,现在一个功能非常简单的apk动不动就是几兆,能做到这个程度,几乎让人怀疑这个软件的是否专业,然而它在低端机器上表现得都非常流畅。我们费劲心思琢磨如何给apk瘦身,结果增加几个大的jar包就打回了原形,我们要增加第三方统计,友盟的百度的都加上,再加上第三方登录,推送,这些sdk再引用了大量的库比如http组件,json解析,安装包一下子膨胀了几兆,难受但是无奈。

一个图片加载框架的加载流程

1. CubeImageView会检查图片是否已经加载过,图片已经加载完成并已经在显示的重复请求直接忽略。
2. 检查图片是否在内存缓存中,如果再内存中,显示内存中的图片。否则,创建一个ImageTask, 传递给 ImageLoader, ImageTaskExecutor会处理这个任务。
3. ImageTaskExecutor 会用后台线程处理这个ImageTask.
4. ImageProvider负责获取图片,如果图片在本地有文件缓存,那么直接从本地文件加载;否则从网络下载,存文件缓存。
5. 获取到图片之后,存内存缓存,通知加载完成。收到加载完成通知后,ImageView就可以显示图片了。

根据这个加载流程,从核心类入手,逐个分析实现这样一个图片加载框架,我们关注的一些细节问题。

核心类功能介绍

CubeImageView

CubeImageView有多个重载的loadImage方法,将加载的信息封装到ImageRequest, 客户端可以指定要加载图片最终显示的宽高。再看下面的代码

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
private void tryLoadImage() {

if (mRequest == null || TextUtils.isEmpty(mRequest.getUrl())) {
return;
}

int width = getWidth();
int height = getHeight();

ViewGroup.LayoutParams lyp = getLayoutParams();
boolean isFullyWrapContent = lyp != null && lyp.height == LayoutParams.WRAP_CONTENT && lyp.width == LayoutParams.WRAP_CONTENT;
// if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content
// view, hold off on loading the image.
if (width == 0 && height == 0 && !isFullyWrapContent) {
return;
}

mRequest.setLayoutSize(width, height);

// 1. Check the previous ImageTask related to this ImageView
if (null != mImageTask) {

// duplicated ImageTask, return directly.
if (mImageTask.isLoadingThisUrl(mRequest)) {
return;
}
// ImageView is reused, detach it from the related ImageViews of the previous ImageTask.
else {
mImageLoader.detachImageViewFromImageTask(mImageTask, this);
}
}

// 2. Let the ImageView hold this ImageTask. When ImageView is reused next time, check it in step 1.
ImageTask imageTask = mImageLoader.createImageTask(mRequest);
//.createImageTask(mUrl, width, height, mImageReuseInfo);
mImageTask = imageTask;

// 3. Query cache, if hit, return at once.
boolean hitCache = mImageLoader.queryCache(imageTask, this);
if (hitCache) {
return;
} else {
mImageLoader.addImageTask(mImageTask, this);
}
}

这段代码注释非常清晰,那么有几个问题。

如何判断ImageView是被复用的,如何判断重复请求?

每个CubeImageView都绑定了一个ImageTaskImageTask对请求进行了封装,内部类ImageViewHolder使用弱引用持有ImageView. 类似android消息处理中的一个类Message, 内部实现了一个池,但是正式发布的代码没有启用,作者说是因为不太稳定就没用。如果不使用对象池的话,更简单的做法是使用ImageView的setTag方法绑定请求或者加载地址,来达到防止重复加载或者图片错位的目的。
ImageTask这个成员变量如果为null, 表示是第一次加载图片,并且创建ImageTask,查询内存缓存,内存缓存未击中,ze 否则的话这个ImageView就是复用的,接着根据url判断重复请求,如果是新的请求,移除原来的绑定。并将真正的加载任务LoadImageTask从任务队列中移除,取消正在执行的且不是预加载的图片请求。

SimpleTask

SimpleTask继承Runnable, 是一个抽象类,定义了任务的4种状态,STATE_NEW, STATE_RUNNING, STATE_FINISH, STATE_CANCELLED, 代表任务不同的执行状态,使用主线程Looper创建静态Handler与主线程通信,通知任务完成或者取消。如果任务执行过程中出错,设置ImageTask的标记位mFlag, 在回调过程中检查加载是否成功,调用DefaultImageLoadHandler进行view更新,显示loading图片,下载失败的图片或者正确加载的图片。SimpleTask有一个mCurrentThread成员,保存当前正在执行的线程,可以在需要的时候方便调用中断退出任务。

LoadImageTask

LoadImageTask继承SimpleTask, 实现doInBackground方法,从Disk缓存或者网络取数据。重写onFinish, onCancel方法,onFinish是在主线程中执行的,调用ImageLoadHandler对图片进行处理和显示,删除任务队列中已经完成或者取消的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void handleMessage(Message msg) {
SimpleTask work = (SimpleTask) msg.obj;
switch (msg.what) {
case MSG_TASK_DONE:
boolean isCanceled = work.isCancelled();
work.mState.set(STATE_FINISH);
work.onFinish(isCanceled);
break;
default:
break;
}
}

1)为什么要使用ConcurrentHashMap保存待执行的任务?

配置线程池DefaultImageTaskExecutor管理线程,线程池与工作队列BlockingQueue密切相关,其中在工作队列中保存了所有等待执行的任务。所有提交的任务都放入了这个队列,Executor从队列中取出任务执行,用户往队列中添加任务,形成了一个生产者-消费者模型。既然在创建ThreadPoolExecutor时就已经传入了一个BlockingQueue, 为何还需要使用ConcurrentHashMap保存所有的任务呢?原因是ImageLoader启用了生命周期管理,随着Activity或者Fragment生命周期的变迁,在UI从部分可见变为可见时调用pauseWork暂停工作,UI变为可见时调用resumeWork恢复工作,UI变为完全不可见时调用stopWork,将标记位mExitTaskEarly设置为true, 标记位mPauseWork设置为true, 正在执行的任务进入阻塞状态或者退出,已经执行完毕的任务可能在未更新界面的情况下退出,但是这些任务并没有从map里移除,UI从完全不可见变为可见时调用recoverWork, 重新提交map里剩余未执行完的任务。

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
@Override
public void doInBackground() {
if (DEBUG) {
CLog.d(LOG_TAG, MSG_TASK_DO_IN_BACKGROUND, this, mImageTask);
}

if (mImageTask.getStatistics() != null) {
mImageTask.getStatistics().s1_beginLoad();
}
Bitmap bitmap = null;
// Wait here if work is paused and the task is not cancelled
synchronized (mImageLoader.mPauseWorkLock) {
while (mImageLoader.mPauseWork && !isCancelled()) {
try {
if (DEBUG) {
CLog.d(LOG_TAG, MSG_TASK_WAITING, this, mImageTask);
}
mImageLoader.mPauseWorkLock.wait();
} catch (InterruptedException e) {
}
}
}

// If this task has not been cancelled by another
// thread and the ImageView that was originally bound to this task is still bound back
// to this task and our "exit early" flag is not set then try and fetch the bitmap from
// the cache
if (!isCancelled() && !mImageLoader.mExitTasksEarly && (mImageTask.isPreLoad() || mImageTask.stillHasRelatedImageView())) {
try {
bitmap = mImageLoader.mImageProvider.fetchBitmapData(mImageLoader, mImageTask, mImageLoader.mImageReSizer);
if (DEBUG) {
CLog.d(LOG_TAG, MSG_TASK_AFTER_fetchBitmapData, this, mImageTask, isCancelled());
}
mDrawable = mImageLoader.mImageProvider.createBitmapDrawable(mImageLoader.mResources, bitmap);
mImageLoader.mImageProvider.addBitmapToMemCache(mImageTask.getIdentityKey(), mDrawable);
} catch (Exception e) {
e.printStackTrace();
} catch (OutOfMemoryError e) {
e.printStackTrace();
}
}
}

@Override
public void onFinish(boolean canceled) {
if (DEBUG) {
CLog.d(LOG_TAG, MSG_TASK_FINISH, this, mImageTask, mImageLoader.mExitTasksEarly);
}
if (mImageLoader.mExitTasksEarly) {
return;
}

if (!isCancelled() && !mImageLoader.mExitTasksEarly) {
mImageTask.onLoadTaskFinish(mDrawable, mImageLoader.mImageLoadHandler);
}

mImageLoader.mLoadWorkList.remove(mImageTask.getIdentityKey());
}

2)如何退出正在执行的任务,暂停任务和恢复任务?

1
2
3
4
5
6
7
8
9
10
11
synchronized (mImageLoader.mPauseWorkLock) {
while (mImageLoader.mPauseWork && !isCancelled()) {
try {
if (DEBUG) {
CLog.d(LOG_TAG, MSG_TASK_WAITING, this, mImageTask);
}
mImageLoader.mPauseWorkLock.wait();
} catch (InterruptedException e) {
}
}
}

调用wait使任务挂起。当条件改变时,调用notifyAll,唤醒所有在mPauseWorkLock上等待的任务。

1
2
3
4
5
6
7
8
private void setPause(boolean pause) {
synchronized (mPauseWorkLock) {
mPauseWork = pause;
if (!pause) {
mPauseWorkLock.notifyAll();
}
}
}

3)如何配置线程池?

使用一个线程工厂ThreadFactory来创建我们自定义名称的线程,根据cpu的核心数指定ThreadPoolExecutor的corePoolSize和maximumPoolSize,使用一个无界双向队列LinkedBlockingDeque存放任务,设置任务的优先级(FIFO或者LIFO),当队列中没有任务时,设置Timeout退出core线程,防止空闲的线程继续占用系统资源。如何在移动端配置线程池,如何在性能和内存折中,需要去实践对比一下不同的方案。比如Google官方的ThreadSample,对任务进行更细粒度的划分,将图片下载和decode分为两个不同的任务,分别放在下载任务线程池和decode任务线程池中并行执行,这样是能获得性能提升还是会导致性能下降?比如Volley使用一个数组保存启动的工作线程,线程的数目是固定的。

图片复用

比如用户头像,120*120图像下载到本地后,如有80*80的需求,无需再次下载,直接复用120*120图片。那么是如何实现的呢?
假如我需要加载一个地址为http://7xsj0t.com2.z0.glb.qiniucdn.com/IMG_20150613_161908.jpeg?imageView2/2/w/320, 需要先定义一个identity字符串数组,比如[“2/w/120”, “2/w/320”, “2/w/640”], 模式2代表等比缩放, w/120表示宽最多120个像素
请求加载图片时指定图片复用信息,如

1
mImageView.loadImage(mImageLoader, itemData, sImageReuseInfoManger.create("2/w/120"));

ImageReuseInfoManger创建了一个ImageReuseInfo, 保存可以替换的identity字符串数组。假如当前要加载的图片identity是”2/w/320”, 那么就可以复用disk缓存中identity为”2/w/640”的图片。

在加载完图片保存到缓存中的时候,会将该identity和url拼接成一个关键字作为缓存文件的名称。注意到这个url地址是原始地址去掉identity。

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
// read from file cache
inputStream = mDiskCacheProvider.getInputStream(fileCacheKey);

// try to reuse
if (inputStream == null) {
if (reuseInfo != null && reuseInfo.getReuseSizeList() != null && reuseInfo.getReuseSizeList().length > 0) {
if (DEBUG) {
Log.d(TAG, String.format(MSG_FETCH_TRY_REUSE, imageTask));
}

final String[] sizeKeyList = reuseInfo.getReuseSizeList();
for (int i = 0; i < sizeKeyList.length; i++) {
String size = sizeKeyList[i];
final String key = imageTask.generateFileCacheKeyForReuse(size);
inputStream = mDiskCacheProvider.getInputStream(key);

if (inputStream != null) {
if (DEBUG) {
Log.d(TAG, String.format(MSG_FETCH_REUSE_SUCCESS, imageTask, size));
}
break;
} else {
if (DEBUG) {
Log.d(TAG, String.format(MSG_FETCH_REUSE_FAIL, imageTask, size, key));
}
}
}
}
}

最后在取disk缓存时,会检查复用信息,比如想要加载地址是“…/2/w/120”的图片,尝试读取key为“…/2/w/320”的缓存图片,没有的话再尝试下一个key为“…/2/w/640”的图片,查询不到复用的缓存就只能从网络下载。大致就是这样

Executors.newCachedThreadPool有毒 - Executors.newCachedThreadPool() considered harmful
ThreadSample - Google官方最佳实践系列之使用多线程
ImageLoader文档 - Cube SDK官方文档
七牛文档 - 图片基本处理