如何实现一个图片加载框架

一、前言

图片加载的轮子有很多了,Universal-Image-Loader, Picasso, Glide, Fresco等。
网上各种分析和对比文章很多,我们这里就不多作介绍了。

古人云:“纸上得来终觉浅,绝知此事要躬行”。
只看分析,不动手实践,终究印象不深。
所以,我们通过手撕一个图片加载框架,一窥其中奥秘。

话不多说,先来两张图暖一下气氛:

二、 框架命名

命名是比较令人头疼的一件事。
在反复翻了单词表之后,决定用Doodle作为框架的名称。

Picasso是画家毕加索的名字,Fresco翻译过来是“壁画”,比ImageLoader之类的要更有格调;
本来想起Van、Vince之类的,但想想还是不要冒犯这些巨擘了。

Doodle为涂鸦之意,除了单词本身内涵之外,外在也很有趣,很像一个单词:Google。
这样的兼具有趣灵魂和好看皮囊的词,真的不多了。

三、流程&架构

3.1 加载流程

概括来说,图片加载包含封装,解析,下载,解码,变换,缓存,显示等操作。
流程图如下:


  • 封装参数:从指定来源,到输出结果,中间可能经历很多流程,所以第一件事就是封装参数,这些参数会贯穿整个过程;
  • 解析路径:图片的来源有多种,格式也不尽相同,需要规范化;
  • 读取缓存:为了减少计算,通常都会做缓存;同样的请求,从缓存中取图片(Bitmap)即可;
  • 查找文件/下载文件:如果是本地的文件,直接解码即可;如果是网络图片,需要先下载;
  • 解码:这一步是整个过程中最复杂的步骤之一,有不少细节;
  • 变换:解码出Bitmap之后,可能还需要做一些变换处理(圆角,滤镜等);
  • 缓存:得到最终bitmap之后,可以缓存起来,以便下次请求时直接取结果;
  • 显示:显示结果,可能需要做些动画(淡入动画,crossFade等)。

以上简化版的流程(只是众多路径中的一个分支),后面我们将会看到,完善各种细节之后,会比这复杂很多。
但万事皆由简入繁,先简单梳理,后续再慢慢填充,犹如绘画,先绘轮廓,再描细节。

3.2 基本架构

解决复杂问题,思路都是相似的:分而治之。
参考MVC的思路,我们将框架划分三层:

  • Interface: 框架入口和外部接口
  • Processor: 逻辑处理层
  • Storage:存储层,负责各种缓存。

具体划分如下:

  • 外部接口
    Doodle: 提供全局参数配置,图片加载入口,以及内存缓存接口。
    Config: 全局参数配置。包括缓存路径,缓存大小,图片编码等参数。
    Request: 封装请求参数。包括数据源,解码参数,行为参数,以及目标。

  • 执行单元
    Dispatcher : 负责请求调度, 以及结果显示。
    Worker: 工作线程,异步执行加载,解码,变换,存储等。
    Downloader: 负责文件下载。
    Source: 解析数据源,提供统一的解码接口。
    Decoder: 负责具体的解码工作。

  • 存储组件
    MemoryCache: 管理Bitmap缓存。
    DiskCache: 图片“结果”的磁盘缓存(原图由OkHttp缓存)。

四、功能实现

上一节分析了流程和架构,接下来就是在理解流程,了解架构的前提下,
先分别实现关键功能,然后串联起来,之后就是不断地添加功能和完善细节。
简而言之,就是自顶向下分解,自底向上填充。

4.1 API设计

众多图片加载框架中,Picasso和Glide的API是比较友好的。

Picasso.with(context)
        .load(url)
        .placeholder(R.drawable.loading)
        .into(imageView);

Glide的API和Picasso类似。

当参数较多时,构造者模式就可以搬上用场了,其链式API能使参数指定更加清晰,而且更加灵活(随意组合参数)。
Doodle也用类似的API,而且为了方便理解,有些方法命名也参照Picasso和 Glide。

4.1.1 全局参数

  • Config
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
    }
    // ....
}
  • Doodle
object Doodle {
    fun config() : Config {
        return Config
    }
}
  • 框架初始化
Doodle.config()
        .setDiskCacheCapacity(256L shl 20)
        .setGifDecoder(gifDecoder)

虽然也是链式API,但是没有参照Picasso那样的构造者模式的用法(读写分离),因为那种写法有点麻烦,而且不直观。
Config是一个单例,除了GifDecoder之外,其他参数都有默认值。

4.1.2 图片请求

加载图片:

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)
    }
}
  • Request
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类:

  • 1、图片源;
  • 2、解码参数:宽高,scaleType,图片配置(ARGB_8888, RGB_565)等;
  • 3、加载行为:加载优先级,缓存策略,占位图,动画等;
  • 4、目标,ImageView或者回调等。

其中,图片源和解码参数决定了最终的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时需要考虑的。

4.2 缓存设计

几大图片加载框架都实现了缓存,各种文章中,有说二级缓存,有说三级缓存。
其实从存储来说,可简单地分为内存缓存和磁盘缓存;
只是同样是内存/磁盘缓存,也有多种形式,例如Glide的“磁盘缓存”就分为“原图缓存”和“结果缓存”。

