圖片加載的輪子有不少了,Universal-Image-Loader, Picasso, Glide, Fresco等。
網上各類分析和對比文章不少,咱們這裏就很少做介紹了。前端
古人云:「紙上得來終覺淺,絕知此事要躬行」。
只看分析,不動手實踐,終究印象不深。
用當下流行的「神經網絡」來講,就是要經過「輸出」,造成「反饋」,才能更有效地「訓練」。
因此,咱們經過手撕一個圖片加載框架,一窺其中奧祕。
java
話很少說,先來兩張圖暖一下氣氛:android
命名是比較使人頭疼的一件事。
在反覆翻了單詞表以後,決定用Doodle做爲框架的名稱。git
Picasso是畫家畢加索的名字,Fresco翻譯過來是「壁畫」,比ImageLoader之類的要更有格調;
原本想起Van、Vince之類的,但想一想仍是不要冒犯這些巨擘了。github
Doodle爲塗鴉之意,除了單詞自己內涵以外,外在也頗有趣,很像一個單詞:Google。
這樣的兼具備趣靈魂和好看皮囊的詞,真的很少了。編程
歸納來講,圖片加載包含封裝,解析,下載,解碼,變換,緩存,顯示等操做。 流程圖以下: 後端
以上簡化版的流程(只是衆多路徑中的一個分支),後面咱們將會看到,完善各類細節以後,會比這複雜不少。
但萬事皆由簡入繁,先簡單梳理,後續再慢慢填充,猶如繪畫,先繪輪廓,再描細節。api
解決複雜問題,思路都是類似的:分而治之。
參考MVC的思路,咱們將框架劃分三層:緩存
具體劃分以下: 網絡
外部接口
Doodle: 提供全局參數配置,圖片加載入口,以及內存緩存接口。
Config: 全局參數配置。包括緩存路徑,緩存大小,圖片編碼等參數。
Request: 封裝請求參數。包括數據源,剪裁參數,行爲參數,以及目標。
執行單元
Dispatcher : 負責請求調度, 以及結果顯示。
Worker: 工做線程,異步執行加載,解碼,轉換,存儲等。
Downloader: 負責文件下載。
Source: 解析數據源,提供統一的解碼接口。
Decoder: 負責具體的解碼工做。
存儲組件
MemoryCache: 管理Bitmap緩存。
DiskCache: 圖片「結果」的磁盤緩存(原圖由OkHttp緩存)。
上一節分析了流程和架構,接下來就是在理解流程,瞭解架構的前提下,
先分別實現關鍵功能,而後串聯起來,以後就是不斷地添加功能和完善細節。
簡而言之,就是自頂向下分解,自底向上填充。
衆多圖片加載框架中,Picasso和Glide的API是比較友好的。
Picasso.with(context)
.load(url)
.placeholder(R.drawable.loading)
.into(imageView);
複製代碼
Glide的API和Picasso相似。
當參數較多時,構造者模式就能夠搬上用場了,其鏈式API能使參數指定更加清晰,並且更加靈活(隨意組合參數)。
Doodle也用相似的API,並且爲了方便理解,有些方法命名也參照Picasso和 Glide。
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
}
// ....
}
複製代碼
object Doodle {
fun config() : Config {
return Config
}
}
複製代碼
Doodle.config()
.setDiskCacheCapacity(256L shl 20)
.setGifDecoder(gifDecoder)
複製代碼
雖然也是鏈式API,可是沒有參照Picasso那樣的構造者模式的用法(讀寫分離),由於那種寫法有點麻煩,並且不直觀。
Config是一個單例,除了GifDecoder以外,其餘參數都有默認值。
加載圖片:
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)
}
}
複製代碼
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類:
其中,圖片源和解碼參數決定了最終的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時須要考慮的。
幾大圖片加載框架都實現了緩存,各類文章中,有說二級緩存,有說三級緩存。
其實從存儲來講,可簡單地分爲內存緩存和磁盤緩存;
只是一樣是內存/磁盤緩存,也有多種形式,例如Glide的「磁盤緩存」就分爲「原圖緩存」和「結果緩存」。
爲了複用計算結果,提升用戶體驗,一般會作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
曲面提到,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淘汰規則外,還可實現最大保留時間,刪除一些過久沒用到的圖片文件。
雖然名爲磁盤緩存,其實不只僅緩存文件,「文件記錄」也很關鍵,兩者關係猶如文件內容和文件的元數據, 相輔相成。
SDK提供了BitmapFactory,提供各類API,從圖片源解碼成bitmap,但這僅是圖片解碼的最基礎的工做;
圖片解碼,前先後後要準備各類材料,留心各類細節,是圖片加載過程當中最繁瑣的步驟之一。
前面提到,圖片的來源有多種,咱們須要識別圖片來源,
而後根據各自的特色提供統一的處理方法,爲後續的具體解碼工做提供方便。
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等。
其中,網絡文件從OkHttp的網絡請求得到,若是緩存了原圖, 則會得到FileSource。
其實各類圖片源最終均可以轉化爲InputStream,例如AssetInputStream其實就是InputStream的一種, 文件也能夠轉化爲FileInputStream。
那爲何區分開來呢? 這一切都要從讀取圖片頭信息開始講。
解碼過程當中一般須要預讀一些頭信息,如文件格式,圖片分辨率等,做爲接下來解碼策略的參數,例如用圖片分辨率來計算壓縮比例。
當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更節約內存。
有時候須要顯示的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)階段處理。
相信很多開發都遇到拍照後圖片旋轉的問題(尤爲是三星的手機)。
網上有很多關於此問題的解析,這是其中一篇:關於圖片EXIF信息中旋轉參數Orientation的理解
Android SDK提供了ExifInterface 來獲取Exif信息,Picasso正是用此API獲取旋轉參數的。
很惋惜ExifInterface要到 API level 24 才支持經過InputStream構造對象,低於此版本,僅支持經過文件路徑構造對象。
故此,Picasso當前版本僅在傳入參數是文件路徑(或者文件的Uri)時可處理旋轉問題。
Glide本身實現了頭部解析,主要是獲取文件類型和exif旋轉信息。
Doodle抽取了Glide的HeaderParse,並結合工程作了一些精簡和代碼優化, 嗯, 又一個「改裝版」。
decode出bitmap以後,根據獲取的旋轉信息,調用setRotate和postScale進行對應的旋轉和翻轉,便可還原正確的顯示。
解碼出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尋找,
雖然不能直接導入引用, 可是處理方法是相似的,改造一下就可以使用-_-
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()方法便可。
不少文章講圖片優化時都會提到兩個點,壓縮和圖片複用。
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定位是小而美的輕量級圖片框架,過程當中移除了很多價值不高的功能和複雜的實現。
有舍必有得,編程與生活,莫不如此。
圖片獲取和解碼都是耗時的操做,需放在異步執行;
而一般須要同時請求多張圖片,故此,線程調度不可或缺。
Doodle的線程調度依賴於筆者的另外一個項目Task,
具體內容詳見:《如何實現一個線程調度框架》(又發了一波廣告?-_-)。
簡單的說,主要用到了Task的幾個特性:
關於任務去重,主要是以Request的key做爲任務的tag, 相同tag的任務串行執行,
如此,當第一個任務完成,後面的任務讀緩存便可,避免了重複計算。
對於網絡圖片源的任務,則以URL做爲tag, 以避免重複下載。
此外,線程池,在UI線程回調結果,在當前線程獲取結果等操做,都能基於Task簡單地實現。
從Request,到開始解碼,從解碼完成,到顯示圖片, 之間很多零碎的處理。
把這些處理都放到一個類中,殊不知道怎麼命名了,且命名爲Dispatcher吧。
都有哪些處理呢?
其中,最後一點,在顯示有大量數據源的RecycleView或者ListView時,
執行快速滑動時最好能暫停任務,停下來才恢復加載,這樣能節省不少沒必要要的請求。
簡而言之,Dispatcher有兩個職責:
一、橋接的做用,鏈接外部於內部組件(有點像主板);
二、處理結果的反饋(如圖片的顯示)。
第三章梳理了流程和架構; 第四章分解了各部分功能實現; 這一章咱們作一下回顧和梳理。
先回顧一下圖片框架的架構:
整個框架以Doodle爲起點,以Worker爲核心,類之間調用不會太深, 整體上結構仍是比較緊湊的。
瞭解這幾個類,就基本上了解整個框架的構成了。
這一節,咱們結合各個核心類,再次梳理一下執行流程:
上圖依然是簡化版的執行流,但弄清楚了基本流程,其餘細枝末節的流程也都好理解了。
一、圖片加載流程,從框架的 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, 刷新ImageView
internal object Dispatcher {
fun feedback(request: Request, imageView: ImageView? ...) {
if (bitmap != null) {
imageView.setImageBitmap(bitmap)
}
}
}
複製代碼
前面說了這麼多實現細節,那到底最終都實現了些什麼功能呢?
看有什麼功能,看接口層的三個類便可。
方法 | 做用 |
---|---|
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) | 發送頁面生命週期事件(通知頁面銷燬以取消請求等) |
方法 | 做用 |
---|---|
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解碼器 |
方法 | 做用 |
---|---|
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 |
本文從架構,流程等方面入手,詳細分析了圖片加載框架的各類實現細節。
從文中能夠看出,實現過程大量借鑑了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下來運行一下,會比看文章有更多的收穫。