code小生,一个专注 Android 领域的技术平台
公众号回复 Android 加入我的安卓技术群
作者:北国雪WRG
链接:https://www.jianshu.com/p/f77b9f68265d
声明:本文已获北国雪WRG
授权发表,转发等请联系原作者授权
以下内容完全是探索性的尝试,加载大量照片请用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张照片。这显然是很糟糕的。怎么解决呢?
先看看效果图

哈哈,是不是感觉整体效果还不错。因为展示的都是照(害)片(羞),所有没有截取太长的视频。
上面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<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执行完成之后,才能获取尺寸。但是问题来了,没有这个生命周期呀!
其实很简单,我们可以用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%3A%2F%2Fgithub.com%2FRengaoWu%2FCOSCloud-android%2Fblob%2Fmaster%2Fapp%2Fsrc%2Fmain%2Fjava%2Fcom%2Feasylink%2Fcloud%2Fcontrol%2Ftest%2FTestGlideActivity.java
如果我每件事都模仿别人,那我还有什么特色