1. 程式人生 > >ViewPager懶載入極致優化

ViewPager懶載入極致優化

目錄介紹

  • 01.ViewPager簡單介紹
  • 02.ViewPager弊端分析
  • 03.ViewPager預載入
  • 04.ViewPager部分原始碼
  • 05.懶加載出現問題
  • 06.如何實現預載入機制
  • 07.懶載入配合狀態管理器

呂詩禹想換個工作,渴望同行內推

好訊息

  • 部落格筆記大彙總【16年3月到至今】,包括Java基礎及深入知識點,Android技術部落格,Python學習筆記等等,還包括平時開發中遇到的bug彙總,當然也在工作之餘收集了大量的面試題,長期更新維護並且修正,持續完善……開源的檔案是markdown格式的!同時也開源了生活部落格,從12年起,積累共計N篇[近100萬字,陸續搬到網上],轉載請註明出處,謝謝!
  • 連結地址:https://github.com/yangchong211/YCBlogs
  • 如果覺得好,可以star一下,謝謝!當然也歡迎提出建議,萬事起於忽微,量變引起質變!

01.ViewPager簡單介紹

  • ViewPager使用一個鍵物件來關聯每一頁,而不是管理View。這個鍵用於追蹤和唯一標識在adapter中獨立位置中的一頁。呼叫方法startUpdate(ViewGroup)表明ViewPager中的內容需要更改。
  • 通過呼叫一次或多次呼叫instantiateItem(ViewGroup, int)來構造頁面檢視。
  • 呼叫destroyItem(ViewGroup, int, Object)來取消ViewPager關聯的頁面檢視。
  • 最後,當一次更新(新增和/或移除)完成之後將會呼叫finishUpdate(ViewGroup)來通知adapter, 提交關聯和/或取消關聯的操作。這三個方法就是用於ViewPager使用回撥的方式來通知PagerAdapter來管理其中的頁面。
  • 一個非常簡單的方式就是使用每頁檢視作為key來關聯它們自己,在方法instantiateItem(ViewGroup, int)中建立和新增它們到ViewGroup之後,返回該頁檢視。與之相匹配的方法destroyItem(ViewGroup, int, Object)實現從ViewGroup中移除檢視。當然必須在isViewFromObject(View, Object)中這樣實現:return view == object;.
  • PagerAdapter支援資料改變時重新整理介面,資料改變必須在主執行緒中呼叫,並在資料改變完成後呼叫方法notifyDataSetChanged(), 和AdapterView中派生自BaseAdapter相似。一次資料的改變可能關聯著頁面的新增、移除、或改變位置。ViewPager將根據adapter中實現getItemPosition(Object)方法返回的結果,來判斷是否保留當前已經構造的活動頁面(即重用,而不完全自行構造)。

02.ViewPager弊端分析

  • 普通的viewpager如果你不使用setoffscreenpagelimit(int limit)這個方法去設定預設載入數的話是會預設載入頁面的左右兩頁的,也就是說當你進入viewpager第一頁的時候第二頁和第一頁是會被一起載入的,這樣同時載入就會造成一些問題,試想我們如果設定了setoffscreenpagelimit為3的話,那麼進入viewpager以後就會同時載入4個fragment,像我們平時的專案中在這些fragment中一般都是會發送網路請求的,也就是說我們有4個fragment同時傳送網路請求去獲取資料,這樣的結果顯而易見給使用者的體驗是不好的(如:浪費使用者流量,造成卡頓等等)。
  • 懶載入的實現弊端
    • 概念:當需要時才載入,載入之後一直保持該物件。
    • 而關於Fragment實現的PagerAdapter都沒有完全儲存其引用和狀態。FragmentPageAdapter需要重建檢視,FragmentStatePageAdapter使用狀態恢復,View都被銷燬,但是恢復的方式不同,而通常我們想得到的結果是,Fragment一旦被載入,其檢視也不會被銷燬,即不會再重新走一遍生命週期。而且ViewPager為了實現滑動效果,都是預載入左右兩側的頁面。
    • 我們通常想要實現的兩種效果:不提供滑動,需要時才構造,並且只走一遍生命週期,避免在Fragment中做過多的狀態儲存和恢復。