4.2.1 内存缓存

为了复用计算结果,提高用户体验,通常会做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源码中都出现过。

最后,综合LruCacheWeakCache,统一索引:

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

4.2.2 磁盘缓存

前面提到,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是比较通用的磁盘缓存解决方案,笔者觉得对于简单地存个图片文件可以更精简一些,所以自己设计了一个更专用的方案。

其实磁盘缓存的管理最主要是设计记录日志,方案要点如下:
1、一条记录存储key(long)和最近访问时间(long),一条记录16字节;
2、每条记录依次排列,由于比较规整,可以根据偏移量随机读写;
3、用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, 二级存储,文件读一次之后接下来都是写入。

该方案的优点为:
1、节省空间,一页(4K)能记录256个文件;
2、格式规整,解析快;
3、mmap映射,可批量记录,自动定时写入磁盘,降低磁盘IO消耗;
4、二级存储,访问速度快。

当容量超出限制需要淘汰时,根据访问时间,先删除最久没被访问的文件;
除了实现LRU淘汰规则外,还可实现最大保留时间,删除一些太久没用到的图片文件。

虽然名为磁盘缓存,其实不仅仅缓存文件,“文件记录”也很关键,二者关系犹如文件内容和文件的元数据, 相辅相成。

4.3 解码

SDK提供了BitmapFactory,提供各种API,从图片源解码成bitmap,但这仅是图片解码的最基础的工作;
图片解码,前前后后要准备各种材料,留心各种细节,是图片加载过程中最繁琐的步骤之一。

4.3.1 解析数据源

前面提到,图片的来源有多种,我们需要识别图片来源,
然后根据各自的特点提供统一的处理方法,为后续的具体解码工作提供方便。

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等。

  • FileSource: 本地文件
  • AssetSource:asset文件,drawable/raw资源文件
  • StreamSource:网络文件,ContentProvider提供的图片文件,如相机,相册等。

其中,网络文件从OkHttp的网络请求获得,如果缓存了原图, 则会获得FileSource。
其实各种图片源最终都可以转化为InputStream,例如AssetInputStream其实就是InputStream的一种, 文件也可以转化为FileInputStream。
那为什么区分开来呢? 这一切都要从读取图片头信息开始讲。

4.3.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来重新读取;
而对于其他的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更节约内存。

4.3.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也换成这种压缩策略了。

平时设计给切图,要放对文件夹,也是这个道理。
比如设计给了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)阶段处理。

4.3.4 图片旋转

相信不少开发都遇到拍照后图片旋转的问题(尤其是三星的手机)。
网上有不少关于此问题的解析,这是其中一篇:关于图片EXIF信息中旋转参数Orientation的理解

Android SDK提供了ExifInterface 来获取Exif信息,Picasso正是用此API获取旋转参数的。
很可惜ExifInterface要到 API level 24 才支持通过InputStream构造对象,低于此版本,仅支持通过文件路径构造对象。
故此,Picasso当前版本仅在传入参数是文件路径(或者文件的Uri)时可处理旋转问题。

Glide自己实现了头部解析,主要是获取文件类型和exif旋转信息。
Doodle抽取了Glide的HeaderParser,并结合工程做了一些精简和代码优化, 嗯, 又一个“改装版”。
decode出bitmap之后,根据获取的旋转信息,调用setRotatepostScale进行对应的旋转和翻转,即可还原正确的显示。

4.3.5 变换

解码出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:大小调整,宽高缩放到指定大小。

需要指出的一点是, Request中指定大小之后并不总是能够解码出指定大小的bitmap,
如果原图分辨率小于指定大小,基于向下采样的策略,并不会主动缩放到指定的大小(前面有提到)。
若需要确定大小的bitmap, 可应用ResizeTransformation。

更多的变换,可以到glide-transformations寻找,
虽然不能直接导入引用, 但是处理方法是类似的,改造一下就可使用。

4.3.6 GIF图

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()方法即可。

4.3.7 图片复用

很多文章讲图片优化时都会提到两个点,压缩和图片复用。
Doodle在设计阶段也考虑了图片复用,并且也实现了,但实现后一直纠结其收益和成本-_-

  • 1、正在使用的图片不能被复用,所以要添加引用计数策略,附加代码很多;
  • 2、即使图片没有被引用,根据局部性原理,该图片可能稍后有可能被访问,所以也不应该马上被复用;
  • 3、大多数情况下,符合复用条件(不用一段时间,尺寸符合要求)的并不多;
  • 4、占用一些额外的计算资源。

最终,在看了帖子 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定位是小而美的轻量级图片框架,过程中移除了不少价值不高的功能和复杂的实现。
有舍必有得,编程与生活,莫不如此。

4.4 线程调度

图片获取和解码都是耗时的操作,需放在异步执行;
而通常需要同时请求多张图片,故此,线程调度不可或缺。

