圖片加載框架ImageLoader實現原理

圖片加載框架ImageLoader實現原理

聲明:本篇文章已授權微信公衆號guolin_blog(郭霖)獨家發佈。

前序:在製作App的時候,會經常需要加載一些網絡圖片,在圖片加載框架出來之前,我們都是通過 網絡拉取 的方式去服務器端獲取到圖片的文件流後,再通過BitmapFactory.decodeStream(InputStream in)來加載圖片,這種方式加載一兩張圖片倒不會出現問題,但是如果短時間內加載十幾張或者幾十張圖片的時候,就很有可能會造成OOM(內存溢出),因爲現在的圖片資源大小都是非常大的,所以我們在加載圖片之前還需要進行相應的 圖片壓縮 處理;但又有個問題來了,在蜂窩數據如此昂虧的情況下,如果用戶每次進入App的時候都會去進行網絡拉取圖片,這樣就會非常的浪費數據流量,這時我們又需要對圖片資源進行一些相應的 內存緩存 以及 磁盤緩存 處理,這樣不僅節省用戶的數據流量,還能加快圖片的加載速度;雖然利用緩存的方式可以加快圖片的加載速度,但當我們需要加載很多張圖片的時候(例如圖片牆效果),就還需用到多線程來加載圖片,使用多線程就會涉及到線程 同步加載異步加載 問題;

總結:任何的圖片加載框架都會涉及到這幾個方面:內存緩存,磁盤緩存,網絡拉取,圖片壓縮,同步加載,異步加載

接下來我們一步一步來實現一個圖片加載框架,仿寫ImageLoader來實現一個圖片加載框架PictureLoader:

圖片壓縮:

如何加載一個圖片呢?BitmapFactory類爲我們提供了四類方法來加載Bitmap:decodeFile、decodeResource、decodeStream、decodeByteArray;

通常在加載圖片之前,我們需要先對Bitmap進行壓縮處理,那如何對Bitmap進行壓縮處理?首先通常都是創建一個BitmapFactory.Options,然後將inJustDecodeBounds設置爲true,並通過decodeResource加載圖片,但這個時候並不是真正意義上加載圖片,而是對圖片的寬高進行獲取,得到圖片的寬高後,與我們的ImageView的寬高通過計算得出縮放比,因爲我們的ImageView的寬高遠遠小於我們需要加載圖片的寬高,所以這個時候我們需要對圖片進行縮放,那如何計算這個縮放比呢?官方規定這個縮放比通常是2的倍數,但是該縮放比不是越大越好,而是需要恰到好處,如果縮放比過大,會導致圖片壓縮過多,這時候在ImageView展示的時候,該圖片就會被拉伸,這樣會非常嚴重的影響用戶體驗;計算出縮放比後,將該值賦予option的inSampleSize,然後再將inJustDecodeBounds設置爲false,並通過decodeResource加載圖片,這時候就會加載縮放後的圖片了;

// resize the picture from resource;
public Bitmap resizePictureFromResource(Resources res, int viewId, int reqWidth, int reqHeight) {
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, viewId, options);
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, viewId, options);
}

// the algorithm for calculate the inSampleSize;
public int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
    if (reqWidth == 0 || reqHeight == 0) return 1;
    final int height = options.outHeight;
    final int width = options.outWidth;
    Log.e(TAG, "origin, width = " + width + " , height = " + height);
    int inSampleSize = 1;
    if (height > reqHeight || width > reqWidth) {
        final int halfWidth = width / 2;
        final int halfHeight = height / 2;
        while ((halfHeight / inSampleSize) >= reqHeight
                && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2;
        }
    }
    Log.e(TAG, "inSampleSize = " + inSampleSize);
    return inSampleSize;
}

上面縮放方式可以縮放來自於Resource資源的圖片,該方式適用於內存緩存(LruCache),但是如果我們用DiskLruCache作爲磁盤緩存的話,從DiskLruCache獲取緩存的時候,獲取到的是一個SnapShot對象,接着我們可以通過SnapShot對象得到緩存的文件輸入流,有了文件輸入流,就可以得到Bitmap對象了,但是這個時候我們依然使用decodeResource方式來進行圖片縮放的話,就會存在問題,因爲FileInputStream是一種有序的文件流,而兩次decodeStream調用會影響文件流的位置屬性,就會導致第二次decodeStream時得到null,爲了解決這個問題,我們可以通過文件流來得到所對應的文件描述符,然後再通過BitmapFactory.decodeFileDescriptor來得到一張縮放後的圖片;

// resize the picture from file descriptor;
public Bitmap resizePictureFromFileDescriptor(FileDescriptor fd, int reqWidth, int reqHeight) {
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeFileDescriptor(fd, null, options);
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeFileDescriptor(fd, null, options);
}

內存緩存