03.ViewPager預載入

  • ViewPager的預載入機制。那麼,我們可不可以設定ViewPager的預載入為0,不就解決問題了嗎?也就是程式碼這樣操作:
    vp.setOffscreenPageLimit(0);
    
  • 然後看一下原始碼
    • 即使你設定為0,那麼還是會在裡面判斷後設為預設值1。所以這個方法是行不通的。
    public void setOffscreenPageLimit(int limit) {
        if (limit < 1) {
            Log.w("ViewPager", "Requested offscreen page limit " + limit + " too small; defaulting to " + 1);
            limit = 1;
        }
    
        if (limit != this.mOffscreenPageLimit) {
            this.mOffscreenPageLimit = limit;
            this.populate();
        }
    
    }
    
  • ViewPager預設情況下的載入,當切換到當前頁面時,會預設預載入左右兩側的佈局到ViewPager中,儘管兩側的View並不可見的,我們稱這種情況叫預載入;由於ViewPager對offscreenPageLimit設定了限制,頁面的預載入是不可避免……
  • 初始化快取(mOffscreenPageLimit == 1)
    • 當初始化時,當前顯示頁面是第0頁;mOffscreenPageLimit為1,所以預載入頁面為第1頁,再往後的頁面就不需要載入了(這裡的2, 3, 4頁)
    • image
  • 中間頁面快取(mOffscreenPageLimit == 1)
    • 當向右滑動到第2頁時,左右分別需要快取一頁,第0頁就需要銷燬掉,第3頁需要預載入,第4頁不需要載入
    • image

04.ViewPager部分原始碼

  • ViewPager.setAdapter方法
    • 銷燬舊的Adapter資料,用新的Adaper更新UI
    • 清除舊的Adapter,對已載入的item呼叫destroyItem,
    • 將自身滾動到初始位置this.scrollTo(0, 0)
    • 設定PagerObserver: mAdapter.setViewPagerObserver(mObserver);
    • 呼叫populate()方法計算並初始化View(這個方法後面會詳細介紹)
    • 如果設定了OnAdapterChangeListener,進行回撥
  • ViewPager.populate(int newCurrentItem)
    • 該方法是ViewPager非常重要的方法,主要根據引數newCurrentItem和mOffscreenPageLimit計算出需要初始化的頁面和需要銷燬頁面,然後通過呼叫Adapter的instantiateItem和destroyItem兩個方法初始化新頁面和銷燬不需要的頁面!
    • 根據newCurrentItem和mOffscreenPageLimit計算要載入的page頁面,計算出startPos和endPos
    • 根據startPos和endPos初始化頁面ItemInfo,先從快取裡面獲取,如果沒有就呼叫addNewItem方法,實際呼叫mAdapter.instantiateItem
    • 將不需要的ItemInfo移除: mItems.remove(itemIndex),並呼叫mAdapter.destroyItem方法
    • 設定LayoutParams引數(包括position和widthFactor),根據position排序待繪製的View列表:mDrawingOrderedChildren,重寫了getChildDrawingOrder方法
    • 最後一步獲取當前顯示View的焦點:currView.requestFocus(View.FOCUS_FORWARD)
  • ViewPager.dataSetChanged()
    • 當呼叫Adapter的notifyDataSetChanged時,會觸發這個方法,該方法會重新計算當前頁面的position,
    • 移除需要銷燬的頁面的ItemInfo物件,然後再呼叫populate方法重新整理頁面
    • 迴圈mItems(每個page對應的ItemInfo物件),呼叫int newPos = mAdapter.getItemPosition方法
    • 當newPos等於PagerAdapter.POSITION_UNCHANGED表示當前頁面不需要更新,不用銷燬,當newPos等於PagerAdapter.POSITION_NONE時,需要更新,移除item,呼叫mAdapter.destroyItem
    • 迴圈完成後,最後計算出顯示頁面的newCurrItem,呼叫setCurrentItemInternal(newCurrItem, false, true)方法更新UI(實際呼叫populate方法重新計算頁面資訊)
  • ViewPager.scrollToItem(int item, boolean smoothScroll, int velocity, boolean dispatchSelected)
    • 滑動到指定頁面,內部會觸發OnPageChangeListener
  • ViewPager.calculatePageOffsets(ItemInfo curItem, int curIndex, ItemInfo oldCurInfo)
    • 這個方法主要用於計算每個頁面對應ItemInfo的offset變數,這個變數用於記錄當前view在所有快取View中(包含當前顯示頁)的索引,用於佈局的時候計算該View應該放在哪個位置
    • 在populate方法中更新完頁面資料後,會呼叫該方法計算所有頁面的offset

