(二)Doodle - 精簡的圖片加載框架 - 原理篇

本篇是系列的第二篇仔拟,專門講述Doodle的設(shè)計(jì)和實(shí)現(xiàn)厨诸,概述和用法見另外兩篇文章:
(一)Doodle - 精簡的圖片加載框架 - 概述篇
(三)Doodle - 精簡的圖片加載框架 - 用法篇

原理篇涉及代碼較多,最好能配合源碼閱讀蜜托。
https://github.com/BillyWei01/Doodle

一抄囚、架構(gòu)

解決復(fù)雜問題,思路都是相似的:分而治之橄务。
Doodle的核心的類不多:


參考MVC的思路幔托,我們將框架劃分三層:

  • Interface: 框架入口和外部接口
  • Processor: 邏輯處理層
  • Storage:存儲(chǔ)層,負(fù)責(zé)各種緩存蜂挪。

結(jié)構(gòu)圖如下重挑,包含了框架的部分核心類及其依賴關(guān)系(A->B表示A依賴B)。

  • 外部接口
    Doodle: 提供全局參數(shù)配置棠涮,圖片加載入口谬哀,以及緩存,生命周期严肪,任務(wù)暫停/恢復(fù)等接口史煎。
    Config: 全局參數(shù)配置。包括緩存路徑驳糯,緩存大小篇梭,默認(rèn)編碼,自定義Downloader/Decoder等參數(shù)酝枢。
    Request: 封裝請求參數(shù)恬偷。包括數(shù)據(jù)源,解碼參數(shù)帘睦,行為參數(shù)袍患,以及目標(biāo)(ImageView等)。

  • 執(zhí)行單元
    Controller : 負(fù)責(zé)請求調(diào)度, 以及結(jié)果反饋等官脓,是“請求-工作線程-目標(biāo)”之間的橋梁。
    Worker: 工作線程涝焙,異步執(zhí)行加載卑笨,解碼,變換仑撞,存儲(chǔ)等赤兴。
    Decoder: 負(fù)責(zé)具體的解碼工作妖滔,包括計(jì)算縮放比例(降采樣/上采樣),圖片剪裁桶良,圖片方向處理等座舍。
    DataFetcher: 負(fù)責(zé)數(shù)據(jù)源的解析,提供統(tǒng)一的信息提取陨帆,文件解碼等接口曲秉。
    Downloader: 負(fù)責(zé)文件下載。

  • 存儲(chǔ)組件
    MemoryCache: 管理Bitmap緩存疲牵,包含LRU緩存和引用緩存承二。
    DiskCache: 文件緩存管理,分別提供給Worker(結(jié)果緩存)和Downloader(原圖緩存)纲爸。

二亥鸠、流程

僅從結(jié)構(gòu)圖不足以了解框架的運(yùn)作機(jī)制,接下來我們結(jié)合流程作分析识啦。
概括來說负蚊,圖片加載包含封裝參數(shù),獲取數(shù)據(jù)颓哮,解碼家妆,變換,緩存题翻,顯示等操作揩徊。

  1. 獲取Request
    Doodle類中定義了幾個(gè)靜態(tài)方法,返回Request對象嵌赠,作為請求的開始塑荒。

  2. 封裝參數(shù)
    從指定來源,到輸出結(jié)果姜挺,中間可能經(jīng)歷很多流程齿税,這些參數(shù)會(huì)貫穿整個(gè)過程,前面的結(jié)構(gòu)圖可以看出這一點(diǎn)炊豪。
    封裝參數(shù)完成后凌箕,會(huì)構(gòu)造出一個(gè)key, 用于索引緩存。

  3. 內(nèi)存緩存
    Controller-Worker是UI線程和后臺(tái)線程的分界词渤。
    請求的開始牵舱,先讀一下內(nèi)存緩存是否存在所請求的bitmap, 如果存在則直接顯示,否則啟用后臺(tái)線程缺虐。

  4. Worker
    進(jìn)入工作線程后芜壁,其實(shí)還是會(huì)再檢查一次內(nèi)存緩存,上圖中簡略了,沒有畫出來慧妄。
    如果內(nèi)存緩存中沒有所需要的bitmap, 則先嘗試讀取結(jié)果緩存:
    如果存在顷牌,則直接解碼,得到bitmap后緩存到內(nèi)存并顯示塞淹;
    若不存在窟蓝,則需要獲取數(shù)據(jù)并解碼,這個(gè)解碼要比從讀取結(jié)果緩存后的解碼要復(fù)雜許多(可能需要采樣或剪裁)饱普。
    解碼原文件后运挫,如果請求中設(shè)置了Transformation的話,需要執(zhí)行Transformation费彼,完了之后還在保存到內(nèi)存以及磁盤(結(jié)果緩存)滑臊。
    值得一提的是:只又網(wǎng)絡(luò)文件才有“原圖緩存”一說,本地文件不需要再做“緩存”箍铲。

  5. 顯示圖片
    顯示結(jié)果雇卷,可能需要做些動(dòng)畫(淡入動(dòng)畫,crossFade等)颠猴;
    如果結(jié)果動(dòng)圖(Animatable)关划,則啟動(dòng)一下動(dòng)畫。

以上簡化版的流程(只是眾多路徑中的一個(gè)分支)翘瓮,更多細(xì)節(jié)我們接下來慢慢分析贮折。

三、API設(shè)計(jì)

前面提到资盅,Config類負(fù)責(zé)全局參數(shù)配置调榄,Request承載單個(gè)請求的參數(shù)封裝。
二者都有Doodle的靜態(tài)方法提供對象實(shí)例呵扛。

public final class Doodle {
    public static Config config() {
        return Config.INSTANCE;
    }

    //  load bitmap by file path, url, or asserts path
    public static Request load(String path) {
        return new Request(path);
    }

    // load bitmap from drawable or raw resource
    public static Request load(int resID) {
        return new Request(resID);
    }

    public static Request load(Uri uri) {
        return new Request(uri);
    }
}

示例用法如下:

全局配置:

Doodle.config()
    .setLogger(Logger)
    .setSourceFetcher(OkHttpSourceFetcher)
    .addDrawableDecoders(GifDecoder)

圖片加載請求:

Doodle.load(path).into(imageView);

Request類:

public final class Request {
    // 緩存key
    private CacheKey key;

    // 數(shù)據(jù)源
    final String path;
    Uri uri;
    private String sourceKey;

    // 解碼參數(shù)
    int targetWidth;
    int targetHeight;
    ClipType clipType = ClipType.NOT_SET;
    boolean enableUpscale = false;
    DecodeFormat decodeFormat = DecodeFormat.ARGB_8888;
    List<Transformation> transformations;
    Map<String, String> options;

    // 加載行為
    MemoryCacheStrategy memoryCacheStrategy = MemoryCacheStrategy.LRU;
    DiskCacheStrategy diskCacheStrategy = DiskCacheStrategy.ALL;
    int placeholderId = -1;
    int errorId = -1;
    int animationId;
    // ...此處省略一些參數(shù)...

    // 目標(biāo)
    Request.Waiter waiter;
    SimpleTarget simpleTarget;
    WeakReference<ImageView> targetReference;
}

Request主要職能是封裝請求參數(shù)每庆,參數(shù)可以大約劃分為4類:

  • 1、圖片源:內(nèi)置支持File今穿,Uri缤灵,http,drawable蓝晒,raw腮出,assets等,可以擴(kuò)展芝薇。
  • 2胚嘲、解碼參數(shù):寬高,縮放/剪裁類型洛二,解碼格式……等馋劈。
  • 3立倍、加載行為:緩存策略,占位圖侣滩,動(dòng)畫……等。
  • 4变擒、目標(biāo):ImageView或者接口回調(diào)等君珠。

其中,圖片源解碼參數(shù)決定了最終的bitmap, 所以娇斑,我們拼接這些參數(shù)請求作為key策添,這個(gè)key會(huì)用于緩存的索引和任務(wù)的去重。
拼接參數(shù)后字符串很長毫缆,所以需要壓縮成摘要唯竹,128bit的摘要即可(原理參考:生日攻擊)。

