如何實現一個圖片載入框架
一、前言
圖片載入的輪子有很多了,Universal-Image-Loader, Picasso, Glide, Fresco等。
網上各種分析和對比文章很多,我們這裡就不多作介紹了。
古人云:“紙上得來終覺淺,絕知此事要躬行”。
只看分析,不動手實踐,終究印象不深。
用當下流行的“神經網路”來說,就是要通過“輸出”,形成“反饋”,才能更有效地“訓練”。
當然,大千世界,包羅永珍,我們不可能任何事情都去經歷。
能挑自己感興趣的方面探究一番,已經幸事。
圖片載入是筆者比較感興趣的,其中有不少知識和技巧值得研究探討。
話不多說,先來兩張圖暖一下氣氛:

金克斯

輪子媽
暖場結束,我們開始吧:

ez
二、 框架命名
“電腦科學只存在兩個難題:快取失效和命名。” ——Phil KarIton
這個論斷或許有失偏頗,但誠然,命名確實是比較頭疼的一件事。
在反覆翻了單詞表之後,決定用 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原始碼中都出現過。
最後,綜合 LruCache 和 WeakCache ,統一索引:
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 StreamSourceconstructor(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的圖片呢? 可以的。
檢視 ofollow,noindex">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之後,根據獲取的旋轉資訊,呼叫 setRotate 和 postScale 進行對應的旋轉和翻轉,即可還原正確的顯示。
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、佔用一些額外的計算資源,一定程度搶佔Cache的物件鎖。
最終,在看了帖子 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?, result: Any?, beforeLoading: Boolean) { if (result != null) { if (bitmap != null) { if (request.alwaysAnimation || !beforeLoading) { imageView.setImageBitmap(bitmap) startAnimation(request, imageView) } else { imageView.setImageBitmap(bitmap) } } else if (result is Drawable) { // 顯示gif imageView.setImageDrawable(result) } // 不需要else, 因為result不是Bitmap就是Drawable } else { if (beforeLoading) { setPlaceholder(request, imageView) } else { setError(request, imageView) } } } }
關於圖片載入完之後的動畫顯示,Picasso等框架的處理是:
如果bitmap是從記憶體快取中獲取的,不做動畫,否則做動畫(預設是淡入動畫)。
Doodle基本按照此規則,不同之處在於,Doodle預設不做動畫,需要顯示動畫的話可以呼叫fadeIn, crossFade,或者設定自定義動畫;
然後如果需要無論從哪裡獲取到圖片都做動畫,呼叫alwaysAnimation即可。
六、總結
本文通過從靜態的結構,到動態的流程兩方面,介紹了Doodle的基本框架和主要的實現。
從文中可以看出,Doodle大量借鑑了Glide和Picasso, 在此對Glide和Picasso的開源工作者表示敬意和感謝。
專案已釋出到jcenter和github, 程式碼量不多,但功能應該是比較完備的。
專案地址: Doodle