Fragment懶載入的探究和實現
前言
在Android開發中,利用ViewPager+Fragment實現頁籤的切換幾乎是每個app的必備功能,雖然實現起來並不難,但是還是有一些需要我們注意的地方。我自己此前也瞭解過一些關於Fragment懶載入的實現,但是基本停留在拿來直接用的程度,很多地方還有一些疑惑,本文主要介紹一下使用ViewPager和Fragment的一些技巧和可能會踩到的坑,也算是對這塊知識的梳理。
1.Fragment的懶載入
所謂懶載入,指的就是延遲載入,在需要的時候再載入資料,這個概念其實在Web開發中很常見,那麼在Android開發中,為什麼Fragment要實現懶載入?什麼場景下需要實現懶載入呢?
相信我們在開發中都實現過底部和頂部的標籤導航功能,點選相應的標籤可以切換到相應的頁面,當然使用的就是Fragment,由於同一時間只有一個頁面(Fragment)能顯示在螢幕中,因此沒有顯示出來的Fragment就沒有必要在此時載入資料,特別是從網路獲取資料這種比較耗時的操作,會產生不太好的使用者體驗,理想的情況是在Fragment可見的時候才載入資料,這就是為什麼Fragment需要懶載入的原因。在實際開發中,實現Fragment的切換有兩種方式,使用 FragmentManager 或 ViewPager ,那麼這兩種情況下Fragment是何時載入的呢,我們來看兩個常見場景:
場景一 使用FragmentManager實現底部導航欄頁面切換
首先簡單介紹一下FragmentManager,用於管理Fragment,能夠實現Fragment的新增、移除、顯示和隱藏,雖然我們可能已經用過很多次了,但還是有一些需要注意的地方。系統提供了三個API來獲得FragmentManager,但是它們的使用場景是不一樣的:
- getSupportFragmentManager
getSupportFragmentManager()用於Activity中,用於管理Activity中新增的Fragment,該方法只有在 FragmentActivity 中才有,FragmentActivity是v4包中的,繼承自Activity,用於相容低版本沒有Fragment的API問題,AppCompatActivity就是繼承了FragmentActivity,因此如果我們的Activity是繼承自AppCompatActivity,可以直接使用getSupportFragmentManager()方法來獲得FragmentManager。 - getFragmentManager
該方法既可用於Activity中,也可以用於Fragment中。該方法位於Activity類中,如果用於Activity中,與getSupportFragmentManager()方法作用相同,用於獲取Activity中的FragmentManager,需要注意的是該方法是app包中的,因此如果我們使用的Fragment是v4包中的,那麼應該讓Activity繼承自FragmentActivity,使用getSupportFragmentManager()。Fragment中也有該方法,返回的是管理當前Fragment自身的那個FragmentManager,也就是將當前Fragment新增進來的FragmentManager,有可能是Activity中的FragmentManager,也有可能是Fragment中的FragmentManager。 - getChildFragmentManager
getChildFragmentManager()用於Fragment中,用於管理當前Fragment中新增的子Fragment,換句話說就是Fragment中巢狀Fragment的情況。
總結一下,在Activity中管理Fragment,如果Fragment是位於v4包中的,使用getSupportFragmentManager();如果Fragment是位於app包中的,使用getFragmentManager()。如果要在Fragment中巢狀子Fragment,使用getChildFragmentManager()。
實現底部導航欄的方式有很多:包括RadioButton、TabHost甚至是LinearLayout都可以,這裡使用了官方design庫提供的 BottomNavigationView ,使用方式不是本文的重點,也比較簡單,這裡就不提了,可以自行百度或是參考我的 Demo 。在使用時有幾個需要注意的問題:
-
Tab標籤多於3個時標籤切換預設會有動畫效果,就像這樣:
大於三個item切換動畫
大多數情況下我們其實並不需要要這種效果,如何取消呢,針對design庫的版本有不同的解決方法:
com.android.support:design:28.0.0以下:
通過反射呼叫 setShiftingMode(false)
方法,完整程式碼如下:
public void disableShiftMode(BottomNavigationView view) { BottomNavigationMenuView menuView = (BottomNavigationMenuView) view.getChildAt(0); try { Field shiftingMode = menuView.getClass().getDeclaredField("mShiftingMode"); shiftingMode.setAccessible(true); shiftingMode.setBoolean(menuView, false); shiftingMode.setAccessible(false); for (int i = 0; i < menuView.getChildCount(); i++) { BottomNavigationItemView item = (BottomNavigationItemView) menuView.getChildAt(i); //noinspection RestrictedApi item.setShiftingMode(false); // set once again checked value, so view will be updated //noinspection RestrictedApi item.setChecked(item.getItemData().isChecked()); } } catch (NoSuchFieldException e) { Log.e("BNVHelper", "Unable to get shift mode field", e); } catch (IllegalAccessException e) { Log.e("BNVHelper", "Unable to change value of shift mode", e); } }
使用時直接呼叫該方法,傳入BottomNavigationView即可:
// BottomNavigationView禁止3個item以上動畫切換效果 BottomNavigationViewHelper.disableShiftMode(mBottomNavigationView);
com.android.support:design:28.0.0:
無法呼叫 setShiftingMode()
方法,官方提供瞭解決方法,只需要在xml佈局檔案的BottomNavigationView下新增 app:labelVisibilityMode="labeled"
屬性即可。
<android.support.design.widget.BottomNavigationView android:id="@+id/bnv_bar" android:layout_width="match_parent" android:layout_height="wrap_content" app:itemIconTint="@drawable/nav_item_color_state" app:itemTextColor="@drawable/nav_item_color_state" app:labelVisibilityMode="labeled" app:menu="@menu/menu_bottom_navigation" />
-
Tab切換時文字大小會變化
其實這個效果是否需要保留因人而異,通過檢視BottomNavigationItemView的原始碼我們可以發現選中和未選中字型的大小是由兩個屬性決定的。
public BottomNavigationItemView(Context context, AttributeSet attrs, int defStyleAttr) { ... int inactiveLabelSize = res.getDimensionPixelSize(android.support.design.R.dimen.design_bottom_navigation_text_size); int activeLabelSize = res.getDimensionPixelSize( android.support.design.R.dimen.design_bottom_navigation_active_text_size); ... }
如果我們想要去掉切換時文字的大小變化,只要在自己專案中的values資料夾下新建dimens.xml檔案,宣告同名的屬性,覆蓋BottomNavigationView的預設屬性值,將選中和未選中時的字型大小設定成相等的值就可以了。
<!-- BottomNavigationView選中和未選中文字大小 --> <dimen name="design_bottom_navigation_active_text_size">14sp</dimen> <dimen name="design_bottom_navigation_text_size">14sp</dimen>
下面回到正題,我給BottomNavigationView添加了三個標籤,分別對應三個Fragment,每個Fragment的程式碼結構基本一致,只是載入的資料不一樣,在Fragment的生命週期回撥方法中列印日誌,完整程式碼如下:
import android.content.Context; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.example.viewpagerfragment.R; import com.example.viewpagerfragment.adapter.ListAdapter; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; public class HomeFragment extends Fragment { private RecyclerView mRecyclerView; private ListAdapter mAdapter; private List<String> mData; @Override public void onAttach(Context context) { super.onAttach(context); Log.e("TAG", "HomeFragment onAttach()"); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.e("TAG", "HomeFragment onCreate()"); } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { Log.e("TAG", "HomeFragment onCreateView()"); View view = inflater.inflate(R.layout.fragment_home, container, false); initView(view); initData(); initEvent(); return view; } /** * 初始化檢視 * * @param view */ private void initView(View view) { mRecyclerView = view.findViewById(R.id.rv_home); mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); mRecyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), DividerItemDecoration.VERTICAL)); } /** * 初始化資料 */ private void initData() { mData = new ArrayList<>(); // 模擬資料的延遲載入 Observable.timer(3, TimeUnit.SECONDS) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Consumer<Long>() { @Override public void accept(Long aLong) throws Exception { for (int i = 0; i < 20; i++) { mData.add("首頁文章" + (i + 1)); } mAdapter = new ListAdapter(getActivity(), mData); mRecyclerView.setAdapter(mAdapter); } }); } /** * 初始化事件 */ private void initEvent() { } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); Log.e("TAG", "HomeFragment onActivityCreated()"); } @Override public void onStart() { super.onStart(); Log.e("TAG", "HomeFragment onStart()"); } @Override public void onResume() { super.onResume(); Log.e("TAG", "HomeFragment onResume()"); } @Override public void onPause() { super.onPause(); Log.e("TAG", "HomeFragment onPause()"); } @Override public void onStop() { super.onStop(); Log.e("TAG", "HomeFragment onStop()"); } @Override public void onDestroyView() { super.onDestroyView(); Log.e("TAG", "HomeFragment onDestroyView()"); } @Override public void onDestroy() { super.onDestroy(); Log.e("TAG", "HomeFragment onDestroy()"); } @Override public void onDetach() { super.onDetach(); Log.e("TAG", "HomeFragment onDetach()"); } }
使用FragmentManager管理Fragment,呼叫 hide()
和 show()
方法來切換頁面的顯示。
/** * 顯示當前Fragment * * @param index */ private void showFragment(int index) { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); hideFragment(ft); switch (index) { case FRAGMENT_HOME: /** * 如果Fragment為空,就新建一個例項 * 如果不為空,就將它從棧中顯示出來 */ if (homefragment == null) { homefragment = new HomeFragment(); ft.add(R.id.fl_container, homefragment, HomeFragment.class.getName()); } else { ft.show(homefragment); } break; case FRAGMENT_KNOWLEDGESYSTEM: if (knowledgeSystemFragment == null) { knowledgeSystemFragment = new KnowledgeSystemFragment(); ft.add(R.id.fl_container, knowledgeSystemFragment, KnowledgeSystemFragment.class.getName()); } else { ft.show(knowledgeSystemFragment); } break; case FRAGMENT_PROJECT: if (projectFragment == null) { projectFragment = new ProjectFragment(); ft.add(R.id.fl_container, projectFragment, ProjectFragment.class.getName()); } else { ft.show(projectFragment); } break; default: break; } ft.commit(); } /** * 隱藏全部Fragment * * @param ft */ private void hideFragment(FragmentTransaction ft) { // 如果不為空,就先隱藏起來 if (homefragment != null) { ft.hide(homefragment); } if (knowledgeSystemFragment != null) { ft.hide(knowledgeSystemFragment); } if (projectFragment != null) { ft.hide(projectFragment); } }
在Fragment的幾個生命週期回撥方法中列印日誌,下面我們就來看一下整個載入過程。
-
初始狀態顯示第一個Fragment
可以看出,此時依次回調了第一個Fragment的生命週期方法,並沒有載入其他的兩個Fragment。
-
切換到第二個Fragment
此時依次回調了第二個Fragment的生命週期方法,並沒有載入第三個Fragment,第一個Fragment也沒有被銷燬。
-
切換到第三個Fragment
此時依次回調了第三個Fragment的生命週期方法,前兩個Fragment並沒有被銷燬。之後在幾個Fragment之間切換也不會回撥任何的生命週期方法。
其實這種情況和我們理想的情況是一致的,即當Fragment第一次真正顯示出來時才進行建立,載入資料,並且資料只加載一次。因此可以得出結論, 當我們是通過呼叫hide()和show()方法來實現Fragment的切換時,不需要做額外的操作即可實現懶載入 。
那麼可能有的人就會有疑問了,如果是呼叫 replace()
來實現Fragment的切換呢,會不會銷燬掉之前的Fragment呢?下面來看一下這種情況。
/** * 顯示當前Fragment * * @param index */ private void showFragment(int index) { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); switch (index) { case FRAGMENT_HOME: if (homefragment == null) { homefragment = new HomeFragment(); ft.add(R.id.fl_container, homefragment, HomeFragment.class.getName()); } ft.replace(R.id.fl_container, homefragment); break; case FRAGMENT_KNOWLEDGESYSTEM: if (knowledgeSystemFragment == null) { knowledgeSystemFragment = new KnowledgeSystemFragment(); ft.add(R.id.fl_container, knowledgeSystemFragment, KnowledgeSystemFragment.class.getName()); } ft.replace(R.id.fl_container, knowledgeSystemFragment); break; case FRAGMENT_PROJECT: if (projectFragment == null) { projectFragment = new ProjectFragment(); ft.add(R.id.fl_container, projectFragment, ProjectFragment.class.getName()); } ft.replace(R.id.fl_container, projectFragment); break; default: break; } ft.commit(); }
-
初始狀態顯示第一個Fragment
與呼叫hide()和show()的情況沒有區別,只是執行了第一個Fragment的生命週期方法。
-
切換到第二個Fragment
這種情況下就有區別了,可以發現在呼叫第二個Fragment的生命週期方法同時銷燬了第一個Fragment。
-
切換到第三個Fragment
同上分析,建立第三個Fragment的同時回調了第二個Fragment銷燬相關的生命週期方法。
之後切換回前幾個Fragment,我們應該能夠想到會發生什麼情況,由於之前的Fragment已經被銷燬,因此會重新建立Fragment,載入資料,同時銷燬切換前的Fragment物件。
因此,當呼叫replace()實現Fragment的動態顯示時,會銷燬不可見的Fragment,重新建立當前Fragment,雖然Fragment也是在可見時載入資料的,但是會導致資料的多次載入,浪費資源,因此相比於hide()和show()方法,並不推薦這種方法切換Fragment。
最後總結一下,當我們使用FragmentManager管理多個Fragment,實現Fragment之間的切換時,有兩種方法: hide()
+ show()
或者 replace()
,兩種方法的共同點是隻有在Fragment顯示時才建立Fragment物件,載入頁面資料,也就是實現了懶載入,區別是前者在Fragment切換時不會銷燬之前的Fragment物件,後者會銷燬,推薦使用第一種方式,當然還是要看實際情況哪種方式更適合。
場景二 使用ViewPager實現頂部標籤欄頁面切換
實現頂部標籤欄的方式同樣有很多,github上很多優秀的第三方庫,可以實現各種酷炫的效果,這裡為了簡單,依然是使用官方design庫中提供的 TabLayout ,使用方式比較簡單,就不展示了,配合ViewPager可以實現頁面的滑動切換。
Fragment依然使用之前的那三個,完整程式碼如下:
import android.os.Bundle; import android.support.annotation.Nullable; import android.support.design.widget.TabLayout; import android.support.v4.app.Fragment; import android.support.v4.view.ViewPager; import android.support.v7.app.AppCompatActivity; import com.example.viewpagerfragment.R; import com.example.viewpagerfragment.adapter.MyPagerAdapter; import java.util.ArrayList; import java.util.List; public class TabActivity extends AppCompatActivity { private TabLayout mTabLayout; private ViewPager mViewPager; private HomeFragment homefragment; private KnowledgeSystemFragment knowledgeSystemFragment; private ProjectFragment projectFragment; private List<Fragment> mFragments; private MyPagerAdapter mAdapter; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_tab); initView(); initData(); initEvent(); } /** * 初始化檢視 */ private void initView() { mTabLayout = findViewById(R.id.tl_tabs); mViewPager = findViewById(R.id.vp_tabs); } /** * 初始化資料 */ private void initData() { mFragments = new ArrayList<>(); homefragment = new HomeFragment(); knowledgeSystemFragment = new KnowledgeSystemFragment(); projectFragment = new ProjectFragment(); mFragments.add(homefragment); mFragments.add(knowledgeSystemFragment); mFragments.add(projectFragment); mAdapter = new MyPagerAdapter(getSupportFragmentManager(), mFragments); mViewPager.setAdapter(mAdapter); // 關聯ViewPager mTabLayout.setupWithViewPager(mViewPager); // mTabLayout.setupWithViewPager方法內部會remove所有的tabs,這裡重新設定一遍tabs的text,否則tabs的text不顯示 mTabLayout.getTabAt(0).setText("首頁"); mTabLayout.getTabAt(1).setText("知識體系"); mTabLayout.getTabAt(2).setText("專案"); } /** * 初始化事件 */ private void initEvent() { } }
這裡提幾點在使用TabLayout和ViewPager時需要注意的地方,呼叫 setupWithViewPager()
關聯ViewPager後,TabLayout會remove掉所有的tab,執行後會發現無法顯示標籤文字。解決方法有兩種:第一種是重寫ViewPager的adapter的 getPageTitle()
方法,設定每個Tab的標題。
@Override public CharSequence getPageTitle(int position) { String title; switch (position) { case 0: title = "首頁"; break; case 1: title = "知識體系"; break; case 2: title = "專案"; break; default: title = ""; break; } return title; }
第二種是在setupWithViewPager()後重新設定Tab標題,這種方式更適合與Tab個數和標題未知的情況。
mTabLayout.getTabAt(0).setText("首頁"); mTabLayout.getTabAt(1).setText("知識體系"); mTabLayout.getTabAt(2).setText("專案");
ViewPager的Adapter有兩種: FragmentPagerAdapter 和 FragmentStatePagerAdapter ,這兩種的區別是什麼呢,我們分別繼承一下這兩種Adapter,看一下效果。繼承只需要實現幾個方法就可以了,方法名一看就知道是什麼意思,這裡就不展示了。
- 繼承FragmentPagerAdapter
1.初始狀態顯示第一個Fragment

可以看出,此時不僅載入了第一個Fragment,第二個Fragment也建立並載入了。
2.切換到第二個Fragment

此時,第三個Fragment被建立並載入。
3.切換到第三個Fragment

此時,第一個Fragment依次執行onPause()、onStop()和onDestoryView()方法,注意只是銷燬了檢視,並沒有執行onDestory()方法,銷燬Fragment物件。
當我們重新切換到第二個Fragment時,第一個Fragment依次執行onCreateView()、onActivityCreate()、onStart()和onResume()方法,重新建立檢視。

之後我們再切換回第一個Fragment,可以發現第三個Fragment的檢視被銷燬。

- 繼承FragmentStatePagerAdapter
下面我們再來看一下繼承FragmentStatePagerAdapter的情況。
1.初始狀態顯示第一個Fragment

和繼承FragmentPagerAdapter的情況沒有區別,同樣是建立加載出了前兩個Fragment。
2切換到第二個Fragment

同樣是提前創建出了第三個Fragment。
3.切換到第三個Fragment

這裡就有區別了,同樣是要銷燬第一個Fragment,繼承FragmentPagerAdapter時只是銷燬了檢視,並沒有執行onDestory()方法;而繼承FragmentStatePagerAdapter不僅會銷燬檢視,還銷燬了Fragment物件,執行了onDestory()和onDetach()方法。
之後切換回第二個Fragment,會重新建立第一個Fragment物件,執行onAttach()和onCreate()方法。

再切換回第一個Fragment,第三個Fragment被銷燬。

上面展示了不同情況下打印出來的生命週期執行日誌,可能不是很清楚,這裡我就總結一下,使用ViewPager切換Fragment時,預設會提前加載出下一個位置Fragment,與當前位置間隔超過1的Fragment會被銷燬,這裡又分為了兩種情況: 如果ViewPager的adapter繼承自FragmentPagerAdapter,那麼只會銷燬Fragment的檢視,不會銷燬Fragment物件;如果ViewPager的adapter繼承自FragmentStatePagerAdapter,那麼不僅會銷燬Fragment的檢視,而且也會銷燬Fragment物件 (這好像是廢話,物件都銷燬了哪裡來的檢視)。
由於FragmentStatePagerAdapter會完全銷燬Fragment物件,因此更適用於Fragment比較多的情況,保證Fragment的回收,節省記憶體;FragmentPagerAdapter更適合Fragment數量較少的情況,不會頻繁地建立和銷燬Fragment物件。
有人可能要問了,有沒有什麼方法可以防止Fragment的銷燬呢?當然有了,這裡先介紹一種方式,後面介紹ViewPager的預載入時會再介紹一種方法。通過檢視FragmentStatePagerAdapter的原始碼會發現,Fragment的銷燬是在 destroyItem()
方法中宣告的(FragmentPagerAdapter也是這樣),如果我們不需要銷燬Fragment,只需要複寫該方法即可,記住不要使用super呼叫父類的實現。
@Override public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { //super.destroyItem(container, position, object); }
2.ViewPager的預載入機制
通過之前的例子我們已經知道ViewPager會提前載入下一個位置的Fragment,這就叫做VIewPager的預載入機制,作用是為了讓ViewPager的切換更加流暢。提到ViewPager的預載入機制,我們就不得不提到一個方法 setOffscreenPageLimit(int limit)
。該方法的作用就是設定ViewPager的預載入頁面數量,同時也決定了ViewPager能夠快取的頁面數量。舉個例子,如果我們呼叫 mViewPager.setOffscreenPageLimit(3)
,那麼ViewPager會提前載入當前頁面兩邊相鄰的3個Fragment,此時VIewPager可快取的Fragment數量為 2*3+1=7
,與當前Fragment間距超過3的Fragment就會被銷燬回收(是否會銷燬Fragment例項物件由我們繼承的Adapter決定)。limit的預設值是1,這就解釋了為什麼ViewPager會提前載入下一個位置的Fragment,並且顯示第三個Fragment時會銷燬第一個Fragment。
這裡依然採用之前頂部標籤欄的例子,新增一行程式碼,設定ViewPager的預載入數量,重新來看一下Fragment的建立和載入過程。
mViewPager.setOffscreenPageLimit(mFragments.size());

初始狀態顯示第一個Fragment
可以看出當初始狀態顯示第一個Fragment時,就已經建立並載入了所有的Fragment,並且當我們在幾個Tab之間切換時,也不會銷燬並重新建立Fragment。
這就是我在上文中提到的如何防止Fragment被銷燬的第二種方法,就是通過setOffscreenPageLimit(),設定預載入數量為Tab總數,使得所有Fragment都能被快取。
ViewPager的預載入機制其實和我們想要實現的懶載入是背道而馳的,那麼我們可以取消預載入嗎?答案是不能,或許有的人想到了設定預載入數量為0,但是並不起作用,這是為什麼呢,我們來看一下setOffscreenPageLimit()方法內部就明白了。
private static final int DEFAULT_OFFSCREEN_PAGES = 1; public void setOffscreenPageLimit(int limit) { if (limit < DEFAULT_OFFSCREEN_PAGES) { Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to " + DEFAULT_OFFSCREEN_PAGES); limit = DEFAULT_OFFSCREEN_PAGES; } if (limit != mOffscreenPageLimit) { mOffscreenPageLimit = limit; populate(); } }
我們可以很清楚地看出,如果我們傳入了小於1的值,最後都會取預設值1,因此這種方法是無法取消預載入的。
3.如何實現Fragment的懶載入
既然我們無法取消ViewPager的預載入,那就只能從Fragment的角度來實現懶載入了。基本思路是判斷Fragment是否可見,當可見時才載入資料,這就涉及到了Fragment的兩個方法: setUserVisibleHint(boolean isVisibleToUser)
和 onHiddenChanged(boolean hidden)
,下面我們就來具體看一下這兩個方法。
-
setUserVisibleHint
setUserVisibleHint()方法只有在使用ViewPager管理Fragment時才會呼叫,有一個引數isVisibleToUser,字面意思就是是否對於使用者可見,那麼我們是否可以直接利用該引數來判斷Fragment的可見與否呢?先別急,我們來看一下該方法的執行情況,依然採用之前頂部標籤欄的例子,在每個Fragment中重寫setUserVisibleHint()方法,列印isVisibleToUser的值。
首先來看一下初始狀態顯示第一個Fragment時的情況,由於已經設定了預載入數量為3,因此三個Fragment全部被建立和載入,但是我們注意setUserVisibleHint()方法的執行,對於第二和第三個Fragment來說,和我們預想中的一樣,執行了一次,isVisibleToUser的值為false,也就是不可見;但對於第一個Fragment,setUserVisibleHint()方法執行了兩次,並且第一次執行打印出來的isVisibleToUser的值為false,第二次才為true。再看一下setUserVisibleHint()的執行時機,我們發現該方法是在Fragment所有的生命週期方法之前就執行的,這一點需要注意。
再來看一下切換到第二個和第三個Fragment時的情況

切換到第二個Fragment

切換到第三個Fragment
這兩種情況下就和預想的一樣了,分別只執行了一次該方法,將相應Fragment的可見狀態改變。
之後切換ViewPager都會執行兩個Fragment的setUserVisibleHint()方法,不可見的的那個isVisibleToUser的值為false,顯示出來的那個isVisibleToUser的值為true。

結合一開始顯示第一Fragment時列印的結果來看,每個Fragment的setUserVisibleHint()方法都會至少執行兩次,一次是在Fragment的生命週期方法執行之前,此時isVisibleToUser的值為false;一次是在Fragment變為可見時,此時isVisibleToUser的值為true。
這裡還需要提一下 getUserVisibleHint()
方法,也有人是利用該方法來判斷Fragment是否可見的,那麼該方法的返回值代表什麼呢,通過檢視原始碼,我們可以發現,其實getUserVisibleHint()的返回值就是setUserVisibleHint()方法的isVisibleToUser引數,因此,這種判斷方式本質上和利用isVisibleToUser來判斷是一樣的。
public void setUserVisibleHint(boolean isVisibleToUser) { if (!mUserVisibleHint && isVisibleToUser && mState < STARTED && mFragmentManager != null && isAdded()) { mFragmentManager.performPendingDeferredStart(this); } // 這裡對mUserVisibleHint賦值 mUserVisibleHint = isVisibleToUser; mDeferStart = mState < STARTED && !isVisibleToUser; } public boolean getUserVisibleHint() { return mUserVisibleHint; }
-
onHiddenChanged
onHiddenChanged()方法只有在利用FragmentManager管理Fragment,並且使用hide()和show()方法切換Fragment時才會被呼叫,該方法同樣有一個引數hidden,表示Fragment是否隱藏,下面我們就以之前底部導航欄的例子驗證一下onHiddenChanged()方法的執行時機和作用。
首先是初始狀態顯示第一個Fragment時,可以發現並沒有執行第一個Fragment的onHiddenChanged()方法,這是由於我在程式碼添加了判斷,如果Fragment例項物件為空,就呼叫add()方法先將Fragment新增到FragmentTransaction中,並沒有呼叫show()方法。
切換到第二個Fragment時,由於呼叫了hide()方法隱藏第一個Fragment,因此執行了第一個Fragment的onHiddenChanged()方法,hidden引數的值為true,表示隱藏了Fragment。

切換到第三個Fragment時同上,會呼叫第二個Fragment的onHiddenChanged()方法,hidden引數的值為true。大家可能注意到了,我在程式碼中是先呼叫了hide()方法隱藏了所有不為空的Fragment,那麼為什麼這裡這裡沒有呼叫第一個Fragment的onHiddenChanged()方法呢,其實很簡單,因為之前第一個Fragment就已經是隱藏狀態了,我們注意方法名字尾是'Changed',因此只有在隱藏或顯示狀態改變的情況下才會呼叫onHiddenChanged()方法。

之後在任意兩個Fragment之間切換時會分別執行兩個Fragment的onHiddenChanged()方法,可見的那個hidden值為false,表示顯示;不可見的hidden值為true,表示隱藏。

由此我們可以得出結論,onHiddenChanged()方法是在呼叫show()和hide()方法時被呼叫的,並且只有在Fragment的隱藏或顯示狀態發生了改變時才會呼叫。不同於setUserVisibleHint()方法,呼叫onHiddenChanged()時Fragment已經完成了建立相關生命週期(onAttach()~onResume())的回撥。
既然已經清楚了這兩個方法的呼叫時機和作用,那麼我們就可以來實現懶載入了,首先確定實現思路:
- 要在Fragment可見時載入資料,並且只加載一次。
- 由於ViewPager的預載入機制,因此要利用setUserVisibleHint()方法,根據引數isVisibleToUser來判斷Fragment是否可見。
- setUserVisibleHint()方法不止會在Fragment切換時呼叫,在onCreateView()之前也會被呼叫,此時isVisibleToUser的值為false,這時是獲取不到檢視和控制元件的,因此不能只根據isVisibleToUser來判斷是否需要載入資料,需要引入一個變數標識檢視是否已經載入完成。
- 由於載入資料後繼續切換ViewPager仍然會執行setUserVisibleHint()方法,因此還需要引入一個變數標識是否已經載入過資料,防止資料的重複載入。
- 只有當同時滿足以下三個條件時才載入資料:
- 檢視已載入完成
- 資料未載入
- Fragment可見
清楚了思路後,我們就可以來封裝自己的LazyFragment了,完整程式碼如下:
import android.content.Context; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; public abstract class LazyFragment extends Fragment { private Context mContext; private boolean hasViewCreated; // 檢視是否已載入 private boolean isFirstLoad; // 是否首次載入 private ProgressDialog mProgressDialog; // 載入進度對話方塊 @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); mContext = getActivity(); } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { hasViewCreated = true; isFirstLoad = true; View view = LayoutInflater.from(mContext).inflate(getContentViewId(), null); initView(view); initData(); initEvent(); lazyLoad(); return view; } /** * 設定佈局資源id * * @return */ protected abstract int getContentViewId(); /** * 初始化檢視 * * @param view */ protected void initView(View view) { } /** * 初始化資料 */ protected void initData() { } /** * 初始化事件 */ protected void initEvent() { } /** * 懶載入 */ protected void onLazyLoad(){ } @Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); if (isVisibleToUser) { lazyLoad(); } } private void lazyLoad() { if (!hasViewCreated || !isFirstLoad || !getUserVisibleHint()) { return; } isFirstLoad = false; onLazyLoad(); } }
像我前面分析的那樣,聲明瞭兩個變數,分別標識檢視是否載入完成和資料是否已載入,執行懶載入的條件有三個:檢視已載入、資料未載入、isVisibleToUser的值為true。lazyLoad()方法的作用是判斷是否可以載入資料,真正的載入資料邏輯在onLazyLoad()方法中宣告。
關於上面的程式碼有幾點我要說一下,第一點是為什麼要在onCreateView中再執行一次lazyLoad()方法,我們前面分析過,setUserVisibleHint()是在onCreateView()之前呼叫的,這時hasViewCreated的值為false,不滿足條件,是無法執行載入資料的邏輯的,因此要在onCreateView中將hasViewCreated設定為true之後再判斷一次是否可以載入資料,這也是為什麼我要單獨寫一個lazyLoad()方法的原因。第二點是我在lazyLoad()方法中使用了getUserVisibleHint()方法,之前提到過,該方法的返回值就是setUserVisibleHint()中的引數isVisibleToUser,因此可以直接利用該方法來判斷Fragment的可見性,就不需要額外再宣告一個變量了。使用方法也很簡單,只需要繼承LazyFragment,實現getContentViewId()方法,返回佈局檔案id即可。如果不需要實現懶載入,就重寫initData()方法,內部新增資料載入邏輯;如果需要實現懶載入,就不需要重寫initData()方法,將載入資料的邏輯放到onLazyLoad()方法中就可以了。
這樣封裝其實也有一個問題,就是當同一個Fragment同時需要用於FragmentManager場景和ViewPager場景中時,如果將載入資料邏輯放到onLazyLoad()中,那麼在使用FragmentManager管理Fragment時不會呼叫setUsersetUserVisibleHint()方法,也就無法載入資料了;如果把載入資料邏輯放到initData()中,那麼就失去了懶載入的作用。我有看到過一種解決方法是重寫onHiddenChanged()方法,根據相同的判斷條件,執行載入資料邏輯,但是這樣有一個問題是在每一個Fragment第一次呼叫 add()
方法被新增後,需要手動呼叫 hide()
和 show()
方法來觸發onHiddenChanged()方法,個人覺得還是有些奇怪,這裡就不展示了。考慮到這種情況也不是很常見,如果真的遇到了,還是寫兩個Fragment吧。
為了效果明顯我在LazyFragment中添加了一個ProgressDialog來顯示資料載入進度,我們來看一下實現懶載入後的效果,只有當ViewPager切換到Fragment時才開始載入資料,如下圖所示:

Fragment實現懶載入
其實懶載入Fragment的具體封裝方式有很多,但都是基於setUserVisibleHint()方法的,上面的程式碼只是我自己的一種封裝,大家可以根據自己習慣的編碼方式來實現自己的懶載入Fragment,重點還是要清楚原理和思路。
總結與後記
本文主要介紹了Fragment的懶載入實現以及ViewPager的預載入機制。實現Fragment的切換有兩種方式:FragmentManager和ViewPager,其中前者不會提前載入Fragment,因此不需要實現懶載入;後者由於自身的預載入機制,需要考慮懶載入來使得頁面的載入更加流暢。我們要清楚懶載入的實現並不是因為Fragment被延遲載入了,Fragment仍然會被預載入,只是當Fragment可見時才載入資料而已。
關於ViewPager和Fragment還有很多使用的技巧和可以深入去挖掘的東西,限於個人水平的原因,就不多提了,大家如果感興趣可以查閱相關的資料。現在谷歌官方新推出了一個新的元件ViewPager2來取代ViewPager,支援了豎直方向的滑動,雖然由於相容性等問題,短時間內ViewPager還不會被取代,但是有興趣的話還是可以瞭解一下的。
本文的相關程式碼我已經上傳到了github,由於自身水平的原因,我可能有些地方分析地不是很準確,表述地不是很清楚,歡迎大家指正,這樣才能不斷進步嘛。
Demo地址