嘗試加載一千張照片

以下內(nèi)容完全是探索性的嘗試叨吮,加載大量照片請用Glide或者Picasso

背景百新,我在搗鼓一個圖片上傳App,我需要上傳手機上的照片惭嚣,首先要把照片顯示出來,類似于微信發(fā)送朋友圈選取照片的場景悔政。假說我用一個RecyclerView去顯示所有的照片(1000張)完箩。在不適用Glide的情況下兄淫,如何盡可能好的去加載這些照片。

加載一張照片可以直接

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

沒問題,但如果加載滿滿一個RecyclerView的照片瓜富,那就很容易導(dǎo)致ANR

以下是此次嘗試,學(xué)到的知識點:

  1. 加載一張照片到內(nèi)存,不是很耗時集畅。但是當(dāng)照片很多時候,這個累積的耗時就不能被忽略了缅糟,直接在onBindViewHolder中加載挺智,會阻塞UI線程。怎么辦窗宦?
  2. Java實現(xiàn)了四種線程池赦颇,F(xiàn)ixed,Cache赴涵,Schedule和Single媒怯,其中Cache給的介紹是適合大量耗時短的操作,這里Cache線程池真的適合嗎句占?
  3. RecyclerView一共要加載上千張照片沪摄,每次顯示ViewHolder就去加載有什么問題躯嫉?
  4. 計算采樣率纱烘,要獲取ImageView的width和height,但是在onCreate祈餐,onStart擂啥,onResume中都無法獲取ImageView尺寸,怎么辦帆阳?
  5. 當(dāng)用戶快速滑動的時候哺壶,如果試圖去加載照片⊙寻可以想象以下場景山宾,如果用戶在10s內(nèi)快速滑動到了第1000張照片,那么第1000張照片被加載出來的前提是加載完成前999張照片鳍徽。這顯然是很糟糕的资锰。怎么解決呢?

先看看效果圖

效果圖.gif

哈哈阶祭,是不是感覺整體效果還不錯绷杜。因為展示的都是照(害)片(羞),所有沒有截取太長的視頻濒募。

上面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分析圖。

Cache Thread Pool CPU

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)為可以加載照片。這個值從哪兒來的呢剃诅?

圖片.png

我叫我同學(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();
                }
        }
    }

}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末署辉,一起剝皮案震驚了整個濱河市族铆,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌哭尝,老刑警劉巖哥攘,帶你破解...
    沈念sama閱讀 218,546評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異材鹦,居然都是意外死亡逝淹,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,224評論 3 395
  • 文/潘曉璐 我一進(jìn)店門侠姑,熙熙樓的掌柜王于貴愁眉苦臉地迎上來创橄,“玉大人,你說我怎么就攤上這事莽红。” “怎么了邦邦?”我有些...
    開封第一講書人閱讀 164,911評論 0 354
  • 文/不壞的土叔 我叫張陵安吁,是天一觀的道長。 經(jīng)常有香客問我燃辖,道長鬼店,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,737評論 1 294
  • 正文 為了忘掉前任黔龟,我火速辦了婚禮妇智,結(jié)果婚禮上滥玷,老公的妹妹穿的比我還像新娘。我一直安慰自己巍棱,他們只是感情好惑畴,可當(dāng)我...
    茶點故事閱讀 67,753評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著航徙,像睡著了一般如贷。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上到踏,一...
    開封第一講書人閱讀 51,598評論 1 305
  • 那天杠袱,我揣著相機與錄音,去河邊找鬼窝稿。 笑死楣富,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的伴榔。 我是一名探鬼主播菩彬,決...
    沈念sama閱讀 40,338評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼潮梯!你這毒婦竟也來了骗灶?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,249評論 0 276
  • 序言:老撾萬榮一對情侶失蹤秉馏,失蹤者是張志新(化名)和其女友劉穎耙旦,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體萝究,經(jīng)...
    沈念sama閱讀 45,696評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡免都,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,888評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了帆竹。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片绕娘。...
    茶點故事閱讀 40,013評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖栽连,靈堂內(nèi)的尸體忽然破棺而出险领,到底是詐尸還是另有隱情,我是刑警寧澤秒紧,帶...
    沈念sama閱讀 35,731評論 5 346
  • 正文 年R本政府宣布绢陌,位于F島的核電站,受9級特大地震影響熔恢,放射性物質(zhì)發(fā)生泄漏脐湾。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,348評論 3 330
  • 文/蒙蒙 一叙淌、第九天 我趴在偏房一處隱蔽的房頂上張望秤掌。 院中可真熱鬧愁铺,春花似錦、人聲如沸闻鉴。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,929評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽椒拗。三九已至似将,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蚀苛,已是汗流浹背在验。 一陣腳步聲響...
    開封第一講書人閱讀 33,048評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留堵未,地道東北人腋舌。 一個月前我還...
    沈念sama閱讀 48,203評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像渗蟹,于是被迫代替她去往敵國和親块饺。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,960評論 2 355

推薦閱讀更多精彩內(nèi)容

  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴(yán)謹(jǐn) 對...
    cosWriter閱讀 11,103評論 1 32
  • 所有知識點已整理成app app下載地址 J2EE 部分: 1.Switch能否用string做參數(shù)雌芽? 在 Jav...
    侯蛋蛋_閱讀 2,440評論 1 4
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫授艰、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,105評論 4 62
  • 1.正交試驗法介紹 正交試驗法是研究多因素世落、多水平的一種試驗法淮腾,它是利用正交表來對試驗進(jìn)行設(shè)計,通過少數(shù)的試驗替代...
    Yvanna_15閱讀 2,900評論 0 4
  • 原來竟是這樣屉佳。所有人都被這件事驚住了谷朝,就連蘇家大小姐自己都被這話驚得沉默了。 “歡喜城的主人武花,果然好本事圆凰,老夫不得...
    邵小妮er閱讀 220評論 0 3