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

一、前言

圖片加載的輪子有很多了,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

    @JvmStatic
    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的摘要即可(原理參考《漫談散列函數》)。

如果說圖片源是input的話,目標便是output。

圖片文件的來源,通常有 網絡圖片,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爲單位映射。

插播一條“廣告”:
總體來看,此日誌方案有點類似筆者的另一個存儲組件: LightKV, 有興趣的讀者可以瞭解一下。

相對而言,該方案的優點爲:
1、節省空間,一頁(4K)能記錄256個文件;
2、格式規整,解析快;
3、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設置爲false時, BitmapFactory不會返回bitmap, 而是僅僅讀取文件頭信息,其中最重要的是圖片分辨率。

val options = BitmapFactory.Options()
options.inJustDecodeBounds = false
BitmapFactory.decodeStream(inputStream, null, options)

讀取了頭信息,計算解碼參數之後,將inJustDecodeBounds設置爲true,
再次調用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(預讀完成再關閉mark)。
但是即使如此,後面還是有反饋有的圖片無法預讀到圖片的大小。
筆者很幸運地遇到了這種情況,經調試代碼,最終發現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.

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(get) : Bitmap? 當前線程獲取圖片,加載時阻塞當前線程
into(SimpleTarget) 加載圖片後通過SimpleTarget回調圖片(加載是不阻塞當前線程)
into(ImageView, Callback) 加載圖片圖片到ImageView,同時通過Callback回調。如果Callback中返回true, 說明已經處理該bitmap了,則Doodle不會再setBitmap到ImageView了。
into(ImageView?) 加載圖片圖片到ImageView

七、總結

本文從架構,流程等方面入手,詳細分析了圖片加載框架的各種實現細節。
從文中可以看出,實現過程大量借鑑了Glide和Picasso, 在此對Glide和Picasso的開源工作者表示敬意和感謝。

項目已發佈到jcenter和github, 代碼量不多,但功能應該是比較完備的。
看多遍不如跑一遍,感興趣的讀者可以Download下來,運行一下,會比看文章有更多的收穫。
項目地址:https://github.com/No89757/Doodle

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章