如何實現一個圖片加載框架

1、前言

圖片加載的輪子有不少了,Universal-Image-Loader, Picasso, Glide, Fresco等。
網上各類分析和對比文章不少,咱們這裏就很少做介紹了。前端

古人云:「紙上得來終覺淺,絕知此事要躬行」。
只看分析,不動手實踐,終究印象不深。
用當下流行的「神經網絡」來講,就是要經過「輸出」,造成「反饋」,才能更有效地「訓練」。
因此,咱們經過手撕一個圖片加載框架,一窺其中奧祕。
java

話很少說,先來兩張圖暖一下氣氛:android

2、 框架命名

命名是比較使人頭疼的一件事。
在反覆翻了單詞表以後,決定用Doodle做爲框架的名稱。git

Picasso是畫家畢加索的名字,Fresco翻譯過來是「壁畫」,比ImageLoader之類的要更有格調;
原本想起Van、Vince之類的,但想一想仍是不要冒犯這些巨擘了。github

Doodle爲塗鴉之意,除了單詞自己內涵以外,外在也頗有趣,很像一個單詞:Google。
這樣的兼具備趣靈魂和好看皮囊的詞,真的很少了。編程

3、流程&架構

3.1 加載流程

歸納來講,圖片加載包含封裝,解析,下載,解碼,變換,緩存,顯示等操做。 流程圖以下: 後端

  • 封裝參數:從指定來源,到輸出結果,中間可能經歷不少流程,因此第一件事就是封裝參數,這些參數會貫穿整個過程;
  • 解析路徑:圖片的來源有多種,格式也不盡相同,須要規範化;
  • 讀取緩存:爲了減小計算,一般都會作緩存;一樣的請求,從緩存中取圖片(Bitmap)便可;
  • 查找文件/下載文件:若是是本地的文件,直接解碼便可;若是是網絡圖片,須要先下載;
  • 解碼:這一步是整個過程當中最複雜的步驟之一,有很多細節;
  • 變換:解碼出Bitmap以後,可能還須要作一些變換處理(圓角,濾鏡等);
  • 緩存:獲得最終bitmap以後,能夠緩存起來,以便下次請求時直接取結果;
  • 顯示:顯示結果,可能須要作些動畫(淡入動畫,crossFade等)。

以上簡化版的流程(只是衆多路徑中的一個分支),後面咱們將會看到,完善各類細節以後,會比這複雜不少。
但萬事皆由簡入繁,先簡單梳理,後續再慢慢填充,猶如繪畫,先繪輪廓,再描細節。api

3.2 基本架構

解決複雜問題,思路都是類似的:分而治之。
參考MVC的思路,咱們將框架劃分三層:緩存

  • Interface: 框架入口和外部接口
  • Processor: 邏輯處理層
  • Storage:存儲層,負責各類緩存。

具體劃分以下: 網絡

  • 外部接口
    Doodle: 提供全局參數配置,圖片加載入口,以及內存緩存接口。
    Config: 全局參數配置。包括緩存路徑,緩存大小,圖片編碼等參數。
    Request: 封裝請求參數。包括數據源,剪裁參數,行爲參數,以及目標。

  • 執行單元
    Dispatcher : 負責請求調度, 以及結果顯示。
    Worker: 工做線程,異步執行加載,解碼,轉換,存儲等。
    Downloader: 負責文件下載。
    Source: 解析數據源,提供統一的解碼接口。
    Decoder: 負責具體的解碼工做。

  • 存儲組件
    MemoryCache: 管理Bitmap緩存。
    DiskCache: 圖片「結果」的磁盤緩存(原圖由OkHttp緩存)。

4、功能實現

上一節分析了流程和架構,接下來就是在理解流程,瞭解架構的前提下,
先分別實現關鍵功能,而後串聯起來,以後就是不斷地添加功能和完善細節。
簡而言之,就是自頂向下分解,自底向上填充。

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類:

  • 一、圖片源;
  • 二、解碼參數:寬高,scaleType,圖片配置(ARGB_8888, RGB_565)等;
  • 三、加載行爲:加載優先級,緩存策略,佔位圖,動畫等;
  • 四、目標,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是比較通用的磁盤緩存解決方案,筆者以爲對於簡單地存個圖片文件能夠更精簡一些,因此本身設計了一個更專用的方案。

其實磁盤緩存的管理最主要是設計記錄日誌,方案要點以下:
一、一條記錄存儲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淘汰規則外,還可實現最大保留時間,刪除一些過久沒用到的圖片文件。

雖然名爲磁盤緩存,其實不只僅緩存文件,「文件記錄」也很關鍵,兩者關係猶如文件內容和文件的元數據, 相輔相成。

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:大小調整,寬高縮放到指定大小;
BlurTransformation:高斯模糊。

