图片加载的轮子有不少了,Universal-Image-Loader, Picasso, Glide, Fresco等。
网上各类分析和对比文章不少,咱们这里就很少做介绍了。前端
古人云:“纸上得来终觉浅,绝知此事要躬行”。
只看分析,不动手实践,终究印象不深。
用当下流行的“神经网络”来讲,就是要经过“输出”,造成“反馈”,才能更有效地“训练”。
因此,咱们经过手撕一个图片加载框架,一窥其中奥秘。
java
话很少说,先来两张图暖一下气氛:android
命名是比较使人头疼的一件事。
在反复翻了单词表以后,决定用Doodle做为框架的名称。git
Picasso是画家毕加索的名字,Fresco翻译过来是“壁画”,比ImageLoader之类的要更有格调;
原本想起Van、Vince之类的,但想一想仍是不要冒犯这些巨擘了。github
Doodle为涂鸦之意,除了单词自己内涵以外,外在也颇有趣,很像一个单词:Google。
这样的兼具备趣灵魂和好看皮囊的词,真的很少了。编程
归纳来讲,图片加载包含封装,解析,下载,解码,变换,缓存,显示等操做。 流程图以下: 后端
以上简化版的流程(只是众多路径中的一个分支),后面咱们将会看到,完善各类细节以后,会比这复杂不少。
但万事皆由简入繁,先简单梳理,后续再慢慢填充,犹如绘画,先绘轮廓,再描细节。api
解决复杂问题,思路都是类似的:分而治之。
参考MVC的思路,咱们将框架划分三层:缓存
具体划分以下: 网络
外部接口
Doodle: 提供全局参数配置,图片加载入口,以及内存缓存接口。
Config: 全局参数配置。包括缓存路径,缓存大小,图片编码等参数。
Request: 封装请求参数。包括数据源,剪裁参数,行为参数,以及目标。
执行单元
Dispatcher : 负责请求调度, 以及结果显示。
Worker: 工做线程,异步执行加载,解码,转换,存储等。
Downloader: 负责文件下载。
Source: 解析数据源,提供统一的解码接口。
Decoder: 负责具体的解码工做。
存储组件
MemoryCache: 管理Bitmap缓存。
DiskCache: 图片“结果”的磁盘缓存(原图由OkHttp缓存)。
上一节分析了流程和架构,接下来就是在理解流程,了解架构的前提下,
先分别实现关键功能,而后串联起来,以后就是不断地添加功能和完善细节。
简而言之,就是自顶向下分解,自底向上填充。
众多图片加载框架中,Picasso和Glide的API是比较友好的。
Picasso.with(context)
.load(url)
.placeholder(R.drawable.loading)
.into(imageView);
复制代码
Glide的API和Picasso相似。
当参数较多时,构造者模式就能够搬上用场了,其链式API能使参数指定更加清晰,并且更加灵活(随意组合参数)。
Doodle也用相似的API,并且为了方便理解,有些方法命名也参照Picasso和 Glide。
object Config {
internal var userAgent: String = ""
internal var diskCachePath: String = ""
internal var diskCacheCapacity: Long = 128L shl 20
internal var diskCacheMaxAge: Long = 30 * 24 * 3600 * 1000L
internal var bitmapConfig: Bitmap.Config = Bitmap.Config.ARGB_8888
internal var gifDecoder: GifDecoder? = null
// ...
fun setUserAgent(userAgent: String): Config {
this.userAgent = userAgent
return this
}
fun setDiskCachePath(path: String): Config {
this.diskCachePath = path
return this
}
// ....
}
复制代码
object Doodle {
fun config() : Config {
return Config
}
}
复制代码
Doodle.config()
.setDiskCacheCapacity(256L shl 20)
.setGifDecoder(gifDecoder)
复制代码
虽然也是链式API,可是没有参照Picasso那样的构造者模式的用法(读写分离),由于那种写法有点麻烦,并且不直观。
Config是一个单例,除了GifDecoder以外,其余参数都有默认值。
加载图片:
Doodle.load(url)
.placeholder(R.drawable.loading)
.into(topIv)
复制代码
实现方式和Config是相似的:
object Doodle {
// ....
fun load(path: String): Request {
return Request(path)
}
fun load(resID: Int): Request {
return Request(resID)
}
fun load(uri: Uri): Request {
return Request(uri)
}
}
复制代码
class Request {
internal val key: Long by lazy { MHash.hash64(toString()) }
// 图片源
internal var uri: Uri? = null
internal var path: String
private var sourceKey: String? = null
// 图片参数
internal var viewWidth: Int = 0
internal var viewHeight: Int = 0
// ....
// 加载行为
internal var priority = Priority.NORMAL
internal var memoryCacheStrategy= MemoryCacheStrategy.LRU
internal var diskCacheStrategy = DiskCacheStrategy.ALL
// ....
// target
internal var simpleTarget: SimpleTarget? = null
internal var targetReference: WeakReference<ImageView>? = null
internal constructor(path: String) {
if (TextUtils.isEmpty(path)) {
this.path = ""
} else {
this.path = if (path.startsWith("http") || path.contains("://")) path else "file://$path"
}
}
fun sourceKey(sourceKey: String): Request {
this.sourceKey = sourceKey
return this
}
fun into(target: ImageView?) {
if (target == null) {
return
}
targetReference = WeakReference(target)
if (noClip) {
fillSizeAndLoad(0, 0)
} else if (viewWidth > 0 && viewHeight > 0) {
fillSizeAndLoad(viewWidth, viewHeight)
}
// ...
}
private fun fillSizeAndLoad(targetWidth: Int, targetHeight: Int) {
viewWidth = targetWidth
viewHeight = targetHeight
// ...
Dispatcher.start(this)
}
override fun toString(): String {
val builder = StringBuilder()
if (!TextUtils.isEmpty(sourceKey)) {
builder.append("source:").append(sourceKey)
} else {
builder.append("path:").append(path)
}
// ....
return builder.toString()
}
}
复制代码
Request主要职能是封装请求参数,参数能够大约划分为4类:
其中,图片源和解码参数决定了最终的bitmap, 因此,咱们拼接这些参数做为请求的key,这个key会用于缓存的索引和任务的去重。
拼接参数后字符串很长,因此须要压缩成摘要,因为终端上的图片数量不会太多,64bit的摘要便可(原理参考《漫谈散列函数》)。
图片文件的来源,一般有网络图片,drawable/raw资源, assets文件,本地文件等。
固然,严格来讲,除了网络图片以外,其余都是本地文件,只是有各类形式而已。
Doodle支持三种参数, id(Int), path(String), 和Uri(常见于调用相机或者相册时)。
对于有的图片源,路径可能会变化,好比url, 里面可能有一些动态的参数:
val url = "http://www.xxx.com/a.jpg?t=1521551707"
复制代码
请求服务端的时候,其实返回的是同一张图片。
可是若是用整个url做为请求的key的一部分,由于动态参数的缘由,每次请求key都不同,会致使缓存失效。
为此,能够将url不变的部分做为制定为图片源的key:
val url = "http://www.xxx.com/a.jpg"
Skate.load(url + "?t=" + System.currentTimeMillis())
.sourceKey(url)
.into(testIv);
复制代码
有点相似Glide的StringSignature。
请求的target最多见的应该是ImageView,
此外,有时候须要单纯获取Bitmap,
或者同时获取Bitmap和ImageView,
抑或是在当前线程获取Bitmap ……
总之,有各类获取结果的需求,这些都是设计API时须要考虑的。
几大图片加载框架都实现了缓存,各类文章中,有说二级缓存,有说三级缓存。
其实从存储来讲,可简单地分为内存缓存和磁盘缓存;
只是一样是内存/磁盘缓存,也有多种形式,例如Glide的“磁盘缓存”就分为“原图缓存”和“结果缓存”。
为了复用计算结果,提升用户体验,一般会作bitmap的缓存;
而因为要限制缓存的大小,须要淘汰机制(一般是LRU策略)。
Android SDK提供了LruCache类,查看源码,其核心是LinkedHashMap。
为了更好地定制,这里咱们不用SDK提供的LruCache,直接用LinkedHashMap,封装本身的LruCache。
internal class BitmapWrapper(var bitmap: Bitmap) {
var bytesCount: Int = 0
init {
this.bytesCount = Utils.getBytesCount(bitmap)
}
}
复制代码
internal object LruCache {
private val cache = LinkedHashMap<Long, BitmapWrapper>(16, 0.75f, true)
private var sum: Long = 0
private val minSize: Long = Runtime.getRuntime().maxMemory() / 32
@Synchronized
operator fun get(key: Long?): Bitmap? {
val wrapper = cache[key]
return wrapper?.bitmap
}
@Synchronized
fun put(key: Long, bitmap: Bitmap?) {
val capacity = Config.memoryCacheCapacity
if (bitmap == null || capacity <= 0) {
return
}
var wrapper: BitmapWrapper? = cache[key]
if (wrapper == null) {
wrapper = BitmapWrapper(bitmap)
cache[key] = wrapper
sum += wrapper.bytesCount.toLong()
if (sum > capacity) {
trimToSize(capacity * 9 / 10)
}
}
}
private fun trimToSize(size: Long) {
val iterator = cache.entries.iterator()
while (iterator.hasNext() && sum > size) {
val entry = iterator.next()
val wrapper = entry.value
WeakCache.put(entry.key, wrapper.bitmap)
iterator.remove()
sum -= wrapper.bytesCount.toLong()
}
}
}
复制代码
LinkedHashMap 构造函数的第三个参数:accessOrder,传入true时, 元素会按访问顺序排列,最后访问的在遍历器最后端。
进行淘汰时,移除遍历器前端的元素,直至缓存总大小下降到指定大小如下。
有时候须要加载比较大的图片,占用内存较高,放到LruCache可能会“挤掉”其余一些bitmap;
或者有时候滑动列表生成大量的图片,也有可能会“挤掉”一些bitmap。
这些被挤出LruCache的bitmap有可能很快又会被用上,但在LruCache中已经索引不到了,若是要用,需从新解码。
值得指出的是,被挤出LruCache的bitmap,在GC时并不必定会被回收,若是bitmap还被引用,则不会被回收;
可是无论是否被回收,在LruCache中都索引不到了。
咱们能够将一些可能短暂使用的大图片,以及这些被挤出LruCache的图片,放到弱引用的容器中。
在被回收以前,仍是能够根据key去索引到bitmap。
internal object WeakCache {
private val cache = HashMap<Long, BitmapWeakReference>()
private val queue = ReferenceQueue<Bitmap>()
private class BitmapWeakReference internal constructor(
internal val key: Long,
bitmap: Bitmap,
q: ReferenceQueue<Bitmap>) : WeakReference<Bitmap>(bitmap, q)
private fun cleanQueue() {
var ref: BitmapWeakReference? = queue.poll() as BitmapWeakReference?
while (ref != null) {
cache.remove(ref.key)
ref = queue.poll() as BitmapWeakReference?
}
}
@Synchronized
operator fun get(key: Long?): Bitmap? {
cleanQueue()
val reference = cache[key]
return reference?.get()
}
@Synchronized
fun put(key: Long, bitmap: Bitmap?) {
if (bitmap != null) {
cleanQueue()
val reference = cache[key]
if (reference == null) {
cache[key] = BitmapWeakReference(key, bitmap, queue)
}
}
}
}
复制代码
以上实现中,BitmapWeakReference是WeakReference的子类,除了引用Bitmap的功能以外,还记录着key, 以及关联了ReferenceQueue;
当Bitmap被回收时,BitmapWeakReference会被放入ReferenceQueue,
咱们能够遍历ReferenceQueue,移除ReferenceQueue的同时,取出其中记录的key, 到cache中移除对应的记录。
利用WeakReference和ReferenceQueue的机制,索引对象的同时又不至于内存泄漏,相似用法在WeakHashMap和Glide源码中都出现过。
最后,综合LruCache和WeakCache,统一索引:
internal object MemoryCache {
fun getBitmap(key: Long): Bitmap? {
var bitmap = LruCache[key]
if (bitmap == null) {
bitmap = WeakCache[key]
}
return bitmap
}
fun putBitmap(key: Long, bitmap: Bitmap, toWeakCache: Boolean) {
if (toWeakCache) {
WeakCache.put(key, bitmap)
} else {
LruCache.put(key, bitmap)
}
}
// ......
}
复制代码
声明内存缓存策略:
object MemoryCacheStrategy{
const val NONE = 0
const val WEAK = 1
const val LRU = 2
}
复制代码
NONE: 不缓存到内存
WEAK: 缓存到WeakCache
LRU:缓存到LRUCache
曲面提到,Glide有两种磁盘缓存:“原图缓存”和“结果缓存”,
Doodle也仿照相似的策略,能够选择缓存原图和结果。
原图缓存指的是Http请求下来的未经解码的文件;
结果缓存指通过解码,剪裁,变换等,变成最终的bitmap以后,经过**bitmap.compress()**压缩保存。
其中,后者一般比前者更小,并且解码时不须要再次剪裁和变换等,因此从结果缓存获取bitmap一般要比从原图获取快得多。
为了尽可能使得api类似,Doodle设置直接用Glide v3的缓存策略定义(Glide v4有一些变化)。
object DiskCacheStrategy {
const val NONE = 0
const val SOURCE = 1
const val RESULT = 2
const val ALL = 3
}
复制代码
NONE: 不缓存到磁盘
SOURCE: 只缓存原图
RESULT: 只缓存结果
ALL: 既缓存原图,也缓存结果。
Doodle的HttpClient是用的OkHttp, 因此网络缓存,包括原图的缓存就交给OkHttp了,
至于本地的图片源,本就在SD卡,只是各类形式而已,也就无所谓缓存了。
结果缓存,Doodle没有用DiskLruCache, 而是本身实现了磁盘缓存。
DiskLruCache是比较通用的磁盘缓存解决方案,笔者以为对于简单地存个图片文件能够更精简一些,因此本身设计了一个更专用的方案。
其实磁盘缓存的管理最主要是设计记录日志,方案要点以下:
一、一条记录存储key(long)和最近访问时间(long),一条记录16字节;
二、每条记录依次排列,因为比较规整,能够根据偏移量随机读写;
三、用mmap方式映射日志文件,以4K为单位映射。
文件记录以外,内存中还须要一个HashMap记录key到"文件记录"的映射, 其中,文件记录对象以下:
private class JournalValue internal constructor(
internal var key: Long,
internal var accessTime: Long,
internal var fileLen: Long,
internal var offset: Int) : Comparable<JournalValue> {
// ...
}
复制代码
只需记录key, 访问时间,文件大小,以及记录在日志文件中的位置便可。
那文件名呢?文件命名为key的十六进制,因此能够根据key运算出文件名。
运做机制:
访问DiskCache时,先读取日志文件,填充HashMap;
后面的访问中,只需读取HashMap就能够知道有没有对应的磁盘缓存;
存入一个“结果文件”则往HashMap存入记录,同时更新日志文件。
这种机制其实有点像SharePreferences, 二级存储,文件读一次以后接下来都是写入。
相对而言,该方案的优势为:
一、节省空间,一页(4K)能记录256个文件;
二、格式规整,解析快;
三、mmap映射,可批量记录,自动定时写入磁盘,下降磁盘IO消耗;
四、二级存储,访问速度快。
当容量超出限制须要淘汰时,根据访问时间,先删除最久没被访问的文件;
除了实现LRU淘汰规则外,还可实现最大保留时间,删除一些过久没用到的图片文件。
虽然名为磁盘缓存,其实不只仅缓存文件,“文件记录”也很关键,两者关系犹如文件内容和文件的元数据, 相辅相成。
SDK提供了BitmapFactory,提供各类API,从图片源解码成bitmap,但这仅是图片解码的最基础的工做;
图片解码,前先后后要准备各类材料,留心各类细节,是图片加载过程当中最繁琐的步骤之一。
前面提到,图片的来源有多种,咱们须要识别图片来源,
而后根据各自的特色提供统一的处理方法,为后续的具体解码工做提供方便。
internal abstract class Source : Closeable {
// 魔数,提供文件格式的信息
internal abstract val magic: Int
// 旋转方向,EXIF专属信息
internal abstract val orientation: Int
internal abstract fun decode(options: BitmapFactory.Options): Bitmap?
internal abstract fun decodeRegion(rect: Rect, options: BitmapFactory.Options): Bitmap?
internal class FileSource constructor(private val file: File) : Source() {
//...
}
internal class AssetSource(private val assetStream: AssetManager.AssetInputStream) : Source() {
//...
}
internal class StreamSource constructor(inputStream: InputStream) : Source() {
//...
}
companion object {
private const val ASSET_PREFIX = "file:///android_asset/"
private const val FILE_PREFIX = "file://"
fun valueOf(src: Any?): Source {
if (src == null) {
throw IllegalArgumentException("source is null")
}
return when (src) {
is File -> FileSource(src)
is AssetManager.AssetInputStream -> AssetSource(src)
is InputStream -> StreamSource(src)
else -> throw IllegalArgumentException("unsupported source " + src.javaClass.simpleName)
}
}
fun parse(request: Request): Source {
val path = request.path
return when {
path.startsWith("http") -> {
val builder = okhttp3.Request.Builder().url(path)
if (request.diskCacheStrategy and DiskCacheStrategy.SOURCE == 0) {
builder.cacheControl(CacheControl.Builder().noCache().noStore().build())
} else if (request.onlyIfCached) {
builder.cacheControl(CacheControl.FORCE_CACHE)
}
valueOf(Downloader.getSource(builder.build()))
}
path.startsWith(ASSET_PREFIX) -> valueOf(Doodle.appContext.assets.open(path.substring(ASSET_PREFIX.length)))
path.startsWith(FILE_PREFIX) -> valueOf(File(path.substring(FILE_PREFIX.length)))
else -> valueOf(Doodle.appContext.contentResolver.openInputStream((request.uri ?: Uri.parse(path))))
}
}
}
}
复制代码
以上代码,从资源id, path, 和Uri等形式,最终转换成FileSource, AssetSource, StreamSource等。
其中,网络文件从OkHttp的网络请求得到,若是缓存了原图, 则会得到FileSource。
其实各类图片源最终均可以转化为InputStream,例如AssetInputStream其实就是InputStream的一种, 文件也能够转化为FileInputStream。
那为何区分开来呢? 这一切都要从读取图片头信息开始讲。
解码过程当中一般须要预读一些头信息,如文件格式,图片分辨率等,做为接下来解码策略的参数,例如用图片分辨率来计算压缩比例。
当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来从新读取;
而对于其余的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最终仍是把图片加载出来了,由于其catch了Throwable, 而后从新直接解码(不预读大小);
虽然加载出来了,可是代价不小:只能全尺寸加载,以及前面预读时申请的大量内存(虽然最终会被GC),所形成的内存抖动。
Glide没有这个问题,由于Glide本身实现了相似BufferedInputStream功能的InputStream,完美地绕过了这个坑;
Doodle则是copy了Android 8.0的SDK的BufferedInputStream, 精简代码,加入一些缓冲区复用的代码等,能够说是改装版BufferedInputStream。
回头看前面一节的问题,为何不统一用“改装版BufferedInputStream”来解码?
由于有的图片预读的长度很长,须要开辟较大的缓冲区,从这个角度看,FileSource和AssetSource更节约内存。
有时候须要显示的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也换成这种压缩策略了。
平时设计给切图,要放对文件夹,也是这个道理。
好比设计给了144 * 144(xxhdpi) 的icon, 若是不当心放到hdpi的资源目录下;
假如机器的dpi在320dpi ~ 480dpi之间(xxhdpi),则解码出来的bitmap是288 * 288的分辨率,;
若是恰好ImageView又是wrap_content设置的宽高,视觉上会比预期的翻了一番-_-。
言归正传,解码的过程为,经过获取图片的原始分辨率,结合Request的width和height, 以及ScaleType,
计算出最终要解码的宽高, 设置inDensity和inTargetDensity而后decode。
固然,有时候decode出来以后还要作一些加工,比方说ScaleType为CENTER_CROP而图片宽高又不相等,
则须要在decode以后进行裁剪,取出中间部分的像素。
关于ScaleType,Doodle是直接获取ImageView的ScaleType, 因此无需再特别调用函数指定;
固然也提供了指定ScaleType的API, 对于target不是ImageView时或许会用到。
fun scaleType(scaleType: ImageView.ScaleType)
复制代码
还有就是,解码阶段的压缩是向下采样的。
好比,若是原图只有100 * 100, 可是ImageView是200 * 200,最终也是解码出100 * 100的bitmap。
不过ImageView假如是CENTER_CROP或者FIX_XY等ScaleType,显示时一般会在渲染阶段自行缩放的。
若是确实就是须要200 * 200的分辨率,能够在解码后的变换(Transformation)阶段处理。
相信很多开发都遇到拍照后图片旋转的问题(尤为是三星的手机)。
网上有很多关于此问题的解析,这是其中一篇:关于图片EXIF信息中旋转参数Orientation的理解
Android SDK提供了ExifInterface 来获取Exif信息,Picasso正是用此API获取旋转参数的。
很惋惜ExifInterface要到 API level 24 才支持经过InputStream构造对象,低于此版本,仅支持经过文件路径构造对象。
故此,Picasso当前版本仅在传入参数是文件路径(或者文件的Uri)时可处理旋转问题。
Glide本身实现了头部解析,主要是获取文件类型和exif旋转信息。
Doodle抽取了Glide的HeaderParse,并结合工程作了一些精简和代码优化, 嗯, 又一个“改装版”。
decode出bitmap以后,根据获取的旋转信息,调用setRotate和postScale进行对应的旋转和翻转,便可还原正确的显示。
解码出bitmap以后,有时候还须要作一些处理,如圆形剪裁,圆角,滤镜等。
Picasso和Glide都提供了相似的API:Transformation
interface Transformation {
fun transform(source: Bitmap): Bitmap?
fun key(): String
}
复制代码
实现变换比较简单,实现Transformation接口,处理source,返回处理后的bitmap便可;
固然,还要在key()返回变换的标识,一般写变换的名称就好,若是有参数, 需拼接上参数。
Transformation也是决定bitmap长什么样的因素之一,因此须要重载key(), 做为Request的key的一部分。
Transformation能够设置多个,处理顺序会按照设置的前后顺序执行。
Doodle预置了几个经常使用的Transformation。
CircleTransformation:圆形剪裁,若是宽高不相等,会先取中间部分(相似CENTER_CROP);
RoundedTransformation:圆角剪裁,可指定半径;
ResizeTransformation:大小调整,宽高缩放到指定大小;
BlurTransformation:高斯模糊。
须要指出的一点是, Request中指定大小以后并不老是可以解码出指定大小的bitmap,
若是原图分辨率小于指定大小,基于向下采样的策略,并不会主动缩放到指定的大小(前面有提到)。
若须要肯定大小的bitmap, 可应用ResizeTransformation。
更多的变换,能够到glide-transformations寻找,
虽然不能直接导入引用, 可是处理方法是相似的,改造一下就可以使用-_-
GIF有静态的,也有动态的。 BitmapFactory支持解码GIF图片的第一帧,因此各个图片框架都支持GIF缩率图。
至于GIF动图,Picasso当前是不支持的,Glide支持,但据反馈有些GIF动图Glide显示不是很流畅。
Doodle自己也没有实现GIF动图的解码,可是留了拓展接口,结合第三方GIF解码库, 可实现GIF动图的加载和显示。
GIF解码库,推荐 android-gif-drawable。
具体用法: 在App启动时, 注入GIF解码的实现类(实现GifDecoder 接口):
fun initApplication(context: Application) {
Doodle.init(context)
// ... 其余配置
.setGifDecoder(gifDecoder)
}
private val gifDecoder = object : GifDecoder {
override fun decode(bytes: ByteArray): Drawable {
return GifDrawable(bytes)
}
}
复制代码
使用时和加载到普通的ImageView没区别,若是图片源是GIF图片,会自动调用gifDecoder进行解码。
Doodle.load(url).into(gifImageView)
复制代码
固然也能够指定不须要显示动图, 调用asBitmap()方法便可。
不少文章讲图片优化时都会提到两个点,压缩和图片复用。
Doodle在设计阶段也考虑了图片复用,而且也实现了,但实现后一直纠结其收益和成本-_-
最终,在看了帖子 picasso_vs_glide 以后,下决心移除了图片复用的代码。
如下该帖子中,Picasso的做者JakeWharton 的原话:
Slight correction here: "Glide reuses bitmaps period". Picasso does not at all. Nor do we have plans to. This is actually a performance optimization in some cases as we can retained cached images longer. It'd be nice to support both modes with programmer hints, but since ImageDecoder doesn't even support re-use I see no point to adding it.
Doodle定位是小而美的轻量级图片框架,过程当中移除了很多价值不高的功能和复杂的实现。
有舍必有得,编程与生活,莫不如此。
图片获取和解码都是耗时的操做,需放在异步执行;
而一般须要同时请求多张图片,故此,线程调度不可或缺。
Doodle的线程调度依赖于笔者的另外一个项目Task,
具体内容详见:《如何实现一个线程调度框架》(又发了一波广告?-_-)。
简单的说,主要用到了Task的几个特性:
关于任务去重,主要是以Request的key做为任务的tag, 相同tag的任务串行执行,
如此,当第一个任务完成,后面的任务读缓存便可,避免了重复计算。
对于网络图片源的任务,则以URL做为tag, 以避免重复下载。
此外,线程池,在UI线程回调结果,在当前线程获取结果等操做,都能基于Task简单地实现。
从Request,到开始解码,从解码完成,到显示图片, 之间很多零碎的处理。
把这些处理都放到一个类中,殊不知道怎么命名了,且命名为Dispatcher吧。
都有哪些处理呢?
其中,最后一点,在显示有大量数据源的RecycleView或者ListView时,
执行快速滑动时最好能暂停任务,停下来才恢复加载,这样能节省不少没必要要的请求。
简而言之,Dispatcher有两个职责:
一、桥接的做用,链接外部于内部组件(有点像主板);
二、处理结果的反馈(如图片的显示)。
第三章梳理了流程和架构; 第四章分解了各部分功能实现; 这一章咱们作一下回顾和梳理。
先回顾一下图片框架的架构:
整个框架以Doodle为起点,以Worker为核心,类之间调用不会太深, 整体上结构仍是比较紧凑的。
了解这几个类,就基本上了解整个框架的构成了。
这一节,咱们结合各个核心类,再次梳理一下执行流程:
上图依然是简化版的执行流,但弄清楚了基本流程,其余细枝末节的流程也都好理解了。
一、图片加载流程,从框架的 Doodle.load() 开始,返回Request对象;
object Doodle {
fun load(path: String): Request {
return Request(path)
}
}
复制代码
二、封装Request参数以后,以into收尾,由Dispatcher启动请求;
class Request {
fun into(target: ImageView?)
fillSizeAndLoad(viewWidth, viewHeight)
}
private fun fillSizeAndLoad(targetWidth: Int, targetHeight: Int) {
Dispatcher.start(this)
}
}
复制代码
三、先尝试从内存缓存获取bitmap, 无则开启异步请求
internal object Dispatcher {
fun start(request: Request?) {
val bitmap = MemoryCache.getBitmap(request.key)
if (bitmap == null) {
val loader = Worker(request, imageView)
loader.priority(request.priority)
.hostHash(request.hostHash)
.execute()
}
}
}
复制代码
四、核心的工做都在Worker中执行,包括获取文件(解析,下载),解码,变换,及缓存图片等
internal class Worker(private val request: Request, imageView: ImageView?) : UITask<Void, Void, Any>() {
private var fromMemory = false
private var fromDiskCache = false
override fun doInBackground(vararg params: Void): Any? {
var bitmap: Bitmap? = null
var source: Source? = null
try {
bitmap = MemoryCache.getBitmap(key) // 检查内存缓存
if (bitmap == null) {
val filePath = DiskCache[key] // 检查磁盘缓存(结果缓存)
fromDiskCache = !TextUtils.isEmpty(filePath)
source = if (fromDiskCache) Source.valueOf(File(filePath!!)) else Source.parse(request) // 解析
bitmap = Decoder.decode(source, request, fromDiskCache) // 解码
bitmap = transform(request, bitmap) // 变换
if (bitmap != null) {
if (request.memoryCacheStrategy != MemoryCacheStrategy.NONE) {
val toWeakCache = request.memoryCacheStrategy == MemoryCacheStrategy.WEAK
MemoryCache.putBitmap(key, bitmap, toWeakCache) // 缓存到内存
}
if (!fromDiskCache && request.diskCacheStrategy and DiskCacheStrategy.RESULT != 0) {
storeResult(key, bitmap) // 缓存到磁盘
}
}
}
return bitmap
} catch (e: Throwable) {
LogProxy.e(TAG, e)
} finally {
Utils.closeQuietly(source)
}
return null
}
override fun onPostExecute(result: Any?) {
val imageView = target
if (imageView != null) {
imageView.tag = null
}
// 显示结果
Dispatcher.feedback(request, imageView, result, false)
}
}
复制代码
以上代码中,有两点须要提一下:
五、回归Dispatcher, 刷新ImageView
internal object Dispatcher {
fun feedback(request: Request, imageView: ImageView? ...) {
if (bitmap != null) {
imageView.setImageBitmap(bitmap)
}
}
}
复制代码
前面说了这么多实现细节,那到底最终都实现了些什么功能呢?
看有什么功能,看接口层的三个类便可。
方法 | 做用 |
---|---|
config() : Config | 回全局配置 |
trimMemory(int) | 整理内存(LruCache),传入ComponentCallbacks2的不一样level有不一样的策略 |
clearMemory() | 移除LruCache中全部bitmap |
load(String): Request | 传入图片路径,返回Request |
load(int): Request | 传入资源ID,返回Request |
load(Uri): Request | 传入URI,返回Request |
downloadOnly(String): File? | 仅下载图片文件,不解码。此方法会走网络请求,不可再UI线程调用 |
getSourceCacheFile(url: String): File? | 获取原图缓存,无则返回null。不走网络请求,能够在UI线程调用 |
cacheBitmap(String,Bitmap,Boolean) | 缓存bitmap到Doodle的MemoryCache, 至关于开放MemoryCache, 复用代码,统一管理。 |
getCacheBitmap(String): Bitmap? | 获取缓存在Cache中的bitmap |
pauseRequest() | 暂停往任务队列中插入请求,对RecycleView快速滑动等场景,可调用此函数 |
resumeRequest() | 恢复请求 |
notifyEvent(Any, int) | 发送页面生命周期事件(通知页面销毁以取消请求等) |
方法 | 做用 |
---|---|
setUserAgent(String) | 设置User-Agent头,网络请求将自动填上此Header |
setDiskCachePath(String) | 设置结果缓存的存储路径 |
setDiskCacheCapacity(Long) | 设置结果缓存的容量 |
setDiskCacheMaxAge(Long) | 设置结果缓存的最大保留时间(从最近一次访问算起),默认30天 |
setSourceCacheCapacity(Long) | 设置原图缓存的容量 |
setMemoryCacheCapacity(Long) | 设置内存缓存的容量,默认为maxMemory的1/6 |
setCompressFormat(Bitmap.CompressFormat) | 设置结果缓存的压缩格式, 默认为PNG |
setDefaultBitmapConfig(Bitmap.Config) | 设置默认的Bitmap.Config,默认为ARGB_8888 |
setGifDecoder(GifDecoder) | 设置GIF解码器 |
方法 | 做用 |
---|---|
sourceKey(String) | 设置数据源的key url默认状况下做为Request的key的一部分,有时候url有动态的参数,使得url频繁变化,从而没法缓存。 此时能够设置sourceKey,提到path做为Request的key的一部分。 |
override(int, int) | 指定剪裁大小 并不最终bitmap等大小并不必定等于override指定的大小(优先按照 ScaleType剪裁,向下采样), 若需确切大小的bitmap可配合ResizeTransformation实现。 |
scaleType(ImageView.ScaleType) | 指定缩放类型 若是target为ImageView则会自动从ImageView获取。 |
memoryCacheStrategy(int) | 设置内存缓存策略,默认LRU策略 |
diskCacheStrategy(int) | 设置磁盘缓存策略,默认ALL |
noCache() | 不作任何缓存,包括磁盘缓存和内存缓存 |
onlyIfCached(boolean) | 指定网络请求是否只从缓存读取(原图缓存) |
noClip() | 直接解码,不作剪裁和压缩 |
config(Bitmap.Config) | 指定单个请求的Bitmap.Config |
transform(Transformation) | 设置解码后的图片变换,能够连续调用(会按顺序执行) |
priority(int) | 请求优先级 |
keepOriginalDrawable() | 默认状况下请求开始会先清空ImageView以前的Drawable, 调用此方法后会保留以前的Drawable |
placeholder(int) | 设置占位图,在结果加载完成以前会显示此drawable |
placeholder(Drawable) | 同上 |
error(int) | 设置加载失败后的占位图 |
error(Drawable) | 同上 |
goneIfMiss() | 加载失败后imageView.visibility = View.GONE |
animation(int) | 设置加载成功后的过渡动画 |
animation(Animation) | 同上 |
fadeIn(int) | 加载成功后显示淡入动画 |
crossFate(int) | 这个动画效果是原图从透明度100到0, bitmap从0到100。 当设置placeholder且内存缓存中没有指定图片时, placeholder为原图。 若是没有设置placeholder, 效果和fadeIn差很少。 须要注意的是,这个动画在原图和bitmap宽高不相等时,动画结束时图片会变形。 所以,慎用crossFade。 |
alwaysAnimation(Boolean) | 默认状况下仅在图片是从磁盘或者网络加载出来时才作动画,可经过此方法设置老是作动画 |
asBitmap() | 当设置了GifDecoder时,默认状况下只要图片是GIF图片,则用GifDecoder解码。 调用此方法后,只取Gif文件第一帧,返回bitmap |
host(Any) | 参加Task的host |
preLoad() | 预加载 |
get(long) : Bitmap? | 当前线程获取图片,加载时阻塞当前线程, 可设定timeout时间(默认3000ms),超时未完成则取消任务,返回null。 |
into(SimpleTarget) | 加载图片后经过SimpleTarget回调图片(加载时不阻塞当前线程) |
into(ImageView, Callback) | 加载图片图片到ImageView,同时经过Callback回调。 若是Callback中返回true, 说明已经处理该bitmap了,则Doodle不会再setBitmap到ImageView了。 |
into(ImageView?) | 加载图片图片到ImageView |
本文从架构,流程等方面入手,详细分析了图片加载框架的各类实现细节。
从文中能够看出,实现过程大量借鉴了Glide和Picasso, 在此对Glide和Picasso的开源工做者表示敬意和感谢。
这里就不作太详细的对比了,这里只比较下方法数和包大小(功能和性能不太比如较)。
框架 | 版本 | 方法数 | 包大小 |
---|---|---|---|
Glide | 4.8.0 | 3193 | 691k |
Picasso | 2.71828 | 527 | 119k |
Doodle | 1.1.0 | 442 | 104k |
Doodle先是用Java实现的,后面用Kotlin改写,方法数从200多增长到400多,包大小从60多K增长到100K(用kotlin改写library, 包大小会增长50%左右)。
麻雀虽小,五脏俱全。
Doodle能够说是一个比较完善的图片加载框架了,而且有很多微创新。
相比于Picasso,Doodle的实现更加完备(缓存设计,生命周期,任务调度,GIF支持,解码方案等多方面,比Picasso考虑的细节更多);
相比于Glide,Doodle的实现更加轻量(方法数400+,包大小104K)。
对于大小敏感的项目,或可考虑使用这个框架。
感兴趣的读者能够参与进来,欢迎提建议和提代码。
项目已发布到jcenter和github, 项目地址:github.com/No89757/Doo… 看多遍不如跑一遍,能够Download下来运行一下,会比看文章有更多的收获。