本篇是系列的第二篇,专门讲述Doodle的设计和实现,概述和用法见另外两篇文章:
(一)Doodle - 精简的图片加载框架 - 概述篇
(三)Doodle - 精简的图片加载框架 - 用法篇
原理篇涉及代码较多,最好能配合源码阅读。
https://github.com/BillyWei01/Doodle
一、架构
解决复杂问题,思路都是相似的:分而治之。
Doodle的核心的类不多:
参考MVC的思路,我们将框架划分三层:
- Interface: 框架入口和外部接口
- Processor: 逻辑处理层
- Storage:存储层,负责各种缓存。
结构图如下,包含了框架的部分核心类及其依赖关系(A->B表示A依赖B)。
外部接口
Doodle: 提供全局参数配置,图片加载入口,以及缓存,生命周期,任务暂停/恢复等接口。
Config: 全局参数配置。包括缓存路径,缓存大小,默认编码,自定义Downloader/Decoder等参数。
Request: 封装请求参数。包括数据源,解码参数,行为参数,以及目标(ImageView等)。执行单元
Controller : 负责请求调度, 以及结果反馈等,是“请求-工作线程-目标”之间的桥梁。
Worker: 工作线程,异步执行加载,解码,变换,存储等。
Decoder: 负责具体的解码工作,包括计算缩放比例(降采样/上采样),图片剪裁,图片方向处理等。
DataFetcher: 负责数据源的解析,提供统一的信息提取,文件解码等接口。
Downloader: 负责文件下载。存储组件
MemoryCache: 管理Bitmap缓存,包含LRU缓存和引用缓存。
DiskCache: 文件缓存管理,分别提供给Worker(结果缓存)和Downloader(原图缓存)。
二、流程
仅从结构图不足以了解框架的运作机制,接下来我们结合流程作分析。
概括来说,图片加载包含封装参数,获取数据,解码,变换,缓存,显示等操作。
获取Request
Doodle类中定义了几个静态方法,返回Request对象,作为请求的开始。封装参数
从指定来源,到输出结果,中间可能经历很多流程,这些参数会贯穿整个过程,前面的结构图可以看出这一点。
封装参数完成后,会构造出一个key, 用于索引缓存。内存缓存
Controller-Worker是UI线程和后台线程的分界。
请求的开始,先读一下内存缓存是否存在所请求的bitmap, 如果存在则直接显示,否则启用后台线程。Worker
进入工作线程后,其实还是会再检查一次内存缓存,上图中简略了,没有画出来。
如果内存缓存中没有所需要的bitmap, 则先尝试读取结果缓存:
如果存在,则直接解码,得到bitmap后缓存到内存并显示;
若不存在,则需要获取数据并解码,这个解码要比从读取结果缓存后的解码要复杂许多(可能需要采样或剪裁)。
解码原文件后,如果请求中设置了Transformation的话,需要执行Transformation,完了之后还在保存到内存以及磁盘(结果缓存)。
值得一提的是:只又网络文件才有“原图缓存”一说,本地文件不需要再做“缓存”。显示图片
显示结果,可能需要做些动画(淡入动画,crossFade等);
如果结果动图(Animatable),则启动一下动画。
以上简化版的流程(只是众多路径中的一个分支),更多细节我们接下来慢慢分析。
三、API设计
前面提到,Config类负责全局参数配置,Request承载单个请求的参数封装。
二者都有Doodle的静态方法提供对象实例。
public final class Doodle {
public static Config config() {
return Config.INSTANCE;
}
// load bitmap by file path, url, or asserts path
public static Request load(String path) {
return new Request(path);
}
// load bitmap from drawable or raw resource
public static Request load(int resID) {
return new Request(resID);
}
public static Request load(Uri uri) {
return new Request(uri);
}
}
示例用法如下:
全局配置:
Doodle.config()
.setLogger(Logger)
.setSourceFetcher(OkHttpSourceFetcher)
.addDrawableDecoders(GifDecoder)
图片加载请求:
Doodle.load(path).into(imageView);
Request类:
public final class Request {
// 缓存key
private CacheKey key;
// 数据源
final String path;
Uri uri;
private String sourceKey;
// 解码参数
int targetWidth;
int targetHeight;
ClipType clipType = ClipType.NOT_SET;
boolean enableUpscale = false;
DecodeFormat decodeFormat = DecodeFormat.ARGB_8888;
List<Transformation> transformations;
Map<String, String> options;
// 加载行为
MemoryCacheStrategy memoryCacheStrategy = MemoryCacheStrategy.LRU;
DiskCacheStrategy diskCacheStrategy = DiskCacheStrategy.ALL;
int placeholderId = -1;
int errorId = -1;
int animationId;
// ...此处省略一些参数...
// 目标
Request.Waiter waiter;
SimpleTarget simpleTarget;
WeakReference<ImageView> targetReference;
}
Request主要职能是封装请求参数,参数可以大约划分为4类:
- 1、图片源:内置支持File,Uri,http,drawable,raw,assets等,可以扩展。
- 2、解码参数:宽高,缩放/剪裁类型,解码格式……等。
- 3、加载行为:缓存策略,占位图,动画……等。
- 4、目标:ImageView或者接口回调等。
其中,图片源和解码参数决定了最终的bitmap, 所以,我们拼接这些参数请求作为key,这个key会用于缓存的索引和任务的去重。
拼接参数后字符串很长,所以需要压缩成摘要,128bit的摘要即可(原理参考:生日攻击)。
图片文件的来源,通常有网络文件,drawable/raw资源, assets文件,本地文件等。
当然,严格来说,除了网络文件之外,其他都是本地文件,只是不同形式而已。
四、 缓存设计
几大图片加载框架都实现了缓存,各种文章中,有说二级缓存,有说三级缓存。
其实从存储来说,可简单地分为内存缓存和磁盘缓存。
同样是内存/磁盘缓存,也有多种形式,例如Glide的“磁盘缓存”就分为“原图缓存”和“结果缓存”,
而Picasso/Coil只依赖OkHttp缓存网络图片的原图,并没有实现自己的磁盘缓存,也就没有保存解码后的结果了。
4.1 内存缓存
为了复用计算结果,提高用户体验,通常会做bitmap的缓存;
而由于要限制缓存的大小,需要淘汰机制(通常是LRU策略)。
Android SDK提供了LruCache类,查看源码,其核心是LinkedHashMap。
为了更好地定制,这里我们不用SDK提供的LruCache,直接用LinkedHashMap,封装自己的LruCache。
private static class BitmapWrapper {
final Bitmap bitmap;
final int bytesCount;
BitmapWrapper(Bitmap bitmap) {
this.bitmap = bitmap;
this.bytesCount = Utils.getBytesCount(bitmap);
}
}
final class LruCache {
private static final long MIN_TRIM_SIZE = Runtime.getRuntime().maxMemory() / 64;
private static long sum = 0;
private static final Map<CacheKey, BitmapWrapper> cache = new LinkedHashMap<>(16, 0.75f, true);
static synchronized Bitmap get(CacheKey key) {
BitmapWrapper wrapper = cache.get(key);
return wrapper != null ? wrapper.bitmap : null;
}
static synchronized void put(CacheKey key, Bitmap bitmap) {
long capacity = Config.memoryCacheCapacity;
if (bitmap == null || capacity <= 0 || cache.containsKey(key)) {
return;
}
BitmapWrapper wrapper = new BitmapWrapper(bitmap);
cache.put(key, wrapper);
sum += wrapper.bytesCount;
if (sum > capacity) {
trimToSize(capacity * 9 / 10);
}
}
private static void trimToSize(long size) {
Iterator<Map.Entry<CacheKey, BitmapWrapper>> iterator = cache.entrySet().iterator();
while (iterator.hasNext() && sum > size) {
Map.Entry<CacheKey, BitmapWrapper> entry = iterator.next();
BitmapWrapper wrapper = entry.getValue();
WeakCache.put(entry.getKey(), wrapper.bitmap);
iterator.remove();
sum -= wrapper.bytesCount;
}
}
}
LinkedHashMap 构造函数的第三个参数:accessOrder,传入true时, 元素会按访问顺序排列,最后访问的在遍历器最后端。
进行淘汰时,移除遍历器前端的元素,直至缓存总大小降低到指定大小以下。
有时候需要加载比较大的图片,占用内存较高,放到LruCache可能会“挤掉”其他一些bitmap;
或者有时候滑动列表生成大量的图片,也有可能会“挤掉”一些bitmap。
这些被挤出LruCache的bitmap有可能很快又会被用上,但在LruCache中已经索引不到了,如果要用,需重新解码。
值得指出的是,被挤出LruCache的bitmap,在GC时并不一定会被回收,如果bitmap还被引用,则不会被回收;
但是不管是否被回收,在LruCache中都索引不到了。
我们可以将一些可能短暂使用的大图片,以及这些被挤出LruCache的图片,放到弱引用的容器中。
在被回收之前,还是可以根据key去索引到bitmap。
private static class BitmapReference extends WeakReference<Bitmap> {
private final CacheKey key;
BitmapReference(CacheKey key, Bitmap bitmap, ReferenceQueue<Bitmap> q) {
super(bitmap, q);
this.key = key;
}
}
final class WeakCache {
private static final Map<CacheKey, BitmapReference> cache = new HashMap<>();
private static final ReferenceQueue<Bitmap> queue = new ReferenceQueue<>();
static synchronized Bitmap get(CacheKey key) {
cleanQueue();
BitmapReference ref = cache.get(key);
return ref != null ? ref.get() : null;
}
static synchronized void put(CacheKey key, Bitmap bitmap) {
cleanQueue();
if (bitmap != null) {
BitmapReference ref = cache.get(key);
if (ref == null || ref.get() != bitmap) {
cache.put(key, new BitmapReference(key, bitmap, queue));
}
}
}
private static void cleanQueue() {
BitmapReference reference = (BitmapReference) queue.poll();
while (reference != null) {
BitmapReference ref = cache.get(reference.key);
if (ref != null && ref.get() == null) {
cache.remove(reference.key);
}
reference = (BitmapReference) queue.poll();
}
}
}
以上实现中,BitmapWeakReference是WeakReference的子类,除了引用Bitmap的功能之外,还记录着key, 以及关联了ReferenceQueue;
当Bitmap被回收时,BitmapWeakReference会被放入ReferenceQueue,
我们可以遍历ReferenceQueue,移除ReferenceQueue的同时,取出其中记录的key, 到cache中移除对应的记录。
利用WeakReference和ReferenceQueue的机制,索引对象的同时又不至于内存泄漏。
最后,综合LruCache和WeakCache,统一索引:
final class MemoryCache {
static Bitmap getBitmap(CacheKey key) {
Bitmap bitmap = LruCache.get(key);
if (bitmap == null) {
bitmap = WeakCache.get(key);
}
return bitmap;
}
static void putBitmap(CacheKey key, Bitmap bitmap, boolean toWeakCache) {
if (toWeakCache) {
WeakCache.put(key, bitmap);
} else {
LruCache.put(key, bitmap);
}
}
// ...
}
声明内存缓存策略:
public enum MemoryCacheStrategy {
NONE,
WEAK,
LRU
}
NONE: 不缓存到内存
WEAK: 缓存到WeakCache
LRU:缓存到LruCache
4.2 磁盘缓存
前面提到,Glide有两种磁盘缓存:“原图缓存”和“结果缓存”,
Doodle也仿照类似的策略,可以选择缓存原图和结果。
原图缓存指的是Http请求下来的未经解码的文件;
结果缓存指经过解码,剪裁,变换等,变成最终的bitmap之后,通过bitmap.compress()压缩保存。
其中,后者通常比前者更小,而且解码时不需要再次剪裁和变换等,所以从结果缓存获取bitmap通常要比从原图获取快得多。
为了尽量使得api相似,Doodle直接用Glide v3的缓存策略定义(Glide v4有一些变化)。
public enum DiskCacheStrategy {
NONE,
SOURCE,
RESULT,
ALL;
}
NONE: 不缓存到磁盘
SOURCE: 只缓存原图
RESULT: 只缓存结果
ALL: 既缓存原图,也缓存结果。
磁盘缓存需要有一个管理工具,通常见得最多的是DiskLruCache,比如OkHttp和Glide都是用的DiskLruCache。
笔者觉得DiskLruCache的日志写入效率不够高,于是自己自己实现了磁盘缓存管理类: DiskCache。
DiskCache机制:
内存中维护一个CacheKey->Record和HashMap, Record包含“CacheKey, 访问order, 文件大小”;
磁盘上对应一个日志文件,记录所有Record:CacheKey占16字节,order占4字节,文件大小占4字节,共24字节。
日志文件用mmap的方式打开, 更新Record时,根据Record的offset进行写入。
新增和读取缓存:获取"maxOrder +1", 作为Record的新order。
当容量超出限制,或者缓存数量超过限制,先删除order最小的文件(其实就是LRU策略)。
Detele操作:磁盘中,Record的order更新为0(这样打开日志文件时可以知道这条记录失效了),HashMap中对应的Record。
相比于DiskLruCache, DiskCache日志记录更加紧凑(二进制),写入更加快速(mmap),
此外, 除了增加Record外,DiskCache不需要追加内容(不需要频繁扩容):Record的更新和删除,只需覆写日志文件中对应的order字段即可。
五、 解码
SDK提供了BitmapFactory/MediaMetadataRetrieverI,用于降图片/视频文件解码成bitmap,但这仅是图片解码的最基础的工作;
图片解码,前前后后要准备各种材料,留心各种细节,是图片加载过程中最复杂的步骤之一。
5.1 数据读取
前面提到 ,Doodle支持File,Uri,http,drawable,raw,assets等数据源。
不同的数据源,获取数据的方式的API不一样,但大致可以分为两种,File和InputStream。
例如,http文件可以下载完成后用File打开,也可以直接用网络API返回的InputStream读取;
assets可以通过AssetManager获取InputStream;
uri可以通过ContentResolver获取InputStream;
最后,如果以上API都无法读取,可以通过自定义DataParser,使Doodle支持该类型的数据源。
public interface DataParser {
InputStream parse(String path);
}
数据读取的大部分代码在DataLoader类中, 这里贴一下解析部分的代码:
static DataFetcher parse(Request request) throws IOException {
DataLoader loader;
boolean fromSourceCache = false;
String path = request.path;
if (path.startsWith("http")) {
CacheKey key = new CacheKey(path);
String cachePath = Downloader.getCachePath(key);
if (cachePath != null) {
loader = new FileLoader(new File(cachePath));
fromSourceCache = true;
} else {
if (request.onlyIfCached) {
throw new IOException("No cache");
}
if (request.diskCacheStrategy.savaSource()) {
loader = new FileLoader(Downloader.download(path, key));
} else {
loader = new StreamLoader(path, Downloader.getInputStream(path), null);
}
}
} else if (path.startsWith(ASSET_PREFIX)) {
loader = new StreamLoader(path, Utils.appContext.getAssets().open(path.substring(ASSET_PREFIX_LENGTH)), null);
} else if (path.startsWith(FILE_PREFIX)) {
loader = new FileLoader(new File(path.substring(FILE_PREFIX_LENGTH)));
} else {
InputStream inputStream = handleByDataParsers(path);
if (inputStream != null) {
loader = new StreamLoader(path, inputStream, null);
} else {
Uri uri = request.uri != null ? request.uri : Uri.parse(path);
loader = new StreamLoader(path, Utils.getContentResolver().openInputStream(uri), uri);
}
}
return new DataFetcher(path, loader, fromSourceCache);
}
private static InputStream handleByDataParsers(String path) {
if (Config.dataParsers != null) {
for (DataParser parser : Config.dataParsers) {
InputStream inputStream = parser.parse(path);
if (inputStream != null) {
return inputStream;
}
}
}
return null;
}
DataParser负责提供数据读取的API,而具体读取数据在DataLoader中实现。
DataLoader是接口,有两个实现类:FileLoader和StreamLoader。
对于File而言,其实也可以转化为FileInputStream,这样的话只需要一个StreamLoader就可以了。
那为什么区分开来呢? 这一切都要从读取图片头信息开始讲。
5.2 文件预读
解码过程中通常需要预读一些头信息,如文件格式,图片分辨率等,作为接下来解码策略的参数,例如用图片分辨率来计算采样比例。
当inJustDecodeBounds设置为true时, BitmapFactory不会返回bitmap, 而是仅仅读取文件头信息,其中最重要的是图片分辨率。
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream, null, options)
读取了头信息,计算解码参数之后,将inJustDecodeBounds设置为false,
再次调用BitmapFactory.decodeStream即可获取所需bitmap。
可是,有的InputStream不可重置读取位置,同时BitmapFactory.decodeStream方法要求从头开始读取。
那先关闭流,然后再次打开不可以吗? 可以,不过效率极低,尤其是网络资源时,断开连接再重连?代价太大了。
有的InputStream实现了mark(int)和reset()方法,就可以通过标记和重置支持重新读取。
这一类InputStream会重载markSupported()方法,并返回true, 我们可以据此判断InputStream是否支持重读。
幸运的是AssetInputStream就支持重读;
不幸的是FileInputStream居然不支持,OkHttp的byteStream()返回InputStream也不支持。
对于文件,我们通过搭配RandomAccessFile和FileDescriptor来重新读取(RandomAccessFile有seek方法);
而对于其他的InputStream,只能曲折一点,通过缓存已读字节来支持重新读取。
SDK提供的BufferedInputStream就是这样一种思路, 通过设置一定大小的缓冲区,以滑动窗口的形式提供缓冲区内重新读取。
遗憾的是,BufferedInputStream的mark函数需指定readlimit,缓冲区会随着需要预读的长度增加而扩容,但是不能超过readlimit;
若超过readlimit,则读取失败,从而解码失败。
/**
* @param readlimit the maximum limit of bytes that can be read before
* the mark position becomes invalid.
*/
public void mark(int readlimit) {
marklimit = readlimit;
markpos = pos;
}
于是readlimit设置多少就成了考量的因素了。
Picasso早期版本设置64K, 结果遭到大量的反馈说解码失败,因为有的图片需要预读的长度不止64K。
从Issue的回复看,Picasso的作者也很无奈,最终妥协地将readlimit设为MAX_INTEGER。
但即便如此,后面还是有反馈有的图片无法预读到图片的大小。
笔者很幸运地遇到了这种情况,经调试代码,最终发现Android 6.0的BufferedInputStream,
其skip函数的实现有问题,每次skip都会扩容,即使skip后的位置还在缓冲区内也会扩容。
造成的问题是有的图片预读时需多次调用skip函数,然后缓冲区就一直double直至抛出OutOfMemoryError……
不过Picasso最终还是把图片加载出来了,因为Picasso catch了Throwable, 然后重新直接解码(不预读大?。?;
虽然加载出来了,但是代价不小:只能全尺寸加载,以及前面预读时申请的大量内存(虽然最终会被GC),所造成的内存抖动。
Glide没有这个问题,因为Glide自己实现了类似BufferedInputStream功能的InputStream,完美地绕过了这个坑;
Doodle则是copy了Android 8.0的SDK的BufferedInputStream,精简代码,加入一些byte[]复用的代码等,可以说是改装版BufferedInputStream。
回头看前面一节的问题,为什么不统一用“改装版BufferedInputStream”来解码?
因为有的图片预读的长度很长,需要开辟较大的缓冲区,从这个角度看,用RandomAccessFile更节约内存。
同时,Doodle读取数据时会缓存头部的部分字节,如此,对于判断文件类型等需要用到头部字节的地方,就不需要重复读取了。
5.3 图片采样
有时候需要显示的bitmap比原图的分辨率小。
比方说原图是 4096 * 4096, 如果按照ARGB_8888的配置全尺寸解码出来,需要占用64M的内存!
不过app中所需的bitmap通?;嵝『芏?, 这时就要降采样了。
比方说需要300 * 300的bitmap, 该怎么做呢?
网上通常的说法是设置 options.inSampleSize 来降采样。
阅读SDK文档,inSampleSize 需是整数,而且是2的倍数,
不是2的倍数时,会被 “be rounded down to the nearest power of 2”。
比方说前面的 4096 * 4096 的原图,
当inSampleSize = 16时,解码出256 * 256 的bitmap;
当inSampleSize = 8时,解码出512 * 512 的bitmap。
即使是inSampleSize = 8,所需内存也只有原来的1/64(1M),效果还是很明显的。
Picasso和Glide v3就是这么降采样的。
如果你发现解码出来的图片是300 * 300 (比如使用Picasso时调用了fit()函数),应该是有后续的处理(通过Matrix 和 Bitmap.createBitmap 继续缩放)。
那能否直接解码出300 * 300的图片呢? 可以的。
查看 BitmapFactory.cpp 的源码,其中有一段:
const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
scale = (float) targetDensity / density;
}
对应BitmapFactory.Options的两个关键参数:inDensity 和 inTargetDensity。
上面的例子,设置inTargetDensity=300, inDensity=4096(还要设置inScale=true), 则可解码出300 * 300的bitmap。
额外提一下,Glide v4也换成这种采样策略了。
解码的过程为,通过获取图片的原始分辨率,结合Request的width和height, 以及ScaleType,
计算出最终要解码的宽高, 设置inDensity和inTargetDensity然后decode。
当然,有时候decode出来之后还要做一些加工,比方说ScaleType为CENTER_CROP,
则需要在decode之后进行裁剪,取出中间部分的像素。
关于ScaleType,Doodle是直接获取ImageView的ScaleType, 所以无需再特别调用函数指定;
当然也提供了指定ScaleType的API, 对于target不是ImageView时或许会用到。
public Request scaleType(ImageView.ScaleType scaleType)
还有就是,解码时默认是向下采样的。
比如,如果原图只有100 * 100, 但是ImageView是200 * 200,最终也是解码出100 * 100的bitmap。
因为ImageView假如是CENTER_CROP或者FIX_XY等ScaleType,显示时通常会在渲染阶段自行缩放的。
如果确实就是需要200 * 200的分辨率,可以调用enableUpscale() 方法。
调用enableUpscale()后,不管原图是100100还是400400,最终都可以得到一个200*200的bitmap。
5.4 图片方向
相信不少开发都遇到拍照后图片旋转的问题(尤其是三星的手机)。
网上有不少关于此问题的解析,这是其中一篇:关于图片EXIF信息中旋转参数Orientation的理解
Android SDK提供了ExifInterface 来获取Exif信息,Picasso正是用此API获取旋转参数的。
很可惜ExifInterface要到 API level 24 才支持通过InputStream构造对象,低于此版本,仅支持通过文件路径构造对象。
故此,Picasso当前版本仅在传入参数是文件路径时才可处理旋转问题。
Glide自己实现了头部解析,主要是获取文件类型和exif旋转信息。
Doodle抽取了Glide的HeaderParser,并结合工程做了一些精简和代码优化, 嗯,又一个“改装版”。
decode出bitmap之后,根据获取的旋转信息,调用setRotate和postScale进行对应的旋转和翻转,即可还原正确的显示。
5.5 变换
解码出bitmap之后,有时候还需要做一些处理,如圆形剪裁,圆角,滤镜等。
Doodle参考Picasso/Glide, 提供了类似的API:Transformation
public interface Transformation {
Bitmap transform(Bitmap source);
String key();
}
实现变换比较简单,实现Transformation接口,处理source,返回处理后的bitmap即可;
当然,还要在key()返回变换的标识,通常写变换的名称就好,如果有参数, 需拼接上参数。
Transformation也是决定bitmap最终结果的因素之一,所以需要重载key(), 作为Request的key的一部分。
Transformation可以设置多个,处理顺序会按照设置的先后顺序执行。
Doodle预置了两个常用的Transformation。
CircleTransformation:圆形剪裁,如果宽高不相等,会先取中间部分(类似CENTER_CROP)。
RoundedTransformation:圆角剪裁,可指定半径。
更多的变换,可以到glide-transformations寻找,
虽然不能直接导入引用, 但是对bitmap的处理是相同的,改造一下就可使用。
5.6 自定义解码
Doodle内置了使用BitmapFactory获取图片,和使用MediaMetadataRetriever来获取视频缩略图的Decoder。
对于其他BitmapFactory和MediaMetadataRetriever都不支持的文件,可以注入自定义Decoder来解码。
Doodle提供两个自定义解码接口:
public interface DrawableDecoder {
Drawable decode(DecodingInfo info);
}
public interface BitmapDecoder {
Bitmap decode(DecodingInfo info);
}
Glide有类似的接口:ResourceDecoder。
但ResourceDecoder需要实现两个方法,在handles方法中判断是否能处理,返回true才会调用decode方法。
public interface ResourceDecoder<T, Z> {
boolean handles(@NonNull T source, @NonNull Options options) throws IOException;
Resource<Z> decode(@NonNull T source, int width, int height, @NonNull Options options)
throws IOException;
}
相比于Glide,Doodle简化了接口的定义:
- 只需实现decode方法;
- 规约了结果的类型(Bitmap, Drawable)。
实现类根据DecodingInfo判断是否可以处理,如果可以处理,解码成Bitmap/Drawable返回,否则直接返回null即可。
DecodingInfo提供的信息如下:
public final class DecodingInfo {
public final String path;
public final int targetWidth;
public final int targetHeight;
public final ClipType clipType; // 缩放类型
public final DecodeFormat decodeFormat; // 解码格式(RGB_8888,RGB565,RGB_AUTO)
public final Map<String, String> options; // 自定义参数
// ...省略部分代码...
// 获取头部26个字节(大部分文件可以通过头部字节识别出文件类型)
public byte[] getHeader() throws IOException {
return getDataFetcher().getHeader();
}
// Doodle内置了部分文件类型的解析
public MediaType getMediaType() throws IOException {
return getDataFetcher().getMediaType();
}
// 获取文件的所有数据
public byte[] getData() throws IOException {
return getDataFetcher().getData();
}
}
实现了接口后,可通过两种方法使用:
- 注册到全局配置Config中:对所有请求生效,每个请求都会先走一遍所有注册的自定义的Decoder。
- 设置到Request中,仅对单个请求生效。
接下来分别举例这两种用法。
5.7 GIF图
GIF有静态的,也有动态的。
BitmapFactory支持解码GIF图片的第一帧,所以各个图片框架都支持GIF缩率图。
至于GIF动图,Picasso当前是不支持的,Glide支持,但据反馈有些GIF动图Glide显示不是很流畅。
Doodle本身也没有实现GIF动图的解码,但是留了解码接口,结合第三方GIF解码库, 可实现GIF动图的加载和显示。
GIF解码库,推荐 android-gif-drawable。
具体用法:
实现DrawableDecoder接口。
import pl.droidsonroids.gif.GifDrawable
object GifDecoder : DrawableDecoder {
override fun decode(info: DecodingInfo): Drawable? {
if (info.mediaType != MediaType.GIF) {
return null
}
return GifDrawable(info.data)
}
}
在App启动时,注入实现类:
fun initApplication(context: Application) {
Doodle.config().addDrawableDecoders(GifDecoder)
}
注册了Gif解码器后,请求图片和普通的请求没区别:如果图片源是GIF动图,会解码得到GifDrawable。
Doodle.load(url).into(gifImageView)
当然也可以指定不需要显示动图, 调用asBitmap方法即可。
这里而额外提一下Glide的情况:
Glide有三个接口:
asDrawable(默认), asBimap, asGif。
asDrawable时,如果源文件是动图则显示动图,如果源文件是静态图则显示静态图(bitmap);
asBitmap时,总是显示静态图;
asGif时,如果源文件是动图则显示动图,如果源文件是静态图则不显示(空白)。
我原本以为asGif就是鸡肋,有asDrawable和asBitmap就够了,直到我遇到这么一个case:
我当时在测试相册相关的代码,先调用了asBitmap,确实就都显示静态图了;然后再调asDrawable, 重新编译,启动,我原本预期相册列表中如果原文件是Gif文件能显示动图,结果总是显示静态图片。
然后我改动代码,当mime(媒体数据库中获取)等于"image/gif", 调用asGif,这才显示了动图;
而且还有例外,有的图片文件本身是Gif动图,单文件后缀是jpg, 在媒体数据库中mime也是"image/jpeg"。
对于这种例外,要么忽略,要么只能先读取每一个图片文件的头部字节,以判断文件是不是Gif文件;
而在图片框架之外读头部字节是有代价的,在主线程的话怕ANR,在IO线程读的话会让代码破碎,“变丑”,不管用协程还是线程。
我没有细究Glide为什么会如此。
在写Doodle时,我只创建了asBitmap方法,因为在Doodle的实现中,asBitmap为true或false是两个不同的请求(CacheKey不一样),不会相互干扰。
5.8 相册缩略图
很多APP内置了自定义的ImagePicker, ImagePicker需要显示媒体库中的视频/图片。
直接读取媒体库的文件去解码的话比较耗时,更快的做法是读取SDK提供的获取缩略图的接口,访问系统已经生成好的缩略图文件。
Glide中也有类似的实现:MediaStoreImageThumbLoader/MediaStoreVideoThumbLoader。
但是获取缩略图的方法在Android高版本已经失效了(我测试的机器是Android 10)。
使用Glide且希望通过读缩略图文件显示相册的话需要自己实现ModelLoader和ResourceDecoder。
Doodle内置的读取媒体缩略图的实现:
class MediaThumbnailDecoder implements BitmapDecoder {
static final String KEY = "ThumbnailDecoder";
static final MediaThumbnailDecoder INSTANCE = new MediaThumbnailDecoder();
@Override
public Bitmap decode(DecodingInfo info) {
String path = info.path;
if (!(path.startsWith("content://media/external/") && info.options.containsKey(KEY))) {
return null;
}
Bitmap bitmap = null;
try {
Uri uri = Uri.parse(path);
ContentResolver contentResolver = Utils.appContext.getContentResolver();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
bitmap = contentResolver.loadThumbnail(uri, new Size(info.targetWidth, info.targetHeight), null);
} catch (Exception ignore) {
}
}
if (bitmap == null) {
int index = path.lastIndexOf('/');
if (index > 0) {
long mediaId = Long.parseLong(path.substring(index + 1));
bitmap = MediaStore.Video.Thumbnails.getThumbnail(
contentResolver,
mediaId,
MediaStore.Video.Thumbnails.MINI_KIND,
null
);
}
}
} catch (Throwable e) {
LogProxy.e("Doodle", e);
}
return bitmap;
}
}
要启用该Decoder可以调用一下Request的方法:
public Request enableThumbnailDecoder() {
addOption(MediaThumbnailDecoder.KEY, "");
return setBitmapDecoder(MediaThumbnailDecoder.INSTANCE);
}
这个方法只作用于当前Request,不会干扰其他请求。
比如有的地方要访问媒体文件的预览图,也是传入相同的uri,该请求不会被这个Decoder拦截处理,就会读取原文件。
稳妥起见,enableThumbnailDecoder方法还设置了options,。
options会参与CacheKey的摘要计算,这里设置options主要是为了确保CacheKey不同于直接访问原图的请求(否则可能会读到彼此的缓存)。
5.9 图片复用
很多文章讲图片优化时都会提图片复用。
Doodle在设计阶段也考虑了图片复用,并且也实现了,但实现后一直纠结其收益和成本。
- 正在使用的图片不能被复用,所以要添加引用计数策略,附加代码很多,且占用一些额外的计算资源。
- 即使图片没有被引用,根据局部性原理,该图片可能稍后有可能被访问,所以也不应该马上被复用。
- 大多数情况下,符合复用条件(不用一段时间,尺寸符合要求)的并不多。
- 通过统计ImageView是否引用bitmap的策略,有可能“逃逸”,比方说可以从ImageView获取Drawable,然后获取其中的bitmap, 用作其他用途,这样即使ImageView被回收了,其曾经attach的bitmap其实也是“在用”的。一旦统计不能覆盖,并且被复用了,会导致原来在用的的地方显示错误。
个人观点,或许某些封闭的使用场景做图片复用会比较合适,但对于图片加载框架而言,使用场景比较复杂,做图片服用的风险和成本大于收益。
综合考虑,Doodle没有去做bitmap复用。
六、 任务调度
图片加载的过程中,数据获取和图片解码等操作发生在后台线程。
一旦涉及异步,就得考虑并发控制,时序控制,线程切换,任务取消等情况。
任务调度这部分,笔者的另一篇文章其实有讲述过,考虑阅读流畅性,就不开新篇了,直接在本篇写吧。
6.1 并发控制
Doodle需要后台线程的有两处:图片加载和缓存结果到磁盘。
我们希望两种任务相互独立,并且后者串行执行就好(缓存相对加载没那么优先)。
常规做法是创建两个线程池,一个调newFixedThreadPool, 一个调newSingleThreadExecutor。
但是这样的话两个线程池的线程旧不能彼此复用了,然后还得维持几个核心线程。
Doodle的做法是在真正执行任务的Executor上套队列,由队列控制并发窗口,这样既各自控制了并发。
然后就是,负责图片加载的任务队列,设置多少并发量呢?
final class Scheduler {
private static final int CUP_COUNT = Runtime.getRuntime().availableProcessors();
private static final int WINDOW_SIZE = Math.min(Math.max(2, CUP_COUNT), 4);
static final PipeExecutor pipeExecutor = new PipeExecutor(WINDOW_SIZE, WINDOW_SIZE * 2);
}
默认情况下,WINDOW_SIZE由可用处理器数量决定,并且限定在[2,4]之间,考虑到目前新设备基本在4个以上,所以大多数情况下就是4了。
不直接设定为CPU_COUNT的原因之一,是考虑限制功耗(一些CPU在发热时会关闭核心或者降频),
毕竟解码图片是计算密集型任务,挺消耗CPU的;
而且除了解码外,还有一个保存结果的任务队列,需要将bitmap编码为文件,也是计算密集型任务。
总得留点计算资源刷新UI吧。
对于需要下载网络文件的任务,相对于占用CPU的时间,其消耗在IO的时间要更多,所以通常对于IO密集型的任务,建议增加并发。
故此,在下载前,Doodle会将并发窗口+1(最多增加到WINDOW_SIZE*2), 在下载完成后将并发窗口-1(最少减到WINDOW_SIZE)。
Doodle的源码中,将实现这部分逻辑的Executor命名为PipeExecutor。
6.2 任务排队
图片加载可能会碰到这样的场景:
几乎同一时间,需要加载相同路径的图片,到不同的ImageView, 并且这些ImageView的宽高和ScaleType相同。
比方说聊天窗口的头像,就是这种case。
就拿这个case来说,打开聊天窗口,就会生成多个相同的Request(CacheKey相同),在还没有内存缓存的情况下,会创建多个异步任务,同时解码。
这样的话就会生成多个相同的bitmap, 浪费CPU和内存。
还有另一种case, 需要下载网络图片的任务,在没有原图缓存的情况下,也有可能会重复下载。
为了避免重复解码或者重复下载,需要做一些措施。
Doodle的做法是,用tag标记任务,用一个Set记录正在执行的任务,用一个Map缓存等待执行的任务。
执行一个任务,如果Set中保存该任务的tag, 则将任务保存到一个 tag->list 的Map中排队,等Set中的任务执行结束后,再从Map中取出执行。
对于解码任务而言,正好用CacheKey作为tag;而如果这个任务是需要网络下载的,则用Url构造CacheKey作为tag。
代码和示意图如下:
static class TagExecutor {
private static final Set<CacheKey> scheduledTags = new HashSet<>();
private static final Map<CacheKey, LinkedList<Runnable>> waitingQueues = new HashMap<>();
public synchronized void execute(CacheKey tag, Runnable r) {
if (r == null) {
return;
}
if (!scheduledTags.contains(tag)) {
start(tag, r);
} else {
LinkedList<Runnable> queue = waitingQueues.get(tag);
if (queue == null) {
queue = new LinkedList<>();
waitingQueues.put(tag, queue);
}
queue.offer(r);
}
}
private void start(CacheKey tag, Runnable r) {
scheduledTags.add(tag);
pipeExecutor.execute(new Wrapper(r) {
@Override
public void run() {
try {
task.run();
} finally {
scheduleNext(tag);
}
}
});
}
private synchronized void scheduleNext(CacheKey tag) {
scheduledTags.remove(tag);
LinkedList<Runnable> queue = waitingQueues.get(tag);
if (queue != null) {
Runnable r = queue.poll();
if (r == null) {
waitingQueues.remove(tag);
} else {
start(tag, r);
}
}
}
}
private static abstract class Wrapper implements Runnable {
final Runnable task;
Wrapper(Runnable r) {
this.task = r;
}
}
TagExecutor实现的效果就是:相同tag的任务串行,不同tag的任务并行。
相同tag的任务串行为什么可以防止重复解码?因为框架中有MemeryCache, 解码成功后会保存cache,排在后面的相同CacheKey的任务,读取cache就好,不需要再次解码了。
下载的case同理。
示意图中的假定真正执行任务的Executor的并发为2, 实际上我们会设定一个并发更大的Executor作为RealExecutor, 毕竟PipeExecutor已经做了并发控制了。
RealExecutor可以在Config中设定,如果没有设定,Doodle会调用Executors.newCachedThreadPool()创建一个。
总的而言,在RealExecutor套两层队列,分别实现了并发控制和防止重复任务的功能。
6.3 任务管理
准备好Executor, 只是任务调度的一部分。
我希望有一个工具,支持一下功能:
- 主线程/后台线程切换;
- 取消任务;
- 调用一个方法,block当前线程,直到后台线程完成时,在当前方法返回结果。
SDK其实有这样的工具:AsyncTask。
AsyncTask目前已经被标记“Deprecated”了,而且不方便定制功能,于是,我抽取了AsyncTask的部分代码,实现了工具类ExAsyncTask。
FutureTask+Callable+Handler(我称之为AsyncTask三??停芎玫厥迪至松厦嫣岬饺龉δ?。
相对AsyncTask,做了一些改动,包括:
- 移除了范型;
- 精简了一些不需要的方法,比如onPreExecute,onProgressUpdate等;
- Executor换上前面提到的TagExecutor。
如果仅仅是这些,那么完全可以extend AsyncTask来实现。
但是接下来的功能,需要引用其中的一些私有成员,所以没办法,只能copy其关键代码重新定义一个类(ExAsyncTask)了,
6.4 生命周期
异步任务还在执行而UI界面已销毁的情况是比较普遍的,图片加载也不例外。
需要有相关的机制,在页面销毁时通知图片加载任务取消。
提到“通知”,很自然地就会想到观察者模式。
LifecycleManager维护了 hostHash -> List<WeakReference<ExAysncTask>> 的一个map (应为hostHash是int类型,用SparseArray来承载)。
其中List<WeakReference<ExAysncTask>> 由中间类Holder持有和维护。
host指的任务所在的“宿主”,其实就是“界面”的具体对象实例,通常是Activity,当然也可以是Fragment或者View, 这个由使用者决定,可以通过Request的observeHost方法指定。
public Request observeHost(Object host) {
this.hostHash = System.identityHashCode(host);
return this;
}
通过identityHashCode取hash,可以避免直接引用host, 以免内存泄漏。
identityHashCode的区分度要比hashCode要更高,并且考虑到同一时刻加载图片的“界面”不会有太多个,所以用identityHashCode替代host是可行的。
退一万步讲,即使同一时刻有两个host的identityHashCode一样,也不会导致太大的问题,最多不过是任务取消而已。
如果用户没有特别设定,Doodle会通过ImageView找到其所attach的Activity并取其identityHashCode作为hostHash。
另一方面,在Doodle初始化时,做了监听Activity的生命周期回调,并在回调中调用notify方法。
static void registerActivityLifecycle(final Context context) {
if (!(context instanceof Application)) {
return;
}
((Application) context).registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
public void onActivityResumed(Activity activity) {
Doodle.notifyResume(this);
}
public void onActivityPaused(Activity activity) {
Doodle.notifyPause(this);
}
public void onActivityDestroyed(Activity activity) {
Doodle.notifyDestroy(activity);
}
});
}
如果用户通过Request指定了非Activity的host, 可以自行在该host对应的生命周期中调notify方法,
当然,这并非必须的操作:即使没有调notify也没关系,最多就是任务不能及时取消而已,没有什么大问题。
ExAsyncTask关于生命周期部分的实现:
abstract class ExAsyncTask {
public final void execute(int hostHash) {
if (mStatus != Status.PENDING) {
return;
}
if (hostHash != 0) {
LifecycleManager.register(hostHash, this);
}
mHostHash = hostHash;
mStatus = Status.RUNNING;
Scheduler.tagExecutor.execute(generateTag(), mFuture);
}
private void finish(Object result) {
detachHost();
if (isCancelled()) {
onCancelled();
} else {
onPostExecute(result);
}
mStatus = Status.FINISHED;
}
private void detachHost() {
if (mHostHash != 0) {
LifecycleManager.unregister(mHostHash, this);
}
}
void handleEvent(int event) {
if (!isCancelled() && mStatus != Status.FINISHED) {
if (event == Event.PAUSE) {
Scheduler.pipeExecutor.pushBack(mFuture);
} else if (event == Event.RESUME) {
Scheduler.pipeExecutor.popFront(mFuture);
} else if (event == Event.DESTROY) {
mHostHash = 0;
cancel(true);
}
}
}
}
Worker worker = new Worker(request, imageView);
worker.execute(request.hostHash);
Worker是ExAsyncTask的实现类。
Worker启动时,会将请求的hostHash以及自身注册到LifecycleManager;
Worker结束时,如果mHostHash不为0,则执行LifecycleManager的unregister。
在启动后,结束前,如果host通过LifecycleManager发送“PAUSE/RESUME/DESTROY"消息, ExAsyncTask的handleEvent会被回调。
ExAsyncTask在handleEvent做对应的响应。
特别地,如果host销毁,取消任务。
另外,在页面发送pause时,任务会切换到back队列,发送resume时,任务会切换到front队列;
这个是PipeExecutor的另外一个特性:内置front/back两个队列,取任务时先从front队列取。
效果就是,在页面不可见(但又不是销毁)时发送pause事件,加载任务优先级会降低(低于新的页面)。
这是加载时间加长1s,并发窗口设置为1后的效果图:
代码实现上,Worker继承了ExAsyncTask之后,只需专注于在doInBackground方法中加载图片,在onPostExecute方法中显示结果即可。
以上所述的各种功能/效果都分散到ExAsyncTask、TagExecutor、PipeExecutor、LifecycleManager等类中了。
七、加载前后处理
在启动异步线程之前:
- 如果目标是ImageView, 取其attache的Activity出来,判断其是否finishing或者destroy, 是则返回。
- 检查ImageView的tag(Request对象)的key和当前请求是否相同,如果相同则直接返回,
否则,取消之前的任务(Request有Worke的弱引用)。 - 清空ImageView当前的Drawable。
- 如果有bitmap缓存,直接取bitmap设置带到ImageView, 返回;
否则,设置placeholder drawable。 - 创建Worker, 用WeakReference包裹,赋值给Request的workerReference变量。
- 给ImageView设置tag, tag为Request对象。
用setTag(int key, final Object tag)方法记录tag, 这样就不会和常规的setTag(Object tag)冲突了。
Glide用的就是setTag(Object tag),应该有朋友踩过这个坑。
异步线程结束之后:
- 访问Request对象的targetReference变量(ImageView的弱引用),尝试取出ImageView。
- 若成功取出ImageView, 判断其tag和当前Request是否相等,若相等则说明加载任务的对象没变,否则直接返回。
- 从ImageView取出Activity, 判断其是否finishing或者destroy, 是则返回。
- 若Worker返回了result(bitmap或者drawable), 设置到ImageView;若返回了null,设置error drawable。
这些处理大多在Controller中实现,除了这些之外,Controller还负责实现任务的暂停/恢复(多用在RecycleView滚动)。
可以说,Controller是Request, Target, 和Worker之间的桥梁。
八、总结
通篇下来,读者可能也注意到了,Doodle的实现大量参考了Picasso和Glide,尤其是后者,有的部分甚至直接copy其处理(Exif部分),关于这一点我大方承认,三人行,必有我师嘛。
当然,有正面借鉴也有反面借鉴,比如asBitmap, setTag的处理。
然后也有创新的部分,比如DiskCache和任务调度。
概括地说,图片加载过程可分为几个部分:数据源,数据获取,文件解码,结果,目标。
Glide的实现中的,大量使用了接口和范型,对图片加载的各过程进行抽象。
Doodle定义的接口相对Glide简单很多,但也足够通过自定义解码,实现加载任意类型的文件。
好了,原理篇就分析到这里,希望对读者有所启发。