尝试加载一千张照片

以下内容完全是探索性的尝试,加载大量照片请用Glide或者Picasso

背景,我在捣鼓一个图片上传App,我需要上传手机上的照片,首先要把照片显示出来,类似于微信发送朋友圈选取照片的场景。假说我用一个RecyclerView去显示所有的照片(1000张)。在不适用Glide的情况下,如何尽可能好的去加载这些照片。

加载一张照片可以直接

imageView.setImageBitmap(BitmapUtil.decodeBitmapFromFile(path, size, size))

没问题,但如果加载满满一个RecyclerView的照片,那就很容易导致ANR

以下是此次尝试,学到的知识点:

  1. 加载一张照片到内存,不是很耗时。但是当照片很多时候,这个累积的耗时就不能被忽略了,直接在onBindViewHolder中加载,会阻塞UI线程。怎么办?
  2. Java实现了四种线程池,Fixed,Cache,Schedule和Single,其中Cache给的介绍是适合大量耗时短的操作,这里Cache线程池真的适合吗?
  3. RecyclerView一共要加载上千张照片,每次显示ViewHolder就去加载有什么问题?
  4. 计算采样率,要获取ImageView的width和height,但是在onCreate,onStart,onResume中都无法获取ImageView尺寸,怎么办?
  5. 当用户快速滑动的时候,如果试图去加载照片。可以想象以下场景,如果用户在10s内快速滑动到了第1000张照片,那么第1000张照片被加载出来的前提是加载完成前999张照片。这显然是很糟糕的。怎么解决呢?

先看看效果图

效果图.gif

哈哈,是不是感觉整体效果还不错。因为展示的都是照(害)片(羞),所有没有截取太长的视频。

上面5个问题,下面来各个击破:

Q1:加载一张照片到内存耗,不是很耗时。但是当照片很多时候,这个耗时就不能被忽略了,直接在onBindViewHolder中加载,会阻塞UI线程。怎么办?

我最开始就是这么做的,整个应用直接GG。太耗时了,应用直接ANR挂掉。这里使用线程池,当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分析图。

Cache Thread Pool CPU

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,我认为可以加载照片。这个值从哪儿来的呢?

图片.png

我叫我同学试了试,我把滑动速度的日志打印下来作了这个图。蓝色部分是他缓慢滑动的速度,绿色部分是他快速滑动的速度图像。缓慢滑动速度基本在在100一下,我试了一下也差不多是这个曲线,那么就愉快的使用这个阈值吧。专门去学了一下python可视化内容(哇!Python画图确实方便)。

还有一些细节:比如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);
                // 每次滑动会调用三次
                // 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);
                // 滑动过程中,会被多次调用,每次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);
                }
            }

            // 解决错位问题,当被回收的时候,我们用空杯展位图去代替
            @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));
        // 动画效果导致的闪烁问题
       ((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;
        }
    }

    // 权限相关,可忽略
    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();
        }
    }

    // 权限相关,可忽略
    @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, "你没有授权", Toast.LENGTH_LONG).show();
                }
        }
    }

}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,034评论 1 32
  • 所有知识点已整理成app app下载地址 J2EE 部分: 1.Switch能否用string做参数? 在 Jav...
    侯蛋蛋_阅读 2,356评论 1 4
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,612评论 4 59
  • 1.正交试验法介绍 正交试验法是研究多因素、多水平的一种试验法,它是利用正交表来对试验进行设计,通过少数的试验替代...
    Yvanna_15阅读 2,541评论 0 4
  • 原来竟是这样。所有人都被这件事惊住了,就连苏家大小姐自己都被这话惊得沉默了。 “欢喜城的主人,果然好本事,老夫不得...
    邵小妮er阅读 205评论 0 3