Andoird ListView快速加載圖片的終極解決之道

圖片下載應用

從網上下載圖片非常簡單,使用Android framework中提供的HTTP相關類就很容易實現,下面提供了一段樣例代碼:

01 static Bitmap downloadBitmap(String url) {
02   final AndroidHttpClient client = AndroidHttpClient.newInstance("Android");
03   final HttpGet getRequest = new HttpGet(url);
04   try {
05     HttpResponse response = client.execute(getRequest);
06     final int statusCode = response.getStatusLine().getStatusCode();
07     if (statusCode != HttpStatus.SC_OK) {
08       Log.w("ImageDownloader""Error " + statusCode + " while retrieving bitmap from " + url);
09       return null;
10     }
11     final HttpEntity entity = response.getEntity();
12     if (entity != null) {
13       InputStream inputStream = null;
14       try {
15         inputStream = entity.getContent();
16         final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
17         return bitmap;
18       finally {
19         if (inputStream != null) {
20           inputStream.close();
21         }
22         entity.consumeContent();
23       }
24     }
25   catch (Exception e) {
26   
27     // 可以在這裏提供更多更詳細的關於IOException和IllegalStateException的錯誤信息
28   
29     getRequest.abort();
30     Log.w("ImageDownloader""Error while retrieving bitmap from " + url + e.toString());
31   finally {
32     if (client != null) {
33       client.close();
34     }
35   }
36   return null;
37 }
上面的代碼創建了一個發送HTTP請求的客戶端,如果HTTP請求正確返回,將會返回一張圖片的二進制編碼流,通過它可以解碼創建一個Bitmap對象。當然,如果想要上面這段代碼正常運行,必須保證網絡環境正常。(【 譯者注】: 網絡環境包括一個可達的web服務器,你的Android應用要有android.permission.INTERNET權限,另外有一點,自從Android3.0以後,Android強制不允許在Android主線程中做網絡相關操作,否則會拋出NetworkOnMainThreadException異常,想嘗試上面的代碼的讀者要注意這幾點。)
注意:上面這個版本中使用的BitmapFactory.decodeStream 在網絡連接速度慢的情況下可能會導致圖片編碼失敗,這個問題可以使用FlushedInputStream(inputStream)替代BitmapFactory.decodeStream方法來解決。下面是一個實現:(【 譯者注】: 這裏原作者說BitmapFactory.decodeStream當網速慢的時候可能會失敗,這個現象是正確的,原因是因爲InputStream中的skip方法有問題,這個問題google一把,有很多相關內容,下面這個實現就是解決這個問題的。)
01 static class FlushedInputStream extends FilterInputStream {
02   public FlushedInputStream(InputStream inputStream) {
03     super(inputStream);
04   }
05   
06   @Override
07   public long skip(long n) throws IOException {
08     long totalBytesSkipped = 0L;
09     while (totalBytesSkipped < n) {
10       long bytesSkipped = in.skip(n - totalBytesSkipped);
11       if (bytesSkipped == 0L) {
12         int bytes = read();
13         if (bytes < 0) {
14           break;  //讀到文件結束
15         else {
16           bytesSkipped = 1// 讀一個字節
17         }
18       }
19       totalBytesSkipped += bytesSkipped;
20     }
21     return totalBytesSkipped;
22   }
23 }
上面代碼中的skip()方法能略過指定的字節數,除非已經讀到了文件盡頭。如果直接在ListAdapter的getView方法中使用上面的下載圖片的代碼的話,會導致界面一頓一頓的,用戶體驗會很差,每張圖片的顯示都需要等待圖片下載完成,使得界面無法順暢的上下滾動。 (【譯者注】: 如上所注,如果用API level 11以上的(包括11),那會直接拋異常,不會執行。現在的Android強制網絡操作必須在UI線程之外做。)
 這樣看來這的確不是一個好主意,所以AndroidHttpClient不允許在主線程中啓動,上面的代碼會報”This thread forbids HTTP requests” 的錯誤,所以如果你真想嘗試的話,請使用DefaultHttpClient代替。 (【譯者注】: 事實上,我在Android4.0.3上使用DefaultHttpClient也不行。)

 使用異步任務

AsyncTask類提供了一種最爲簡單的方法從UI啓動一個新的任務,我們現在創建一個ImageDownloader類負責創建這些任務,這個類中的download方法可以從給定URL下載圖片並且將該圖片賦給一個ImageView控件。
1 public class ImageDownloader {
2   public void download(String url, ImageView imageView) {
3     BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
4     task.execute(url);
5   }
6 }

 BitmapDownloaderTask類繼承自AsynTask,它提供下載圖片的功能。它的execute方法可以即時返回,因此速度非常快,從UI線程調用的時候就不會感覺到有什麼卡頓的感覺,而這正是我們的目的。下面是BitmapDownloaderTask的實現代碼:

01 class BitmapDownloaderTask extends AsyncTask<String, Void, Bitmap> {
02   private String url;
03   private final WeakReference<ImageView> imageViewReference;
04   public BitmapDownloaderTask(ImageView imageView) {
05     imageViewReference = new WeakReference<ImageView>(imageView);
06   }
07   
08   @Override
09   // 這裏是在下載線程中真正要執行的代碼
10   protected Bitmap doInBackground(String... params) {
11     // 參數params是從execute方法傳遞過來的,params[0]就是要下載的圖片url
12     return downloadBitmap(params[0]);
13   }
14   
15   @Override
16   // 一旦圖片下載完成,就將它在ImageView控件上顯示出來
17   protected void onPostExecute(Bitmap bitmap) {
18     if (isCancelled()) {
19       bitmap = null;
20     }
21     if (imageViewReference != null) {
22       ImageView imageView = imageViewReference.get();
23       if (imageView != null) {
24         imageView.setImageBitmap(bitmap);
25       }
26     }
27   }
28 }
實際上真正在後臺異步運行的是doInBackground方法,在這個方法中只是簡單的調用本文開頭時提供的downloadBitmap方法。
當異步任務執行完成以後,也就是doInBackground方法執行完成之後,onPostExecute方法將被調用,Android會自動將doInBackground方法的返回結果作爲參數傳遞給onPostExecute方法,在這段代碼中,onPostExecute就是簡單的將doInBackground方法下載的圖片與ImageView關聯起來。值得注意的一點是,在這裏的ImageView是通過一個WeakReference關聯的,所以在下載過程沒有結束前,ImageView所在的Activity有可能被結束,ImageView對象被垃圾回收,所以這裏在使用ImageView之前必須要做兩次是否爲null的驗證,一次是WeakReference對象的驗證,一次是ImageView對象的驗證。
這個簡單的例子介紹了AsyncTask的使用,嘗試一下,你就會發現只需要添加很少的代碼就可以讓你的ListView性能有很大的提高,現在它可以平滑的滾動了。這裏有篇文章“ 輕鬆的多線程”,介紹了更多關於AsyncTask的細節。
然而,由於ListView的一個特性使我們當前實現還是有問題。事實上,爲了更高效的使用內存,當用戶滾動ListView的時候,它會重複利用顯示控件。當手指在屏幕上猛的一劃,讓ListView滾動幅度很大的時候,一個ImageView對象會被多次利用(【 譯者注】 :這個應用有點像走馬燈一樣,用有限的幾個ImageView顯示很多圖片,因此可能一個ImageView會用來顯示多個圖片),每次顯示該對象時都會觸發下載圖片任務,然後更新自己顯示的圖片。那麼問題出在哪裏呢?正如大多數多線程應用一樣,最關鍵的問題就是順序問題。在我們現在這個應用場景中,根本沒有保證先開始的下載任務會先結束,導致的結果當前顯示的圖片有可能是上一次下載任務的圖片。這種情況一般在下載時間較長的時候會發生。實際上,如果你下載的圖片只顯示一次,並且都在ListView中的ImageView對象上顯示的話,這就不算什麼問題(【 譯者注】: 也不一定,比如看連環畫的時候,圖片順序非常重要),不過我們爲了適應更普遍的需求還是把這個問題給修好。( 【譯者注】: 實際上,如果你是依次新建AsyncTask同時下載圖片,而且都是用execute()方法觸發的話,如果你用的是Android1.6~Android3.0之間的版本的話那會出現原作者說的這個問題,否則你會發現他還真的是先開始的任務先結束。而且只有當第一個任務結束後,下一個任務纔會開始。因爲在Android1.6之前,Android的所有AsyncTask都會在除了UI線程之外的一個線程中運行,Android1.6開始,會爲每個AsyncTask新建一個線程,後來可能是爲了防止無止境的濫用線程或者一些其他的問題,從Android3.0開始,所有的AsyncTask又只在一個線程中運行,如果想要多個AsyncTask同時運行,需要自己建一個Excutor,然後用AsyncTask的executeOnExecutor方法執行。)

處理併發問題

爲了解決這個問題,我們需要記下每個下載任務的順序,確保最後一個開始下載的圖片是最終顯示的圖片。這樣的話就需要每一個ImageView對象都記得它最後一次下載的是哪張圖片。我們現在就用一個包裝過的Drawable子類來保存這一信息,然後在下載過程中將這個Drawable子類對象與ImageView控件綁定,下面給出DowloadedDrawable類的實現代碼:

01 static class DownloadedDrawable extends ColorDrawable {
02   private final WeakReference<BitmapDownloaderTask> bitmapDownloaderTaskReference;
03   public DownloadedDrawable(BitmapDownloaderTask bitmapDownloaderTask) {
04     super(Color.BLACK);
05     bitmapDownloaderTaskReference = new WeakReference<BitmapDownloaderTask>(
06     bitmapDownloaderTask);
07   }
08   
09   public BitmapDownloaderTask getBitmapDownloaderTask() {
10     return bitmapDownloaderTaskReference.get();
11   }
12 }
此方法是通過繼承ColorDrawable類實現的,效果是在下載過程中,ImageView控件會顯示一個黑色背景,當然你也可以使用一個進度條替代單純的黑色背景從而給用戶下載進度的反饋信息。另外,注意要使用WeakRefernece來減弱對象之間的依賴關係。
下面我們改進之前的ImageDownloader類的download方法,首先先創建一個DownloadedDrawable的實例,並且賦給ImageView,代碼如下:
1 public void download(String url, ImageView imageView) {
2   if (cancelPotentialDownload(url, imageView)) {
3     BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
4     DownloadedDrawable downloadedDrawable = new DownloadedDrawable(task);
5     imageView.setImageDrawable(downloadedDrawable);
6     task.execute(url, cookie);
7   }
8 }
再添加新的方法cancelPotentialDownload方法,如果一個新的下載任務開始時,發現上一個下載任務還沒有結束的話,就通過該方法結束上一個下載任務。但是請注意,即使這麼做也不能保證最新下載的圖片一定會被最終顯示出來,因爲每個任務結束後會執行它的onPostExecute方法,而這個方法有可能在最新的任務完成後再被執行。 
01 private static boolean cancelPotentialDownload(String url, ImageView imageView) {
02   BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView);
03   if (bitmapDownloaderTask != null) {
04     String bitmapUrl = bitmapDownloaderTask.url;
05     if ((bitmapUrl == null) || (!bitmapUrl.equals(url))) {
06       bitmapDownloaderTask.cancel(true);
07     else {
08       // 相同的URL已經在下載過程中了,因此不取消,因爲不管是先下載還是後下載,反正下載的都是同一個文件
09       return false;
10     }
11   }
12   return true;
13 }
cancelPotentialDownload使用AsyncTask類的cancel方法來終止一個還沒有結束的下載任務。大多數時候它會返回true,因此在download方法中可以新啓動一個下載任務。但是如果相同的URL已經在下載過程中,那我們就沒有必要取消該任務再新建一個任務來下載該URL的圖片了。還需要注意一點,如果下載任務對應的ImageView對象已經被垃圾回收了,而該任務還沒有結束,這種情況該怎麼辦?這時可以使用RecyclerListener來處理這種情況。
如果要使用RecyclerListener的話,我們先要使用一個輔助函數,getBitMapDownloaderTask,代碼如下:
01 private static BitmapDownloaderTask getBitmapDownloaderTask(ImageView imageView) {
02   if (imageView != null) {
03     Drawable drawable = imageView.getDrawable();
04     if (drawable instanceof DownloadedDrawable) {
05       DownloadedDrawable downloadedDrawable = (DownloadedDrawable)drawable;
06       return downloadedDrawable.getBitmapDownloaderTask();
07     }
08   }
09   return null;
10 }
然後,在下載任務的onPostExecute方法中,先判斷自己對應的ImageView還是不是依然與本任務相對應,只有當它們依然對應時,纔會將下載下來的圖片在對應的ImageView控件上顯示出來:
1 if(imageViewReference != null) {
2   ImageView imageView = imageViewReference.get();
3   BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView);
4   // Change bitmap only if this process is still associated with it
5   if (this == bitmapDownloaderTask) {
6     imageView.setImageBitmap(bitmap);
7   }
8 }
經過這些修改,我們的ImageDownloader類提供了我們期望的最基本功能,普遍適應大多數情況,可以在你的應用中直接使用該類或者異步模式用來確保你的應用可以即時響應。

Demo

本文的源代碼可以在Google Code上下載,點擊 這裏。你可以比較一下本文中描述的三種實現(不使用異步任務的,不將下載任務和ImageView綁定的,還有最終的版本)。請注意爲了更好的演示效果,我們只緩存10張圖片。(【 譯者注】: 這裏原作者說的緩存在前面的文章中沒有提到,在他的代碼中每次下載圖片前都先嚐試從緩存中獲取該URL對應的圖片,如果這張圖片已經被下載過了就不再新建下載任務而直接使用緩存中的圖片,其實用適當的緩存來減少消耗大的操作也是個相當好的經驗)。
下一步工作
本文的代碼只注重了並行方面的需求,爲此簡化了不少,從而很多有用的功能都沒有包含在內。ImageDownloader類首先應該使用緩存,特別當它與ListView配合使用的時候,因爲這種情況下屏幕上下來回滾動,一張圖片可能會被顯示多次。這時可以使用LinkedHashMap來實現一個緩存,用來存儲最近使用過的圖片,緩存SoftRefernces中以URL爲Key,以BitMap爲value。緩存的大小可以根據本地存儲的大小來決定(【 譯者注】 :不一定都要存在內存中,從本地sdcard中讀取圖片速度也會被從網上下載快得多,因此緩存在sdcard上也是個不錯的選擇)。如果需要的話,還可以添加圖片預覽和圖片縮放的功能。
在我們的實現中,當下載錯誤或者超時的時候,會返回null,在這裏可以考慮使用一張提示錯誤的圖片替代null。 
我們發送HTTP請求的代碼非常簡單,我們可以考慮爲了某些特定的網站添加一些必須的參數或者cookie。(【 譯者注】:比如說有些網站禁止外來訪問,可以添加HTTP協議中的referer或者某些網站需要驗證才能下載的等等)。 
本文使用的AsyncTask類是可以非常簡單方便的從UI線程中觸發異步任務。你也可以考慮使用Handler類來對你想做的事情做更好的控制,比如說控制下載線程數的總量等等。(  【譯者注】: 現在的AsyncTask配合Executor使用完全可以做到控制總線程數的功能,並且如果在非UI線程中更新UI會導致“android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views”這樣的異常,因此個人建議儘量使用AsyncTask比較好。)

發佈了31 篇原創文章 · 獲贊 4 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章