LruCache是Android提供的一個緩存類,通常運用於內存緩存,LruCache是一個泛型類,它的底層是用一個LinkedHashMap以強引用的方式存儲外界的緩存對象來實現的,爲什麼使用LinkedHashMap來作爲LruCache的存儲,是因爲LinkedHashMap有兩種排序方式,一種是插入排序方式,一種是訪問排序方式,默認情況下是以訪問方式來存儲緩存對象的;LruCache提供了get和put方法來完成緩存的獲取和添加,當緩存滿時,會將最近最少使用的對象移除掉,然後再添加新的緩存對象;在使用LruCache的時候,首先需要獲取當前設備的內存容量,通常情況下會將總容量的八分之一作爲LruCache的容量,然後重寫LruCache的sizeof方法,sizeof方法用於計算緩存對象的大小,單位需要與分配的容量的單位一致;

創建LruCache:

// get system max memory;
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// set LruCache size;
int cacheSize = maxMemory / 8;
memoryCache = new LruCache<String, Bitmap>(cacheSize) {
    @Override
    protected int sizeOf(String uri, Bitmap bitmap) {
        return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
    }
};

添加操作:

memoryCache.put(key, bitmap);

獲取操作:

memoryCache.get(key)

磁盤緩存

DiskLruCache適用於磁盤緩存,雖然其不是官方的API,但是官方還是推薦使用DiskLruCache作爲磁盤緩存,在使用LruCache之前,我們需要給我們的項目添加其依賴:

implementation 'com.jakewharton:disklrucache:2.0.2'

在使用DiskLruCache之前,我們首先需要通過open方法來創建一個DiskLruCache,open方法有四個參數,第一個參數指的是磁盤緩存的路徑,傳參前需要確保該路徑下的文件夾存在,沒有就創建一個;第二個參數是版本號,通常設爲1即可;第三個參數是指單個節點所對應的數據的個數,通常也設爲1即可;第四個參數是指該DiskLruCache的容量大小:

File diskCacheDir = getDiskCahceDir(pContext, "bitmap");
if (!diskCacheDir.exists()) {
    diskCacheDir.mkdirs();
}
if (getUsableSpace(diskCacheDir) > DISK_CACHE_SIZE) {
    try {
        diskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
        isDiskLruCacheCreated = true;
    } catch (IOException e) {
        e.printStackTrace();
    }
}

創建完DiskLruCache後,當我們使用DiskLruCache添加緩存操作的時候,是通過Editor來完成的,Editor表示一個緩存對象的編輯對象,在添加緩存的時候,我們的key通常是url,但是直接使用url作爲key的話,可能會出現一些問題,如果當一個url存在特殊字符的時候,這將影響緩存查找操作,所以在添加之前需要對url進行相應的計算,一般採用url的md5值作爲key,當然也可以使用其他的消息摘要算法:

// the algorithm for uri to key;
private String hashKeyFromUri(String uri) {
    String cacheKey = null;
    try {
        final MessageDigest digest = MessageDigest.getInstance("MD5");
        digest.update(uri.getBytes());
        cacheKey = bytesToHexString(digest.digest());
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
    }
    return cacheKey;
}

// get hex string from bytes;
private String bytesToHexString(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        String hex = Integer.toHexString(0xFF & b);
        sb.append(hex.length() == 1 ? '0' : hex);
    }
    return sb.toString();
}

當url轉成key以後,就可以獲取Editor對象,但是DiskLruCache不允許同時編輯同一個緩存對象,如果編輯一個正在編輯的緩存對,edit就會返回一個null,edit後就會得到一個Editor對象,通過Editor對象就可以得到一個文件輸出流,當我們在網絡上下載圖片的時候,就可以將網絡圖片的文件流通過該文件輸出流寫入磁盤中,最後必須通過Editor的commit方法來提交寫入操作才完成緩存添加操作,如果寫入過程中發生異常,可通過abort方法來回退操作:

if (diskLruCache == null) {
     return null;
}
String key = hashKeyFromUri(uri);
DiskLruCache.Editor editor = diskLruCache.edit(key);
if (editor != null) {
    OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
    if (downloadUriToStream(uri, outputStream)) {
        editor.commit();
    } else {
        editor.abort();
    }
    diskLruCache.flush();
}

在DiskLruCache中查找我們緩存對象的時候,可以使用DiskLruCache的get方法通過相應的key來得到一個SnapShot對象,然後接着通過SnapShot對象就可以得到緩存對象的文件輸入流,在之前的壓縮圖片操作提到過,該文件輸入流不能直接用decodeStream來進行圖片縮放,因爲文件輸入流是一個有序的文件流,兩次decodeStream會使文件流的位置屬性發生變化,從而會導致第二次decodeStream的時候得到的是null,爲了解決這個問題,從磁盤緩存中得到緩存對象SnapShot後,將其得到的文件輸入流得到相對應的文件描述符,然後通過BitmapFactory.decodeFileDescriptor方法來加載一張縮放圖片,並將其添加到內存緩存中去:

Bitmap bitmap = null;
String key = hashKeyFromUri(uri);
DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
if (snapshot != null) {
    FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
    FileDescriptor fileDescriptor = fileInputStream.getFD();
    bitmap = pictureResizer.resizePictureFromFileDescriptor(fileDescriptor, reqWidth, reqHeight);
    if (bitmap != null) {
        // if bitmap is get, add the new bitmap to memory cache;
        addBitmapToMemoryCache(key, bitmap);
    }
}