圖片文件的來源苦丁,通常有網(wǎng)絡(luò)文件浸颓,drawable/raw資源, assets文件旺拉,本地文件等产上。
當(dāng)然,嚴(yán)格來說蛾狗,除了網(wǎng)絡(luò)文件之外晋涣,其他都是本地文件,只是不同形式而已沉桌。

四谢鹊、 緩存設(shè)計(jì)

幾大圖片加載框架都實(shí)現(xiàn)了緩存,各種文章中留凭,有說二級緩存佃扼,有說三級緩存。
其實(shí)從存儲(chǔ)來說冰抢,可簡單地分為內(nèi)存緩存和磁盤緩存松嘶。
同樣是內(nèi)存/磁盤緩存,也有多種形式挎扰,例如Glide的“磁盤緩存”就分為“原圖緩存”和“結(jié)果緩存”翠订,
而Picasso/Coil只依賴OkHttp緩存網(wǎng)絡(luò)圖片的原圖,并沒有實(shí)現(xiàn)自己的磁盤緩存遵倦,也就沒有保存解碼后的結(jié)果了尽超。

4.1 內(nèi)存緩存

為了復(fù)用計(jì)算結(jié)果,提高用戶體驗(yàn)梧躺,通常會(huì)做bitmap的緩存似谁;
而由于要限制緩存的大小傲绣,需要淘汰機(jī)制(通常是LRU策略)。
Android SDK提供了LruCache類巩踏,查看源碼秃诵,其核心是LinkedHashMap。
為了更好地定制塞琼,這里我們不用SDK提供的LruCache菠净,直接用LinkedHashMap,封裝自己的LruCache彪杉。

private static class BitmapWrapper {
    final Bitmap bitmap;
    final int bytesCount;

    BitmapWrapper(Bitmap bitmap) {
        this.bitmap = bitmap;
        this.bytesCount = Utils.getBytesCount(bitmap);
    }
}
final class LruCache {
    private static final long MIN_TRIM_SIZE = Runtime.getRuntime().maxMemory() / 64;
    private static long sum = 0;
    private static final Map<CacheKey, BitmapWrapper> cache =  new LinkedHashMap<>(16, 0.75f, true);

    static synchronized Bitmap get(CacheKey key) {
        BitmapWrapper wrapper = cache.get(key);
        return wrapper != null ? wrapper.bitmap : null;
    }

    static synchronized void put(CacheKey key, Bitmap bitmap) {
        long capacity = Config.memoryCacheCapacity;
        if (bitmap == null || capacity <= 0 || cache.containsKey(key)) {
            return;
        }
        BitmapWrapper wrapper = new BitmapWrapper(bitmap);
        cache.put(key, wrapper);
        sum += wrapper.bytesCount;
        if (sum > capacity) {
            trimToSize(capacity * 9 / 10);
        }
    }

    private static void trimToSize(long size) {
        Iterator<Map.Entry<CacheKey, BitmapWrapper>> iterator = cache.entrySet().iterator();
        while (iterator.hasNext() && sum > size) {
            Map.Entry<CacheKey, BitmapWrapper> entry = iterator.next();
            BitmapWrapper wrapper = entry.getValue();
            WeakCache.put(entry.getKey(), wrapper.bitmap);
            iterator.remove();
            sum -= wrapper.bytesCount;
        }
    }
}

LinkedHashMap 構(gòu)造函數(shù)的第三個(gè)參數(shù):accessOrder毅往,傳入true時(shí), 元素會(huì)按訪問順序排列派近,最后訪問的在遍歷器最后端攀唯。
進(jìn)行淘汰時(shí),移除遍歷器前端的元素渴丸,直至緩存總大小降低到指定大小以下侯嘀。

有時(shí)候需要加載比較大的圖片,占用內(nèi)存較高谱轨,放到LruCache可能會(huì)“擠掉”其他一些bitmap;
或者有時(shí)候滑動(dòng)列表生成大量的圖片残拐,也有可能會(huì)“擠掉”一些bitmap。
這些被擠出LruCache的bitmap有可能很快又會(huì)被用上碟嘴,但在LruCache中已經(jīng)索引不到了溪食,如果要用,需重新解碼娜扇。
值得指出的是错沃,被擠出LruCache的bitmap,在GC時(shí)并不一定會(huì)被回收雀瓢,如果bitmap還被引用枢析,則不會(huì)被回收;
但是不管是否被回收刃麸,在LruCache中都索引不到了醒叁。

我們可以將一些可能短暫使用的大圖片,以及這些被擠出LruCache的圖片泊业,放到弱引用的容器中把沼。
在被回收之前,還是可以根據(jù)key去索引到bitmap吁伺。

private static class BitmapReference extends WeakReference<Bitmap> {
    private final CacheKey key;

    BitmapReference(CacheKey key, Bitmap bitmap, ReferenceQueue<Bitmap> q) {
        super(bitmap, q);
        this.key = key;
    }
}
final class WeakCache {
    private static final Map<CacheKey, BitmapReference> cache = new HashMap<>();
    private static final ReferenceQueue<Bitmap> queue = new ReferenceQueue<>();

    static synchronized Bitmap get(CacheKey key) {
        cleanQueue();
        BitmapReference ref = cache.get(key);
        return ref != null ? ref.get() : null;
    }

    static synchronized void put(CacheKey key, Bitmap bitmap) {
        cleanQueue();
        if (bitmap != null) {
            BitmapReference ref = cache.get(key);
            if (ref == null || ref.get() != bitmap) {
                cache.put(key, new BitmapReference(key, bitmap, queue));
            }
        }
    }

    private static void cleanQueue() {
        BitmapReference reference = (BitmapReference) queue.poll();
        while (reference != null) {
            BitmapReference ref = cache.get(reference.key);
            if (ref != null && ref.get() == null) {
                cache.remove(reference.key);
            }
            reference = (BitmapReference) queue.poll();
        }
    }
}

以上實(shí)現(xiàn)中饮睬,BitmapWeakReference是WeakReference的子類,除了引用Bitmap的功能之外篮奄,還記錄著key, 以及關(guān)聯(lián)了ReferenceQueue;
當(dāng)Bitmap被回收時(shí)捆愁,BitmapWeakReference會(huì)被放入ReferenceQueue割去,
我們可以遍歷ReferenceQueue,移除ReferenceQueue的同時(shí)昼丑,取出其中記錄的key, 到cache中移除對應(yīng)的記錄呻逆。
利用WeakReference和ReferenceQueue的機(jī)制,索引對象的同時(shí)又不至于內(nèi)存泄漏菩帝。

最后页慷,綜合LruCacheWeakCache,統(tǒng)一索引:

final class MemoryCache {
    static Bitmap getBitmap(CacheKey key) {
        Bitmap bitmap = LruCache.get(key);
        if (bitmap == null) {
            bitmap = WeakCache.get(key);
        }
        return bitmap;
    }

    static void putBitmap(CacheKey key, Bitmap bitmap, boolean toWeakCache) {
        if (toWeakCache) {
            WeakCache.put(key, bitmap);
        } else {
            LruCache.put(key, bitmap);
        }
    }

    // ...
}

聲明內(nèi)存緩存策略:

public enum MemoryCacheStrategy {
    NONE,
    WEAK,
    LRU
}

NONE: 不緩存到內(nèi)存
WEAK: 緩存到WeakCache
LRU:緩存到LruCache

4.2 磁盤緩存

前面提到胁附,Glide有兩種磁盤緩存:“原圖緩存”和“結(jié)果緩存”,
Doodle也仿照類似的策略滓彰,可以選擇緩存原圖和結(jié)果控妻。
原圖緩存指的是Http請求下來的未經(jīng)解碼的文件;
結(jié)果緩存指經(jīng)過解碼揭绑,剪裁弓候,變換等,變成最終的bitmap之后他匪,通過bitmap.compress()壓縮保存菇存。
其中,后者通常比前者更小邦蜜,而且解碼時(shí)不需要再次剪裁和變換等依鸥,所以從結(jié)果緩存獲取bitmap通常要比從原圖獲取快得多。

為了盡量使得api相似悼沈,Doodle直接用Glide v3的緩存策略定義(Glide v4有一些變化)贱迟。

public enum DiskCacheStrategy {
    NONE,
    SOURCE,
    RESULT,
    ALL;
}

NONE: 不緩存到磁盤
SOURCE: 只緩存原圖
RESULT: 只緩存結(jié)果
ALL: 既緩存原圖,也緩存結(jié)果絮供。

