作者:藍師傅_Android
(文章很長,不想看過程的朋友可以直接到最後看總結)
這次來面試的是一個有着5年工作經驗的小夥,截取了一段對話如下:
面試官:我看你寫到Glide,為什麼用Glide,而不選擇其它圖檔加載架構?
小夥:Glide 使用簡單,鍊式調用,很友善,一直用這個。
面試官:有看過它的源碼嗎?跟其它圖檔架構相比有哪些優勢?
小夥:沒有,隻是在項目中使用而已~
面試官:假如現在不讓你用開源庫,需要你自己寫一個圖檔加載架構,你會考慮哪些方面的問題,說說大概的思路。
小夥:額~,壓縮吧。
面試官:還有嗎?
小夥:額~,這個沒寫過。
說到圖檔加載架構,大家最熟悉的莫過于Glide了,但我卻不推薦履歷上寫熟悉Glide,除非你熟讀它的源碼,或者參與Glide的開發和維護。
在一般面試中,遇到圖檔加載問題的頻率一般不會太低,隻是問法會有一些差異,例如:
- 履歷上寫Glide,那麼會問一下Glide的設計,以及跟其它同類架構的對比 ;
- 假如讓你寫一個圖檔加載架構,說說思路;
- 給一個圖檔加載的場景,比如網絡加載一張或多張大圖,你會怎麼做;
帶着問題進入正文~
一、談談Glide
1.1 Glide 使用有多簡單?
Glide由于其口碑好,很多開發者直接在項目中使用,使用方法相當簡單
github.com/bumptech/gl…
1、添加依賴:
implementation 'com.github.bumptech.glide:glide:4.10.0'annotationProcessor 'com.github.bumptech.glide:compiler:4.10.0
2、添加網絡權限
3、一句代碼加載圖檔到ImageView
Glide.with(this).load(imgUrl).into(mIv1);
進階一點的用法,參數設定
RequestOptions options = new RequestOptions() .placeholder(R.drawable.ic_launcher_background) .error(R.mipmap.ic_launcher) .diskCacheStrategy(DiskCacheStrategy.NONE) .override(200, 100); Glide.with(this) .load(imgUrl) .apply(options) .into(mIv2);
使用Glide加載圖檔如此簡單,這讓很多開發者省下自己處理圖檔的時間,圖檔加載工作全部交給Glide來就完事,同時,很容易就把圖檔處理的相關知識點忘掉。
1.2 為什麼用Glide?
從前段時間面試的情況,我發現了這個現象:履歷上寫熟悉Glide的,基本都是熟悉使用方法,很多3年-6年工作經驗,除了說Glide使用友善,不清楚Glide跟其他圖檔架構如Fresco的對比有哪些優缺點。
首先,當下流行的圖檔加載架構有那麼幾個,可以拿 Glide 跟Fresco對比,例如這些:
Glide:
- 多種圖檔格式的緩存,适用于更多的内容表現形式(如Gif、WebP、縮略圖、Video)
- 生命周期內建(根據Activity或者Fragment的生命周期管理圖檔加載請求)
- 高效處理Bitmap(bitmap的複用和主動回收,減少系統回收壓力)
- 高效的緩存政策,靈活(Picasso隻會緩存原始尺寸的圖檔,Glide緩存的是多種規格),加載速度快且記憶體開銷小(預設Bitmap格式的不同,使得記憶體開銷是Picasso的一半)
Fresco:
- 最大的優勢在于5.0以下(最低2.3)的bitmap加載。在5.0以下系統,Fresco将圖檔放到一個特别的記憶體區域(Ashmem區)
- 大大減少OOM(在更底層的Native層對OOM進行處理,圖檔将不再占用App的記憶體)
- 适用于需要高性能加載大量圖檔的場景
對于一般App來說,Glide完全夠用,而對于圖檔需求比較大的App,為了防止加載大量圖檔導緻OOM,Fresco 會更合适一些。并不是說用Glide會導緻OOM,Glide預設用的記憶體緩存是LruCache,記憶體不會一直往上漲。
二、假如讓你自己寫個圖檔加載架構,你會考慮哪些問題?
首先,梳理一下必要的圖檔加載架構的需求:
- 異步加載:線程池
- 切換線程:Handler,沒有争議吧
- 緩存:LruCache、DiskLruCache
- 防止OOM:軟引用、LruCache、圖檔壓縮、Bitmap像素存儲位置
- 記憶體洩露:注意ImageView的正确引用,生命周期管理
- 清單滑動加載的問題:加載錯亂、隊滿任務過多問題
當然,還有一些不是必要的需求,例如加載動畫等。
2.1 異步加載:
線程池,多少個?
緩存一般有三級,記憶體緩存、硬碟、網絡。
由于網絡會阻塞,是以讀記憶體和硬碟可以放在一個線程池,網絡需要另外一個線程池,網絡也可以采用Okhttp内置的線程池。
讀硬碟和讀網絡需要放在不同的線程池中處理,是以用兩個線程池比較合适。
Glide 必然也需要多個線程池,看下源碼是不是這樣
public final class GlideBuilder { ... private GlideExecutor sourceExecutor; //加載源檔案的線程池,包括網絡加載 private GlideExecutor diskCacheExecutor; //加載硬碟緩存的線程池 ... private GlideExecutor animationExecutor; //動畫線程池
Glide使用了三個線程池,不考慮動畫的話就是兩個。
2.2 切換線程:
圖檔異步加載成功,需要在主線程去更新ImageView,
無論是RxJava、EventBus,還是Glide,隻要是想從子線程切換到Android主線程,都離不開Handler。
看下Glide 相關源碼:
class EngineJob implements DecodeJob.Callback,Poolable { private static final EngineResourceFactory DEFAULT_FACTORY = new EngineResourceFactory(); //建立Handler private static final Handler MAIN_THREAD_HANDLER = new Handler(Looper.getMainLooper(), new MainThreadCallback());
問RxJava是完全用Java語言寫的,那怎麼實作從子線程切換到Android主線程的? 依然有很多3-6年的開發答不上來這個很基礎的問題,而且隻要是這個問題回答不出來的,接下來有關于原理的問題,基本都答不上來。
有不少工作了很多年的Android開發不知道鴻洋、郭霖、玉剛說,不知道掘金是個啥玩意,内心估計會想是不是還有叫掘銀掘鐵的(我不知道有沒有)。
我想表達的是,幹這一行,真的是需要有對技術的熱情,不斷學習,不怕别人比你優秀,就怕比你優秀的人比你還努力,而你卻不知道。
2.3 緩存
我們常說的圖檔三級緩存:記憶體緩存、硬碟緩存、網絡。
2.3.1 記憶體緩存
一般都是用LruCache
Glide 預設記憶體緩存用的也是LruCache,隻不過并沒有用Android SDK中的LruCache,不過内部同樣是基于LinkHashMap,是以原理是一樣的。
// -> GlideBuilder#buildif (memoryCache == null) { memoryCache = new LruResourceCache(memorySizeCalculator.getMemoryCacheSize());}
既然說到LruCache ,必須要了解一下LruCache的特點和源碼:
為什麼用LruCache?
LruCache 采用最近最少使用算法,設定一個緩存大小,當緩存達到這個大小之後,會将最老的資料移除,避免圖檔占用記憶體過大導緻OOM。
LruCache 源碼分析
public class LruCache {// 資料最終存在 LinkedHashMap 中 private final LinkedHashMap map;...public LruCache(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } this.maxSize = maxSize;// 建立一個LinkedHashMap,accessOrder 傳true this.map = new LinkedHashMap(0, 0.75f, true); } ...
LruCache 構造方法裡建立一個LinkedHashMap,accessOrder 參數傳true,表示按照通路順序排序,資料存儲基于LinkedHashMap。
先看看LinkedHashMap 的原理吧
LinkedHashMap 繼承 HashMap,在 HashMap 的基礎上進行擴充,put 方法并沒有重寫,說明LinkedHashMap遵循HashMap的數組加連結清單的結構,
LinkedHashMap重寫了 createEntry 方法。
看下HashMap 的 createEntry 方法
void createEntry(int hash, K key, V value, int bucketIndex) { HashMapEntry e = table[bucketIndex]; table[bucketIndex] = new HashMapEntry<>(hash, key, value, e); size++;}
HashMap的數組裡面放的是HashMapEntry 對象
看下LinkedHashMap 的 createEntry方法
void createEntry(int hash, K key, V value, int bucketIndex) { HashMapEntry old = table[bucketIndex]; LinkedHashMapEntry e = new LinkedHashMapEntry<>(hash, key, value, old); table[bucketIndex] = e; //數組的添加 e.addBefore(header); //處理連結清單 size++;}
LinkedHashMap的數組裡面放的是LinkedHashMapEntry對象
LinkedHashMapEntry
private static class LinkedHashMapEntry extends HashMapEntry { // These fields comprise the doubly linked list used for iteration. LinkedHashMapEntry before, after; //雙向連結清單private void remove() { before.after = after; after.before = before; }private void addBefore(LinkedHashMapEntry existingEntry) { after = existingEntry; before = existingEntry.before; before.after = this; after.before = this; }
LinkedHashMapEntry繼承 HashMapEntry,添加before和after變量,是以是一個雙向連結清單結構,還添加了addBefore和remove 方法,用于新增和删除連結清單節點。
LinkedHashMapEntry#addBefore
将一個資料添加到Header的前面
private void addBefore(LinkedHashMapEntry existingEntry) { after = existingEntry; before = existingEntry.before; before.after = this; after.before = this;}
existingEntry 傳的都是連結清單頭header,将一個節點添加到header節點前面,隻需要移動連結清單指針即可,添加新資料都是放在連結清單頭header 的before位置,連結清單頭節點header的before是最新通路的資料,header的after則是最舊的資料。
再看下LinkedHashMapEntry#remove
private void remove() { before.after = after; after.before = before; }
連結清單節點的移除比較簡單,改變指針指向即可。
再看下LinkHashMap的put 方法
public final V put(K key, V value) { V previous; synchronized (this) { putCount++; //size增加 size += safeSizeOf(key, value); // 1、linkHashMap的put方法 previous = map.put(key, value); if (previous != null) { //如果有舊的值,會覆寫,是以大小要減掉 size -= safeSizeOf(key, previous); } } trimToSize(maxSize); return previous;}
LinkedHashMap 結構可以用這種圖表示
LinkHashMap 的 put方法和get方法最後會調用trimToSize方法,LruCache 重寫trimToSize方法,判斷記憶體如果超過一定大小,則移除最老的資料
LruCache#trimToSize,移除最老的資料
public void trimToSize(int maxSize) { while (true) { K key; V value; synchronized (this) { //大小沒有超出,不處理 if (size <= maxSize) { break; } //超出大小,移除最老的資料 Map.Entry toEvict = map.eldest(); if (toEvict == null) { break; } key = toEvict.getKey(); value = toEvict.getValue(); map.remove(key); //這個大小的計算,safeSizeOf 預設傳回1; size -= safeSizeOf(key, value); evictionCount++; } entryRemoved(true, key, value, null); }}
對LinkHashMap 還不是很了解的話可以參考:圖解LinkedHashMap原理
LruCache小結:
- LinkHashMap 繼承HashMap,在 HashMap的基礎上,新增了雙向連結清單結構,每次通路資料的時候,會更新被通路的資料的連結清單指針,具體就是先在連結清單中删除該節點,然後添加到連結清單頭header之前,這樣就保證了連結清單頭header節點之前的資料都是最近通路的(從連結清單中删除并不是真的删除資料,隻是移動連結清單指針,資料本身在map中的位置是不變的)。
- LruCache 内部用LinkHashMap存取資料,在雙向連結清單保證資料新舊順序的前提下,設定一個最大記憶體,往裡面put資料的時候,當資料達到最大記憶體的時候,将最老的資料移除掉,保證記憶體不超過設定的最大值。
2.3.2 磁盤緩存 DiskLruCache
依賴:
implementation 'com.jakewharton:disklrucache:2.0.2'
DiskLruCache 跟 LruCache 實作思路是差不多的,一樣是設定一個總大小,每次往硬碟寫檔案,總大小超過門檻值,就會将舊的檔案删除。簡單看下remove操作:
// DiskLruCache 内部也是用LinkedHashMapprivate final LinkedHashMap lruEntries = new LinkedHashMap(0, 0.75f, true);... public synchronized boolean remove(String key) throws IOException { checkNotClosed(); validateKey(key); Entry entry = lruEntries.get(key); if (entry == null || entry.currentEditor != null) { return false; } //一個key可能對應多個value,hash沖突的情況 for (int i = 0; i < valueCount; i++) { File file = entry.getCleanFile(i); //通過 file.delete() 删除緩存檔案,删除失敗則抛異常 if (file.exists() && !file.delete()) { throw new IOException("failed to delete " + file); } size -= entry.lengths[i]; entry.lengths[i] = 0; } ... return true; }
可以看到 DiskLruCache 同樣是利用LinkHashMap的特點,隻不過數組裡面存的 Entry 有點變化,Editor 用于操作檔案。
private final class Entry { private final String key; private final long[] lengths; private boolean readable; private Editor currentEditor; private long sequenceNumber;...}
2.4 防止OOM
加載圖檔非常重要的一點是需要防止OOM,上面的LruCache緩存大小設定,可以有效防止OOM,但是當圖檔需求比較大,可能需要設定一個比較大的緩存,這樣的話發生OOM的機率就提高了,那應該探索其它防止OOM的方法。
方法1:軟引用
回顧一下Java的四大引用:
- 強引用: 普通變量都屬于強引用,比如 private Context context;
- 軟應用: SoftReference,在發生OOM之前,垃圾回收器會回收SoftReference引用的對象。
- 弱引用: WeakReference,發生GC的時候,垃圾回收器會回收WeakReference中的對象。
- 虛引用: 随時會被回收,沒有使用場景。
怎麼了解強引用:
強引用對象的回收時機依賴垃圾回收算法,我們常說的可達性分析算法,當Activity銷毀的時候,Activity會跟GCRoot斷開,至于GCRoot是誰?這裡可以大膽猜想,Activity對象的建立是在ActivityThread中,ActivityThread要回調Activity的各個生命周期,肯定是持有Activity引用的,那麼這個GCRoot可以認為就是ActivityThread,當Activity 執行onDestroy的時候,ActivityThread 就會斷開跟這個Activity的聯系,Activity到GCRoot不可達,是以會被垃圾回收器标記為可回收對象。
軟引用的設計就是應用于會發生OOM的場景,大記憶體對象如Bitmap,可以通過 SoftReference 修飾,防止大對象造成OOM,看下這段代碼
private static LruCache> mLruCache = new LruCache>(10 * 1024){ @Override protected int sizeOf(String key, SoftReference value) { //預設傳回1,這裡應該傳回Bitmap占用的記憶體大小,機關:K //Bitmap被回收了,大小是0 if (value.get() == null){ return 0; } return value.get().getByteCount() /1024; } };
LruCache裡存的是軟引用對象,那麼當記憶體不足的時候,Bitmap會被回收,也就是說通過SoftReference修飾的Bitmap就不會導緻OOM。
當然,這段代碼存在一些問題,Bitmap被回收的時候,LruCache剩餘的大小應該重新計算,可以寫個方法,當Bitmap取出來是空的時候,LruCache清理一下,重新計算剩餘記憶體;
還有另一個問題,就是記憶體不足時軟引用中的Bitmap被回收的時候,這個LruCache就形同虛設,相當于記憶體緩存失效了,必然出現效率問題。
方法2:onLowMemory
當記憶體不足的時候,Activity、Fragment會調用onLowMemory方法,可以在這個方法裡去清除緩存,Glide使用的就是這一種方式來防止OOM。
//Glidepublic void onLowMemory() { clearMemory();}public void clearMemory() { // Engine asserts this anyway when removing resources, fail faster and consistently Util.assertMainThread(); // memory cache needs to be cleared before bitmap pool to clear re-pooled Bitmaps too. See #687. memoryCache.clearMemory(); bitmapPool.clearMemory(); arrayPool.clearMemory(); }
方法3:從Bitmap 像素存儲位置考慮
我們知道,系統為每個程序,也就是每個虛拟機配置設定的記憶體是有限的,早期的16M、32M,現在100+M,
虛拟機的記憶體劃分主要有5部分:
- 虛拟機棧
- 本地方法棧
- 程式計數器
- 方法區
- 堆
而對象的配置設定一般都是在堆中,堆是JVM中最大的一塊記憶體,OOM一般都是發生在堆中。
Bitmap 之是以占記憶體大不是因為對象本身大,而是因為Bitmap的像素資料, Bitmap的像素資料大小 = 寬 * 高 * 1像素占用的記憶體。
1像素占用的記憶體是多少?不同格式的Bitmap對應的像素占用記憶體是不同的,具體是多少呢?
在Fresco中看到如下定義代碼
/** * Bytes per pixel definitions */ public static final int ALPHA_8_BYTES_PER_PIXEL = 1; public static final int ARGB_4444_BYTES_PER_PIXEL = 2; public static final int ARGB_8888_BYTES_PER_PIXEL = 4; public static final int RGB_565_BYTES_PER_PIXEL = 2; public static final int RGBA_F16_BYTES_PER_PIXEL = 8;
如果Bitmap使用 RGB_565 格式,則1像素占用 2 byte,ARGB_8888 格式則占4 byte。在選擇圖檔加載架構的時候,可以将記憶體占用這一方面考慮進去,更少的記憶體占用意味着發生OOM的機率越低。 Glide記憶體開銷是Picasso的一半,就是因為預設Bitmap格式不同。
至于寬高,是指Bitmap的寬高,怎麼計算的呢?看BitmapFactory.Options 的 outWidth
/** * The resulting width of the bitmap. If {@link #inJustDecodeBounds} is * set to false, this will be width of the output bitmap after any * scaling is applied. If true, it will be the width of the input image * without any accounting for scaling. * *
outWidth will be set to -1 if there is an error trying to decode.
*/ public int outWidth;
看注釋的意思,如果 BitmapFactory.Options 中指定 inJustDecodeBounds 為true,則為原圖寬高,如果是false,則是縮放後的寬高。是以我們一般可以通過壓縮來減小Bitmap像素占用記憶體。
扯遠了,上面分析了Bitmap像素資料大小的計算,隻是說明Bitmap像素資料為什麼那麼大。那是否可以讓像素資料不放在java堆中,而是放在native堆中呢?據說Android 3.0到8.0 之間Bitmap像素資料存在Java堆,而8.0之後像素資料存到native堆中,是不是真的?看下源碼就知道了~
- 8.0 Bitmap
java層建立Bitmap方法
public static Bitmap createBitmap(@Nullable DisplayMetrics display, int width, int height, @NonNull Config config, boolean hasAlpha, @NonNull ColorSpace colorSpace) { ... Bitmap bm; ... if (config != Config.ARGB_8888 || colorSpace == ColorSpace.get(ColorSpace.Named.SRGB)) { //最終都是通過native方法建立 bm = nativeCreate(null, 0, width, width, height, config.nativeInt, true, null, null); } else { bm = nativeCreate(null, 0, width, width, height, config.nativeInt, true, d50.getTransform(), parameters); } ... return bm; }
Bitmap 的建立是通過native方法 nativeCreate
對應源碼 8.0.0_r4/xref/frameworks/base/core/jni/android/graphics/Bitmap.cpp
//Bitmap.cppstatic const JNINativeMethod gBitmapMethods[] = { { "nativeCreate