ListView 之非同步載入圖片亂序
文章內容摘取自
Android ListView非同步載入圖片亂序問題,原因分析及解決方案
場景描述
使用 ListView 非同步載入圖片的具體程式碼沒有貼出,但程式的思路就是在 ListView 的 getView()
方法中,開非同步請求(BitmapWorkerTask
),從網路上獲取圖片,當圖片獲取成功後就將圖片顯示到 ImageView 上。
效果,當我們滑動 ListView 時,圖片會自動變來變去,而且圖片顯示的位置也不正確,簡直快亂成一鍋粥了。
原因分析
理解內部原理,很多之前難以解釋的問題就變得有理有據了。
ListView 之所以能夠實現載入成百上千條資料都不會 OOM,最主要在於它內部優秀的實現機制。ListView 在藉助 RecycleBin 機制的幫助下,實現了一個生產者和消費者的模式,不管有任意多條資料需要顯示,ListView 中的子 View 其實來來回回就那麼幾個,移出螢幕的子 View 會很快被移入螢幕的資料重新利用起來,原理示意圖如下所示:
那麼思考一下,目前資料來源就是很多圖片的 URL 地址,而根據 ListView 的工作原理,顯然不可能為每張圖片都單獨分配一個 ImageView 控制元件,ImageView 控制元件的個數其實就比一螢幕顯示的圖片數量稍微多一點而已,移出螢幕的 ImageView 控制元件會進入到 RecycleBin 當中,而新進入螢幕的元素則會從 RecycleBin 中獲取 ImageView 控制元件。
每當有新的元素進入介面時就會回撥 getView() 方法,而在 getView() 方法中會開啟非同步請求從網路上獲取圖片,注意網路操作都是比較耗時的,也就是說當我們快速滑動 ListView 的時候就很有可能出現這樣一種情況,某一個位置上的元素進入螢幕後開始從網路上請求圖片,但是還沒有等到圖片下載完成,它就又被移出了螢幕。這種情況下會產生什麼樣的現象呢?根據 ListView 的工作原理,就會將剛才位置上的圖片顯示到當前的位置上,因為雖然它們位置不同,但都是共用的同一個 ImageView 例項,這樣就出現了圖片亂序的情況。
繼續,新進入螢幕的元素它也會發起一條網路請求來回去當前位置的圖片,等到圖片下載完的時候會設定到同樣的 ImageView 上面,因此就會出現先顯示一張圖片,然後又變成了另外一張圖片的情況,那麼我們剛才看到的圖片會自動變來變去的情況也就得到了解釋。
解決方案
ListView 非同步載入圖片的問題沒有標準的解決方案,很多人都有自己的一套解決思路,下面說明三種比較經典的解決方法,每學習一種思路,水平就能夠更進一步的提高。
方案一 使用 findViewWithTag
操作
public class ImageAdapter extends ArrayAdapter<String> {
private ListView mListView;
......
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (mListView == null) {
mListView = (ListView) parent;
}
String url = getItem(position);
View view;
if (convertView == null) {
view = LayoutInflater.from(getContext()).inflate(R.layout.image_item, null);
} else {
view = convertView;
}
ImageView image = (ImageView) view.findViewById(R.id.image);
image.setImageResource(R.drawable.empty_photo);
image.setTag(url);
BitmapDrawable drawable = getBitmapFromMemoryCache(url);
if (drawable != null) {
image.setImageDrawable(drawable);
} else {
BitmapWorkerTask task = new BitmapWorkerTask();
task.execute(url);
}
return view;
}
......
/**
* 非同步下載圖片的任務。
*
* @author guolin
*/
class BitmapWorkerTask extends AsyncTask<String, Void, BitmapDrawable> {
String imageUrl;
@Override
protected BitmapDrawable doInBackground(String... params) {
imageUrl = params[0];
// 在後臺開始下載圖片
Bitmap bitmap = downloadBitmap(imageUrl);
BitmapDrawable drawable = new BitmapDrawable(getContext().getResources(), bitmap);
addBitmapToMemoryCache(imageUrl, drawable);
return drawable;
}
@Override
protected void onPostExecute(BitmapDrawable drawable) {
ImageView imageView = (ImageView) mListView.findViewWithTag(imageUrl);
if (imageView != null && drawable != null) {
imageView.setImageDrawable(drawable);
}
}
......
}
}
由於使用 findViewWithTag
必須要有 ListView 的例項,那麼我們怎麼才能拿到 ListView 的例項呢?getView() 方法中傳入的第三個引數 ViewGroup parent
就是 ListView 的例項(ListView 本身並不負責繪製,而是由 ListView 當中的子元素來進行繪製的。而每一條 Item 都是通過 getView() 方法來完成的)。那麼這裡我們定義一個全域性變數 mListView
,然後在 getView() 方法中判斷它是否為空,如果為空就把 parent 這個引數賦值給它。
另外在 getView() 方法中我們還做了一個操作,就是呼叫了 ImageView 的 setTag()
方法,並把當前位置圖片的 URL 地址作為引數傳了進去,這個是為後續的 findViewWithTag()
方法做準備。
最後,在 BitmapWorkerTask 中,不再通過建構函式把 ImageView 的例項傳進去,而是在 onPostExecute() 方法當中通過 ListView 的 findViewWithTag() 方法來獲取 ImageView 控制元件的例項。獲取到控制元件例項後判斷下是否為空,如果不為空就讓圖片顯示在控制元件上。
說明
findViewWithTag()
方法就是通過 Tag 的名字來獲取具備該 Tag 名的控制元件,我們先要呼叫控制元件的 setTag()
方法來給控制元件設定一個 Tag,然後再呼叫 ListView 的 findViewWithTag() 方法使用相同的 Tag 名來找回控制元件。
為什麼用了 findViewWithTag() 方法之後,圖片就不會再出現亂序情況了呢?
由於 ListView 中的 ImageView 控制元件都是重用的,移出螢幕的控制元件很快會被進入螢幕的圖片重新利用起來,那麼 getView() 方法就會再次得到執行,而在 getView() 方法中會為這個 ImageView 控制元件設定新的 Tag,這樣老的 Tag 就會被覆蓋掉,於是這時再呼叫 findViewWithTag() 方法並傳入老的 Tag,就只能得到 null 了,而我們判斷只有 ImageView 不等於 null 的時候才會設定圖片,這樣圖片亂序的問題就不存在了。
方案二 使用弱引用關聯
弱引用只是輔助手段,最主要的還是關聯。這種解決方案的本質是要讓 ImageView 和 BitmapWorkerTask 之間建立一個雙向關聯,互相持有對方的引用,再通過適當的邏輯判斷來解決圖片亂序問題,然後為了防止出現記憶體洩漏的情況,雙方關聯要使用弱引用的方式建立。
相比於第一種解決方案,第二種解決方案要明顯複雜不少,但在效能和效率方面都會有更好的表現。
public class ImageAdapter extends ArrayAdapter<String> {
private ListView mListView;
private Bitmap mLoadingBitmap;
/**
* 圖片快取技術的核心類,用於快取所有下載好的圖片,在程式記憶體達到設定值時會將最少最近使用的圖片移除掉。
*/
private LruCache<String, BitmapDrawable> mMemoryCache;
public ImageAdapter(Context context, int resource, String[] objects) {
super(context, resource, objects);
mLoadingBitmap = BitmapFactory.decodeResource(context.getResources(),
R.drawable.empty_photo);
// 獲取應用程式最大可用記憶體
int maxMemory = (int) Runtime.getRuntime().maxMemory();
int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache<String, BitmapDrawable>(cacheSize) {
@Override
protected int sizeOf(String key, BitmapDrawable drawable) {
return drawable.getBitmap().getByteCount();
}
};
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (mListView == null) {
mListView = (ListView) parent;
}
String url = getItem(position);
View view;
if (convertView == null) {
view = LayoutInflater.from(getContext()).inflate(R.layout.image_item, null);
} else {
view = convertView;
}
ImageView image = (ImageView) view.findViewById(R.id.image);
BitmapDrawable drawable = getBitmapFromMemoryCache(url);
if (drawable != null) {
image.setImageDrawable(drawable);
} else if (cancelPotentialWork(url, image)) {
BitmapWorkerTask task = new BitmapWorkerTask(image);
AsyncDrawable asyncDrawable = new AsyncDrawable(getContext()
.getResources(), mLoadingBitmap, task);
image.setImageDrawable(asyncDrawable);
task.execute(url);
}
return view;
}
/**
* 自定義的一個Drawable,讓這個Drawable持有BitmapWorkerTask的弱引用。
*/
class AsyncDrawable extends BitmapDrawable {
private WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;
public AsyncDrawable(Resources res, Bitmap bitmap,
BitmapWorkerTask bitmapWorkerTask) {
super(res, bitmap);
bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(
bitmapWorkerTask);
}
public BitmapWorkerTask getBitmapWorkerTask() {
return bitmapWorkerTaskReference.get();
}
}
/**
* 獲取傳入的ImageView它所對應的BitmapWorkerTask。
*/
private BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
if (imageView != null) {
Drawable drawable = imageView.getDrawable();
if (drawable instanceof AsyncDrawable) {
AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
return asyncDrawable.getBitmapWorkerTask();
}
}
return null;
}
/**
* 取消掉後臺的潛在任務,當認為當前ImageView存在著一個另外圖片請求任務時
* ,則把它取消掉並返回true,否則返回false。
*/
public boolean cancelPotentialWork(String url, ImageView imageView) {
BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (bitmapWorkerTask != null) {
String imageUrl = bitmapWorkerTask.imageUrl;
if (imageUrl == null || !imageUrl.equals(url)) {
bitmapWorkerTask.cancel(true);
} else {
return false;
}
}
return true;
}
/**
* 將一張圖片儲存到LruCache中。
*
* @param key
* LruCache的鍵,這裡傳入圖片的URL地址。
* @param drawable
* LruCache的值,這裡傳入從網路上下載的BitmapDrawable物件。
*/
public void addBitmapToMemoryCache(String key, BitmapDrawable drawable) {
if (getBitmapFromMemoryCache(key) == null) {
mMemoryCache.put(key, drawable);
}
}
/**
* 從LruCache中獲取一張圖片,如果不存在就返回null。
*
* @param key
* LruCache的鍵,這裡傳入圖片的URL地址。
* @return 對應傳入鍵的BitmapDrawable物件,或者null。
*/
public BitmapDrawable getBitmapFromMemoryCache(String key) {
return mMemoryCache.get(key);
}
/**
* 非同步下載圖片的任務。
*
* @author guolin
*/
class BitmapWorkerTask extends AsyncTask<String, Void, BitmapDrawable> {
String imageUrl;
private WeakReference<ImageView> imageViewReference;
public BitmapWorkerTask(ImageView imageView) {
imageViewReference = new WeakReference<ImageView>(imageView);
}
@Override
protected BitmapDrawable doInBackground(String... params) {
imageUrl = params[0];
// 在後臺開始下載圖片
Bitmap bitmap = downloadBitmap(imageUrl);
BitmapDrawable drawable = new BitmapDrawable(getContext().getResources(), bitmap);
addBitmapToMemoryCache(imageUrl, drawable);
return drawable;
}
@Override
protected void onPostExecute(BitmapDrawable drawable) {
ImageView imageView = getAttachedImageView();
if (imageView != null && drawable != null) {
imageView.setImageDrawable(drawable);
}
}
/**
* 獲取當前BitmapWorkerTask所關聯的ImageView。
*/
private ImageView getAttachedImageView() {
ImageView imageView = imageViewReference.get();
BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (this == bitmapWorkerTask) {
return imageView;
}
return null;
}
/**
* 建立HTTP請求,並獲取Bitmap物件。
*
* @param imageUrl
* 圖片的URL地址
* @return 解析後的Bitmap物件
*/
private Bitmap downloadBitmap(String imageUrl) {
Bitmap bitmap = null;
HttpURLConnection con = null;
try {
URL url = new URL(imageUrl);
con = (HttpURLConnection) url.openConnection();
con.setConnectTimeout(5 * 1000);
con.setReadTimeout(10 * 1000);
bitmap = BitmapFactory.decodeStream(con.getInputStream());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (con != null) {
con.disconnect();
}
}
return bitmap;
}
}
}
ImageView 和 BitmapWorkerTask 之間要建立一個雙向的弱引用關聯,就是 ImageView 中可以獲取到它所對應的 BitmapWorkerTask,同時 BitmapWorkerTask 也可以獲取到它所對應的 ImageView。
BitmapWorkerTask 指向 ImageView 的弱引用關聯,就是在 BitmapWorkerTask 中加入一個建構函式,並在建構函式中要求傳入 ImageView 這個引數。不過我們不再直接持有 ImageView 的引用,而是使用 WeakReference
對 ImageView 進行一層包裝。
但是我們很難將 BitmapWorkerTask 的一個弱引用直接設定到 ImageView 當中。這該怎麼辦?
這裡使用了一個巧妙的方法,就是藉助自定義 Drawable 的方式來實現。可以看到,我們自定義了一個 AsyncDrawable
類並讓它繼承自 BitmapDrawable,然後重寫了 AsyncDrawable 的建構函式,在建構函式中要求把 BitmapWorkerTask 傳入,然後在這裡給它包裝一層弱引用。那麼現在 AsyncDrawable 指向 BitmapWorkerTask 的關聯已經有了。但是 ImageView 指向 BitmapWorkerTask 的關聯還不存在,怎麼辦呢?
讓 ImageView 和 AsyncDrawable 再關聯一下就可以了。可以看到,在 getView() 方法當中,我們呼叫 ImageView 的 setImageDrawable()
方法把 AsyncDrawable 設定進去,那麼 ImageView 就可以通過 getDrawable() 方法獲取到和它關聯的 AsyncDrawable,然後再借助 AsyncDrawable 就可以獲取到 BitmapWorkerTask 了。這樣 ImageView 指向 BitmapWorkerTask 的弱引用關聯也成功建立。
雙向弱引用的關聯已經建立好了,接下來就是邏輯判斷的工作了。怎麼通過邏輯判斷來避免圖片出現亂序的情況呢?
這裡引入了兩個方法,一個是 getBitmapWorkerTask()
方法,該方法可以根據傳入的 ImageView 來獲取到它對應的 BitmapWorkerTask,內部的邏輯就是先獲得 ImageView 對應的 AsyncDrawable,再獲取 AsyncDrawable 對應的 BitmapWorkerTask。
另一個是 getAttachedImageView()
方法,這個方法會獲取當前 BitmapWorkerTask 所關聯的 ImageView,然後呼叫 getBitmapWorkerTask()
方法來獲取該 ImageView 所對應的 BitmapWorkerTask,最後判斷,如果獲取到的 BitmapWorkerTask 等於 this,也就是當前的 BitmapWorkerTask,那麼就將 ImageView 返回,否則返回 null。最後在 onPostExecute() 方法當中,只需要使用 getAttachedImageView() 方法獲取到的 ImageView 來顯示圖片就可以了。
getAttachedImageView() 方法中,使用當前 BitmapWorkerTask 所關聯的 ImageView 來反向獲取這個 ImageView 所關聯的 BitmapWorkerTask,然後用這兩個 BitmapWorkerTask 做對比,如果發現是同一個 BitmapWorkerTask 才會返回 ImageView,否則就返回 null。那麼什麼情況下這兩個 BitmapWorkerTask 才會不同呢?比如說某個圖片被移出了螢幕,它的 ImageView 被另外一個新進入螢幕的圖片重用了,那麼就會給這個 ImageView 關聯一個新的 BitmapWorkerTask,這種情況下,上一個 BitmapWorkerTask 和新的 BitmapWorkerTask 肯定就不相等了,這時 getAttachedImageView() 方法會返回 null,而我們又判斷 ImageView 等於 null 的話是不會設定圖片的,因此就不會出現圖片亂序的情況了。
除此之外另外一個非常值得注意的方法是 cancelPotentialWork()
,這個方法可以大大提高整個 ListView 圖片載入的工作效率。這個方法接收兩個引數,一個圖片的 url,一個 ImageView。檢視其內部邏輯,它也是呼叫了 getBitmapWorkerTask() 方法來獲取傳入的 ImageView 所對應的 BitmapWorkerTask,接下來拿 BitmapWorkerTask 中的 imageUrl 和傳入的 url 做比較,如果兩個 url 不等的話就呼叫 BitmapWorkerTask 的 cancel() 方法,然後返回 true,如果兩個 url 相等的話就返回 false。
兩個 url 做對比時,如果發現是相同的,說明請求的是同一張圖片,那麼直接返回 false,這樣就不會啟動 BitmapWorkerTask 來請求圖片,而如果兩個 url 不相同,說明這個 ImageView 被另一張圖片重新利用了,這個時候就呼叫 BitmapWorkerTask 的 cancel() 方法把之前的請求取消掉,然後重新啟動 BitmapWorkerTask 來請求新圖片。有了這個操作保護之後,就可以把一些已經移出螢幕的無效的圖片請求過濾掉,從而整體提升 ListView 載入圖片的工作效率。
方案三 使用 NetworkImageView
NetworkImageView
是 Volley 當中提供的控制元件。操作很簡單,我們只需要把 Imageview 替換成 NetworkImageView ,然後在 ImageAdapter 作相應的修改即可。
我們不需要自己再去寫一個 BitmapWorkerTask 來處理圖片的下載和顯示,也不需要自己再去管理 LruCache 的邏輯,一切 NetworkImageView 都幫我們做好了。不需要額外的邏輯,也根本不會出現圖片亂序的情況。
NetworkImageView 中開始載入圖片的程式碼是 setImageUrl()
方法,原始碼如下:
/**
* @param url The URL that should be loaded into this ImageView.
* @param imageLoader ImageLoader that will be used to make the request.
*/
public void setImageUrl(String url, ImageLoader imageLoader) {
mUrl = url;
mImageLoader = imageLoader;
// The URL has potentially changed. See if we need to load it.
loadImageIfNecessary(false);
}
具體載入邏輯在 loadImageIfNecessary()
方法中
/**
* Loads the image for the view if it isn't already loaded.
* @param isInLayoutPass True if this was invoked from a layout pass, false otherwise.
*/
private void loadImageIfNecessary(final boolean isInLayoutPass) {
int width = getWidth();
int height = getHeight();
boolean isFullyWrapContent = getLayoutParams() != null
&& getLayoutParams().height == LayoutParams.WRAP_CONTENT
&& getLayoutParams().width == LayoutParams.WRAP_CONTENT;
// if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content
// view, hold off on loading the image.
if (width == 0 && height == 0 && !isFullyWrapContent) {
return;
}
// if the URL to be loaded in this view is empty, cancel any old requests and clear the
// currently loaded image.
if (TextUtils.isEmpty(mUrl)) {
if (mImageContainer != null) {
mImageContainer.cancelRequest();
mImageContainer = null;
}
setDefaultImageOrNull();
return;
}
// if there was an old request in this view, check if it needs to be canceled.
if (mImageContainer != null && mImageContainer.getRequestUrl() != null) {
if (mImageContainer.getRequestUrl().equals(mUrl)) {
// if the request is from the same URL, return.
return;
} else {
// if there is a pre-existing request, cancel it if it's fetching a different URL.
mImageContainer.cancelRequest();
setDefaultImageOrNull();
}
}
// The pre-existing content of this view didn't match the current URL. Load the new image
// from the network.
ImageContainer newContainer = mImageLoader.get(mUrl,
new ImageListener() {
@Override
public void onErrorResponse(VolleyError error) {
if (mErrorImageId != 0) {
setImageResource(mErrorImageId);
}
}
@Override
public void onResponse(final ImageContainer response, boolean isImmediate) {
// If this was an immediate response that was delivered inside of a layout
// pass do not set the image immediately as it will trigger a requestLayout
// inside of a layout. Instead, defer setting the image by posting back to
// the main thread.
if (isImmediate && isInLayoutPass) {
post(new Runnable() {
@Override
public void run() {
onResponse(response, false);
}
});
return;
}
if (response.getBitmap() != null) {
setImageBitmap(response.getBitmap());
} else if (mDefaultImageId != 0) {
setImageResource(mDefaultImageId);
}
}
});
// update the ImageContainer to be the new bitmap container.
mImageContainer = newContainer;
}
ImageLoader
的 get() 方法用來請求圖片,返回一個 ImageContainer
物件,該物件封裝了圖片請求地址、Bitmap 等資料,每個 NetworkImageView 中都會對應一個 ImageContainer。
從 ImageContainer 物件中獲取封裝的圖片請求地址,並拿來和當前的請求地址做對比,如果相同的話說明這是一條重複的請求,就直接 return 掉,如果不同的話就呼叫 cancelRequest()
方法將請求取消掉,然後將圖片設定為預設圖片並重新發起請求。
NetworkImageView 解決圖片亂序的核心邏輯就是如果該控制元件已經被移出了螢幕且被重新利用了,就把之前的請求取消掉。
但是,通暢情況下,java 執行緒無法保證一定可以中斷,即使像第二種解決方案裡使用 BitmapWorkerTask 的 cancel() 方法也不能保證一定可以把請求取消掉,所以還需要使用弱引用關聯的處理方式。而在 NetworkImageView 中,僅僅呼叫 cancelRequest() 方法把請求取消掉姐可以了,這主要得益於 Volley 的出色設計,它保證只要是取消掉的請求,就絕對不會進行回撥,既然不會回撥,那麼也就不會回到 NetworkImageView 當中,自然就不會出現亂序的情況。
需要注意的是,Volley 只是保證取消掉的請求不會進行回撥而已,但並沒有說可以中斷請求。由此可見即使是 Volley 也無法做到中斷一個正在執行的執行緒,如果有一個執行緒正在執行,Volley 只會保證在它執行完之後不會進行回撥,但在呼叫者看來,就好像是這個請求就被取消掉了一樣。
文章只是作為個人記錄學習使用,如有不妥之處請指正,謝謝。
文章內容摘取自
相關文章
- Swift多執行緒之Operation:非同步載入CollectionView圖片Swift執行緒非同步View
- Android 基礎之圖片載入(二)Android
- 前端優化之圖片懶載入前端優化
- Flutter 圖片載入Flutter
- 圖片懶載入
- 圖片載入事件事件
- 預載入圖片
- 圖片預載入和懶載入
- 影像延遲載入 && 列表圖順序載入
- (課程學習)Android必學-非同步載入 —— 監聽 ListViewAndroid非同步View
- Vue圖片懶載入之lazyload外掛使用Vue
- 載入本地圖片模糊,Glide載入網路圖片卻很清晰地圖IDE
- TestFlight下載App,載入圖片失效。Xcode安裝App,圖片載入正常。APPXCode
- Android 圖片載入庫Glide知其然知其所以然之載入AndroidIDE
- 載入遠端圖片
- 圖片懶載入原理
- Android 圖片載入框架Android框架
- 圖片懶載入(IntersectionObserver)Server
- glide圖片載入原理IDE
- 圖片預載入,圖片懶載入,和jsonp中的一個疑問JSON
- 單張圖片懶載入
- Flutter載入圖片與GlideFlutterIDE
- 圖片懶載入實現
- 圖片懶載入踩坑
- Android 高效安全載入圖片Android
- 解耦圖片載入庫解耦
- 圖片懶載入大白話
- 通用圖片載入元件UniversalImageLoader元件
- Js圖片懶載入(lazyload)JS
- ListView動態載入資料View
- 類script標籤,非同步載入,順序執行非同步
- 頁面圖片預載入與懶載入策略
- 滾動載入圖片(懶載入)實現原理
- IIS網站圖片不能載入網站
- Vue中圖片的載入方式Vue
- 圖片懶載入js實現JS
- ReactNative載入base64圖片React
- Flutter 例項 - 載入更多的ListViewFlutterView