05.懶加載出現問題

  • 發現Fragment中有一個setUserVisibleHint(boolean isVisibleToUser)方法,這個方法就是告訴使用者,UI對使用者是否可見,可以做懶載入初始化操作。
    • 因為ViewPager會載入好多Fragment,為了節省內容等會在Fragment不可見的某個時候呼叫onDestroyView()將使用者介面銷燬掉但是Fragment的例項還在,所以可能第一次載入沒有問題,但是再次回到第一個Fragment再去載入的時候就會出現UI對使用者可見但是檢視還沒有初始化。
  • 懶載入需要處理的幾個問題
    • 預載入,雖然沒有顯示在介面上,但是當前頁面的上一頁和下一頁的Fragment已經執行了一個Fragment能夠顯示在介面上的所有生命週期方法,但是我們想在跳轉到該頁時才真正構造資料檢視和請求資料。那麼我們可以使用一個佔位檢視,那麼可以想到使用ViewStub,當真正跳轉到該頁時,執行ViewStub.inflate()方法,載入真正的資料檢視和請求資料。
  • 檢視儲存
    • 當某一頁超出可視範圍和預載入範圍,那麼它將會被銷燬,FragmentStatePagerAdapter銷燬整個Fragment, 我們可以自己儲存該Fragment,或使用FragmentPagerAdapter讓FragmentTransition來保留Fragment的引用。雖然這樣,但是它的週期方法已經走完,那麼我們只能手動的儲存Fragment根View的引用,當再次重新進入新的宣告週期方法時,返回原來的View
  • 是否已經被使用者所看到
    • 其實本身而言,FragmentManager並沒有提供為Fragment被使用者所看到的回撥方法,而是在FragmentPagerAdapter和FragmentStatePagerAdapter中,呼叫了Fragment.setUserVisibleHint(boolean)來表明Fragment是否已經被作為primaryFragment. 所以這個方法可以被認為是一個回撥方法。

06.如何實現預載入機制

  • 主要的方法是Fragment中的setUserVisibleHint(),此方法會在onCreateView()之前執行,當viewPager中fragment改變可見狀態時也會呼叫,當fragment 從可見到不見,或者從不可見切換到可見,都會呼叫此方法,使用getUserVisibleHint() 可以返回fragment是否可見狀態。
  • 在BaseLazyFragment中需要在onActivityCreated()及setUserVisibleHint()方法中都調了一次lazyLoad() 方法。如果僅僅在setUserVisibleHint()呼叫lazyLoad(),當預設首頁首先載入時會導致viewPager的首頁第一次展示時沒有資料顯示,切換一下才會有資料。因為首頁fragment的setUserVisible()在onActivityCreated() 之前呼叫,此時isPrepared為false 導致首頁fragment 沒能呼叫onLazyLoad()方法載入資料。
    /**
     * <pre>
     *     @author yangchong
     *     blog  : https://github.com/yangchong211
     *     time  : 2017/7/22
     *     desc  : 懶載入
     *     revise: 懶載入時機:onCreateView()方法執行完畢 + setUserVisibleHint()方法返回true
     * </pre>
     */
    public abstract class BaseLazyFragment extends BaseFragment {
    
        /*
         * 預載入頁面回撥的生命週期流程:
         * setUserVisibleHint() -->onAttach() --> onCreate()-->onCreateView()-->
         *              onActivityCreate() --> onStart() --> onResume()
         */
    
        /**
         * 懶載入過
         */
        protected boolean isLazyLoaded = false;
        /**
         * Fragment的View載入完畢的標記
         */
        private boolean isPrepared = false;
    
        /**
         * 第一步,改變isPrepared標記
         * 當onViewCreated()方法執行時,表明View已經載入完畢,此時改變isPrepared標記為true,並呼叫lazyLoad()方法
         */
        @Override
        public void onActivityCreated(@Nullable Bundle savedInstanceState) {
            super.onActivityCreated(savedInstanceState);
            isPrepared = true;
            //只有Fragment onCreateView好了
            //另外這裡呼叫一次lazyLoad()
            lazyLoad();
        }
    
    
        /**
         * 第二步
         * 此方法會在onCreateView()之前執行
         * 當viewPager中fragment改變可見狀態時也會呼叫
         * 當fragment 從可見到不見,或者從不可見切換到可見,都會呼叫此方法
         * true表示當前頁面可見,false表示不可見
         */
        @Override
        public void setUserVisibleHint(boolean isVisibleToUser) {
            super.setUserVisibleHint(isVisibleToUser);
            LogUtil.d("setUserVisibleHint---"+isVisibleToUser);
            //只有當fragment可見時,才進行載入資料
            if (isVisibleToUser){
                lazyLoad();
            }
        }
    
        /**
         * 呼叫懶載入
         * 第三步:在lazyLoad()方法中進行雙重標記判斷,通過後即可進行資料載入
         */
        private void lazyLoad() {
            if (getUserVisibleHint() && isPrepared && !isLazyLoaded) {
                showFirstLoading();
                onLazyLoad();
                isLazyLoaded = true;
            } else {
                //當檢視已經對使用者不可見並且載入過資料,如果需要在切換到其他頁面時停止載入資料,可以覆寫此方法
                if (isLazyLoaded) {
                    stopLoad();
                }
            }
        }
    
        /**
         * 檢視銷燬的時候講Fragment是否初始化的狀態變為false
         */
        @Override
        public void onDestroyView() {
            super.onDestroyView();
            isLazyLoaded = false;
            isPrepared = false;
        }
    
        /**
         * 第一次可見時,操作該方法,可以用於showLoading操作,注意這個是全域性載入loading
         */
        protected void showFirstLoading() {
            LogUtil.i("第一次可見時show全域性loading");
        }
    
        /**
         * 停止載入
         * 當檢視已經對使用者不可見並且載入過資料,但是沒有載入完,而只是載入loading。
         * 如果需要在切換到其他頁面時停止載入資料,可以覆寫此方法。
         * 存在問題,如何停止載入網路
         */
        protected void stopLoad(){
    
        }
    
        /**
         * 第四步:定義抽象方法onLazyLoad(),具體載入資料的工作,交給子類去完成
         */
        @UiThread
        protected abstract void onLazyLoad();
    }
    
  • onLazyLoad()載入資料條件
    • getUserVisibleHint()會返回是否可見狀態,這是fragment實現懶載入的關鍵,只有fragment 可見才會呼叫onLazyLoad() 載入資料。
    • isPrepared引數在系統呼叫onActivityCreated時設定為true,這時onCreateView方法已呼叫完畢(一般我們在這方法裡執行findviewbyid等方法),確保 onLazyLoad()方法不會報空指標異常。
    • isLazyLoaded確保ViewPager來回切換時BaseFragment的initData方法不會被重複呼叫,onLazyLoad在該Fragment的整個生命週期只調用一次,第一次呼叫onLazyLoad()方法後馬上執行 isLazyLoaded = true。
    • 然後再繼承這個BaseLazyFragment實現onLazyLoad() 方法就行。他會自動控制當fragment 展現出來時,才會載入資料
  • 還有幾個細節需要優化一下
    • 當檢視已經對使用者不可見並且載入過資料,如果需要在切換到其他頁面時停止載入資料,可以覆寫此方法,也就是stopLoad
    • 檢視銷燬的時候講Fragment是否初始化的狀態變為false,這個也需要處理一下
    • 第一次可見時,定義一個showFirstLoading方法,操作該方法,可以用於Loading載入操作,注意這個是全域性載入loading,和下拉重新整理資料或者區域性重新整理的loading不一樣的。可能有些開發app,沒有將loading分的這麼細。

