歡迎光臨
每天分享高質量文章

嘗試載入一千張照片

作者:北國雪WRG

連結:https://www.jianshu.com/p/f77b9f68265d

以下內容完全是探索性的嘗試,載入大量照片請用Glide或者Picasso

背景

我在搗鼓一個圖片上傳App,我需要上傳手機上的照片,首先要把照片顯示出來,類似於微信傳送朋友圈選取照片的場景。假說我用一個RecyclerView去顯示所有的照片(1000張)。在不適用Glide的情況下,如何盡可能好的去載入這些照片。

載入一張照片可以直接

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

沒問題,但如果載入滿滿一個RecyclerView的照片,那就很容易導致NAR。

以下是此次嘗試,學到的知識點:

  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。太耗時了,應用直接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分析圖。

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 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 == nullreturn;// 不新增這一句,可能丟擲一個異常,很奇怪。
            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
https://github.com/RengaoWu/COSCloud-android/blob/master/app/src/main/java/com/easylink/cloud/control/test/TestGlideActivity.java

已同步到看一看
贊(0)

分享創造快樂