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

Horizon757發表於2019-03-02

一、前言

圖片載入的輪子有很多了,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的開源工作者表示敬意和感謝。
這裡就不做太詳細的對比了,這裡只比較下方法數和包大小(功能和效能不太好比較)。

框架 版本 方法數 包大小
Glide 4.8.0 3193 691k
Picasso 2.71828 527 119k
Doodle 1.0.8 419 100k

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下來執行一下,會比看文章有更多的收穫。

相關文章