面試官:簡歷上最好不要寫Glide,不是問源碼那么簡單

這次來面試的是一個有著5年工作經驗的小伙,截取了一段對話如下:

面試官:我看你寫到Glide,為什么用Glide,而不選擇其它圖片加載框架?
小伙:Glide 使用簡單,鏈式調用,很方便,一直用這個。
面試官:有看過它的源碼嗎?跟其它圖片框架相比有哪些優勢?
小伙:沒有,只是在項目中使用而已~
面試官:假如現在不讓你用開源庫,需要你自己寫一個圖片加載框架,你會考慮哪些方面的問題,說說大概的思路。
小伙:額~,壓縮吧。
面試官:還有嗎?
小伙:額~,這個沒寫過。

說到圖片加載框架,大家最熟悉的莫過于Glide了,但我卻不推薦簡歷上寫熟悉Glide,除非你熟讀它的源碼,或者參與Glide的開發和維護。

在一般面試中,遇到圖片加載問題的頻率一般不會太低,只是問法會有一些差異,例如:

  • 簡歷上寫Glide,那么會問一下Glide的設計,以及跟其它同類框架的對比 ;
  • 假如讓你寫一個圖片加載框架,說說思路;
  • 給一個圖片加載的場景,比如網絡加載一張或多張大圖,你會怎么做;

帶著問題進入正文~

一、談談Glide

1.1 Glide 使用有多簡單?

Glide由于其口碑好,很多開發者直接在項目中使用,使用方法相當簡單

https://github.com/bumptech/glide

1、添加依賴:

implementation 'com.github.bumptech.glide:glide:4.10.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.10.0'

2、添加網絡權限

<uses-permission android:name="android.permission.INTERNET" />

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<R> implements DecodeJob.Callback<R>,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#build
if (memoryCache == null) {
  memoryCache = new LruResourceCache(memorySizeCalculator.getMemoryCacheSize());
}

既然說到LruCache ,必須要了解一下LruCache的特點和源碼:

為什么用LruCache?

LruCache 采用最近最少使用算法,設定一個緩存大小,當緩存達到這個大小之后,會將最老的數據移除,避免圖片占用內存過大導致OOM。