磁盤緩存需要有一個(gè)管理工具衣吠,通常見得最多的是DiskLruCache,比如OkHttp和Glide都是用的DiskLruCache壤靶。
筆者覺得DiskLruCache的日志寫入效率不夠高缚俏,于是自己自己實(shí)現(xiàn)了磁盤緩存管理類: DiskCache。

DiskCache機(jī)制:
內(nèi)存中維護(hù)一個(gè)CacheKey->Record和HashMap, Record包含“CacheKey, 訪問order, 文件大小”;
磁盤上對應(yīng)一個(gè)日志文件贮乳,記錄所有Record:CacheKey占16字節(jié)忧换,order占4字節(jié),文件大小占4字節(jié)向拆,共24字節(jié)包雀。
日志文件用mmap的方式打開, 更新Record時(shí),根據(jù)Record的offset進(jìn)行寫入亲铡。
新增和讀取緩存:獲取"maxOrder +1", 作為Record的新order才写。
當(dāng)容量超出限制葡兑,或者緩存數(shù)量超過限制,先刪除order最小的文件(其實(shí)就是LRU策略)赞草。
Detele操作:磁盤中讹堤,Record的order更新為0(這樣打開日志文件時(shí)可以知道這條記錄失效了),HashMap中對應(yīng)的Record厨疙。

相比于DiskLruCache洲守, DiskCache日志記錄更加緊湊(二進(jìn)制),寫入更加快速(mmap)沾凄,
此外, 除了增加Record外梗醇,DiskCache不需要追加內(nèi)容(不需要頻繁擴(kuò)容):Record的更新和刪除,只需覆寫日志文件中對應(yīng)的order字段即可撒蟀。

五叙谨、 解碼

SDK提供了BitmapFactory/MediaMetadataRetrieverI,用于降圖片/視頻文件解碼成bitmap保屯,但這僅是圖片解碼的最基礎(chǔ)的工作手负;
圖片解碼,前前后后要準(zhǔn)備各種材料姑尺,留心各種細(xì)節(jié)竟终,是圖片加載過程中最復(fù)雜的步驟之一。

5.1 數(shù)據(jù)讀取

前面提到 切蟋,Doodle支持File统捶,Uri,http柄粹,drawable瘾境,raw,assets等數(shù)據(jù)源镰惦。
不同的數(shù)據(jù)源迷守,獲取數(shù)據(jù)的方式的API不一樣,但大致可以分為兩種旺入,F(xiàn)ile和InputStream兑凿。
例如,http文件可以下載完成后用File打開茵瘾,也可以直接用網(wǎng)絡(luò)API返回的InputStream讀壤窕;
assets可以通過AssetManager獲取InputStream拗秘;
uri可以通過ContentResolver獲取InputStream圣絮;

最后,如果以上API都無法讀取雕旨,可以通過自定義DataParser扮匠,使Doodle支持該類型的數(shù)據(jù)源捧请。

public interface DataParser {
    InputStream parse(String path);
}

數(shù)據(jù)讀取的大部分代碼在DataLoader類中, 這里貼一下解析部分的代碼:

static DataFetcher parse(Request request) throws IOException {
    DataLoader loader;
    boolean fromSourceCache = false;
    String path = request.path;
    if (path.startsWith("http")) {
        CacheKey key = new CacheKey(path);
        String cachePath = Downloader.getCachePath(key);
        if (cachePath != null) {
            loader = new FileLoader(new File(cachePath));
            fromSourceCache = true;
        } else {
            if (request.onlyIfCached) {
                throw new IOException("No cache");
            }
            if (request.diskCacheStrategy.savaSource()) {
                loader = new FileLoader(Downloader.download(path, key));
            } else {
                loader = new StreamLoader(path, Downloader.getInputStream(path), null);
            }
        }
    } else if (path.startsWith(ASSET_PREFIX)) {
        loader = new StreamLoader(path, Utils.appContext.getAssets().open(path.substring(ASSET_PREFIX_LENGTH)), null);
    } else if (path.startsWith(FILE_PREFIX)) {
        loader = new FileLoader(new File(path.substring(FILE_PREFIX_LENGTH)));
    } else {
        InputStream inputStream = handleByDataParsers(path);
        if (inputStream != null) {
            loader = new StreamLoader(path, inputStream, null);
        } else {
            Uri uri = request.uri != null ? request.uri : Uri.parse(path);
            loader = new StreamLoader(path, Utils.getContentResolver().openInputStream(uri), uri);
        }
    }
    return new DataFetcher(path, loader, fromSourceCache);
}

private static InputStream handleByDataParsers(String path) {
    if (Config.dataParsers != null) {
        for (DataParser parser : Config.dataParsers) {
            InputStream inputStream = parser.parse(path);
            if (inputStream != null) {
                return inputStream;
            }
        }
    }
    return null;
}

DataParser負(fù)責(zé)提供數(shù)據(jù)讀取的API棒搜,而具體讀取數(shù)據(jù)在DataLoader中實(shí)現(xiàn)疹蛉。
DataLoader是接口,有兩個(gè)實(shí)現(xiàn)類:FileLoader和StreamLoader力麸。
對于File而言可款,其實(shí)也可以轉(zhuǎn)化為FileInputStream,這樣的話只需要一個(gè)StreamLoader就可以了克蚂。
那為什么區(qū)分開來呢闺鲸? 這一切都要從讀取圖片頭信息開始講。

5.2 文件預(yù)讀

解碼過程中通常需要預(yù)讀一些頭信息埃叭,如文件格式摸恍,圖片分辨率等,作為接下來解碼策略的參數(shù)游盲,例如用圖片分辨率來計(jì)算采樣比例。
當(dāng)inJustDecodeBounds設(shè)置為true時(shí)蛮粮, BitmapFactory不會(huì)返回bitmap, 而是僅僅讀取文件頭信息益缎,其中最重要的是圖片分辨率。

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

讀取了頭信息然想,計(jì)算解碼參數(shù)之后莺奔,將inJustDecodeBounds設(shè)置為false,
再次調(diào)用BitmapFactory.decodeStream即可獲取所需bitmap变泄。
可是令哟,有的InputStream不可重置讀取位置,同時(shí)BitmapFactory.decodeStream方法要求從頭開始讀取妨蛹。
那先關(guān)閉流屏富,然后再次打開不可以嗎? 可以蛙卤,不過效率極低狠半,尤其是網(wǎng)絡(luò)資源時(shí),斷開連接再重連颤难?代價(jià)太大了神年。

有的InputStream實(shí)現(xiàn)了mark(int)和reset()方法,就可以通過標(biāo)記和重置支持重新讀取行嗤。
這一類InputStream會(huì)重載markSupported()方法已日,并返回true, 我們可以據(jù)此判斷InputStream是否支持重讀。

幸運(yùn)的是AssetInputStream就支持重讀栅屏;
不幸的是FileInputStream居然不支持飘千,OkHttp的byteStream()返回InputStream也不支持堂鲜。

對于文件,我們通過搭配RandomAccessFile和FileDescriptor來重新讀日纪瘛(RandomAccessFile有seek方法)泡嘴;
而對于其他的InputStream,只能曲折一點(diǎn)逆济,通過緩存已讀字節(jié)來支持重新讀取酌予。
SDK提供的BufferedInputStream就是這樣一種思路, 通過設(shè)置一定大小的緩沖區(qū)奖慌,以滑動(dòng)窗口的形式提供緩沖區(qū)內(nèi)重新讀取抛虫。
遺憾的是,BufferedInputStream的mark函數(shù)需指定readlimit简僧,緩沖區(qū)會(huì)隨著需要預(yù)讀的長度增加而擴(kuò)容建椰,但是不能超過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設(shè)置多少就成了考量的因素了啦逆。
Picasso早期版本設(shè)置64K, 結(jié)果遭到大量的反饋說解碼失敗伞矩,因?yàn)橛械膱D片需要預(yù)讀的長度不止64K取劫。
從Issue的回復(fù)看络拌,Picasso的作者也很無奈,最終妥協(xié)地將readlimit設(shè)為MAX_INTEGER弄贿。
但即便如此沟蔑,后面還是有反饋有的圖片無法預(yù)讀到圖片的大小湿诊。
筆者很幸運(yùn)地遇到了這種情況,經(jīng)調(diào)試代碼瘦材,最終發(fā)現(xiàn)Android 6.0的BufferedInputStream厅须,
其skip函數(shù)的實(shí)現(xiàn)有問題,每次skip都會(huì)擴(kuò)容食棕,即使skip后的位置還在緩沖區(qū)內(nèi)也會(huì)擴(kuò)容九杂。
造成的問題是有的圖片預(yù)讀時(shí)需多次調(diào)用skip函數(shù),然后緩沖區(qū)就一直double直至拋出OutOfMemoryError……
不過Picasso最終還是把圖片加載出來了宣蠕,因?yàn)镻icasso catch了Throwable, 然后重新直接解碼(不預(yù)讀大欣 );
雖然加載出來了抢蚀,但是代價(jià)不卸撇恪:只能全尺寸加載,以及前面預(yù)讀時(shí)申請的大量內(nèi)存(雖然最終會(huì)被GC),所造成的內(nèi)存抖動(dòng)唱逢。