須要指出的一點是, 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在設計階段也考慮了圖片複用,而且也實現了,但實現後一直糾結其收益和成本-_-

  • 一、正在使用的圖片不能被複用,因此要添加引用計數策略,附加代碼不少;
  • 二、即便圖片沒有被引用,根據局部性原理,該圖片可能稍後有可能被訪問,因此也不該該立刻被複用;
  • 三、大多數狀況下,符合複用條件(不用一段時間,尺寸符合要求)的並很少;
  • 四、佔用一些額外的計算資源。

最終,在看了帖子 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的幾個特性:

  • 一、支持優先級;
  • 二、支持生命週期(在Activity/Fragment銷燬時取消任務);
  • 三、支持根據 Activity/Fragment 的顯示/隱藏動態調整優先級;
  • 四、支持任務去重。

關於任務去重,主要是以Request的key做爲任務的tag, 相同tag的任務串行執行,
如此,當第一個任務完成,後面的任務讀緩存便可,避免了重複計算。
對於網絡圖片源的任務,則以URL做爲tag, 以避免重複下載。
此外,線程池,在UI線程回調結果,在當前線程獲取結果等操做,都能基於Task簡單地實現。

4.5 Dispatcher

從Request,到開始解碼,從解碼完成,到顯示圖片, 之間很多零碎的處理。
把這些處理都放到一個類中,殊不知道怎麼命名了,且命名爲Dispatcher吧。

都有哪些處理呢?

  • 一、檢查ImageView有沒有綁定任務(啓動任務後會將Request放入ImageView的tag中),
    若是有,判斷是否相同(根據請求的key), 相同且前面的任務在執行,則取消之;
  • 二、啓動任務前顯示佔位圖(若是設置了的話);
  • 三、任務結束,若是任務失敗,顯示錯誤圖片;
  • 四、若是加載成功且設置了過渡動畫,執行動畫;
  • 五、各類target的回調;
  • 六、任務的暫停和開始。

其中,最後一點,在顯示有大量數據源的RecycleView或者ListView時,
執行快速滑動時最好能暫停任務,停下來才恢復加載,這樣能節省不少沒必要要的請求。

簡而言之,Dispatcher有兩個職責:
一、橋接的做用,鏈接外部於內部組件(有點像主板);
二、處理結果的反饋(如圖片的顯示)。

5、回顧

第三章梳理了流程和架構; 第四章分解了各部分功能實現; 這一章咱們作一下回顧和梳理。

5.1 依賴關係

先回顧一下圖片框架的架構:

  • Doodle做爲框架的入口,提供全局參數配置(Config)以及單個圖片的請求(Request);
  • Request被不少類所依賴,事實上,Request貫穿了整個請求過程。 添加功能時,通常也是從Request開始,添加變量和方法,而後在後面的流程中尋找注入點,插入控制代碼,完成功能添加。
  • Dispatcher和Worker是相互依賴的關係,表現爲Dispatcher發起啓動Worker, Worker將結果反饋給Dispatcher。
  • Downloader給Source提供圖片文件的InputStream, 圖片下載的具體執行爲Downloader中的OkHttpClient。、

整個框架以Doodle爲起點,以Worker爲核心,類之間調用不會太深, 整體上結構仍是比較緊湊的。
瞭解這幾個類,就基本上了解整個框架的構成了。

5.2 執行流

這一節,咱們結合各個核心類,再次梳理一下執行流程:

上圖依然是簡化版的執行流,但弄清楚了基本流程,其餘細枝末節的流程也都好理解了。

一、圖片加載流程,從框架的 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啓動Worker以前已經檢查內存緩存了,爲何Worker中又檢查一次? 由於可能存在多個請求的bitmap是相同的(key所決定),只是target不一樣,而後Worker會串行執行這些請求; 當第一個請求結束,圖片已經放到內存緩存了,接下來的請求能夠從內存緩存中直接獲取bitmap,無需再次解碼。
  • 爲何沒有看到Downloader下載文件? Downloader出如今Source.parse(request)方法中,主要是返回一個InputStream; 文件的下載過程在發生在Decoder.decode()方法中,邊下載邊解碼。

五、迴歸Dispatcher, 刷新ImageView

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

6、API

前面說了這麼多實現細節,那到底最終都實現了些什麼功能呢?
看有什麼功能,看接口層的三個類便可。

6.1 Doodle (框架入口)

方法 做用
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) 發送頁面生命週期事件(通知頁面銷燬以取消請求等)

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
preLoad() 預加載
get(long) : Bitmap? 當前線程獲取圖片,加載時阻塞當前線程, 可設定timeout時間(默認3000ms),超時未完成則取消任務,返回null。
into(SimpleTarget) 加載圖片後經過SimpleTarget回調圖片(加載時不阻塞當前線程)
into(ImageView, Callback) 加載圖片圖片到ImageView,同時經過Callback回調。
若是Callback中返回true, 說明已經處理該bitmap了,則Doodle不會再setBitmap到ImageView了。
into(ImageView?) 加載圖片圖片到ImageView

7、總結

本文從架構,流程等方面入手,詳細分析了圖片加載框架的各類實現細節。
從文中能夠看出,實現過程大量借鑑了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下來運行一下,會比看文章有更多的收穫。

相關文章
相關標籤/搜索