以下內(nèi)容完全是探索性的嘗試叨吮,加載大量照片請用Glide或者Picasso
背景百新,我在搗鼓一個圖片上傳App,我需要上傳手機上的照片惭嚣,首先要把照片顯示出來,類似于微信發(fā)送朋友圈選取照片的場景悔政。假說我用一個RecyclerView去顯示所有的照片(1000張)完箩。在不適用Glide的情況下兄淫,如何盡可能好的去加載這些照片。
加載一張照片可以直接
imageView.setImageBitmap(BitmapUtil.decodeBitmapFromFile(path, size, size))
沒問題,但如果加載滿滿一個RecyclerView的照片瓜富,那就很容易導(dǎo)致ANR。
以下是此次嘗試,學(xué)到的知識點:
- 加載一張照片到內(nèi)存,不是很耗時集畅。但是當(dāng)照片很多時候,這個累積的耗時就不能被忽略了缅糟,直接在
onBindViewHolder
中加載挺智,會阻塞UI線程。怎么辦窗宦? - Java實現(xiàn)了四種線程池赦颇,F(xiàn)ixed,Cache赴涵,Schedule和Single媒怯,其中Cache給的介紹是適合大量耗時短的操作,這里Cache線程池真的適合嗎句占?
- RecyclerView一共要加載上千張照片沪摄,每次顯示ViewHolder就去加載有什么問題躯嫉?
- 計算采樣率纱烘,要獲取ImageView的width和height,但是在onCreate祈餐,onStart擂啥,onResume中都無法獲取ImageView尺寸,怎么辦帆阳?
- 當(dāng)用戶快速滑動的時候哺壶,如果試圖去加載照片⊙寻可以想象以下場景山宾,如果用戶在10s內(nèi)快速滑動到了第1000張照片,那么第1000張照片被加載出來的前提是加載完成前999張照片鳍徽。這顯然是很糟糕的资锰。怎么解決呢?
先看看效果圖
哈哈阶祭,是不是感覺整體效果還不錯绷杜。因為展示的都是照(害)片(羞),所有沒有截取太長的視頻濒募。
上面5個問題鞭盟,下面來各個擊破:
Q1:加載一張照片到內(nèi)存耗,不是很耗時瑰剃。但是當(dāng)照片很多時候齿诉,這個耗時就不能被忽略了,直接在
onBindViewHolder
中加載,會阻塞UI線程粤剧。怎么辦遗座?
我最開始就是這么做的,整個應(yīng)用直接GG俊扳。太耗時了途蒋,應(yīng)用直接ANR掛掉。這里使用線程池馋记,當(dāng)BindViewholder被執(zhí)行的時候号坡,把加載照片的任務(wù)交給線程池。
@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實現(xiàn)了四種線程池梯醒,F(xiàn)ixed宽堆,Cache,Schedule和Single茸习,其中Cache給的介紹是適合大量耗時短的操作畜隶,這里Cache線程池真的適合嗎?
問題1中使用到了線程池号胚,看看Java提供的四種線程池
Fixed:固定的核心線程籽慢,用于快速響應(yīng)
Cache:無限制的非核心線程,用于大量耗時短的操作
Schedule:固定非核心線程猫胁,無限制非核心線程箱亿,用于大量耗時相等的操作
Single:單一線程池,被添加的任務(wù)需要被順序執(zhí)行
四種線程池中弃秆,貌似Cache最合適届惋。但是實際測試并不是。一個頁面有大概30張照片菠赚,意味著至少要創(chuàng)建30個線程脑豹,用于處理圖片加載,當(dāng)快速滑動的時候衡查,這個線程數(shù)量將更多瘩欺。這就會導(dǎo)致UI線程很難搶占到CPU資源。并且大量的線程峡捡,使得線程間切換消耗資源击碗。
下面是Cache Thread Pool 和 Fixed Thread Pool 的 CPU分析圖。
可見Fixed Thread Pool占用的CPU較少们拙,我在滑動的過程中也明顯感覺到了Cache Thread Pool的明顯卡頓稍途。有興趣可以去嘗試一下。
Q3 RecyclerView一共要加載上千張照片砚婆,每次顯示ViewHolder就去加載由什么問題械拍?
雖然RecylerView會自己回收內(nèi)存突勇,但是頻繁的滑動會導(dǎo)致頻繁GC,View可以回收坷虑,但是Bitmap對象可能再次被用到甲馋,不應(yīng)直接被回收。這里使用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); // 暫時不用關(guān)注
} else {
Bitmap bitmap = lruCache.get(path);// 讀取Lru緩存
if (bitmap != null) imageView.setImageBitmap(bitmap);// 如果緩存緩存直接加載
else if (state == 0) cacheBitmap(imageView, i, path, width);// 如果不存在緩存定躏,將任務(wù)加載到線程池
}
}
private LruCache<String, Bitmap> lruCache = new LruCache<String, Bitmap>(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執(zhí)行完成之后滞造,才能獲取尺寸。但是問題來了楚里,沒有這個生命周期呀断部!
其實很簡單猎贴,我們可以用View.post方法班缎,當(dāng)Loop開始處理View.post的消息,onResume肯定執(zhí)行完畢她渴。這涉及到Activity的啟動达址,簡單來說,startActivity實質(zhì)上是向Handler H發(fā)送一條Message趁耗,當(dāng)Looper執(zhí)行這條Message的時候沉唠,也就執(zhí)行了create,start和resume回調(diào)苛败。這里不過多展開满葛,總之要想獲取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: 當(dāng)用戶快速滑動的時候缠捌,如果試圖去加載照片锄贷,可以想象以下場景,如果用戶在10s只能快速滑動到了第1000張照片,那么第1000張照片被加載出來的前提是加載完成前999張照片谊却,這會導(dǎo)致第1000張照片遲遲不能被加載出來柔昼,這顯然是很糟糕的。怎么解決呢炎辨?
這里我們注意到問題在于捕透,只要onBindViewHolder被執(zhí)行,我們就去加載這個照片碴萧,這是不正確的激率。在快速滑動的時候,我們應(yīng)該跳過圖片的加載勿决。那如何獲取滑動的速度呢乒躺?這很簡單,我們給recyclerview設(shè)置一個監(jiān)聽器即可低缩。ScrollListener有兩個回調(diào)嘉冒,一個檢測滑動,一個檢測滑動的速度咆繁。
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
Log.d(TAG, "onScrollStateChanged: " + newState);
// 每次滑動會調(diào)用三次
// 回調(diào)依次是:1->2->0
// 1 滑動
// 2 自然滑動
// 0 靜止
if (newState == 0) {
state = 0; // state = 0 則認(rèn)為是靜止讳推,要去加載照片
Objects.requireNonNull(recyclerView.getAdapter()).notifyDataSetChanged();
}
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 滑動過程中,會被多次調(diào)用玩般,每次TOUCH_EVENT作為間隔
// 最后幾次可能都會小于閾值
Log.d(TAG, "onScrolled: " + dy);
state = Math.abs(dy) > 100 ? 1 : 0; // 當(dāng)滑動速度超過100sp/Touch_Event银觅,就認(rèn)為快速滑動,否則認(rèn)為可以加載照片
// 這里為啥用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作為單位的够颠。也就是說當(dāng)滑動速度大于100px熙侍,我認(rèn)為是快速滑動,跳過加載履磨,當(dāng)滑動速度小于100px蛉抓,我認(rèn)為可以加載照片。這個值從哪兒來的呢剃诅?
我叫我同學(xué)試了試巷送,我把滑動速度的日志打印下來作了這個圖。藍(lán)色部分是他緩慢滑動的速度综苔,綠色部分是他快速滑動的速度圖像惩系。緩慢滑動速度基本在在100一下位岔,我試了一下也差不多是這個曲線,那么就愉快的使用這個閾值吧堡牡。專門去學(xué)了一下python可視化內(nèi)容(哇抒抬!Python畫圖確實方便)。
還有一些細(xì)節(jié):比如RecyclerView更新晤柄,閃爍問題擦剑,錯位問題。有興趣可以看看代碼芥颈。
完整代碼:參考Github
public class TestGlideActivity extends CommonActivity {
private static final String TAG = "TestGlideActivity";
@BindView(R.id.rv_test)
RecyclerView recyclerView;
private List<String> list = new ArrayList<>(1000);
private Executor executor = Executors.newFixedThreadPool(4);
//private Executor executor = Executors.newCachedThreadPool();
private int cacheSize = (int) (Runtime.getRuntime().maxMemory() / 1024);
private int width = 0;
private int state = 1;
private LruCache<String, Bitmap> lruCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(@NonNull String key, Bitmap value) {
return value.getByteCount() / 1024;
}
};
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
Log.d(TAG, "onScrollStateChanged: " + newState);
// 每次滑動會調(diào)用三次
// 1->2->0
// 1 滑動
// 2 自然滑動
// 0 靜止
if (newState == 0) {
state = 0;
Objects.requireNonNull(recyclerView.getAdapter()).notifyDataSetChanged();
}
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 滑動過程中惠勒,會被多次調(diào)用,每次TOUCH_EVENT作為間隔
// 最后幾次可能都會小于閾值
Log.d(TAG, "onScrolled: " + dy);
state = Math.abs(dy) > 100 ? 1 : 0;
}
});
recyclerView.setAdapter(new RecyclerView.Adapter<IvHolder>() {
@NonNull
@Override
public IvHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new IvHolder(LayoutInflater
.from(TestGlideActivity.this)
.inflate(R.layout.view_photo_pick, viewGroup, false));
}
@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);// 第一次需要測量width
} else {
Bitmap bitmap = lruCache.get(path);
if (bitmap != null) imageView.setImageBitmap(bitmap);
else if (state == 0) cacheBitmap(imageView, i, path, width);
}
}
// 解決錯位問題纠屋,當(dāng)被回收的時候,我們用空杯展位圖去代替
@Override
public void onViewRecycled(@NonNull IvHolder holder) {
super.onViewRecycled(holder);
holder.imageView.setImageDrawable(getDrawable(R.drawable.blank));
holder.imageView.postInvalidate();// 將這個imageView的post消息取消掉
}
@Override
public int getItemCount() {
return list.size();
}
});
recyclerView.setLayoutManager(new GridLayoutManager(this, 4));
// 動畫效果導(dǎo)致的閃爍問題
((SimpleItemAnimator)Objects.requireNonNull(recyclerView.getItemAnimator())).setSupportsChangeAnimations(false);
checkPermissionForReadStorage();
}
@Override
protected int getLayout() {
return R.layout.activity_test;
}
// 測量View的尺寸
public void measureSize(final ImageView imageView) {
imageView.post(() -> {
width = imageView.getWidth();
Objects.requireNonNull(recyclerView.getAdapter()).notifyDataSetChanged();
});
}
// LruCache 線程池加載圖片盾计,Handler.post更新UI
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);
if (imageView.getTag() == path)
imageView.post(() -> {
imageView.setImageBitmap(lruCache.get(path));
Objects.requireNonNull(recyclerView.getAdapter()).notifyItemChanged(index);
});
});
}
@Override
protected void onDestroy() {
super.onDestroy();
}
class IvHolder extends RecyclerView.ViewHolder {
ImageView imageView;
TextView textView;
IvHolder(@NonNull View itemView) {
super(itemView);
imageView = itemView.findViewById(R.id.iv_photo);
textView = itemView.findViewById(R.id.tv_photo);
}
}
// 讀取所有照片的路徑信息
static class QueryDBTask extends AsyncTask<Void, Void, Void> {
static ContentResolver contentResolver;
List<String> list = null;
QueryDBTask(ContentResolver resolver, List<String> list) {
contentResolver = resolver;
this.list = list;
}
@Override
protected Void doInBackground(Void... voids) {
Cursor cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
null,
null,
null,
MediaStore.Images.Media.DATE_MODIFIED + " desc"
);
if (cursor == null) return null;
int index = cursor.getColumnIndex(MediaStore.Files.FileColumns.DATA);
while (cursor.moveToNext()) list.add(cursor.getString(index));
cursor.close();
return null;
}
}
// 權(quán)限相關(guān)售担,可忽略
public void checkPermissionForReadStorage() {
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_PHONE_STATE},
1);
} else {
new QueryDBTask(getContentResolver(), list).execute();
}
}
// 權(quán)限相關(guān),可忽略
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED
&& grantResults[1] == PackageManager.PERMISSION_GRANTED) {
new QueryDBTask(getContentResolver(), list).execute();
} else {
Toast.makeText(TestGlideActivity.this, "你沒有授權(quán)", Toast.LENGTH_LONG).show();
}
}
}
}