Glide沒有這個(gè)問題吴侦,因?yàn)镚lide自己實(shí)現(xiàn)了類似BufferedInputStream功能的InputStream,完美地繞過了這個(gè)坑坞古;
Doodle則是copy了Android 8.0的SDK的BufferedInputStream备韧,精簡代碼,加入一些byte[]復(fù)用的代碼等痪枫,可以說是改裝版BufferedInputStream织堂。

回頭看前面一節(jié)的問題,為什么不統(tǒng)一用“改裝版BufferedInputStream”來解碼奶陈?
因?yàn)橛械膱D片預(yù)讀的長度很長易阳,需要開辟較大的緩沖區(qū),從這個(gè)角度看吃粒,用RandomAccessFile更節(jié)約內(nèi)存潦俺。
同時(shí),Doodle讀取數(shù)據(jù)時(shí)會(huì)緩存頭部的部分字節(jié)徐勃,如此事示,對于判斷文件類型等需要用到頭部字節(jié)的地方,就不需要重復(fù)讀取了僻肖。

5.3 圖片采樣

有時(shí)候需要顯示的bitmap比原圖的分辨率小肖爵。
比方說原圖是 4096 * 4096, 如果按照ARGB_8888的配置全尺寸解碼出來,需要占用64M的內(nèi)存檐涝!
不過app中所需的bitmap通常會(huì)小很多遏匆, 這時(shí)就要降采樣了法挨。
比方說需要300 * 300的bitmap, 該怎么做呢谁榜?
網(wǎng)上通常的說法是設(shè)置 options.inSampleSize 來降采樣。
閱讀SDK文檔凡纳,inSampleSize 需是整數(shù)窃植,而且是2的倍數(shù),
不是2的倍數(shù)時(shí)荐糜,會(huì)被 “be rounded down to the nearest power of 2”巷怜。
比方說前面的 4096 * 4096 的原圖,
當(dāng)inSampleSize = 16時(shí)暴氏,解碼出256 * 256 的bitmap延塑;
當(dāng)inSampleSize = 8時(shí),解碼出512 * 512 的bitmap答渔。
即使是inSampleSize = 8关带,所需內(nèi)存也只有原來的1/64(1M),效果還是很明顯的沼撕。

Picasso和Glide v3就是這么降采樣的宋雏。
如果你發(fā)現(xiàn)解碼出來的圖片是300 * 300 (比如使用Picasso時(shí)調(diào)用了fit()函數(shù))芜飘,應(yīng)該是有后續(xù)的處理(通過Matrix 和 Bitmap.createBitmap 繼續(xù)縮放)。

那能否直接解碼出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;
}

對應(yīng)BitmapFactory.Options的兩個(gè)關(guān)鍵參數(shù):inDensity 和 inTargetDensity蚪燕。
上面的例子娶牌,設(shè)置inTargetDensity=300, inDensity=4096(還要設(shè)置inScale=true), 則可解碼出300 * 300的bitmap邻薯。
額外提一下裙戏,Glide v4也換成這種采樣策略了。

解碼的過程為厕诡,通過獲取圖片的原始分辨率累榜,結(jié)合Request的width和height, 以及ScaleType,
計(jì)算出最終要解碼的寬高, 設(shè)置inDensity和inTargetDensity然后decode灵嫌。
當(dāng)然壹罚,有時(shí)候decode出來之后還要做一些加工,比方說ScaleType為CENTER_CROP寿羞,
則需要在decode之后進(jìn)行裁剪猖凛,取出中間部分的像素。

關(guān)于ScaleType绪穆,Doodle是直接獲取ImageView的ScaleType, 所以無需再特別調(diào)用函數(shù)指定辨泳;
當(dāng)然也提供了指定ScaleType的API, 對于target不是ImageView時(shí)或許會(huì)用到。

 public Request scaleType(ImageView.ScaleType scaleType)

還有就是玖院,解碼時(shí)默認(rèn)是向下采樣的菠红。
比如,如果原圖只有100 * 100, 但是ImageView是200 * 200难菌,最終也是解碼出100 * 100的bitmap试溯。
因?yàn)镮mageView假如是CENTER_CROP或者FIX_XY等ScaleType,顯示時(shí)通常會(huì)在渲染階段自行縮放的郊酒。
如果確實(shí)就是需要200 * 200的分辨率遇绞,可以調(diào)用enableUpscale() 方法。
調(diào)用enableUpscale()后燎窘,不管原圖是100100還是400400摹闽,最終都可以得到一個(gè)200*200的bitmap。

5.4 圖片方向

相信不少開發(fā)都遇到拍照后圖片旋轉(zhuǎn)的問題(尤其是三星的手機(jī))褐健。
網(wǎng)上有不少關(guān)于此問題的解析付鹿,這是其中一篇:關(guān)于圖片EXIF信息中旋轉(zhuǎn)參數(shù)Orientation的理解

Android SDK提供了ExifInterface 來獲取Exif信息,Picasso正是用此API獲取旋轉(zhuǎn)參數(shù)的。
很可惜ExifInterface要到 API level 24 才支持通過InputStream構(gòu)造對象倘屹,低于此版本银亲,僅支持通過文件路徑構(gòu)造對象。
故此纽匙,Picasso當(dāng)前版本僅在傳入?yún)?shù)是文件路徑時(shí)才可處理旋轉(zhuǎn)問題务蝠。

Glide自己實(shí)現(xiàn)了頭部解析,主要是獲取文件類型和exif旋轉(zhuǎn)信息烛缔。
Doodle抽取了Glide的HeaderParser馏段,并結(jié)合工程做了一些精簡和代碼優(yōu)化, 嗯,又一個(gè)“改裝版”践瓷。
decode出bitmap之后院喜,根據(jù)獲取的旋轉(zhuǎn)信息,調(diào)用setRotatepostScale進(jìn)行對應(yīng)的旋轉(zhuǎn)和翻轉(zhuǎn)晕翠,即可還原正確的顯示喷舀。

5.5 變換

解碼出bitmap之后,有時(shí)候還需要做一些處理淋肾,如圓形剪裁硫麻,圓角,濾鏡等樊卓。
Doodle參考Picasso/Glide拿愧, 提供了類似的API:Transformation

public interface Transformation {
    Bitmap transform(Bitmap source);

    String key();
}

實(shí)現(xiàn)變換比較簡單,實(shí)現(xiàn)Transformation接口碌尔,處理source浇辜,返回處理后的bitmap即可;
當(dāng)然唾戚,還要在key()返回變換的標(biāo)識柳洋,通常寫變換的名稱就好,如果有參數(shù), 需拼接上參數(shù)颈走。
Transformation也是決定bitmap最終結(jié)果的因素之一膳灶,所以需要重載key(), 作為Request的key的一部分咱士。
Transformation可以設(shè)置多個(gè)立由,處理順序會(huì)按照設(shè)置的先后順序執(zhí)行。

Doodle預(yù)置了兩個(gè)常用的Transformation序厉。
CircleTransformation:圓形剪裁锐膜,如果寬高不相等,會(huì)先取中間部分(類似CENTER_CROP)弛房。
RoundedTransformation:圓角剪裁道盏,可指定半徑。