07.懶載入配合狀態管理器

  • 什麼是狀態管理器?
    • 一般在需要使用者等待的場景,顯示一個Loading動畫可以讓使用者知道App正在載入資料,而不是程式卡死,從而給使用者較好的使用體驗。
    • 當載入的資料為空時顯示一個數據為空的檢視、在資料載入失敗時顯示載入失敗對應的UI並支援點選重試會比白屏的使用者體驗更好一些。
    • 載入中、載入失敗、空資料的UI風格,一般來說在App內的所有頁面中需要保持一致,也就是需要做到全域性統一。
  • 如何降低偶性和入侵性
    • 讓View狀態的切換和Activity徹底分離開,必須把這些狀態View都封裝到一個管理類中,然後暴露出幾個方法來實現View之間的切換。 在不同的專案中可以需要的View也不一樣,所以考慮把管理類設計成builder模式來自由的新增需要的狀態View。
    • 那麼如何降低耦合性,讓程式碼入侵性低。方便維護和修改,且移植性強呢?大概具備這樣的條件……
      • 可以運用在activity或者fragment中
      • 不需要在佈局中新增LoadingView,而是統一管理不同狀態檢視,同時暴露對外設定自定義狀態檢視方法,方便UI特定頁面定製
      • 支援設定自定義不同狀態檢視,即使在BaseActivity統一處理狀態檢視管理,也支援單個頁面定製
      • 在載入檢視的時候像異常和空頁面能否用ViewStub代替,這樣減少繪製,只有等到出現異常和空頁面時,才將檢視給inflate出來
      • 當頁面出現網路異常頁面,空頁面等,頁面會有互動事件,這時候可以設定點選設定網路或者點選重新載入等等
  • 那麼具體怎麼操作呢?
    • 可以自由切換內容,空資料,異常錯誤,載入,網路錯誤等5種狀態。父類BaseFragment直接暴露5中狀態,方便子類統一管理狀態切換,這裡fragment的封裝和activity差不多。
    /**
     * <pre>
     *     @author yangchong
     *     blog  : https://github.com/yangchong211
     *     time  : 2017/7/20
     *     desc  : fragment的父類
     *     revise: 注意,該類具有懶載入
     * </pre>
     */
    public abstract class BaseStateFragment extends BaseLazyFragment {
    
        protected StateLayoutManager statusLayoutManager;
        private View view;
    
        @Nullable
        @Override
        public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                                 @Nullable Bundle savedInstanceState) {
            if(view==null){
                view = inflater.inflate(R.layout.base_state_view, container , false);
                initStatusLayout();
                initBaseView(view);
            }
            return view;
        }
    
        @Override
        public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
            super.onViewCreated(view, savedInstanceState);
            initView(view);
            initListener();
        }
    
        @Override
        public void onActivityCreated(@Nullable Bundle savedInstanceState) {
            super.onActivityCreated(savedInstanceState);
        }
    
        /**
         * 獲取到子佈局
         * @param view              view
         */
        private void initBaseView(View view) {
            LinearLayout llStateView = view.findViewById(R.id.ll_state_view);
            llStateView.addView(statusLayoutManager.getRootLayout());
        }
    
    
        /**
         * 初始化狀態管理器相關操作
         */
        protected abstract void initStatusLayout();
    
        /**
         * 初始化View的程式碼寫在這個方法中
         * @param view              view
         */
        public abstract void initView(View view);
    
        /**
         * 初始化監聽器的程式碼寫在這個方法中
         */
        public abstract void initListener();
    
        /**
         * 第一次可見狀態時,showLoading操作,注意下拉重新整理操作時不要用該全域性loading
         */
        @Override
        protected void showFirstLoading() {
            super.showFirstLoading();
            showLoading();
        }
    
        /*protected void initStatusLayout() {
            statusLayoutManager = StateLayoutManager.newBuilder(activity)
                    .contentView(R.layout.common_fragment_list)
                    .emptyDataView(R.layout.view_custom_empty_data)
                    .errorView(R.layout.view_custom_data_error)
                    .loadingView(R.layout.view_custom_loading_data)
                    .netWorkErrorView(R.layout.view_custom_network_error)
                    .build();
        }*/
    
    
        /*---------------------------------下面是狀態切換方法-----------------------------------------*/
    
    
        /**
         * 載入成功
         */
        protected void showContent() {
            if (statusLayoutManager!=null){
                statusLayoutManager.showContent();
            }
        }
    
        /**
         * 載入無資料
         */
        protected void showEmptyData() {
            if (statusLayoutManager!=null){
                statusLayoutManager.showEmptyData();
            }
        }
    
        /**
         * 載入異常
         */
        protected void showError() {
            if (statusLayoutManager!=null){
                statusLayoutManager.showError();
            }
        }
    
        /**
         * 載入網路異常
         */
        protected void showNetWorkError() {
            if (statusLayoutManager!=null){
                statusLayoutManager.showNetWorkError();
            }
        }
    
        /**
         * 載入loading
         */
        protected void showLoading() {
            if (statusLayoutManager!=null){
                statusLayoutManager.showLoading();
            }
        }
    }
    
    //如何切換狀態呢?
    showContent();
    showEmptyData();
    showError();
    showLoading();
    showNetWorkError();
    
    //或者這樣操作也可以
    statusLayoutManager.showLoading();
    statusLayoutManager.showContent();
    
  • 狀態管理器的設計思路
    • StateFrameLayout是繼承FrameLayout自定義佈局,主要是存放不同的檢視,以及隱藏和展示檢視操作
    • StateLayoutManager是狀態管理器,主要是讓開發者設定不同狀態檢視的view,以及切換檢視狀態操作
      • 幾種異常狀態要用ViewStub,因為在介面狀態切換中loading和內容View都是一直需要載入顯示的,但是其他的3個只有在沒資料或者網路異常的情況下才會載入顯示,所以用ViewStub來載入他們可以提高效能。
    • OnRetryListener,為介面,主要是重試作用。比如載入失敗了,點選檢視需要重新重新整理介面,則可以用到這個。開發者也可以自己設定點選事件
    • 關於狀態檢視切換方案,目前市場有多種做法,具體可以看我的這篇部落格:https://juejin.im/post/5d2f014d6fb9a07ea648a959

其他介紹

01.關於部落格彙總連結

02.關於我的部落格

專案地址:https://github.com/yangchong2