作者:北國雪WRG
連結:https://www.jianshu.com/p/f77b9f68265d
以下內容完全是探索性的嘗試,載入大量照片請用Glide或者Picasso
背景
我在搗鼓一個圖片上傳App,我需要上傳手機上的照片,首先要把照片顯示出來,類似於微信傳送朋友圈選取照片的場景。假說我用一個RecyclerView去顯示所有的照片(1000張)。在不適用Glide的情況下,如何盡可能好的去載入這些照片。
載入一張照片可以直接
imageView.setImageBitmap(BitmapUtil.decodeBitmapFromFile(path, size, size))
沒問題,但如果載入滿滿一個RecyclerView的照片,那就很容易導致NAR。
以下是此次嘗試,學到的知識點:
-
載入一張照片到記憶體,不是很耗時。但是當照片很多時候,這個累積的耗時就不能被忽略了,直接在onBindViewHolder中載入,會阻塞UI執行緒。怎麼辦?
-
Java實現了四種執行緒池,Fixed,Cache,Schedule和Single,其中Cache給的介紹是適合大量耗時短的操作,這裡Cache執行緒池真的適合嗎?
-
RecyclerView一共要載入上千張照片,每次顯示ViewHolder就去載入有什麼問題?
-
計算取樣率,要獲取ImageView的width和height,但是在onCreate,onStart,onResume中都無法獲取ImageView尺寸,怎麼辦?
-
當使用者快速滑動的時候,如果試圖去載入照片。可以想象以下場景,如果使用者在10s內快速滑動到了第1000張照片,那麼第1000張照片被載入出來的前提是載入完成前999張照片。這顯然是很糟糕的。怎麼解決呢?
先看看效果圖
效果圖.gif
哈哈,是不是感覺整體效果還不錯。因為展示的都是照(害)片(羞),所有沒有擷取太長的影片。
上面5個問題,下麵來各個擊破:
Q1:載入一張照片到記憶體耗,不是很耗時。但是當照片很多時候,這個耗時就不能被忽略了,直接在onBindViewHolder中載入,會阻塞UI執行緒。怎麼辦?
我最開始就是這麼做的,整個應用直接GG。太耗時了,應用直接NAR掛掉。這裡使用執行緒池,當BindViewholder被執行的時候,把載入照片的任務交給執行緒池。
@Override
public void onBindViewHolder(final IvHolder ivHolder, int i) {
...// 省略部分程式碼
cacheBitmap(imageView, i, path, width);// 載入圖片
}
public void cacheBitmap(final ImageView imageView, int index, final String path, final int size) {
executor.execute(() -> {
Bitmap bitmap = BitmapUtil.decodeBitmapFromFile(path, size, size);
... //省略部分程式碼
}
Q2 :Java實現了四種執行緒池,Fixed,Cache,Schedule和Single,其中Cache給的介紹是適合大量耗時短的操作,這裡Cache執行緒池真的適合嗎?
問題1中使用到了執行緒池,看看Java提供的四種執行緒池
Fixed
:固定的核心執行緒,用於快速響應
Cache
:無限制的非核心執行緒,用於大量耗時短的操作
Schedule
:固定非核心執行緒,無限制非核心執行緒,用於大量耗時相等的操作
Single
:單一執行緒池,被新增的任務需要被順序執行
四種執行緒池中,貌似Cache最合適。但是實際測試並不是。一個頁面有大概30張照片,意味著至少要建立30個執行緒,用於處理圖片載入,當快速滑動的時候,這個執行緒數量將更多。這就會導致UI執行緒很難搶佔到CPU資源。並且大量的執行緒,使得執行緒間切換消耗資源。
下麵是Cache Thread Pool 和 Fixed Thread Pool 的 CPU分析圖。
可見Fixed Thread Pool佔用的CPU較少,我在滑動的過程中也明顯感覺到了Cache Thread Pool的明顯示卡頓。有興趣可以去嘗試一下。
Q3 RecyclerView一共要載入上千張照片,每次顯示ViewHolder就去載入由什麼問題?雖然RecylerView會自己回收記憶體,但是頻繁的滑動會導致頻繁GC,View可以回收,但是Bitmap物件可能再次被用到,不應直接被回收。這裡使用LruCache。
@Override
public void onBindViewHolder(final IvHolder ivHolder, int i) {
final String path = list.get(i);
final ImageView imageView = ivHolder.imageView;
imageView.setTag(path);
if (width == 0) {
measureSize(imageView); // 暫時不用關註
} else {
Bitmap bitmap = lruCache.get(path);// 讀取Lru快取
if (bitmap != null) imageView.setImageBitmap(bitmap);// 如果快取快取直接載入
else if (state == 0) cacheBitmap(imageView, i, path, width);// 如果不存在快取,將任務載入到執行緒池
}
}
private LruCache lruCache = new LruCache(cacheSize) {
@Override
protected int sizeOf(@NonNull String key, Bitmap value) {
return value.getByteCount() / 1024;
}
};
public void cacheBitmap(final ImageView imageView, int index, final String path, final int size) {
executor.execute(() -> {
Bitmap bitmap = BitmapUtil.decodeBitmapFromFile(path, size, size);
if (path == null || bitmap == null) return;// 不新增這一句,可能丟擲一個異常,很奇怪。
lruCache.put(path, bitmap);// 加入LruCache
// 執行緒中不能更新UI,所以這裡使用訊息機制
if (imageView.getTag() == path)
imageView.post(() -> {
imageView.setImageBitmap(lruCache.get(path));
Objects.requireNonNull(recyclerView.getAdapter()).notifyItemChanged(index);
});
});
}
Q4 計算取樣率,要獲取ImageView的width和height,但是在onCreate中無法獲取ImageView,怎麼辦?
在onCreate的時候,View沒有完成Measure過程,所以無法獲取尺寸。我們需要等onResume執行完成之後,才能獲取尺寸。但是問題來了,沒有這個生命週期呀!
其實很簡單,我們可以用View.post方法,當Loop開始處理View.post的訊息,onResume肯定執行完畢。這涉及到Activity的啟動,簡單來說,startActivity實質上是向Handler H傳送一條Message,當Looper執行這條Message的時候,也就執行了create,start和resume回呼。這裡不過多展開,總之要想獲取View的width,height,最好使用該View的Post方法。
public void measureSize(final ImageView imageView) {
imageView.post(() -> {
width = imageView.getWidth();
Objects.requireNonNull(recyclerView.getAdapter()).notifyDataSetChanged();//這裡需要手動去更新一下recyclerview的data,不然recyclerview會顯示一個空串列
});
}
Q5: 當使用者快速滑動的時候,如果試圖去載入照片,可以想象以下場景,如果使用者在10s只能快速滑動到了第1000張照片,那麼第1000張照片被載入出來的前提是載入完成前999張照片,這會導致第1000張照片遲遲不能被載入出來,這顯然是很糟糕的。怎麼解決呢?
這裡我們註意到問題在於,只要onBindViewHolder被執行,我們就去載入這個照片,這是不正確的。在快速滑動的時候,我們應該跳過圖片的載入。那如何獲取滑動的速度呢?這很簡單,我們給recyclerview設定一個監聽器即可。ScrollListener有兩個回呼,一個檢測滑動,一個檢測滑動的速度。
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
Log.d(TAG, "onScrollStateChanged: " + newState);
// 每次滑動會呼叫三次
// 回呼依次是:1->2->0
// 1 滑動
// 2 自然滑動
// 0 靜止
if (newState == 0) {
state = 0; // state = 0 則認為是靜止,要去載入照片
Objects.requireNonNull(recyclerView.getAdapter()).notifyDataSetChanged();
}
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 滑動過程中,會被多次呼叫,每次TOUCH_EVENT作為間隔
// 最後幾次可能都會小於閾值
Log.d(TAG, "onScrolled: " + dy);
state = Math.abs(dy) > 100 ? 1 : 0; // 當滑動速度超過100sp/Touch_Event,就認為快速滑動,否則認為可以載入照片
// 這裡為啥用100 作為閾值呢?請看下圖
}
});
@Override
public void onBindViewHolder(final IvHolder ivHolder, int i) {
final String path = list.get(i);
final ImageView imageView = ivHolder.imageView;
imageView.setTag(path);
if (width == 0) {
measureSize(imageView); // 第一次需要測量一下View的尺寸
} else {
Bitmap bitmap = lruCache.get(path);
if (bitmap != null) imageView.setImageBitmap(bitmap);
// state == 0的時候,滑動速度慢或者靜止,可以載入,否則跳過
else if (state == 0) cacheBitmap(imageView, i, path, width);
}
}
上面我是用了100作為閾值,在Android中,程式碼中的尺寸都是用px作為單位的。也就是說當滑動速度大於100px,我認為是快速滑動,跳過載入,當滑動速度小於100px,我認為可以載入照片。這個值從哪兒來的呢?
我叫我同學試了試,我把滑動速度的日誌列印下來作了這個圖。藍色部分是他緩慢滑動的速度,綠色部分是他快速滑動的速度影象。緩慢滑動速度基本在在100一下,我試了一下也差不多是這個曲線,那麼就愉快的使用這個閾值吧。專門去學了一下python視覺化內容(哇!Python畫圖確實方便)。
還有一些細節:比如RecyclerView更新,閃爍問題,錯位問題。有興趣可以看看程式碼。
完整程式碼
參考Github
https://github.com/RengaoWu/COSCloud-android/blob/master/app/src/main/java/com/easylink/cloud/control/test/TestGlideActivity.java
朋友會在“發現-看一看”看到你“在看”的內容