更多的變換,可以到glide-transformations尋找荷逞,
雖然不能直接導(dǎo)入引用媒咳, 但是對bitmap的處理是相同的,改造一下就可使用种远。

5.6 自定義解碼

Doodle內(nèi)置了使用BitmapFactory獲取圖片涩澡,和使用MediaMetadataRetriever來獲取視頻縮略圖的Decoder。
對于其他BitmapFactory和MediaMetadataRetriever都不支持的文件坠敷,可以注入自定義Decoder來解碼妙同。
Doodle提供兩個(gè)自定義解碼接口:

public interface DrawableDecoder {
    Drawable decode(DecodingInfo info);
}
public interface BitmapDecoder {
    Bitmap decode(DecodingInfo info);
}

Glide有類似的接口:ResourceDecoder。
但ResourceDecoder需要實(shí)現(xiàn)兩個(gè)方法膝迎,在handles方法中判斷是否能處理粥帚,返回true才會(huì)調(diào)用decode方法。

public interface ResourceDecoder<T, Z> {
  boolean handles(@NonNull T source, @NonNull Options options) throws IOException;

  Resource<Z> decode(@NonNull T source, int width, int height, @NonNull Options options)
      throws IOException;
}

相比于Glide限次,Doodle簡化了接口的定義:

  1. 只需實(shí)現(xiàn)decode方法芒涡;
  2. 規(guī)約了結(jié)果的類型(Bitmap, Drawable)。

實(shí)現(xiàn)類根據(jù)DecodingInfo判斷是否可以處理卖漫,如果可以處理拖陆,解碼成Bitmap/Drawable返回,否則直接返回null即可懊亡。
DecodingInfo提供的信息如下:

public final class DecodingInfo {
    public final String path;
    public final int targetWidth;
    public final int targetHeight;
    public final ClipType clipType; // 縮放類型
    public final DecodeFormat decodeFormat; // 解碼格式(RGB_8888,RGB565,RGB_AUTO)
    public final Map<String, String> options; // 自定義參數(shù)

    // ...省略部分代碼...

    // 獲取頭部26個(gè)字節(jié)(大部分文件可以通過頭部字節(jié)識別出文件類型)
    public byte[] getHeader() throws IOException {
        return getDataFetcher().getHeader();
    }

    // Doodle內(nèi)置了部分文件類型的解析
    public MediaType getMediaType() throws IOException {
        return getDataFetcher().getMediaType();
    }

    // 獲取文件的所有數(shù)據(jù)
    public byte[] getData() throws IOException {
        return getDataFetcher().getData();
    }
}

實(shí)現(xiàn)了接口后依啰,可通過兩種方法使用:

  1. 注冊到全局配置Config中:對所有請求生效,每個(gè)請求都會(huì)先走一遍所有注冊的自定義的Decoder店枣。
  2. 設(shè)置到Request中速警,僅對單個(gè)請求生效。

接下來分別舉例這兩種用法鸯两。

5.7 GIF圖

GIF有靜態(tài)的闷旧,也有動(dòng)態(tài)的。
BitmapFactory支持解碼GIF圖片的第一幀钧唐,所以各個(gè)圖片框架都支持GIF縮率圖忙灼。
至于GIF動(dòng)圖,Picasso當(dāng)前是不支持的钝侠,Glide支持该园,但據(jù)反饋有些GIF動(dòng)圖Glide顯示不是很流暢。
Doodle本身也沒有實(shí)現(xiàn)GIF動(dòng)圖的解碼帅韧,但是留了解碼接口里初,結(jié)合第三方GIF解碼庫, 可實(shí)現(xiàn)GIF動(dòng)圖的加載和顯示忽舟。
GIF解碼庫双妨,推薦 android-gif-drawable淮阐。

具體用法:
實(shí)現(xiàn)DrawableDecoder接口。

import pl.droidsonroids.gif.GifDrawable

object GifDecoder : DrawableDecoder {
    override fun decode(info: DecodingInfo): Drawable? {
        if (info.mediaType != MediaType.GIF) {
            return null
        }
       return GifDrawable(info.data)
    }
}

在App啟動(dòng)時(shí)刁品,注入實(shí)現(xiàn)類:

fun initApplication(context: Application) {
      Doodle.config().addDrawableDecoders(GifDecoder)
}

注冊了Gif解碼器后泣特,請求圖片和普通的請求沒區(qū)別:如果圖片源是GIF動(dòng)圖,會(huì)解碼得到GifDrawable挑随。

Doodle.load(url).into(gifImageView)

當(dāng)然也可以指定不需要顯示動(dòng)圖群扶, 調(diào)用asBitmap方法即可。

這里而額外提一下Glide的情況:
Glide有三個(gè)接口:
asDrawable(默認(rèn)), asBimap, asGif镀裤。
asDrawable時(shí)竞阐,如果源文件是動(dòng)圖則顯示動(dòng)圖,如果源文件是靜態(tài)圖則顯示靜態(tài)圖(bitmap);
asBitmap時(shí)暑劝,總是顯示靜態(tài)圖骆莹;
asGif時(shí),如果源文件是動(dòng)圖則顯示動(dòng)圖担猛,如果源文件是靜態(tài)圖則不顯示(空白)幕垦。
我原本以為asGif就是雞肋,有asDrawable和asBitmap就夠了傅联,直到我遇到這么一個(gè)case:
我當(dāng)時(shí)在測試相冊相關(guān)的代碼先改,先調(diào)用了asBitmap,確實(shí)就都顯示靜態(tài)圖了蒸走;然后再調(diào)asDrawable, 重新編譯仇奶,啟動(dòng),我原本預(yù)期相冊列表中如果原文件是Gif文件能顯示動(dòng)圖比驻,結(jié)果總是顯示靜態(tài)圖片该溯。
然后我改動(dòng)代碼,當(dāng)mime(媒體數(shù)據(jù)庫中獲缺鸬搿)等于"image/gif", 調(diào)用asGif狈茉,這才顯示了動(dòng)圖;
而且還有例外掸掸,有的圖片文件本身是Gif動(dòng)圖氯庆,單文件后綴是jpg, 在媒體數(shù)據(jù)庫中mime也是"image/jpeg"。
對于這種例外扰付,要么忽略堤撵,要么只能先讀取每一個(gè)圖片文件的頭部字節(jié),以判斷文件是不是Gif文件悯周;
而在圖片框架之外讀頭部字節(jié)是有代價(jià)的粒督,在主線程的話怕ANR陪竿,在IO線程讀的話會(huì)讓代碼破碎禽翼,“變丑”屠橄,不管用協(xié)程還是線程。

我沒有細(xì)究Glide為什么會(huì)如此闰挡。
在寫Doodle時(shí)锐墙,我只創(chuàng)建了asBitmap方法,因?yàn)樵贒oodle的實(shí)現(xiàn)中长酗,asBitmap為true或false是兩個(gè)不同的請求(CacheKey不一樣)溪北,不會(huì)相互干擾。

5.8 相冊縮略圖

很多APP內(nèi)置了自定義的ImagePicker, ImagePicker需要顯示媒體庫中的視頻/圖片夺脾。
直接讀取媒體庫的文件去解碼的話比較耗時(shí)之拨,更快的做法是讀取SDK提供的獲取縮略圖的接口,訪問系統(tǒng)已經(jīng)生成好的縮略圖文件咧叭。

Glide中也有類似的實(shí)現(xiàn):MediaStoreImageThumbLoader/MediaStoreVideoThumbLoader蚀乔。
但是獲取縮略圖的方法在Android高版本已經(jīng)失效了(我測試的機(jī)器是Android 10)。
使用Glide且希望通過讀縮略圖文件顯示相冊的話需要自己實(shí)現(xiàn)ModelLoader和ResourceDecoder菲茬。

Doodle內(nèi)置的讀取媒體縮略圖的實(shí)現(xiàn):

class MediaThumbnailDecoder implements BitmapDecoder {
    static final String KEY = "ThumbnailDecoder";
    static final MediaThumbnailDecoder INSTANCE = new MediaThumbnailDecoder();