網絡拉取

一個App在加載圖片的時候,可以先從內存緩存中獲取,當內存緩存中不存在緩存對象時,就去磁盤緩存中嘗試緩存,如果此時磁盤緩存中依然不存在時,就需要進行網絡請求獲取圖片的文件流,在這我將採用最原生的網絡請求方式HttpURLConnection方式進行圖片獲取(當然也可以使用流行的開源框架:Okhttp或者Retrofit等等):

Bitmap bitmap = null;
HttpURLConnection urlConnection = null;
BufferedInputStream in = null;
try {
    final URL url = new URL(uri);
    urlConnection = (HttpURLConnection) url.openConnection();
    in = new BufferedInputStream(urlConnection.getInputStream(), IO_BUFFER_SIZE);
    bitmap = BitmapFactory.decodeStream(in);
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (urlConnection != null) urlConnection.disconnect();
    if (in != null) in.close();
}

同步加載與異步加載

如果我們只用單線程來加載大量圖片的時候,雖然可以加載成功,但是會非常地耗時,所以這個時候同步加載圖片的操作不能放在主線程中執行,需要外部在子線程中調用,寫同步加載的時候,首先需要檢測當前的線程的Looper是否爲主線程的Looper,如果是則拋出異常:

private Bitmap loadBitmap(String uri, int reqWidth, int reqHeight) throws IOException {
    Bitmap bitmap = loadBitmapFromMemoryCache(uri);
    if (bitmap != null) {
        return bitmap;
    }
    try {
        bitmap = loadBitmapFromDiskCache(uri, reqWidth, reqHeight);
        if (bitmap != null) {
            return bitmap;
        }
        bitmap = loadBitmapFromHttp(uri, reqWidth, reqHeight);
    } catch (IOException e) {
        e.printStackTrace();
    }
    if (bitmap == null && !isDiskLruCacheCreated) {
        bitmap = downloadBitmapFromUrl(uri);
    }
    return bitmap;
}

異步加載時使用多線程的方式來加載圖片,在使用多線程的時候,我們通常都會使用線程池,因爲大量線程的創建與銷燬是非常消耗資源的,而線程池充分利用資源,複用線程,減少不必要的開銷,當使用多線程方式加載圖片的時候,爲了保證線程安全,這裏將給線程池使用LinkedBlockingQueue的方式來保證線程安全,當圖片獲取成功後將圖片的url、圖片以及ImageView封裝成一個對象,將其通過Handler向主線程發送一個消息,這樣就可以在主線程中更新UI,爲ImageView設置圖片了:

// get system's CPU count;
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// set ThreadPool's core thread count;
private static final int CORE_POOL_SIZE = CPU_COUNT + 1;
// set ThreadPool's max thread count;
private static final int MAX_POOL_SIZE = CORE_POOL_SIZE * 2 + 1;
// set every thread's time alive;
private static final long KEEP_ALIVE = 10L;
// create the ThreadPool;
private static final Executor loaderThreadPoolExecutor = new ThreadPoolExecutor(
        CORE_POOL_SIZE,
        MAX_POOL_SIZE,
        KEEP_ALIVE,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<Runnable>(),
        pThreadFactory
);

public void setBitmap(final String uri, final ImageView imageView
        , final int reqWidth, final int reqHeight) {
    imageView.setTag(TAG_KEY_URI, uri);
    final Bitmap bitmap = loadBitmapFromMemoryCache(uri);
    if (bitmap != null) {
        imageView.setImageBitmap(bitmap);
        return;
    }
    Runnable loadBitmapTask = new Runnable() {
        @Override
        public void run() {
            Bitmap bitmap1 = null;
            try {
                bitmap1 = loadBitmap(uri, reqWidth, reqHeight);
            } catch (IOException e) {
                e.printStackTrace();
            }
            if (bitmap1 != null) {
                LoaderResult result = new LoaderResult(uri, imageView, bitmap1);
                mainHandler.obtainMessage(MESSAGE_POST_RESULT, result).sendToTarget();
            }
        }
    };
    loaderThreadPoolExecutor.execute(loadBitmapTask);
}

以上是ImageLoader圖片加載框架大體的實現,參考ImageLoader,小猿自己仿寫了一個PictureLoader,在網絡加載這塊,採用了Okhttp來實現,並將其打包提交到了JitPack上,使用的時候可以在gradle中添加依賴即可:

allprojects {
    repositories {
        maven { url 'https://jitpack.io' }
    }
}

implementation 'com.github.LanceShu:PictureLoader:1.0'

在Activity中使用的時候,可以這樣使用:

PictureLoader.build(this).setBitmap(String url, ImageView iv, int reqWidth,int reqHeight);

build()方法中主要的操作是對內存緩存以及磁盤緩存的初始化操作,setBitmap()方法中有四個參數,必須要有的參數是url以及ImageView,可選參數是縮放圖片的高度與寬度;

項目地址:https://github.com/LanceShu/PictureLoader

運行效果如圖:

該框架還存在一些bug,還在完善中,歡迎指點意見~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章