LruCache 源碼分析
    public class LruCache<K, V> {
    // 數據最終存在 LinkedHashMap 中
    private final LinkedHashMap<K, V> map;
    ...
    public LruCache(int maxSize) {
        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        this.maxSize = maxSize;
        // 創建一個LinkedHashMap,accessOrder 傳true
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }
    ...

LruCache 構造方法里創建一個LinkedHashMap,accessOrder 參數傳true,表示按照訪問順序排序,數據存儲基于LinkedHashMap。

先看看LinkedHashMap 的原理吧

LinkedHashMap 繼承 HashMap,在 HashMap 的基礎上進行擴展,put 方法并沒有重寫,說明LinkedHashMap遵循HashMap的數組加鏈表的結構

HashMap

LinkedHashMap重寫了 createEntry 方法。

看下HashMap 的 createEntry 方法

void createEntry(int hash, K key, V value, int bucketIndex) {
    HashMapEntry<K,V> 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<K,V> old = table[bucketIndex];
    LinkedHashMapEntry<K,V> e = new LinkedHashMapEntry<>(hash, key, value, old);
    table[bucketIndex] = e; //數組的添加
    e.addBefore(header);  //處理鏈表
    size++;
}

LinkedHashMap的數組里面放的是LinkedHashMapEntry對象

LinkedHashMapEntry

private static class LinkedHashMapEntry<K,V> extends HashMapEntry<K,V> {
    // These fields comprise the doubly linked list used for iteration.
    LinkedHashMapEntry<K,V> before, after; //雙向鏈表

    private void remove() {
        before.after = after;
        after.before = before;
    }

    private void addBefore(LinkedHashMapEntry<K,V> existingEntry) {
        after  = existingEntry;
        before = existingEntry.before;
        before.after = this;
        after.before = this;
    }

LinkedHashMapEntry繼承 HashMapEntry,添加before和after變量,所以是一個雙向鏈表結構,還添加了addBeforeremove 方法,用于新增和刪除鏈表節點。

LinkedHashMapEntry#addBefore
將一個數據添加到Header的前面

private void addBefore(LinkedHashMapEntry<K,V> 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 結構可以用這種圖表示


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<K, V> 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 內部也是用LinkedHashMap
    private final LinkedHashMap<String, Entry> lruEntries =
        new LinkedHashMap<String, Entry>(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;
    ...
}

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<String, SoftReference<Bitmap>> mLruCache = new LruCache<String, SoftReference<Bitmap>>(10 * 1024){
        @Override
        protected int sizeOf(String key, SoftReference<Bitmap> 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。

//Glide
public 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.
     *
     * <p>outWidth will be set to -1 if there is an error trying to decode.</p>
     */
    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.cpp
static const JNINativeMethod gBitmapMethods[] = {
    {   "nativeCreate",             "([IIIIIIZ[FLandroid/graphics/ColorSpace$Rgb$TransferParameters;)Landroid/graphics/Bitmap;",
        (void*)Bitmap_creator },
...

JNI動態注冊,nativeCreate 方法 對應 Bitmap_creator

//Bitmap.cpp
static jobject Bitmap_creator(JNIEnv* env, jobject, jintArray jColors,
                              jint offset, jint stride, jint width, jint height,
                              jint configHandle, jboolean isMutable,
                              jfloatArray xyzD50, jobject transferParameters) {
    ...
    //1. 申請堆內存,創建native層Bitmap
    sk_sp<Bitmap> nativeBitmap = Bitmap::allocateHeapBitmap(&bitmap, NULL);
    if (!nativeBitmap) {
        return NULL;
    }

    ...
    //2.創建java層Bitmap
    return createBitmap(env, nativeBitmap.release(), getPremulBitmapCreateFlags(isMutable));
}

主要兩個步驟:

  1. 申請內存,創建native層Bitmap,看下allocateHeapBitmap方法
    8.0.0_r4/xref/frameworks/base/libs/hwui/hwui/Bitmap.cpp
//
static sk_sp<Bitmap> allocateHeapBitmap(size_t size, const SkImageInfo& info, size_t rowBytes,
        SkColorTable* ctable) {
    // calloc 是c++ 的申請內存函數
    void* addr = calloc(size, 1);
    if (!addr) {
        return nullptr;
    }
    return sk_sp<Bitmap>(new Bitmap(addr, size, info, rowBytes, ctable));
}

可以看到通過c++的 calloc 函數申請了一塊內存空間,然后創建native層Bitmap對象,把內存地址傳過去,也就是native層的Bitmap數據(像素數據)是存在native堆中。

  1. 創建java 層Bitmap
//Bitmap.cpp
jobject createBitmap(JNIEnv* env, Bitmap* bitmap,
        int bitmapCreateFlags, jbyteArray ninePatchChunk, jobject ninePatchInsets,
        int density) {
    ...
    BitmapWrapper* bitmapWrapper = new BitmapWrapper(bitmap);
     //通過JNI回調Java層,調用java層的Bitmap構造方法
    jobject obj = env->NewObject(gBitmap_class, gBitmap_constructorMethodID,
            reinterpret_cast<jlong>(bitmapWrapper), bitmap->width(), bitmap->height(), density,
            isMutable, isPremultiplied, ninePatchChunk, ninePatchInsets);

   ...
    return obj;
}

env->NewObject,通過JNI創建Java層Bitmap對象,gBitmap_class,gBitmap_constructorMethodID這些變量是什么意思,看下面這個方法,對應java層的Bitmap的類名和構造方法。

//Bitmap.cpp
int register_android_graphics_Bitmap(JNIEnv* env)
{
    gBitmap_class = MakeGlobalRefOrDie(env, FindClassOrDie(env, "android/graphics/Bitmap"));
    gBitmap_nativePtr = GetFieldIDOrDie(env, gBitmap_class, "mNativePtr", "J");
    gBitmap_constructorMethodID = GetMethodIDOrDie(env, gBitmap_class, "<init>", "(JIIIZZ[BLandroid/graphics/NinePatch$InsetStruct;)V");
    gBitmap_reinitMethodID = GetMethodIDOrDie(env, gBitmap_class, "reinit", "(IIZ)V");
    gBitmap_getAllocationByteCountMethodID = GetMethodIDOrDie(env, gBitmap_class, "getAllocationByteCount", "()I");
    return android::RegisterMethodsOrDie(env, "android/graphics/Bitmap", gBitmapMethods,
                                         NELEM(gBitmapMethods));
}

8.0 的Bitmap創建就兩個點:

  1. 創建native層Bitmap,在native堆申請內存。
  2. 通過JNI創建java層Bitmap對象,這個對象在java堆中分配內存。

像素數據是存在native層Bitmap,也就是證明8.0的Bitmap像素數據存在native堆中。

7.0 Bitmap

直接看native層的方法,

/7.0.0_r31/xref/frameworks/base/core/jni/android/graphics/Bitmap.cpp

//JNI動態注冊
static const JNINativeMethod gBitmapMethods[] = {
    {   "nativeCreate",             "([IIIIIIZ)Landroid/graphics/Bitmap;",
        (void*)Bitmap_creator },
...

static jobject Bitmap_creator(JNIEnv* env, jobject, jintArray jColors,
                              jint offset, jint stride, jint width, jint height,
                              jint configHandle, jboolean isMutable) {
    ... 
    //1.通過這個方法來創建native層Bitmap
    Bitmap* nativeBitmap = GraphicsJNI::allocateJavaPixelRef(env, &bitmap, NULL);
    ...

    return GraphicsJNI::createBitmap(env, nativeBitmap,
            getPremulBitmapCreateFlags(isMutable));
}

native層Bitmap 創建是通過GraphicsJNI::allocateJavaPixelRef,看看里面是怎么分配的,
GraphicsJNI 的實現類是Graphics.cpp

android::Bitmap* GraphicsJNI::allocateJavaPixelRef(JNIEnv* env, SkBitmap* bitmap,
                                             SkColorTable* ctable) {
    const SkImageInfo& info = bitmap->info();
    
    size_t size;
    //計算需要的空間大小
    if (!computeAllocationSize(*bitmap, &size)) {
        return NULL;
    }

    // we must respect the rowBytes value already set on the bitmap instead of
    // attempting to compute our own.
    const size_t rowBytes = bitmap->rowBytes();
    // 1. 創建一個數組,通過JNI在java層創建的
    jbyteArray arrayObj = (jbyteArray) env->CallObjectMethod(gVMRuntime,
                                                             gVMRuntime_newNonMovableArray,
                                                             gByte_class, size);
    ...
    // 2. 獲取創建的數組的地址
    jbyte* addr = (jbyte*) env->CallLongMethod(gVMRuntime, gVMRuntime_addressOf, arrayObj);
    ...
    //3. 創建Bitmap,傳這個地址
    android::Bitmap* wrapper = new android::Bitmap(env, arrayObj, (void*) addr,
            info, rowBytes, ctable);
    wrapper->getSkBitmap(bitmap);
    // since we're already allocated, we lockPixels right away
    // HeapAllocator behaves this way too
    bitmap->lockPixels();

    return wrapper;
}

可以看到,7.0 像素內存的分配是這樣的:

  1. 通過JNI調用java層創建一個數組
  2. 然后創建native層Bitmap,把數組的地址傳進去。

由此說明,7.0 的Bitmap像素數據是放在java堆的。

當然,3.0 以下Bitmap像素內存據說也是放在native堆的,但是需要手動釋放native層的Bitmap,也就是需要手動調用recycle方法,native層內存才會被回收。這個大家可以自己去看源碼驗證。

native層Bitmap 回收問題

Java層的Bitmap對象由垃圾回收器自動回收,而native層Bitmap印象中我們是不需要手動回收的,源碼中如何處理的呢?

記得有個面試題是這樣的:

說說final、finally、finalize 的關系

三者除了長得像,其實沒有半毛錢關系,final、finally大家都用的比較多,而 finalize 用的少,或者沒用過,finalize 是 Object 類的一個方法,注釋是這樣的:

/**
     * Called by the garbage collector on an object when garbage collection
     * determines that there are no more references to the object.
     * A subclass overrides the {@code finalize} method to dispose of
     * system resources or to perform other cleanup.
     * <p>
     ...**/
  protected void finalize() throws Throwable { }

意思是說,垃圾回收器確認這個對象沒有其它地方引用到它的時候,會調用這個對象的finalize方法,子類可以重寫這個方法,做一些釋放資源的操作。

在6.0以前,Bitmap 就是通過這個finalize 方法來釋放native層對象的。
6.0 Bitmap.java

Bitmap(long nativeBitmap, byte[] buffer, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) {
        ...
        mNativePtr = nativeBitmap;
        //1.創建 BitmapFinalizer
        mFinalizer = new BitmapFinalizer(nativeBitmap);
        int nativeAllocationByteCount = (buffer == null ? getByteCount() : 0);
        mFinalizer.setNativeAllocationByteCount(nativeAllocationByteCount);
}

 private static class BitmapFinalizer {
        private long mNativeBitmap;

        // Native memory allocated for the duration of the Bitmap,
        // if pixel data allocated into native memory, instead of java byte[]
        private int mNativeAllocationByteCount;

        BitmapFinalizer(long nativeBitmap) {
            mNativeBitmap = nativeBitmap;
        }

        public void setNativeAllocationByteCount(int nativeByteCount) {
            if (mNativeAllocationByteCount != 0) {
                VMRuntime.getRuntime().registerNativeFree(mNativeAllocationByteCount);
            }
            mNativeAllocationByteCount = nativeByteCount;
            if (mNativeAllocationByteCount != 0) {
                VMRuntime.getRuntime().registerNativeAllocation(mNativeAllocationByteCount);
            }
        }

        @Override
        public void finalize() {
            try {
                super.finalize();
            } catch (Throwable t) {
                // Ignore
            } finally {
                //2.就是這里了,
                setNativeAllocationByteCount(0);
                nativeDestructor(mNativeBitmap);
                mNativeBitmap = 0;
            }
        }
    }

在Bitmap構造方法創建了一個 BitmapFinalizer類,重寫finalize 方法,在java層Bitmap被回收的時候,BitmapFinalizer 對象也會被回收,finalize 方法肯定會被調用,在里面釋放native層Bitmap對象。

6.0 之后做了一些變化,BitmapFinalizer 沒有了,被NativeAllocationRegistry取代。

例如 8.0 Bitmap構造方法

    Bitmap(long nativeBitmap, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) {
       
        ...
        mNativePtr = nativeBitmap;
        long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();
        //  創建NativeAllocationRegistry這個類,調用registerNativeAllocation 方法
        NativeAllocationRegistry registry = new NativeAllocationRegistry(
            Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);
        registry.registerNativeAllocation(this, nativeBitmap);
    }

NativeAllocationRegistry 就不分析了,
不管是BitmapFinalizer 還是NativeAllocationRegistry,目的都是在java層Bitmap被回收的時候,將native層Bitmap對象也回收掉。 一般情況下我們無需手動調用recycle方法,由GC去盤它即可。

上面分析了Bitmap像素存儲位置,我們知道,Android 8.0 之后Bitmap像素內存放在native堆,Bitmap導致OOM的問題基本不會在8.0以上設備出現了(沒有內存泄漏的情況下),那8.0 以下設備怎么辦?趕緊升級或換手機吧~

image

我們換手機當然沒問題,但是并不是所有人都能跟上Android系統更新的步伐,所以,問題還是要解決~

Fresco 之所以能跟Glide 正面交鋒,必然有其獨特之處,文中開頭列出 Fresco 的優點是:“在5.0以下(最低2.3)系統,Fresco將圖片放到一個特別的內存區域(Ashmem區)”
這個Ashmem區是一塊匿名共享內存,Fresco 將Bitmap像素放到共享內存去了,共享內存是屬于native堆內存。

Fresco 關鍵源碼在 PlatformDecoderFactory 這個類

public class PlatformDecoderFactory {

  /**
   * Provide the implementation of the PlatformDecoder for the current platform using the provided
   * PoolFactory
   *
   * @param poolFactory The PoolFactory
   * @return The PlatformDecoder implementation
   */
  public static PlatformDecoder buildPlatformDecoder(
      PoolFactory poolFactory, boolean gingerbreadDecoderEnabled) {
    //8.0 以上用 OreoDecoder 這個解碼器
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      int maxNumThreads = poolFactory.getFlexByteArrayPoolMaxNumThreads();
      return new OreoDecoder(
          poolFactory.getBitmapPool(), maxNumThreads, new Pools.SynchronizedPool<>(maxNumThreads));
    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
      //大于5.0小于8.0用 ArtDecoder 解碼器
      int maxNumThreads = poolFactory.getFlexByteArrayPoolMaxNumThreads();
      return new ArtDecoder(
          poolFactory.getBitmapPool(), maxNumThreads, new Pools.SynchronizedPool<>(maxNumThreads));
    } else {
      if (gingerbreadDecoderEnabled && Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
        //小于4.4 用 GingerbreadPurgeableDecoder 解碼器
        return new GingerbreadPurgeableDecoder();
      } else {
        //這個就是4.4到5.0 用的解碼器了
        return new KitKatPurgeableDecoder(poolFactory.getFlexByteArrayPool());
      }
    }
  }
}

8.0 先不看了,看一下 4.4 以下是怎么得到Bitmap的,看下GingerbreadPurgeableDecoder這個類有個獲取Bitmap的方法

//GingerbreadPurgeableDecoder
private Bitmap decodeFileDescriptorAsPurgeable(
      CloseableReference<PooledByteBuffer> bytesRef,
      int inputLength,
      byte[] suffix,
      BitmapFactory.Options options) {
    //  MemoryFile :匿名共享內存
    MemoryFile memoryFile = null;
    try {
      //將圖片數據拷貝到匿名共享內存
      memoryFile = copyToMemoryFile(bytesRef, inputLength, suffix);
      FileDescriptor fd = getMemoryFileDescriptor(memoryFile);
      if (mWebpBitmapFactory != null) {
        // 創建Bitmap,Fresco自己寫了一套創建Bitmap方法
        Bitmap bitmap = mWebpBitmapFactory.decodeFileDescriptor(fd, null, options);
        return Preconditions.checkNotNull(bitmap, "BitmapFactory returned null");
      } else {
        throw new IllegalStateException("WebpBitmapFactory is null");
      }
    } 
  }

捋一捋,4.4以下,Fresco 使用匿名共享內存來保存Bitmap數據,首先將圖片數據拷貝到匿名共享內存中,然后使用Fresco自己寫的加載Bitmap的方法。

Fresco對不同Android版本使用不同的方式去加載Bitmap,至于4.4-5.0,5.0-8.0,8.0 以上,對應另外三個解碼器,大家可以從PlatformDecoderFactory 這個類入手,自己去分析,思考為什么不同平臺要分這么多個解碼器,8.0 以下都用匿名共享內存不好嗎?期待你在評論區跟大家分享~

2.5 ImageView 內存泄露

曾經在Vivo駐場開發,帶有頭像功能的頁面被測出內存泄漏,原因是SDK中有個加載網絡頭像的方法,持有ImageView引用導致的。

當然,修改也比較簡單粗暴,將ImageView用WeakReference修飾就完事了。

事實上,這種方式雖然解決了內存泄露問題,但是并不完美,例如在界面退出的時候,我們除了希望ImageView被回收,同時希望加載圖片的任務可以取消,隊未執行的任務可以移除。

Glide的做法是監聽生命周期回調,看 RequestManager 這個類

public void onDestroy() {
    targetTracker.onDestroy();
    for (Target<?> target : targetTracker.getAll()) {
      //清理任務
      clear(target);
    }
    targetTracker.clear();
    requestTracker.clearRequests();
    lifecycle.removeListener(this);
    lifecycle.removeListener(connectivityMonitor);
    mainHandler.removeCallbacks(addSelfToLifecycle);
    glide.unregisterRequestManager(this);
  }

在Activity/fragment 銷毀的時候,取消圖片加載任務,細節大家可以自己去看源碼。

2.6 列表加載問題

圖片錯亂

由于RecyclerView或者LIstView的復用機制,網絡加載圖片開始的時候ImageView是第一個item的,加載成功之后ImageView由于復用可能跑到第10個item去了,在第10個item顯示第一個item的圖片肯定是錯的。

常規的做法是給ImageView設置tag,tag一般是圖片地址,更新ImageView之前判斷tag是否跟url一致。

當然,可以在item從列表消失的時候,取消對應的圖片加載任務。要考慮放在圖片加載框架做還是放在UI做比較合適。

線程池任務過多

列表滑動,會有很多圖片請求,如果是第一次進入,沒有緩存,那么隊列會有很多任務在等待。所以在請求網絡圖片之前,需要判斷隊列中是否已經存在該任務,存在則不加到隊列去。

總結

本文通過Glide開題,分析一個圖片加載框架必要的需求,以及各個需求涉及到哪些技術和原理。

  • 異步加載:最少兩個線程池
  • 切換到主線程:Handler
  • 緩存:LruCache、DiskLruCache,涉及到LinkHashMap原理
  • 防止OOM:軟引用、LruCache、圖片壓縮沒展開講、Bitmap像素存儲位置源碼分析、Fresco部分源碼分析
  • 內存泄露:注意ImageView的正確引用,生命周期管理
  • 列表滑動加載的問題:加載錯亂用tag、隊滿任務存在則不添加

文中也遺留一些問題,例如:
Fresco為什么要在不同Android版本上使用不同解碼器去獲取Bitmap,8.0以下都用匿名共享內存不可以嗎?期待你主動學習并且跟大家分享~


就這樣,有問題評論區留言~

相關文章:
圖解LinkedHashMap原理
談談fresco的bitmap內存分配

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 7.1 壓縮圖片 一、基礎知識 1、圖片的格式 jpg:最常見的圖片格式。色彩還原度比較好,可以支持適當壓縮后保持...
    AndroidMaster閱讀 2,554評論 0 13
  • Android性能優化(高級) 簡述Android的系統架構?android的系統架構從下往上分為Linux內核層...
    梧桐樹biu閱讀 573評論 0 2
  • 【Android 庫 圖片庫比較】 四大圖片庫比較 四大圖片緩存基本信息 Universal ImageLoade...
    Rtia閱讀 3,632評論 0 7
  • 我希望通過這篇文章能夠把Android內存相關的基礎和大部分內存相關問題如:溢出、泄漏、圖片等等產生的都講解清楚,...
    Cactus_b245閱讀 7,241評論 6 82
  • 近期最熱的一部電視劇就是優酷播出的《長安十二時辰》了,因為該劇導致優酷會員數激增,我個人有個習慣,如果電視...
    終身學習者2018閱讀 901評論 0 3