    @Override
    public Bitmap decode(DecodingInfo info) {
        String path = info.path;
        if (!(path.startsWith("content://media/external/") && info.options.containsKey(KEY))) {
            return null;
        }
        Bitmap bitmap = null;
        try {
            Uri uri = Uri.parse(path);
            ContentResolver contentResolver = Utils.appContext.getContentResolver();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                try {
                    bitmap = contentResolver.loadThumbnail(uri, new Size(info.targetWidth, info.targetHeight), null);
                } catch (Exception ignore) {
                }
            }
            if (bitmap == null) {
                int index = path.lastIndexOf('/');
                if (index > 0) {
                    long mediaId = Long.parseLong(path.substring(index + 1));
                    bitmap = MediaStore.Video.Thumbnails.getThumbnail(
                            contentResolver,
                            mediaId,
                            MediaStore.Video.Thumbnails.MINI_KIND,
                            null
                    );
                }
            }
        } catch (Throwable e) {
            LogProxy.e("Doodle", e);
        }
        return bitmap;
    }
}

要啟用該Decoder可以調(diào)用一下Request的方法:

public Request enableThumbnailDecoder() {
    addOption(MediaThumbnailDecoder.KEY, "");
    return setBitmapDecoder(MediaThumbnailDecoder.INSTANCE);
}

這個(gè)方法只作用于當(dāng)前Request吉挣,不會(huì)干擾其他請求。
比如有的地方要訪問媒體文件的預(yù)覽圖婉弹,也是傳入相同的uri睬魂,該請求不會(huì)被這個(gè)Decoder攔截處理,就會(huì)讀取原文件镀赌。
穩(wěn)妥起見氯哮,enableThumbnailDecoder方法還設(shè)置了options,。
options會(huì)參與CacheKey的摘要計(jì)算商佛,這里設(shè)置options主要是為了確保CacheKey不同于直接訪問原圖的請求(否則可能會(huì)讀到彼此的緩存)蛙粘。

5.9 圖片復(fù)用

很多文章講圖片優(yōu)化時(shí)都會(huì)提圖片復(fù)用。
Doodle在設(shè)計(jì)階段也考慮了圖片復(fù)用威彰,并且也實(shí)現(xiàn)了出牧,但實(shí)現(xiàn)后一直糾結(jié)其收益和成本。

  • 正在使用的圖片不能被復(fù)用歇盼,所以要添加引用計(jì)數(shù)策略舔痕,附加代碼很多,且占用一些額外的計(jì)算資源豹缀。
  • 即使圖片沒有被引用伯复,根據(jù)局部性原理,該圖片可能稍后有可能被訪問邢笙,所以也不應(yīng)該馬上被復(fù)用啸如。
  • 大多數(shù)情況下,符合復(fù)用條件(不用一段時(shí)間氮惯,尺寸符合要求)的并不多叮雳。
  • 通過統(tǒng)計(jì)ImageView是否引用bitmap的策略想暗,有可能“逃逸”,比方說可以從ImageView獲取Drawable帘不,然后獲取其中的bitmap, 用作其他用途说莫,這樣即使ImageView被回收了,其曾經(jīng)attach的bitmap其實(shí)也是“在用”的寞焙。一旦統(tǒng)計(jì)不能覆蓋储狭,并且被復(fù)用了,會(huì)導(dǎo)致原來在用的的地方顯示錯(cuò)誤捣郊。

個(gè)人觀點(diǎn)辽狈,或許某些封閉的使用場景做圖片復(fù)用會(huì)比較合適,但對于圖片加載框架而言呛牲,使用場景比較復(fù)雜稻艰,做圖片服用的風(fēng)險(xiǎn)和成本大于收益。
綜合考慮侈净,Doodle沒有去做bitmap復(fù)用尊勿。

六、 任務(wù)調(diào)度

圖片加載的過程中畜侦,數(shù)據(jù)獲取和圖片解碼等操作發(fā)生在后臺(tái)線程元扔。
一旦涉及異步,就得考慮并發(fā)控制旋膳,時(shí)序控制澎语,線程切換,任務(wù)取消等情況验懊。
任務(wù)調(diào)度這部分擅羞,筆者的另一篇文章其實(shí)有講述過,考慮閱讀流暢性义图,就不開新篇了减俏,直接在本篇寫吧。

6.1 并發(fā)控制

Doodle需要后臺(tái)線程的有兩處:圖片加載和緩存結(jié)果到磁盤碱工。
我們希望兩種任務(wù)相互獨(dú)立娃承,并且后者串行執(zhí)行就好(緩存相對加載沒那么優(yōu)先)。
常規(guī)做法是創(chuàng)建兩個(gè)線程池怕篷,一個(gè)調(diào)newFixedThreadPool历筝, 一個(gè)調(diào)newSingleThreadExecutor。
但是這樣的話兩個(gè)線程池的線程舊不能彼此復(fù)用了廊谓,然后還得維持幾個(gè)核心線程梳猪。
Doodle的做法是在真正執(zhí)行任務(wù)的Executor上套隊(duì)列,由隊(duì)列控制并發(fā)窗口蒸痹,這樣既各自控制了并發(fā)春弥。

然后就是呛哟,負(fù)責(zé)圖片加載的任務(wù)隊(duì)列,設(shè)置多少并發(fā)量呢惕稻?

final class Scheduler {
    private static final int CUP_COUNT = Runtime.getRuntime().availableProcessors();
    private static final int WINDOW_SIZE = Math.min(Math.max(2, CUP_COUNT), 4);
    static final PipeExecutor pipeExecutor = new PipeExecutor(WINDOW_SIZE, WINDOW_SIZE * 2);
}

默認(rèn)情況下竖共,WINDOW_SIZE由可用處理器數(shù)量決定蝙叛,并且限定在[2,4]之間俺祠,考慮到目前新設(shè)備基本在4個(gè)以上,所以大多數(shù)情況下就是4了借帘。
不直接設(shè)定為CPU_COUNT的原因之一蜘渣,是考慮限制功耗(一些CPU在發(fā)熱時(shí)會(huì)關(guān)閉核心或者降頻),
畢竟解碼圖片是計(jì)算密集型任務(wù)肺然,挺消耗CPU的蔫缸;
而且除了解碼外,還有一個(gè)保存結(jié)果的任務(wù)隊(duì)列际起,需要將bitmap編碼為文件拾碌,也是計(jì)算密集型任務(wù)。
總得留點(diǎn)計(jì)算資源刷新UI吧街望。
對于需要下載網(wǎng)絡(luò)文件的任務(wù)校翔,相對于占用CPU的時(shí)間,其消耗在IO的時(shí)間要更多灾前,所以通常對于IO密集型的任務(wù)防症,建議增加并發(fā)。
故此哎甲,在下載前蔫敲,Doodle會(huì)將并發(fā)窗口+1(最多增加到WINDOW_SIZE*2), 在下載完成后將并發(fā)窗口-1(最少減到WINDOW_SIZE)。
Doodle的源碼中炭玫,將實(shí)現(xiàn)這部分邏輯的Executor命名為PipeExecutor奈嘿。

6.2 任務(wù)排隊(duì)

圖片加載可能會(huì)碰到這樣的場景:
幾乎同一時(shí)間,需要加載相同路徑的圖片吞加,到不同的ImageView, 并且這些ImageView的寬高和ScaleType相同指么。
比方說聊天窗口的頭像,就是這種case榴鼎。
就拿這個(gè)case來說伯诬,打開聊天窗口,就會(huì)生成多個(gè)相同的Request(CacheKey相同)巫财,在還沒有內(nèi)存緩存的情況下盗似,會(huì)創(chuàng)建多個(gè)異步任務(wù),同時(shí)解碼平项。
這樣的話就會(huì)生成多個(gè)相同的bitmap, 浪費(fèi)CPU和內(nèi)存赫舒。
還有另一種case, 需要下載網(wǎng)絡(luò)圖片的任務(wù)悍及,在沒有原圖緩存的情況下,也有可能會(huì)重復(fù)下載接癌。

為了避免重復(fù)解碼或者重復(fù)下載心赶,需要做一些措施。
Doodle的做法是缺猛,用tag標(biāo)記任務(wù)缨叫,用一個(gè)Set記錄正在執(zhí)行的任務(wù),用一個(gè)Map緩存等待執(zhí)行的任務(wù)荔燎。
執(zhí)行一個(gè)任務(wù)耻姥,如果Set中保存該任務(wù)的tag, 則將任務(wù)保存到一個(gè) tag->list 的Map中排隊(duì),等Set中的任務(wù)執(zhí)行結(jié)束后有咨,再從Map中取出執(zhí)行琐簇。
對于解碼任務(wù)而言,正好用CacheKey作為tag座享;而如果這個(gè)任務(wù)是需要網(wǎng)絡(luò)下載的婉商,則用Url構(gòu)造CacheKey作為tag。
代碼和示意圖如下:

