本文會從內部原理到具體實現來詳細介紹如何開發一個簡潔而實用的Android圖片加載緩存框架,並在內存占用與加載圖片所需時間這兩個方面與主流圖片加載框架之一Universal Image Loader做出比較,來幫助我們量化這個框架的性能。通過開發這個框架,我們可以進一步深入了解Android中的Bitmap操作、LruCache、LruDiskCache,讓我們以后與Bitmap打交道能夠更加得心應手。若對Bitmap的大小計算及inSampleSize計算還不太熟悉,請參考這里:高效加載Bitmap。由於個人水平有限,敘述中難免存在不准確或是不清晰的地方,希望大家能夠指出,謝謝大家:)
一、圖片加載框架需求描述
在着手進行實際開發工作之前,我們先來明確以下我們的需求。通常來說,一個實用的圖片加載框架應該具備以下2個功能:
- 圖片的加載:包括從不同來源(網絡、文件系統、內存等),支持同步及異步方式,支持對圖片的壓縮等等;
- 圖片的緩存:包括內存緩存和磁盤緩存。
下面我們來具體描述下這些需求。
1. 圖片的加載
(1)同步加載與異步加載
我們先來簡單的復習下同步與異步的概念:
- 同步:發出了一個“調用”后,需要等到該調用返回才能繼續執行;
- 異步:發出了一個“調用”后,無需等待該調用返回就能繼續執行。
同步加載就是我們發出加載圖片這個調用后,直到完成加載我們才繼續干別的活,否則就一直等着;異步加載也就是發出加載圖片這個調用后我們可以直接去干別的活。
(2)從不同的來源加載
我們的應用有時候需要從網絡上加載圖片,有時候需要從磁盤加載,有時候又希望從內存中直接獲取。因此一個合格的圖片加載框架應該支持從不同的來源來加載一個圖片。對於網絡上的圖片,我們可以使用HttpURLConnection來下載並解析;對於磁盤中的圖片,我們可以使用BitmapFactory的decodeFile方法;對於內存中的Bitmap,我們直接就可以拿來用。
(3)圖片的壓縮
關於對圖片的壓縮,主要的工作是計算出inSampleSize,剩下的細節在下面實現部分我們會介紹。
2. 圖片的緩存
緩存功能對於一個圖片加載框架來說是十分必要的,因為從網絡上加載圖片既耗時耗電又費流量。通常我們希望把已經加載過的圖片緩存在內存或磁盤中,這樣當我們再次需要加載相同的圖片時可以直接從內存緩存或磁盤緩存中獲取。
(1)內存緩存
訪問內存的速度要比訪問磁盤快得多,因此我們傾向於把更加常用的圖片直接緩存在內存中,這樣加載速度更快,但是內存對於移動設備來說是稀缺資源,因此能夠緩存的圖片比較少。我們可以選擇使用SDK提供的LruCache類來實現內存緩存,這個類使用了LRU算法來管理緩存對象,LRU算法即Least Recently Used(最近最少使用),它的主要思想是當緩存空間已滿時,移除最近最少使用(上一次訪問時間距現在最久遠)的緩存對象。關於LruCache類的具體使用我們下面會進行詳細介紹。
(2)磁盤緩存
磁盤緩存的優勢在於能夠緩存的圖片數量比較多,不足就是磁盤IO的速度比較慢。磁盤緩存我們可以用DiskLruCache來實現,這個類不包含在Android SDK中,它的源碼可以從這里獲取:http://developer.android.com/intl/zh-cn/samples/DisplayingBitmaps/src/com.example.android.displayingbitmaps/util/DiskLruCache.html。無法訪問的同學請看文末給出的本文示例代碼的地址,其中包含了DiskLruCache。
DisLruCache同樣使用了LRU算法來管理緩存,關於它的具體使用我們會在后文進行介紹。
二、緩存類使用介紹
1. LruCache的使用
首先我們來看一下LruCache類的定義:
public class LruCache<K, V> { private final LinkedHashMap<K, V> map; ... public LruCache(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } this.maxSize = maxSize; this.map = new LinkedHashMap<K, V>(0, 0.75f, true); } ... }
由以上代碼我們可以知道,LruCache是個泛型類,它的內部使用一個LinkedHashMap來管理緩存對象。
(1)初始化LruCache
初始化LruCache的慣用代碼如下所示:
1 //獲取當前進程的可用內存(單位KB) 2 int maxMemory = (int) (Runtime.getRuntime().maxMemory() /1024); 3 int memoryCacheSize = maxMemory / 8; 4 mMemoryCache = new LruCache<String, Bitmap>(memoryCacheSize) { 5 @Override 6 protected int sizeOf(String key, Bitmap bitmap) { 7 return bitmap.getByteCount() / 1024; 8 } 9 };
在以上代碼中,我們創建了一個LruCache實例,並指定它的maxSize為當前進程可用內存的1/8。我們使用String作為key,value自然是Bitmap。第6行到第8行我們重寫了sizeOf方法,這個方法被LruCache用來計算一個緩存對象的大小。我們使用了getByteCount方法返回Bitmap對象以字節為單位的大小,又除以了1024,轉換為KB為單位的大小,以達到與cacheSize的單位統一。
(2)獲取緩存對象
LruCache類通過get方法來獲取緩存對象,get方法的源碼如下:
1 public final V get(K key) { 2 if (key == null) { 3 throw new NullPointerException("key == null"); 4 } 5 6 V mapValue; 7 synchronized (this) { 8 mapValue = map.get(key); 9 if (mapValue != null) { 10 hitCount++; 11 return mapValue; 12 } 13 missCount++; 14 } 15 16 /* 17 * Attempt to create a value. This may take a long time, and the map 18 * may be different when create() returns. If a conflicting value was 19 * added to the map while create() was working, we leave that value in 20 * the map and release the created value. 21 */ 22 23 V createdValue = create(key); 24 if (createdValue == null) { 25 return null; 26 } 27 28 synchronized (this) { 29 createCount++; 30 mapValue = map.put(key, createdValue); 31 32 if (mapValue != null) { 33 // There was a conflict so undo that last put 34 map.put(key, mapValue); 35 } else { 36 size += safeSizeOf(key, createdValue); 37 } 38 } 39 40 if (mapValue != null) { 41 entryRemoved(false, key, createdValue, mapValue); 42 return mapValue; 43 } else { 44 trimToSize(maxSize); 45 return createdValue; 46 } 47 }
通過以上代碼我們了解到,首先會嘗試根據key獲取相應value(第8行),若不存在則會調用create方法嘗試新建一個value,並將key-value pair放入到LinkedHashMap中。create方法的默認實現會直接返回null,我們可以重寫這個方法,這樣當key還不存在時,我們可以按照自己的需求根據給定key創建一個value並返回。從get方法的實現我們可以看到,它用synchronized關鍵字作了同步,因此這個方法是線程安全的。實際上,LruCache類對所有可能涉及並發數據訪問的方法都作了同步。
(3)添加緩存對象
在添加緩存對象之前,我們先得確定用什么作為被緩存的Bitmap對象的key,一種很直接的做法便是使用Bitmap的URL作為key,然而由於URL中存在一些特殊字符,所以可能會產生一些問題。基於以上原因,我們可以考慮使用URL的md5值作為key,這能夠很好的保證不同的URL具有不同的key,而且相同的URL具有相同的key。我們自定義一個getKeyFromUrl方法來通過URL獲取key,該方法的代碼如下:
private String getKeyFromUrl(String url) { String key; try { MessageDigest messageDigest = MessageDigest.getInstance("MD5"); messageDigest.update(url.getBytes()); byte[] m = messageDigest.digest(); return getString(m); } catch (NoSuchAlgorithmException e) { key = String.valueOf(url.hashCode()); } return key; } private static String getString(byte[] b){ StringBuffer sb = new StringBuffer(); for(int i = 0; i < b.length; i ++){ sb.append(b[i]); } return sb.toString(); }
得到了key后,我們可以使用put方法向LruCache內部的LinkedHashMap中添加緩存對象,這個方法的源碼如下:
1 public final V put(K key, V value) { 2 if (key == null || value == null) { 3 throw new NullPointerException("key == null || value == null"); 4 } 5 6 V previous; 7 synchronized (this) { 8 putCount++; 9 size += safeSizeOf(key, value); 10 previous = map.put(key, value); 11 if (previous != null) { 12 size -= safeSizeOf(key, previous); 13 } 14 } 15 16 if (previous != null) { 17 entryRemoved(false, key, previous, value); 18 } 19 20 trimToSize(maxSize); 21 return previous; 22 }
從以上代碼我們可以看到這個方法確實也作了同步,它將新的key-value對放入LinkedHashMap后會返回相應key原來對應的value。
(4)刪除緩存對象
我們可以通過remove方法來刪除緩存對象,這個方法的源碼如下:
public final V remove(K key) { if (key == null) { throw new NullPointerException("key == null"); } V previous; synchronized (this) { previous = map.remove(key); if (previous != null) { size -= safeSizeOf(key, previous); } } if (previous != null) { entryRemoved(false, key, previous, null); } return previous; }
這個方法會從LinkedHashMap中移除指定key對應的value並返回這個value,我們可以看到它的內部還調用了entryRemoved方法,如果有需要的話,我們可以重寫entryRemoved方法來做一些資源回收的工作。
2. DiskLruCache的使用
(1)初始化DiskLruCache
通過查看DiskLruCache的源碼我們可以發現,DiskLruCache就存在如下一個私有構造方法:
private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { this.directory = directory; this.appVersion = appVersion; this.journalFile = new File(directory, JOURNAL_FILE); this.journalFileTmp = new File(directory, JOURNAL_FILE_TMP); this.valueCount = valueCount; this.maxSize = maxSize; }
因此我們不能直接調用構造方法來創建DiskLruCache的實例。實際上DiskLruCache為我們提供了open靜態方法來創建一個DiskLruCache實例,我們來看一下這個方法的實現:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) throws IOException { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } if (valueCount <= 0) { throw new IllegalArgumentException("valueCount <= 0"); } // prefer to pick up where we left off DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); if (cache.journalFile.exists()) { try { cache.readJournal(); cache.processJournal(); cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true), IO_BUFFER_SIZE); return cache; } catch (IOException journalIsCorrupt) { // System.logW("DiskLruCache " + directory + " is corrupt: " // + journalIsCorrupt.getMessage() + ", removing"); cache.delete(); } } // create a new empty cache directory.mkdirs(); cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); cache.rebuildJournal(); return cache; }
從以上代碼中我們可以看到,open方法內部調用了DiskLruCache的構造方法,並傳入了我們傳入open方法的4個參數,這4個參數的含義分別如下:
- directory:代表緩存文件在文件系統的存儲路徑;
- appVersion:代表應用版本號,通常設為1即可。需要注意的是,當版本號改變時,該應用的磁盤緩存會被請空。
- valueCount:代表LinkedHashMap中每個節點上的緩存對象數目,通常設為1即可;
- maxSize:代表了緩存的總大小,若緩存對象的總大小超過了maxSize,DiskLruCache會自動刪去最近最少使用的一些緩存對象。
以下代碼展示了初始化DiskLruCache的慣用代碼:
File diskCacheDir= getAppCacheDir(mContext, "images"); if (!diskCacheDir.exists()) { diskCacheDir.mkdirs(); } mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
以上代碼中的getAppCacheDir是我們自定義的用來獲取磁盤緩存目錄的方法,它的定義如下:
public static File getAppCacheDir(Context context, String dirName) { String cacheDirString; if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { cacheDirString = context.getExternalCacheDir().getPath(); } else { cacheDirString = context.getCacheDir().getPath(); } return new File(cacheDirString + File.separator + dirName); }
接下來我們介紹如何添加、獲取和刪除緩存對象。
(2)添加緩存對象
先通過以上介紹的getKeyFromUrl獲取Bitmap對象對應的key,接下來我們就可以把這個Bitmap存入磁盤緩存中了。我們通過Editor來向DiskLruCache添加緩存對象。首先我們要通過edit方法獲取一個Editor對象:
String key = getKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
獲取到Editor對象后,通過調用Editor對象的newOutputStream我們就可以獲取key對應的Bitmap的輸出流,需要注意的是,若我們想通過edit方法獲取的那個緩存對象正在被“編輯”,那么edit方法會返回null。相關的代碼如下:
if (editor != null) { OutputStream outputStream = editor.newOutputStream(0); //參數為索引,由於我們創建時指定一個節點只有一個緩存對象,所以傳入0即可 }
獲取了輸出流后,我們就可以向這個輸出流中寫入圖片數據,成功寫入后調用commit方法即可,若寫入失敗則調用abort方法進行回退。相關的代碼如下:
//getStreamFromUrl為我們自定義的方法,它通過URL獲取輸入流並寫入outputStream,具體實現后文會給出 if (getStreamFromUrl(url, outputStream)) { editor.commit(); } else { //返回false表示寫入outputStream未成功,因此調用abort方法回退整個操作 editor.abort(); } mDiskLruCache.flush(); //將內存中的操作記錄同步到日志文件中
下面我們來看一下getStreamFromUrl方法的實現,這個方法的邏輯很直接,就是創建一個HttpURLConnection,然后獲取InputStream再寫入outputStream,為了提高效率,使用了包裝流。該方法的代碼如下:
public boolean getStreamFromUrl(String urlString, OutputStream outputStream) { HttpURLConnection urlCOnnection = null; BufferedInputStream bis = null; BufferedOutputStream bos = null; try { final URL url = new URL(urlString); urlConnection = (HttpURLConnection) url.openConnection(); bis = new BufferedInputStream(urlConnection.getInputStream(), BUF_SIZE); //BUF_SIZE為使用的緩沖區大小 int byteRead; while ((byteRead = bis.read()) != -1) { bos.write(byteRead); } return true; }catch (IOException e) { e.printStackTrace(); } finally { if (urlConnection != null) { urlConnection.disconnect(); } //HttpUtils為一個自定義工具類 HttpUtils.close(bis); HttpUtils.close(bos); } return false; }
經過以上的步驟,我們已經成功地將圖片寫入了文件系統。
(3)獲取緩存對象
我們使用DiskLruCache的get方法從中獲取緩存對象,這個方法的大致源碼如下:
1 public synchronized Snapshot get(String key) throws IOException { 2 checkNotClosed(); 3 validateKey(key); 4 Entry entry = lruEntries.get(key); 5 if (entry == null) { 6 return null; 7 } 8 9 if (!entry.readable) { 10 return null; 11 } 12 13 /* 14 * Open all streams eagerly to guarantee that we see a single published 15 * snapshot. If we opened streams lazily then the streams could come 16 * from different edits. 17 */ 18 InputStream[] ins = new InputStream[valueCount];19 ... 20 return new Snapshot(key, entry.sequenceNumber, ins); 21 }
我們可以看到,這個方法最終返回了一個Snapshot對象,並以我們要獲取的緩存對象的key作為構造參數之一。Snapshot是DiskLruCache的內部類,它包含一個getInputStream方法,通過這個方法可以獲取相應緩存對象的輸入流,得到了這個輸入流,我們就可以進一步獲取到Bitmap對象了。在獲取緩存的Bitmap時,我們通常都要對它進行一些預處理,主要就是通過設置inSampleSize來適當的縮放圖片,以防止出現OOM。我們之前已經介紹過如何高效加載Bitmap,在那篇文章里我們的圖片來源於Resources。盡管現在我們的圖片來源是流對象,但是計算inSampleSize的方法是一樣的,只不過我們不再使用decodeResource方法而是使用decodeFileDescriptor方法。
相關的代碼如下:
1 Bitmap bitmap = null; 2 String key = getKeyFromUrl(url); 3 DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key); 4 if (snapShot != null) { 5 FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(0); //參數表示索引,同之前的newOutputStream一樣 6 FileDescriptor fileDescriptor = fileInputStream.getFD(); 7 bitmap = decodeSampledBitmapFromFD(fileDescriptor, dstWidth, dstHeight); 8 if (bitmap != null) { 9 addBitmapToMemoryCache(key, bitmap); 10 } 11 }
第7行我們調用了decodeSampledBitmapFromFD來從fileInputStream的文件描述符中解析出Bitmap,decodeSampledBitmapFromFD方法的定義如下:
public Bitmap decodeSampledBitmapFromFD(FileDescriptor fd, int dstWidth, int dstHeight) { final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFileDescriptor(fd, null, options); //calInSampleSize方法的實現請見“Android開發之高效加載Bitmap”這篇博文 options.inSampleSize = calInSampleSize(options, dstWidth, dstHeight); options.inJustDecodeBounds = false; return BitmapFactory.decodeFileDescriptor(fd, null, options); }
第9行我們調用了addBitmapToMemoryCache方法把獲取到的Bitmap加入到內存緩存中,關於這一方法的具體實現下文會進行介紹。
三、圖片加載框架的具體實現
1. 圖片的加載
(1)同步加載
同步加載的相關代碼需要在工作者線程中執行,因為其中涉及到對網絡的訪問,並且可能是耗時操作。同步加載的大致步驟如下:首先嘗試從內存緩存中加載Bitmap,若不存在再從磁盤緩存中加載,若還不存在則從網絡中獲取並添加到磁盤緩存中。同步加載的代碼如下:
public Bitmap loadBitmap(String url, int dstWidth, int dstHeight) { Bitmap bitmap = loadFromMemory(url); if (bitmap != null) { return bitmap; } //內存緩存中不存在相應圖片 try { bitmap = loadFromDisk(url, dstWidth, dstHeight); if (bitmap != null) { return bitmap; } //磁盤緩存中也不存在相應圖片 bitmap = loadFromNet(url, dstWidth, dstHeight); } catch (IOException e) { e.printStackTrace(); } return bitmap; }
loadBitmapFromNet方法的功能是從網絡上獲取指定url的圖片,並根據給定的dstWidth和dstHeight對它進行縮放,返回縮放后的圖片。loadBitmapFromDisk方法則是從磁盤緩存中獲取並縮放,而后返回縮放后的圖片。關於這兩個方法的實現在下面“圖片的緩存”部分我們會具體介紹。下面我們先來看看異步加載圖片的實現。
(2)異步加載
異步加載圖片在實際開發中更經常被使用,通常我們希望圖片加載框架幫我們去加載圖片,我們接着干別的活,等到圖片加載好了,圖片加載框架會負責將它顯示在我們給定的ImageView中。我們可以使用線程池去執行異步加載任務,加載好后通過Handler來更新UI(將圖片顯示在ImageView中)。相關代碼如下所示:
1 public void displayImage(String url, ImageView imageView, int dstWidth, int widthHeight) { 2 imageView.setTag(IMG_URL, url); 3 Bitmap bitmap = loadFromMemory(url); 4 if (bitmap != null) { 5 imageView.setImageBitmap(bitmap); 6 return; 7 } 8 9 Runnable loadBitmapTask = new Runnable() { 10 @Override 11 public void run() { 12 Bitmap bitmap = loadBitmap(url, dstWidth, dstHeigth); 13 if (bitmap != null) { 14 //Result是我們自定義的類,封裝了返回的Bitmap、Bitmap的URL和作為容器的ImageView 15 Result result = new Result(bitmap, url, imageView); 16 //mMainHandler為主線程中創建的Handler 17 Message msg = mMainHandler.obtainMessage(MESSAGE_SEND_RESULT, result); 18 msg.sendToTarget(); 19 } 20 } 21 }; 22 threadPoolExecutor.execute(loadBitmapTask); 23 }
從以上代碼我們可以看到,異步加載與同步加載之間的區別在於,異步加載把耗時任務放入了線程池中執行。同步加載需要我們創建一個線程並在新線程中執行loadBitmap方法,使用異步加載我們只需傳入url、imageView等參數,圖片加載框架負責使用線程池在后台執行圖片加載任務,加載成功后會通過發送消息給主線程來實現把Bitmap顯示在ImageView中。我們來簡單的解釋下obtainMessage這個方法,我們傳入了兩個參數,第一個參數代表消息的what屬性,這時個int值,相當於我們給消息指定的一個標識,來區分不同的消息;第二個參數代表消息的obj屬性,表示我們附帶的一個數據對象,就好比我們發email時帶的附件。obtainMessage用於從內部的消息池中獲取一個消息,就像線程池對線程的復用一樣,通過這個方法獲取消息更加高效。獲取了消息並設置好它的what、obj后,我們在第18行調用sendToTarget方法來發送消息。
下面我們來看看mMainHandler和threadPoolExecutor的創建代碼:
private static final int CORE_POOL_SIZE = CPU_COUNT + 1; //corePoolSize為CPU數加1 private static final int MAX_POOL_SIZE = 2 * CPU_COUNT + 1; //maxPoolSize為2倍的CPU數加1 private static final long KEEP_ALIVE = 5L; //存活時間為5s public static final Executor threadPoolExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); private Handler mMainHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { Result result = (Result) msg.what; ImageView imageView = result.imageView; String url = (String) imageView.getTag(IMG_URL); if (url.equals(result.url)) { imageView.setImageBitmap(result.bitmap); } else { Log.w(TAG, "The url associated with imageView has changed"); } }; };
從以上代碼中我們可以看到創建mMainHandler時使用了主線程的Looper,因此構造mMainHandler的代碼可以放在子線程中執行。另外,注意以上代碼中我們在給imageView設置圖片時首先判斷了下它的url是否等於result中的url,若相等才顯示。我們知道ListView會對其中Item的View進行復用,剛移出屏幕的Item的View會被即將顯示的Item所復用。那么考慮這樣一個場景:剛移出的Item的View中的圖片還在未加載完成,而這個View被新顯示的Item復用時圖片加載好了,那么圖片就會顯示在新Item處,這顯然不是我們想看到的。因此我們通過判斷imageView的url是否與剛加載完的圖片的url是否相等,並在
只有兩者相等時才顯示,就可以避免以上提到的情況。
2. 圖片的緩存
(1)緩存的創建
我們在圖片加載框架類(FreeImageLoader)的構造方法中初始化LruCache和DiskLruCache,相關代碼如下:
private LruCache<String, Bitmap> mMemoryCache; private DiskLruCache mDiskLruCache; private ImageLoader(Context context) { mContext = context.getApplicationContext(); int maxMemory = (int) (Runtime.getRuntime().maxMemory() /1024); int cacheSize = maxMemory / 8; mMemorySize = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeof(String key, Bitmap bitmap) { return bitmap.getByteCount() / 1024; } }; File diskCacheDir = getAppCacheDir(mContext, "images"); if (!diskCacheDir.exists()) { diskCacheDir.mkdirs(); } if (diskCacheDir.getUsableSpace() > DISK_CACHE_SIZE) { //剩余空間大於我們指定的磁盤緩存大小 try { mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE); } catch (IOException e) { e.printStackTrace(); } } }
(2)緩存的獲取與添加
內存緩存的添加與獲取我們已經介紹過,只需調用LruCache的put與get方法,示例代碼如下:
private void addToMemoryCache(String key, Bitmap bitmap) { if (getFromMemoryCache(key) == null) { //不存在時才添加 mMemoryCache.put(key, bitmap); } } private Bitmap getFromMemoryCache(String key) { return mMemoryCache.get(key); }
接下來我們看一下如何從磁盤緩存中獲取Bitmap:
private loadFromDiskCache(String url, int dstWidth, int dstHeight) throws IOException { if (Looper.myLooper() == Looper.getMainLooper()) { //當前運行在主線程,警告 Log.w(TAG, "should not load Bitmap in main thread"); } if (mDiskLruCache == null) { return null; } Bitmap bitmap = null; String key = getKeyFromUrl(url); DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); if (snapshot != null) { FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(0); FileDescriptor fileDescriptor = fileInputStream.getFD(); bitmap = decodeSampledBitmapFromFD(fileDescriptor, dstWidth, dstHeight); if (bitmap != null) { addToMemoryCache(key, bitmap); } } return bitmap; }
把Bitmap添加到磁盤緩存中的工作在loadFromNet方法中完成,當從網絡上成功獲取圖片后,會把它存入磁盤緩存中。相關代碼如下:
private Bitmap loadFromNet(String url, int dstWidth, int dstHeight) throws IOException { if (Looper.myLooper() == Looper.getMainLooper()) { throw new RuntimeException("Do not load Bitmap in main thread."); } if (mDiskLruCache == null) { return null; } String key = getKeyFromUrl(url); DiskLruCache.Editor editor = mDiskLruCache.edit(key); if (editor != null) { OutputStream outputStream = editor.newOutputStream(0); if (getStreamFromUrl(url, outputStream)) { editor.commit(); } else { editor.abort(); } mDiskLruCache.flush(); } return loadFromDiskCache(url, dstWidth, dstHeight); }
以上代碼的大概邏輯是:當確認當前不在主線程並且mDiskLruCache不為空時,從網絡上得到圖片並保存到磁盤緩存,然后從磁盤緩存中得到圖片並返回。
以上貼出的兩段代碼在最開頭都判斷了是否在主線程中,對於loadFromDiskCache方法來說,由於磁盤IO相對耗時,不應該在主線程中運行,所以只會在日志輸出一個警告;而對於loadFromNet方法來說,由於在主線程中訪問網絡是不允許的,因此若發現在主線程,直接拋出一個異常,這樣做可以避免做了一堆准備工作后才發現位於主線程中不能訪問網絡(即我們提早拋出了異常,防止做無用功)。
另外,我們在以上兩段代碼中都對mDiskLruCache是否為空進行了判斷。這也是很必要的,設想我們做了一堆工作后發現磁盤緩存根本還沒有初始化,豈不是白白浪費了時間。我們通過兩個if判斷可以盡量避免做無用功。
現在我們已經實現了一個簡潔的圖片加載框架,下面我們來看看它的實際使用性能如何。
四、簡單的性能測試
關於性能優化的姿勢,Android Developer已經給出了最佳實踐方案,胡凱大神整理了官方的性能優化典范,請見這里:Android性能專題。這里我們主要從內存分配和圖片的平均加載時間這兩個方面來看一下我們的圖片加載框架是否能達到勉強可用的程度。完整的demo請見這里:FreeImageLoader
1. 內存分配情況
運行我們的demo,待圖片加載完全,我們用adb看一下我們的應用的內存分配情況,我這里得到的情況如下圖所示:
從上圖我們可以看到,Dalvik Heap分配的內存為18003KB, Native Heap則分配了6212KB。下面我們來看一下FreeImageLoader平均每張圖片的加載時間。
2. 平均加載時間
這里我們獲取平均加載時間的方法非常直接,基本思想是如以下所示:
//加載圖片前的時間點 long beforeTime = System.currentTimeMillis(); //加載圖片完成的時間點 long afterTime = System.currentTimeMillis(); //total為圖片的總數,averTime為加載每張圖片所需的平均時間 int averTime = (int) ((afterTime - beforeTime) / total)
然后我們維護一個計數值counts,每加載完一張就加1,當counts為total時我們便調用一個回調方法onAfterLoad,在這個方法中獲取當前時間點並計算平均加載時間。具體的代碼請看上面給出的demo地址。
我這里測試加載30張圖片時,平均每張所需時間為1.265s。下面我們來用Universal Image Loader來加載這30張圖片,並與我們的FreeImageLoader比較一下。
3. 與UIL的比較
我這里用UIL加載圖片完成后,得到的內存情況如下:
我們可以看到在,Native Heap的分配上,FreeImageLoader與UIL差不多;在Dalvik Heap分配上,UIL的大小快達到了FreeImageLoader的2倍。由於框架的量級不同,這說明不了FreeImageLoader在內存占用上優於UIL,但通過這個比較我們可以認為我們剛剛實現的框架還是勉強可用的:)
我們再來看一下UIL的平均加載時間,我這里測試的結果是1.516ms,考慮到框架量級的差異,看來我們的框架在加載時間上還有提升空間。
五、更進一步
經過以上的步驟,我們可以看到,實現一個具有基本功能的圖片加載框架並不復雜,但我們可以做的還有更多:
- 現在的異步加載圖片方法需要顯式提供我們期望的圖片大小,一個實用的框架應該能夠根據給定的ImageVIew自動計算;
- 整個框架封裝在一個類中,模塊化方面顯然還可以做的更好;
- 不具備一個成熟的圖片加載框架應該具有的各種功能...
由於個人一直對圖片加載框架充滿濃厚的興趣,日后也會不斷的探索這一方面的相關技術,有新的收獲時會來與大家共同分享:)
六、參考資料
1. Displaying Bitmap Efficiently
2. 《Android開發藝術探索》