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

一、前言

图片加载的轮子有很多了,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
    // ...
    fun setUserAgent(userAgent: String): Config {
        this.userAgent = userAgent
        return this
    }

    fun setDiskCachePath(path: String): Config {
        this.diskCachePath = path
        return this
    }
    // ....
}
  • Doodle
object Doodle {
    internal lateinit var appContext: Context

    fun init(context: Context) : Config {
        appContext = context as? Application ?: context.applicationContext
        registerActivityLifeCycle(appContext)
        return Config
    }
}
  • 框架初始化
Doodle.init(context)
      .setDiskCacheCapacity(256L shl 20)
      .setMemoryCacheCapacity(128L shl 20)
      .setDefaultBitmapConfig(Bitmap.Config.ARGB_8888)

虽然也是链式API,但是没有参照Picasso那样的构造者模式的用法(读写分离),因为那种写法有点麻烦,而且不直观。
Doodle在初始化的时候传入context(最好传入Application), 这样后面请求单个图片时,就不用像Picasso和Glide那样用with传context了。

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的HeaderParse,并结合工程做了一些精简和代码优化, 嗯, 又一个“改装版”。
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(Context) : Config初始化,传入context, 返回全局配置
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)参见Task的host
cacheInterceptor(CacheInterceptor)(原图)缓存拦截器,可自定义单个请求的缓存路径,自己管理缓存,以免被LRU或者过时规则删除
preLoad()预加载
get(int) : Bitmap?当前线程获取图片,加载时阻塞当前线程, 可设定timeout时间(默认3s),超时未完成则取消任务,返回null。
into(SimpleTarget)加载图片后通过SimpleTarget回调图片(加载是不阻塞当前线程)
into(ImageView, Callback)加载图片图片到ImageView,同时通过Callback回调。如果Callback中返回true, 说明已经处理该bitmap了,则Doodle不会再setBitmap到ImageView了。
into(ImageView?)加载图片图片到ImageView

七、总结

本文从架构,流程等方面入手,详细分析了图片加载框架的各种实现细节。
从文中可以看出,实现过程大量借鉴了Glide和Picasso, 在此对Glide和Picasso的开源工作者表示敬意和感谢。
这里就不做太详细的对比了,这里只比较下方法数和包大小(功能和性能不太好比较)。

框架版本方法数包大小
Glide4.8.03193691k
Picasso2.71828527119k
Doodle1.0.8419100k

Doodle先是用Java写的,后面用Kotlin改写,方法数从200多增加到400多,包大小从60多K增加到100K,真是作啊-_-
Picasso的版本停在2.71828(自然对数e≈2.71828, 刚开始还以为作者弃疗了~)好久了,说要出Picasso 3, 但是时间过去N久了也没见影;
从完备度和稳定性而言,Glide都要优于Picasso,毕竟一直有大量的反馈以及持续的维护。
Doodle在完备度上是不输Picasso的,并且相对前二者有一些微创新;
但毕竟是新项目,一个人的力量有限,必然会有不足的地方。
感兴趣的读者可以参与进来,欢迎提建议和提代码。

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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值