static class TagExecutor {
    private static final Set<CacheKey> scheduledTags = new HashSet<>();
    private static final Map<CacheKey, LinkedList<Runnable>> waitingQueues = new HashMap<>();

    public synchronized void execute(CacheKey tag, Runnable r) {
        if (r == null) {
            return;
        }
        if (!scheduledTags.contains(tag)) {
            start(tag, r);
        } else {
            LinkedList<Runnable> queue = waitingQueues.get(tag);
            if (queue == null) {
                queue = new LinkedList<>();
                waitingQueues.put(tag, queue);
            }
            queue.offer(r);
        }
    }

    private void start(CacheKey tag, Runnable r) {
        scheduledTags.add(tag);
        pipeExecutor.execute(new Wrapper(r) {
            @Override
            public void run() {
                try {
                    task.run();
                } finally {
                    scheduleNext(tag);
                }
            }
        });
    }

    private synchronized void scheduleNext(CacheKey tag) {
        scheduledTags.remove(tag);
        LinkedList<Runnable> queue = waitingQueues.get(tag);
        if (queue != null) {
            Runnable r = queue.poll();
            if (r == null) {
                waitingQueues.remove(tag);
            } else {
                start(tag, r);
            }
        }
    }
}

private static abstract class Wrapper implements Runnable {
    final Runnable task;

    Wrapper(Runnable r) {
        this.task = r;
    }
}

TagExecutor實(shí)現(xiàn)的效果就是:相同tag的任務(wù)串行渣叛,不同tag的任務(wù)并行丈秩。
相同tag的任務(wù)串行為什么可以防止重復(fù)解碼?因?yàn)榭蚣苤杏蠱emeryCache, 解碼成功后會(huì)保存cache诗箍,排在后面的相同CacheKey的任務(wù)癣籽,讀取cache就好,不需要再次解碼了滤祖。
下載的case同理筷狼。

示意圖中的假定真正執(zhí)行任務(wù)的Executor的并發(fā)為2, 實(shí)際上我們會(huì)設(shè)定一個(gè)并發(fā)更大的Executor作為RealExecutor, 畢竟PipeExecutor已經(jīng)做了并發(fā)控制了匠童。
RealExecutor可以在Config中設(shè)定埂材,如果沒有設(shè)定,Doodle會(huì)調(diào)用Executors.newCachedThreadPool()創(chuàng)建一個(gè)汤求。
總的而言俏险,在RealExecutor套兩層隊(duì)列,分別實(shí)現(xiàn)了并發(fā)控制和防止重復(fù)任務(wù)的功能扬绪。

6.3 任務(wù)管理

準(zhǔn)備好Executor, 只是任務(wù)調(diào)度的一部分竖独。
我希望有一個(gè)工具,支持一下功能:

  1. 主線程/后臺(tái)線程切換挤牛;
  2. 取消任務(wù)莹痢;
  3. 調(diào)用一個(gè)方法,block當(dāng)前線程,直到后臺(tái)線程完成時(shí)竞膳,在當(dāng)前方法返回結(jié)果航瞭。

SDK其實(shí)有這樣的工具:AsyncTask。
AsyncTask目前已經(jīng)被標(biāo)記“Deprecated”了坦辟,而且不方便定制功能刊侯,于是,我抽取了AsyncTask的部分代碼锉走,實(shí)現(xiàn)了工具類ExAsyncTask滨彻。

FutureTask+Callable+Handler(我稱之為AsyncTask三劍客),很好地實(shí)現(xiàn)了上面提到三個(gè)功能挠日。
相對AsyncTask疮绷,做了一些改動(dòng)翰舌,包括:

  • 移除了范型嚣潜;
  • 精簡了一些不需要的方法,比如onPreExecute椅贱,onProgressUpdate等懂算;
  • Executor換上前面提到的TagExecutor。

如果僅僅是這些庇麦,那么完全可以extend AsyncTask來實(shí)現(xiàn)计技。
但是接下來的功能,需要引用其中的一些私有成員山橄,所以沒辦法垮媒,只能copy其關(guān)鍵代碼重新定義一個(gè)類(ExAsyncTask)了,

6.4 生命周期

異步任務(wù)還在執(zhí)行而UI界面已銷毀的情況是比較普遍的航棱,圖片加載也不例外睡雇。
需要有相關(guān)的機(jī)制,在頁面銷毀時(shí)通知圖片加載任務(wù)取消饮醇。
提到“通知”它抱,很自然地就會(huì)想到觀察者模式。

LifecycleManager維護(hù)了 hostHash -> List<WeakReference<ExAysncTask>> 的一個(gè)map (應(yīng)為hostHash是int類型朴艰,用SparseArray來承載)观蓄。
其中List<WeakReference<ExAysncTask>> 由中間類Holder持有和維護(hù)。
host指的任務(wù)所在的“宿主”祠墅,其實(shí)就是“界面”的具體對象實(shí)例侮穿,通常是Activity,當(dāng)然也可以是Fragment或者View, 這個(gè)由使用者決定毁嗦,可以通過Request的observeHost方法指定亲茅。

    public Request observeHost(Object host) {
        this.hostHash = System.identityHashCode(host);
        return this;
    }

通過identityHashCode取hash,可以避免直接引用host, 以免內(nèi)存泄漏。
identityHashCode的區(qū)分度要比hashCode要更高芯急,并且考慮到同一時(shí)刻加載圖片的“界面”不會(huì)有太多個(gè)勺届,所以用identityHashCode替代host是可行的。
退一萬步講娶耍,即使同一時(shí)刻有兩個(gè)host的identityHashCode一樣免姿,也不會(huì)導(dǎo)致太大的問題,最多不過是任務(wù)取消而已榕酒。

如果用戶沒有特別設(shè)定胚膊,Doodle會(huì)通過ImageView找到其所attach的Activity并取其identityHashCode作為hostHash。
另一方面想鹰,在Doodle初始化時(shí)紊婉,做了監(jiān)聽Activity的生命周期回調(diào),并在回調(diào)中調(diào)用notify方法辑舷。

static void registerActivityLifecycle(final Context context) {
        if (!(context instanceof Application)) {
            return;
        }
        ((Application) context).registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
            public void onActivityResumed(Activity activity) {
                Doodle.notifyResume(this);
            }

            public void onActivityPaused(Activity activity) {
                Doodle.notifyPause(this);
            }

            public void onActivityDestroyed(Activity activity) {
                Doodle.notifyDestroy(activity);
            }
        });
}

如果用戶通過Request指定了非Activity的host, 可以自行在該host對應(yīng)的生命周期中調(diào)notify方法喻犁,
當(dāng)然,這并非必須的操作:即使沒有調(diào)notify也沒關(guān)系何缓,最多就是任務(wù)不能及時(shí)取消而已肢础,沒有什么大問題。

ExAsyncTask關(guān)于生命周期部分的實(shí)現(xiàn):

abstract class ExAsyncTask {
    public final void execute(int hostHash) {
        if (mStatus != Status.PENDING) {
            return;
        }
        if (hostHash != 0) {
            LifecycleManager.register(hostHash, this);
        }
        mHostHash = hostHash;
        mStatus = Status.RUNNING;
        Scheduler.tagExecutor.execute(generateTag(), mFuture);
    }

    private void finish(Object result) {
        detachHost();
        if (isCancelled()) {
            onCancelled();
        } else {
            onPostExecute(result);
        }
        mStatus = Status.FINISHED;
    }

    private void detachHost() {
        if (mHostHash != 0) {
            LifecycleManager.unregister(mHostHash, this);
        }
    }

    void handleEvent(int event) {
        if (!isCancelled() && mStatus != Status.FINISHED) {
            if (event == Event.PAUSE) {
                Scheduler.pipeExecutor.pushBack(mFuture);
            } else if (event == Event.RESUME) {
                Scheduler.pipeExecutor.popFront(mFuture);
            } else if (event == Event.DESTROY) {
                mHostHash = 0;
                cancel(true);
            }
        }
    }
}
Worker worker = new Worker(request, imageView);
worker.execute(request.hostHash);

