同事写的一篇文章,mark下。
1. 开始
本文主要介绍了android现下流行的图片加载库中的3个,通过读取源码,比较设计架构,来总结移动应用中图片加载的共同特点,同时比较这三个库的设计模式,博取众家之长,补己之短,第一篇文章,词法不好之处还请海涵,若有错误不到之处,还望指正!
2. App图片加载的通用策略
Android应用中使用的图片来源不外乎这几种:本地资源drawable/assets/content/file,网络资源https/http。单纯从文件加载来说,android系统原生sdk已经提供了读取和解码的方法,但是这些加载任务需要和UI主线程并存,共享有限的硬件资源,为保证页面流畅性,通常UI线程享有最高的优先级,工作者线程的优先级总是比UI线程要低,所以图片加载策略在这方面会考虑得很细致,同时图片加载要尽可能节省无线流量,一方面是为用户流量费用考虑,另一方面也是考虑到系统性能问题。
基于上面的设计思路,图片加载策略都会包含这些技术实现:两级缓存(内存/磁盘)、一级网络请求、线程池、降采样解码、重复请求合并等,下面用这张图来说明通用的加载策略流程:
在Android应用中,将处理当前UI操作的线程称为主线程(MainThread),主线程的流畅性直接决定了用户对一款应用的打分,频繁的界面卡顿或者ANR(Application Not Response)就是因为主线程的负担太重(磁盘操作)或者线程被阻塞(网络请求),所以必须把费时的任务(Task)从主线程移出,放到后台线程去处理,其中磁盘文件读取和网络请求就是两大典型的任务。
从图片加载的实例来看,主线程要为ImageView加载一张可显示的图片,通常都会提供一个URI,目标资源可能在内存、磁盘或者远程服务器,三者的时间消耗就如颜色的警示:内存<<磁盘<<网络。Android为了线程安全,规定UI操作必须在主线程内执行,内存Cache查询属于高效的操作也可以在主线程中运行,除此之外的其他操作都应该异步化,放在Worker线程去执行,这是保证UI流畅性的必然策略!Worker线程通常放在线程池中做统一管理,完成任务后,通过Handler通知主线程更新UI。
以上是Android应用开发中图片加载的通用策略介绍,本文剩余部分将按顺序介绍3个图片加载库的具体设计方案,这3个库都是面向接口编程的,具备很好的扩展性,每个库都按照架构图、流程图、类关系图以及核心类解析的顺序进行说明。
- Volley: https://android.googlesource.com/platform/frameworks/volley
- UIL:https://github.com/dodola/Android-Universal-Image-Loader
- Picasso:http://square.github.io/picasso/
3. Volley
3.1 简介
Volley 是 Google 推出的 Android 异步网络请求框架和图片加载框架。在 Google I/O 2013 大会上发布。
3.2 架构图
Volley的设计架构非常清晰,以任务的不同阶段可以将Volley的设计框架分为以上四层,每个请求对象对应一项Task,Task通过全局单例RequestQueue对象加入调度线程的等待队列,调度线程从阻塞等待队列取得Request,根据目标数据的存储位置调用不同的请求数据接口,得到数据后通过实现ResponseDelivery接口的对象,把任务执行结果通知到主线程。
3.3 流程图
Volley的调用接口非常简单,同名的Volley类提供了静态方法newRequestQueue(),用于返回一个任务管理对象RequestQueue,并在这个方法内完成了RequestQueue对象的所有初始化操作。
RequestQueue对象聚合了1个Set和3个队列,这四个对象构成Request对象生命周期的中转站,作用不言而喻:
(1)mCurrentRuqests:保存当前正在进行或者等待的非重复请求(Uri不同);
(2)mWaitingQueue:合并相同uri对应的请求,在此等候;
(3)mCacheQueue:读取本地缓存的请求队列,缓存miss时将会加入mNetworkQueue;
(4)mNetworkQueue:需要网络连接的请求队列,请求不能缓存时从主线程直接加入,请求可缓存但缓存未命中时,由CacheDispatcher线程加入。
mWaitingQueue是一个Map对象,用于缓存相同的请求,在请求处理完成后,相同请求通过调用RequestQueue的finish()方法被回调,这是volley处理相同请求的方式。
Volley的Request处理方式使用的是生产者-消费者模式,这一步Volley实现了通用设计框架的任务异步化。Worker线程包含一个读本地缓存的线程CacheDispatcher,4个网络请求线程NetWorkDispatcher(使用默认配置时),CacheDispatcher作为RequestQueue的一个成员对象,NetWorkDispatcher[n]作为RequestQueue的成员数组对象,并都持有mNetworkQueue阻塞队列,NetWorkDispatcher线程作为队列的消费者,在这里Volley并没有选择使用线程池管理Worker线程,这5个Worker线程在队列为空时会阻塞等待。
需要指出的是默认情况下Volley并没有实现内存缓存,在构造ReuqestQueue的时候只添加了DiskBasedCache这个磁盘缓存,这个类实现了Cache接口,为了快速查询磁盘中缓存的文件,这个类定义了CacheHeader这个内部类,并且定义了Map<string, cacheheader> mEntries = new LinkedHashMap<string, cacheheader>(16, .75f, true);成员对象,用于在内存中缓存磁盘文件的描述信息,mEntries可以认为是磁盘文件在内存中的索引,占用空间很小,这个设计值得借鉴。LinkedHashMap的accessOrder入参为true,开启LRU排序,每次缓存新对象前都会调用pruneIfNeeded()方法,预留足够的空间,Volley在这个地方又做了一个小优化策略,每次裁剪后的缓存空间是最大缓存空间(默认5M)的HYSTERESIS_FACTOR倍(默认=0.9),这样避免了在缓存空间满的情况下,加入新对象时每次都要进行prune操作。
Volley缓存的磁盘文件内容是原始HttpResponse的ByteStream,也就是保存原始的数据,并且在DiskBasedCache中自定义了缓存文件的格式。
请求处理完成后通过ExecutorDelivery对象post到主线程,在RequestQueue的构造方法中需要一个ResponseDelivery对象,默认传入的ExecutorDelivery对象持有向主线程发送消息的Handler,new ExecutorDelivery(new Handler(Looper.getMainLooper())),这是Volley实现通用设计框架的最后一步。
3.4 类关系图
Volley的类关系图很简洁,所有的请求类都派生于Request类,默认提供了4个子类,数据请求都是面向接口编程(interface Cache / NetWork),Response的分发也是通过interface ResponseDelivery来定义,可以说是一个高度解耦的框架。
3.5 核心类
从类关系图可以看出Volley包含四个核心类:
(1)Request:所有请求都必须继承的抽象类,约定了请求处理流程中用到的所有必备接口方法,同时这个抽象类实现了Comparable<request>接口,可以对Request的优先级进行排序。
(2)RequestQueue:Volley的对象管理核心,聚合了4个请求队列、1个CacheDispatcher对象、1个NetWorkDispatcher对象数组、引用实现了Cache/Network/ResponseDelivery接口的对象。通过RequestQueue.add(Request)方法添加请求到阻塞队列,可以认为是生产者,加入队列中的请求由消费者线程取走。
(3)CacheDispatcher:继承于Thread类,本身代表了一个Worker线程类,从RequestQueue.mCacheQueue队列获取Request对象,如果缓存命中,就在自己线程内处理完请求,否则将请求移交给网络请求队列RequestQueue.mNetworkQueue,相当于网络请求队列的生产者。
(4)NetworkDispatcher:继承于Thread类,作为Worker线程类,在Volley中作为RequestQueue.mNetworkQueue队列的消费者,负责网络请求的具体实现。
3.6 思考
(1)Volley面向接口编程,各个模块间高度解耦,代码组织上也很清晰,在此基础上用户可以很容易地将其定制为符合自己需要的lib
(2)Volley能够加载任意的对象资源,不仅仅是图片,作为一个通用的框架,没有为图片加载过程的细节做太多的考虑,比如没有留图片预处理和后处理的操作接口、列表滚动时不能暂停网络请求、缺少图片的内存Cache、不能设置加载前的占位图,有这些需求要自己添加改造,所以使用Volley作为图片加载库的功能还不够完善。
4. UIL
4.1 简介
UIL(Android Universal Image Loader)为App提供异步图片加载、缓存和显示的解决方案,支持Android2.0+。
4.2 架构图
UIL通过ImageLoader对外提供两个图片加载的调用方法,图片加载的请求根据是否命中MemeoryCache,被封装为两种任务(实现Rnnable接口),ImageLoader将任务提交给ImageLoaderEngine的线程池开始处理,封装好的任务流程调用数据请求接口,得到Bitmap对象后,通过Handler通知UI主线程。
4.3 流程图
UIL是专门下载图片的Lib,不提供下载其他数据的接口(不同于Volley),几乎支持所有的图片的来源,这是UIL代码中枚举出的可支持的图片来源scheme:HTTP("http"), HTTPS("https"), FILE("file"), CONTENT("content"), ASSETS("assets"), DRAWABLE("drawable"), UNKNOWN("")
使用UIL需要通过ImageLoaderConfiguration对象对ImageLoader进行初始化,初始化项包括:内存Cache、磁盘Cache、ImageDownloader、ImageDecoder,3个后台线程池。最顶层的ImageLoader被设计为全局单例,通过init(ImageLoaderConfiguration)方法接收配置参数,同时在内部构造了ImageLoaderEngine对象。
UIL为用户提供了两个图片加载方法:void displayImage(String uri, ImageView imageView, DisplayImageOptions options, ImageLoadingListener listener)是通用的图片加载方法,也就是上面主流程的入口,loadImage不接收ImageView入参,用于加载窗口小部件的图片,最终也是调用displayImage方法加载图片。图片加载过程的特定步骤可以通过入参ImageLoadingListener得到通知。
UIL对图片加载可能用到的处理方法都预留了接口,预处理PreProcessor在加入内存缓存前执行,后处理PostProcessor在加入内存缓存后执行,所以如果要对图片做统一的处理,可以添加PreProcessor对象,特定处理可以添加PostProcessor对象(比如旋转、缩放)。磁盘中保存的是压缩后的原始图片,这些处理任务都是在工作者线程中完成的。
实际任务的处理流程被封装为:ProcessAndDisplayImageTask和LoadAndDisplayImageTask这两个任务,第一个任务在内存Cache命中时处理PostPrecessor,再通过Handler.post(DisplayBitmapTask·)分发出去,任务很轻;第二个任务包含内存Cache Miss后的全部任务流程:调用Decoder从本地磁盘加载图片,失败时调用ImageDownloader从远程下载图片,处理PreProcessor和PostProcessor,写DiskCache和MemoryCache,回调ImageLoadingListener,最后把任务处理结果通过Handler.post(DisplayBitmapTask)分发出去。
在Volley中我们说明了相同请求的处理方法,使用的是Map<string, queue<request<?>>> mWaitingQueue;这个结构,在UIL中使用的则是ReentranceLock锁,每个锁实例通过Map<string, reentrantlock> uriLocks与uri关联,相同的uri被映射到同一个锁实例,在第一个任务启动后,锁即被Lock,相同的uri只能阻塞等待,直到第一个任务将锁Unlock,等待锁的任务被唤醒后,会先查询一次MemoryCache,如果前一个任务成功写了MemoryCache,这个重复的任务可以直接命中MemoryCache。Uri不同的任务照常执行,这里抢占锁的几率比较低,所以使用锁的性能消耗不大。
流程图的下半部分有多个红色的圆圈,这是LoadAndDisplayImageTask任务在执行过程中检查是否要取消当前任务的测试点,任务取消的来源可以是:(1) ImageView重用绑定了另一个uri;(2) 线程被interrupted中断。
ImageLoaderEngine提供了resume ()和 pause ()方法来启动或暂停引擎任务,在LoadAndDisplayImageTask入口的地方同步了engine.paused对象,所以未启动的任务将被阻塞,已启动的任务继续执行。为此UIL还提供了PauseOnScrollListener接口,用于监听滚动列表的状态变化事件,在进入OnScrollListener.SCROLL_STATE_FLING状态时,调用pause()停止任务,在进入OnScrollListener.SCROLL_STATE_IDLE状态时,调用resume()启动任务。
4.4 类关系图
UIL对图片加载流程的各个细节和步骤考虑得很细致,对应提供的类也比较多,所有被聚合的类成员对象都是通过接口定义的,从类关系图可以清晰地看到各个功能模块之间的依赖关系,通过接口定义将模块间的耦合度降到最低。
4.5 核心类
(1)ImageLoader:是universalimageloader.core包下的类,也是UIL最顶层的核心类,在整个应用上下文中是单例,对用户提供两个调用方法:void displayImage(String uri, ImageView imageView, DisplayImageOptions options, ImageLoadingListener listener)和void loadImage(String uri, ImageSize targetImageSize, DisplayImageOptions options, ImageLoadingListener listener),接口使用极其简单,内部组合了1个ImageLoaderEngine对象,负责执行实际的图片加载任务。
(2)ImageLoaderConfiguration:为ImageLoader提供配置参数,配置项包括内存Cache、磁盘Cache、ImageDownloader、ImageDecoder和FileNameGenerator等核心模块,查看UIL提供的源码,应用启动时在UILApplication.onCreate()中通过建造者模式构造了这个对象,作为ImageLoader核心类的默认配置,从类关系图可以看到ImageLoaderConfiguration聚合的核心模块都是通过接口定义的,模块之间高度解耦,并且可定制。
(3)ImageLoaderEngine:UIL的图片加载引擎,称之为引擎是因为这个类提供了3个完成Task任务的线程池对象。taskExecutor是负责从远程网络加载图片的线程池对象,默认大小为3;taskExecutorForCachedImages负责从本地缓存加载图片,默认大小也是3; taskDistributor是向taskExecutor和taskExecutorForCachedImages线程池分发Task任务的线程池,其工作线程不会阻塞,所以池的大小没有限制,其实用单线程来代替也是完全可以的。
(4)LoadAndDisplayImageTask:定义具体图片加载任务的类,负责从磁盘缓存或远程网络加载图片数据,并写入MemCache和DiskCache,图片加载完成后通过handler.post(DisplayBitmapTask)通知主线程,使用到的加载工具对象都来源于内部持有的ImageLoaderConfiguration对象,与初始化ImageLoader的配置对象是同一个,具体的任务执行流程请看UIL流程图部分。
(5)BaseImageDownloader的网络请求实现基于HttpUrlConnection,HttpClientImageDownloader继承于BaseImageDownloader,重写了基类的getStreamFromNetwork()方法,使用org.apache.http.client.HttpClient取而代之
4.6 思考
(1)UIL同样基于接口聚合各个功能模块,模块之间解耦可定制;
(2)UIL从网络加载Bitmap到本地后,先根据压缩格式写入了磁盘,然后把这个Bitmap recycle()掉,接着又从磁盘decode出这个url对应的图片,所以UIL在从远程网络加载图片时多了一次从磁盘decode图片的操作。
(我对于第二点的理解有偏差,请看14F笔歌的说明,下面第(3)点是根据笔歌的建议,避免误导他人,我重新纠正论述一下)
(3)UIL从网络加载图片的动作可以分两步:第一步从网络加载图片时,进行的降采样decode实际上是根据全局配置对象ImageLoaderConfiguration 中设置的 maxImageWidthForDiscCache 和 maxImageHeightForDiscCache 这两个变量限制的,得到的图片压缩后存入磁盘;接着从磁盘再次decode图片时,是根据实际view的尺寸加载的,bitmap会小很多。所以对于同一个uri,在磁盘中只对应一张图片,并且这张图片在下载时会尽可能细致,而decode到内存时是按需加载,所以内存中会有这张图片不同size的版本,内存Cache的memoryCacheKey为了保存这些不同size的图片,会在key的末尾加后缀,格式为:" uri_WxH "如 " uri_128x256 "," uri_256x512 "。UIL这么设计是为了各个分辨率的图片能有最佳的显示效果,否则下载小图再放大质量就太差了。
5. Picasso
5.1 简介
Picasso是Square公司的开源项目,是一款强大的图片加载和缓存库。
5.2 架构图
Picasso下载图片的参数使用Builder模式进行配置,所以写成一句话就可以完成图片加载;请求被封装为Action,并提供RequestHandler的实现类对不同Action进行处理;任务的调度使用Dispatcher对象进行调度,耗时的加载流程放在后台线程池执行;数据接口包含内存Cache、DownLoader,磁盘Cache使用Http缓存,完成任务时通过Handler通知UI主线程。
5.3 流程图
Picasso的图片加载流程可以根据任务所在的线程来区分不同阶段。
首先是Picasso的初始化,调用Picasso.with(Context)即可,第一次调用会使用Picasso.Builder构造一个全局的单例对象,在构造过程new了一个Dispatcher对象,这个对象很重要,作为Picasso的任务调度器,将Action从UI主线程调度到Worker线程,任务完成后又从Worker线程调度回UI主线程。启动图片加载任务使用Picasso.with(ApplicationContext).load(imageUrl).centerCrop().into(ImageView); 一句话完成指定url图片的定制和加载,接口再简单不过了,详细使用方法请查看官网说明:http://square.github.io/picasso/
这里需要重点说明Dispatcher对象的任务调度原理,Dispatcher对象内部持有一个线程类对象:class DispatcherThread extends HandlerThread,可以看到DispatcherHandler继承于Android系统的HandlerThread,因此可以利用这个线程的Handler把各种消息post到Looper上,Looper在收到消息时,又会主动回调Handler的handleMessage方法,根据消息ID区分不同的消息,这是Picasso任务调度的设计方案,显得非常优雅。从上面的流程图可以看到Dispatcher线程在UI主线程和Worker线程之间起到了桥接的作用。Volley和UIL直接使用生产者-消费者模式(PriorityBlockingQueue)将请求任务异步化。在任务执行完毕后,这三个框架都是使用主线程的Handler把处理结果post到主线程,这种做法也是Android异步线程和主线程通信的标准做法。
Picasso有一些很好的细节设计,需要说明一下,请看流程图中红色①~⑤标记的位置:
(1)DeferredRequestCreator是Request请求的延迟构造器,这个类实现了ViewTreeObserver.OnPreDrawListener接口,这个接口中的方法在Android对ViewTree调用完layout()之后调用,那这个类存在的意义显然和android的视图绘制机制有关。对于一个View,如果它的布局参数要依赖于其他View,那么系统在对视图树调用layout()之前,这个view的宽高是未知的。由于Picasso在图片加载时要对图片计算降采样率,View的宽高不确定,对于Picasso来说就是不知道应该加载什么样的图片最合适,所以有了这个延迟构造器。
(2)在UIL中提到了PauseOnScrollListener接口,用于在滚动列表Fling时停止引擎的加载任务,Picasso同样提供了这个功能,请看流程图中②的位置,Tag标识的请求被暂停时,请求直接进入pauseActions等待,Picasso.resumeTag(Object)和Picasso.pauseTag(Object)就是用于唤醒和停止请求任务的API,这里的Object是全局唯一的应用上下文。源码的示例也提供了SampleScrollListener来演示这个功能。
(3)在Volley和UIL中都提到了重复请求的处理方法:Volley使用mWaitingQueue,UIL使用了ReentranceLock,这里请看流程图中③的位置, Picasso策略和Volley相同:先使用Action的请求参数计算requestKey,作为Action的唯一标识,requestKey相同的Action将被放入BitmapHunter.actions列表中,在任务执行完毕后,一并得到回调通知,通知动作发生在流程图中⑥的位置。
(4)流程图中④的位置对得到的bitmap进行了变换操作,这里会开辟新的内存用于缓存中间结果的图片,如果多个后台线程同时执行这一步,很可能导致应用OOM而挂掉,所以Picasso借鉴了Volley在解析图片时的保守策略(详见Volley的ImageRequest.parseNetworkResponse()方法),使用synchronized (DECODE_LOCK)同步了一个静态类成员,同一时刻只允许一个线程进行这个操作,避免潜在的OOM bug。这个设计来源在BitmapHunter.java类文件中DECODE_LOCK变量定义的位置是有注释说明的。
(5)Picasso的任务处理结果是按批次分发出去的,这个动作发生在流程图中⑤的位置,操作流程是这样的:设置任务完成的Messag要延迟200ms(默认时长)后再发送出去,同时这个任务会被加入Dispatcher.batch的这个成员对象中,如果在接下去的200ms内有新完成的任务到达,也会被加入Dispatcher.batch成员对象中,等200ms延时到达时,将batch统一发送到主线程,然后清空batch,用于下一批的Message的容器。
(6)Volley和UIL都对请求进行优先级排序,Picasso也有相同的功能实现,将BitmapHunter任务包装到PicassoFutureTask中,并实现Comparable接口,class PicassoFutureTask extends FutureTask implements Comparable。Picasso提供了LOW/NORMAL/HIGH三个任务优先级,线程池使用PriorityBlockingQueue,所以优先执行高优先级的任务。
最后再说一下Picasso的磁盘缓存,Picasso使用OkHttpClient和HttpUrlConnection作为Downloader接口的实现,并且优先使用OkHttpClient,Picasso会默认开启Client的Response缓存,所以不像UIL那样专门提供了DiskCacheAware的磁盘缓存接口。
5.4 类关系图
Picasso的类设计大量使用了组合模式,总体架构设计得很巧妙,对于内存Cache和Downloader也是通过接口聚合,这一点和Volley/UIL是相同的。
5.5 核心类
(1)Picasso:与Lib同名的Picasso类作为最上层的管理类,聚合了lib运行时必须的功能模块,包括Dispatcher、内存Cache、RequestHandler列表等,同时对用户提供简洁的调用接口。
(2)Dispatcher:Picasso的任务调度类,提供调度任务的接口,通过内部组合的DispatcherThread线程类对象在UI线程和Worker线程间传递任务,内部的调度流程对用户是透明的。
(3)Action:包装Request请求的抽象类,同时提供请求处理成功或失败时使用的资源,子类必须实现complete()和error()抽象方法,作为任务处理结果的回调。
(4)RequestHandler:处理异步图片加载任务的抽象类,子类必须实现canHandlerRequest(Request)和load(Request,int)两个抽象方法,前者判断这个RequestHandler能否处理入参Request,后者执行实际的Request请求。Picasso默认提供了7种RequestHandler,如果用户有其他类型的Handler,可以通过Picasso.Builder. addRequestHandler(RequestHandler)进行注册。新加入的RequestHandler从List requestHandlers的第1个下标位置开始放,Picasso遍历调用每个已注册的RequestHandler.canHandlerRequest()方法,找到第一个可以处理Request的RequestHandler即返回,所以能够优先使用用户自定义的RequestHandler。
(5)BitmapHunter:封装异步任务的具体处理流程,该类实现了Runnable接口,每个BitmapHunter实例对应一个独立不重复的请求(指requestKey独立不相同),任务流程在Worker线程执行,负责从磁盘或远程网络加载图片。
5.6 思考
(1)Picasso写得很专业,每个功能都有详细的测试用例,理清Picasso的设计思路,其中的细节值得参考借鉴。
6. 总结比较
| Volley | UIL | Picasso |
初始化 | Volley.newRequestQueue() | ImageLoader.getInstance(). init(ImageLoaderConfiguration) | Picasso.with(Context) |
调用接口 | RequestQueue.add(Request<T>) | ImageLoader.displayImage(Url, ImageView); ImageLoader.loadImage(Url, ImageLoadingListener); | Picasso.with(Context).load(url) .placeHolder(). error(R.drawable.error).into(); |
多类型请求 | 通过构造不同的Request子类实现 | 只支持图片资源:http, https, file,content, assets, drawable | 通过提供不同的Action子类和RequestHandler子类实现 |
支持的资源 | 远程Json、Image、String | 远程和本地的Image | 远程和本地的Image |
重复请求 | 使用Map<String, Queue<Request<?>>> mWaitingQueue去重 | 使用ReentranceLock去重 | 使用BitmapHunter类的List<Action> actions去重 |
列表滚动停止加载 | Volley本身不提供支持方法 | ImageLoaderEngine提供 resume()和pause()方法支持 | Picasso提供resumeTag(Object)和PauseTag(Object)方法支持 |
降采样加载解码 | 完整读取远程图片的字节流, 在本地进行降采样解码 | 远程读取的图片尽可能清晰(见4.6(3)的说明) 压缩后保存到本地, 之后根据目标view降采样解码出不同size的图片 | 远程和本地都支持降采样解码 |
Volley是Google官方提供的通用IO框架,并不针对每个细节进行优化,但是总体的框架值得学习;UIL的设计针对客户端图片加载,对各类图片来源都支持,框架的各个模块通过接口定义,耦合度底,使用时对不需要的功能可以进行裁剪;Picasso也是针对客户端图片加载,框架设计的很巧妙,并且每个功能包含详细的测试用例,功能也很完善。要从这三个图片加载框架选择,还是推荐Picasso,这三个Lib只是学习用,当然还有其他的选择。