Doodle的线程调度依赖于笔者的另一个项目Task, 具体内容详见:《如何实现一个线程调度框架》(又发了一波广告?-_-)。
简单的说,主要用到了Task的几个特性:

  • 1、支持优先级;
  • 2、支持生命周期(在Activity/Fragment销毁时取消任务);
  • 3、支持根据 Activity/Fragment 的显示/隐藏动态调整优先级;
  • 4、支持任务去重。

关于任务去重,主要是以Request的key作为任务的tag, 相同tag的任务串行执行,
如此,当第一个任务完成,后面的任务读缓存即可,避免了重复计算。
对于网络图片源的任务,则以URL作为tag, 以免重复下载。
此外,线程池,在UI线程回调结果,在当前线程获取结果等操作,都能基于Task简单地实现。

4.5 Dispatcher

从Request,到开始解码,从解码完成,到显示图片, 之间不少零碎的处理。
把这些处理都放到一个类中,却不知道怎么命名了,且命名为Dispatcher吧。

都有哪些处理呢?
1、检查ImageView有没有绑定任务(启动任务后会将Request放入ImageView的tag中),
如果有,判断是否相同(根据请求的key), 相同且前面的任务在执行,则取消之;
2、启动任务前显示占位图(如果设置了的话);
3、任务结束,如果任务失败,显示错误图片;
4、如果加载成功且设置了过渡动画,执行动画;
5、各种target的回调;
6、任务的暂停和开始。

其中,最后一点,在显示有大量数据源的RecycleView或者ListView时,
执行快速滑动时最好能暂停任务,停下来才恢复加载,这样能节省很多不必要的请求。

简而言之,Dispatcher有两个职责:
1、桥接的作用,连接外部于内部组件(有点像主板);
2、处理结果的反馈(如图片的显示)。

五、回顾

第三章梳理了流程和架构;
第四章分解了各部分功能实现;
这一章我们做一下回顾和梳理。

5.1 依赖关系

先回顾一下图片框架的架构:


  • Doodle作为框架的入口,提供全局参数配置(Config)以及单个图片的请求(Request);
  • Request被很多类所依赖,事实上,Request贯穿了整个请求过程。
    添加功能时,一般也是从Request开始,添加变量和方法,然后在后面的流程中寻找注入点,插入控制代码,完成功能添加。
  • Dispatcher和Worker是相互依赖的关系,表现为Dispatcher发起启动Worker, Worker将结果反馈给Dispatcher。
  • Downloader给Source提供图片文件的InputStream, 图片下载的具体执行为Downloader中的OkHttpClient。

整个框架以Doodle为起点,以Worker为核心,类之间调用不会太深, 总体上结构还是比较紧凑的。
了解这几个类,就基本上了解整个框架的构成了。

5.2 执行流

这一节,我们结合各个核心类,再次梳理一下执行流程:

上图依然是简化版的执行流,但弄清楚了基本流程,其他细枝末节的流程也都好理解了。

1、图片加载流程,从框架的Doodle.load()开始,返回Request对象;

object Doodle {
    fun load(path: String): Request {
        return Request(path)
    }
}

2、封装Request参数之后,以into收尾,由Dispatcher启动请求;

class Request {
    fun into(target: ImageView?) 
        fillSizeAndLoad(viewWidth, viewHeight)
    }
    
    private fun fillSizeAndLoad(targetWidth: Int, targetHeight: Int) {
        Dispatcher.start(this)
    }
}

3、先尝试从内存缓存获取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()
        }
    }
}

4、核心的工作都在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启动Worker之前已经检查内存缓存了,为什么Worker中又检查一次?
    因为可能存在多个请求的bitmap是相同的(key所决定),只是target不同,然后Worker会串行执行这些请求;
    当第一个请求结束,图片已经放到内存缓存了,接下来的请求可以从内存缓存中直接获取bitmap,无需再次解码。
  • 为什么没有看到Downloader下载文件?
    Downloader出现在Source.parse(request)方法中,主要是返回一个InputStream;
    文件的下载过程在发生在Decoder.decode()方法中,边下载边解码。

5、回归Dispatcher, 刷新ImageView

internal object Dispatcher {
    fun feedback(request: Request, imageView: ImageView? ...) {
        if (bitmap != null) {
            imageView.setImageBitmap(bitmap)
        } 
    }
}

六、API

前面说了这么多实现细节,那到底最终都实现了些什么功能呢?
看有什么功能,看接口层的三个类就可以了。

6.1 Doodle (框架入口)

方法 作用
init() : 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) 发送页面生命周期事件(通知页面销毁以取消请求等)

6.2 Config (全局配置)

方法 作用
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解码器

6.3 Request (图片请求)

方法 作用
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) 传入宿主(Activity/Fragment), 以观察其生命周期
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的,并且相对前二者有不少微创新。
而相对于Glide, Doodle则是较轻量。
对于大小敏感的项目,或可尝试一下这个框架。

感兴趣的读者可以参与进来,欢迎提建议和提代码。

项目已发布到jcenter和github, 项目地址:https://github.com/BillyWei001/Doodle
看多遍不如跑一遍,可以Download下来运行一下,会比看文章有更多的收获。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容