Worker是ExAsyncTask的實(shí)現(xiàn)類碌廓。
Worker啟動(dòng)時(shí)传轰,會(huì)將請求的hostHash以及自身注冊到LifecycleManager;
Worker結(jié)束時(shí)谷婆,如果mHostHash不為0慨蛙,則執(zhí)行LifecycleManager的unregister。
在啟動(dòng)后纪挎,結(jié)束前期贫,如果host通過LifecycleManager發(fā)送“PAUSE/RESUME/DESTROY"消息, ExAsyncTask的handleEvent會(huì)被回調(diào)廷区。
ExAsyncTask在handleEvent做對應(yīng)的響應(yīng)唯灵。
特別地,如果host銷毀隙轻,取消任務(wù)埠帕。
另外,在頁面發(fā)送pause時(shí)玖绿,任務(wù)會(huì)切換到back隊(duì)列敛瓷,發(fā)送resume時(shí),任務(wù)會(huì)切換到front隊(duì)列斑匪;
這個(gè)是PipeExecutor的另外一個(gè)特性:內(nèi)置front/back兩個(gè)隊(duì)列呐籽,取任務(wù)時(shí)先從front隊(duì)列取。
效果就是,在頁面不可見(但又不是銷毀)時(shí)發(fā)送pause事件狡蝶,加載任務(wù)優(yōu)先級會(huì)降低(低于新的頁面)庶橱。

這是加載時(shí)間加長1s,并發(fā)窗口設(shè)置為1后的效果圖:

代碼實(shí)現(xiàn)上贪惹,Worker繼承了ExAsyncTask之后苏章,只需專注于在doInBackground方法中加載圖片,在onPostExecute方法中顯示結(jié)果即可奏瞬。
以上所述的各種功能/效果都分散到ExAsyncTask枫绅、TagExecutor、PipeExecutor硼端、LifecycleManager等類中了并淋。

七畜吊、加載前后處理

在啟動(dòng)異步線程之前:

  • 如果目標(biāo)是ImageView, 取其attache的Activity出來够庙,判斷其是否finishing或者destroy, 是則返回绵载。
  • 檢查ImageView的tag(Request對象)的key和當(dāng)前請求是否相同狐援,如果相同則直接返回,
    否則菲盾,取消之前的任務(wù)(Request有Worke的弱引用)磺芭。
  • 清空ImageView當(dāng)前的Drawable。
  • 如果有bitmap緩存骆撇,直接取bitmap設(shè)置帶到ImageView, 返回;
    否則父叙,設(shè)置placeholder drawable神郊。
  • 創(chuàng)建Worker, 用WeakReference包裹,賦值給Request的workerReference變量趾唱。
  • 給ImageView設(shè)置tag, tag為Request對象涌乳。
    用setTag(int key, final Object tag)方法記錄tag, 這樣就不會(huì)和常規(guī)的setTag(Object tag)沖突了。
    Glide用的就是setTag(Object tag)甜癞,應(yīng)該有朋友踩過這個(gè)坑夕晓。

異步線程結(jié)束之后:

  • 訪問Request對象的targetReference變量(ImageView的弱引用),嘗試取出ImageView悠咱。
  • 若成功取出ImageView, 判斷其tag和當(dāng)前Request是否相等蒸辆,若相等則說明加載任務(wù)的對象沒變,否則直接返回析既。
  • 從ImageView取出Activity, 判斷其是否finishing或者destroy, 是則返回躬贡。
  • 若Worker返回了result(bitmap或者drawable), 設(shè)置到ImageView;若返回了null眼坏,設(shè)置error drawable拂玻。

這些處理大多在Controller中實(shí)現(xiàn),除了這些之外,Controller還負(fù)責(zé)實(shí)現(xiàn)任務(wù)的暫停/恢復(fù)(多用在RecycleView滾動(dòng))檐蚜。
可以說魄懂,Controller是Request, Target, 和Worker之間的橋梁。

八闯第、總結(jié)

通篇下來逢渔,讀者可能也注意到了,Doodle的實(shí)現(xiàn)大量參考了Picasso和Glide乡括,尤其是后者肃廓,有的部分甚至直接copy其處理(Exif部分),關(guān)于這一點(diǎn)我大方承認(rèn)诲泌,三人行盲赊,必有我?guī)熉铩?br> 當(dāng)然,有正面借鑒也有反面借鑒敷扫,比如asBitmap, setTag的處理哀蘑。
然后也有創(chuàng)新的部分,比如DiskCache和任務(wù)調(diào)度葵第。

概括地說绘迁,圖片加載過程可分為幾個(gè)部分:數(shù)據(jù)源,數(shù)據(jù)獲取卒密,文件解碼缀台,結(jié)果,目標(biāo)哮奇。
Glide的實(shí)現(xiàn)中的膛腐,大量使用了接口和范型,對圖片加載的各過程進(jìn)行抽象鼎俘。
Doodle定義的接口相對Glide簡單很多哲身,但也足夠通過自定義解碼,實(shí)現(xiàn)加載任意類型的文件贸伐。

好了勘天,原理篇就分析到這里,希望對讀者有所啟發(fā)捉邢。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末脯丝,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子歌逢,更是在濱河造成了極大的恐慌巾钉,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,820評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件秘案,死亡現(xiàn)場離奇詭異砰苍,居然都是意外死亡潦匈,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,648評論 3 399
  • 文/潘曉璐 我一進(jìn)店門赚导,熙熙樓的掌柜王于貴愁眉苦臉地迎上來茬缩,“玉大人,你說我怎么就攤上這事吼旧』宋” “怎么了?”我有些...
    開封第一講書人閱讀 168,324評論 0 360
  • 文/不壞的土叔 我叫張陵圈暗,是天一觀的道長掂为。 經(jīng)常有香客問我,道長员串,這世上最難降的妖魔是什么勇哗? 我笑而不...
    開封第一講書人閱讀 59,714評論 1 297
  • 正文 為了忘掉前任,我火速辦了婚禮寸齐,結(jié)果婚禮上欲诺,老公的妹妹穿的比我還像新娘。我一直安慰自己渺鹦,他們只是感情好扰法,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,724評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著毅厚,像睡著了一般塞颁。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上卧斟,一...
    開封第一講書人閱讀 52,328評論 1 310
  • 那天殴边,我揣著相機(jī)與錄音,去河邊找鬼珍语。 笑死,一個(gè)胖子當(dāng)著我的面吹牛竖幔,可吹牛的內(nèi)容都是我干的板乙。 我是一名探鬼主播,決...
    沈念sama閱讀 40,897評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼拳氢,長吁一口氣:“原來是場噩夢啊……” “哼募逞!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起馋评,我...
    開封第一講書人閱讀 39,804評論 0 276
  • 序言:老撾萬榮一對情侶失蹤放接,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后留特,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體纠脾,經(jīng)...
    沈念sama閱讀 46,345評論 1 318
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡玛瘸,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,431評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了苟蹈。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片糊渊。...
    茶點(diǎn)故事閱讀 40,561評論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖慧脱,靈堂內(nèi)的尸體忽然破棺而出渺绒,到底是詐尸還是另有隱情,我是刑警寧澤菱鸥,帶...
    沈念sama閱讀 36,238評論 5 350
  • 正文 年R本政府宣布宗兼,位于F島的核電站,受9級特大地震影響氮采,放射性物質(zhì)發(fā)生泄漏针炉。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,928評論 3 334
  • 文/蒙蒙 一扳抽、第九天 我趴在偏房一處隱蔽的房頂上張望篡帕。 院中可真熱鬧,春花似錦贸呢、人聲如沸镰烧。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,417評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽怔鳖。三九已至,卻和暖如春固蛾,著一層夾襖步出監(jiān)牢的瞬間结执,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,528評論 1 272
  • 我被黑心中介騙來泰國打工艾凯, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留献幔,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,983評論 3 376
  • 正文 我出身青樓趾诗,卻偏偏與公主長得像蜡感,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子恃泪,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,573評論 2 359

推薦閱讀更多精彩內(nèi)容