1. 程式人生 > >Fragment全解析系列(一):那些年踩過的坑

Fragment全解析系列(一):那些年踩過的坑

本篇主要介紹一些最常見的Fragment的坑以及官方Fragment庫的那些自身的BUG,並給出解決方案;這些BUG在你深度使用時會遇到,比如Fragment巢狀時或者單Activity+多Fragment架構時遇到的坑。


Fragment是可以讓你的app縱享絲滑的設計,如果你的app想在現在基礎上效能大幅度提高,並且佔用記憶體降低,同樣的介面Activity佔用記憶體比Fragment要多,響應速度Fragment比Activty在中低端手機上快了很多,甚至能達到好幾倍!如果你的app當前或以後有移植平板等平臺時,可以讓你節省大量時間和精力。


簡陋的目錄
1、getActivity()空指標


2、異常:Can not perform this action after onSaveInstanceState
3、Fragment重疊異常-----正確使用hide、show的姿勢
4、Fragment巢狀的那些坑
5、未必靠譜的出棧方法remove()
6、多個Fragment同時出棧的深坑BUG
7、深坑 Fragment轉場動畫


開始之前

最新版知乎,單Activity多Fragment的架構,響應可以說非常“絲滑”,非要說缺點的話,就是沒有轉場動畫,並且轉場會有類似閃屏現象。我猜測可能和Fragment轉場動畫的一些BUG有關。(這系列的最後一篇文章我會給出我的解決方案,可以自定義轉場動畫,並能在各種特殊情況下正常執行。)

但是!Fragment相比較Activity要難用很多,在多Fragment以及巢狀Fragment的情況下更是如此。
更重要的是Fragment的坑真的太多了,看Square公司的這篇文章吧,Square:從今天開始拋棄Fragment吧!

當然,不能說不再用Fragment,Fragment的這些坑都是有解決辦法的,官方也在逐步修復一些BUG。
下面羅列一些,有常見的,也有極度隱蔽的一些坑,也是我在用單Activity多Fragment時遇到的坑,可能有更多坑可以挖掘...

在這之前為了方便後面文章的介紹,先規定一個“術語”,安卓app有一種特殊情況,就是 app執行在後臺的時候,系統資源緊張的時候導致把app的資源全部回收(殺死app的程序),這時把app再從後臺返回到前臺時,app會重啟。這種情況下文簡稱為:“記憶體重啟”

。(螢幕旋轉等配置變化也會造成當前Activity重啟,本質與“記憶體重啟”類似)

在系統要把app回收之前,系統會把Activity的狀態儲存下來,Activity的FragmentManager負責把Activity中的Fragment儲存起來。在“記憶體重啟”後,Activity的恢復是從棧頂逐步恢復,Fragment會在宿主Activity的onCreate方法呼叫後緊接著恢復(從onAttach生命週期開始)。


getActivity()空指標

可能你遇到過getActivity()返回null,或者平時執行完好的程式碼,在“記憶體重啟”之後,呼叫getActivity()的地方卻返回null,報了空指標異常。

大多數情況下的原因:你在呼叫了getActivity()時,當前的Fragment已經onDetach()了宿主Activity。
比如:你在pop了Fragment之後,該Fragment的非同步任務仍然在執行,並且在執行完成後呼叫了getActivity()方法,這樣就會空指標。

解決辦法:
更"安全"的方法:(對於Fragment已經onDetach這種情況,我們應該避免在這之後再去呼叫宿主Activity物件,比如取消這些非同步任務,但我們的團隊可能會有粗心大意的情況,所以下面給出的這個方案會保證安全)

在Fragment基類裡設定一個Activity mActivity的全域性變數,在onAttach(Activity activity)裡賦值,使用mActivity代替getActivity(),保證Fragment即使在onDetach後,仍持有Activity的引用(有引起記憶體洩露的風險,但是非同步任務沒停止的情況下,本身就可能已記憶體洩漏,相比Crash,這種做法“安全”些),即:

protected Activity mActivity;
@Override
public void onAttach(Activity activity) {
    super.onAttach(activity);
    this.mActivity = activity;
}

/**
*  如果你用了support 23的庫,上面的方法會提示過時,有強迫症的小夥伴,可以用下面的方法代替
*/
@Override
public void onAttach(Context context) {
    super.onAttach(context);
    this.mActivity = (Activity)context;
}

異常:Can not perform this action after onSaveInstanceState

有很多小夥伴遇到這個異常,這個異常產生的原因是:

在你離開當前Activity等情況下,系統會呼叫onSaveInstanceState()幫你儲存當前Activity的狀態、資料等,直到再回到該Activity之前(onResume()之前),你執行Fragment事務,就會丟擲該異常!(一般是其他Activity的回撥讓當前頁面執行事務的情況,會引發該問題)

解決方法:

  • 1、該事務使用commitAllowingStateLoss()方法提交,但是有可能導致該次提交無效!(宿主Activity被強殺時)

對於popBackStack()沒有對應的popBackStackAllowingStateLoss()方法,所以可以在下次可見時提交事務,參考2

  • 2、利用onActivityForResult()/onNewIntent(),可以做到事務的完整性,不會丟失事務

一個簡單的示例程式碼 :

// ReceiverActivity 或 其子Fragment:
void start(){
   startActivityForResult(new Intent(this, SenderActivity.class), 100);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
     super.onActivityResult(requestCode, resultCode, data);
     if (requestCode == 100 && resultCode == 100) {
         // 執行Fragment事務
     }
 }

// SenderActivity 或 其子Fragment:
void do() { // 操作ReceiverActivity(或其子Fragment)執行事務
    setResult(100);
    finish();
}

Fragment重疊異常-----正確使用hide、show的姿勢

在類onCreate()的方法載入Fragment,並且沒有判斷saveInstanceState==nullif(findFragmentByTag(mFragmentTag) == null),導致重複載入了同一個Fragment導致重疊。(PS:replace情況下,如果沒有加入回退棧,則不判斷也不會造成重疊,但建議還是統一判斷下)

@Override 
protected void onCreate(@Nullable Bundle savedInstanceState) {
// 在頁面重啟時,Fragment會被儲存恢復,而此時再載入Fragment會重複載入,導致重疊 ;
    if(saveInstanceState == null){
    // 或者 if(findFragmentByTag(mFragmentTag) == null)
       // 正常情況下去 載入根Fragment 
    } 
}

詳細原因:從原始碼角度分析,為什麼會發生Fragment重疊?

如果你add()了幾個Fragment,使用show()、hide()方法控制,比如微信、QQ的底部tab等情景,如果你什麼都不做的話,在“記憶體重啟”後回到前臺,app的這幾個Fragment介面會重疊。

原因是FragmentManager幫我們管理Fragment,當發生“記憶體重啟”,他會從棧底向棧頂的順序一次性恢復Fragment;
但是因為官方沒有儲存Fragment的mHidden屬性,預設為false,即show狀態,所以所有Fragment都是以show的形式恢復,我們看到了介面重疊。
(如果是replace,恢復形式和Activity一致,只有當你pop之後上一個Fragment才開始重新恢復,所有使用replace不會造成重疊現象)

v4-24.0.0+ 開始,官方修復了上述 沒有儲存mHidden的問題,所以如果你在使用24.0.0+的v4包,下面分析的2個解決方案可以自行跳過...

這裡給出2個解決方案:
1、是大家比較熟悉的 findFragmentByTag

即在add()或者replace()時繫結一個tag,一般我們是用fragment的類名作為tag,然後在發生“記憶體重啟”時,通過findFragmentByTag找到對應的Fragment,並hide()需要隱藏的fragment。

下面是個標準恢復寫法:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity);

    TargetFragment targetFragment;
    HideFragment hideFragment;
  
    if (savedInstanceState != null) {  // “記憶體重啟”時呼叫
        targetFragment = getSupportFragmentManager().findFragmentByTag(TargetFragment.class.getName);
        hideFragment = getSupportFragmentManager().findFragmentByTag(HideFragment.class.getName);
        // 解決重疊問題
        getFragmentManager().beginTransaction()
                .show(targetFragment)
                .hide(hideFragment)
                .commit();
    }else{  // 正常時
        targetFragment = TargetFragment.newInstance();
        hideFragment = HideFragment.newInstance();

        getFragmentManager().beginTransaction()
                .add(R.id.container, targetFragment, targetFragment.getClass().getName())
                .add(R.id,container,hideFragment,hideFragment.getClass().getName())
                .hide(hideFragment)
                .commit();
    }
}

如果你想恢復到使用者離開時的那個Fragment的介面,你還需要在onSaveInstanceState(Bundle outState)裡儲存離開時的那個可見的tag或下標,在onCreate“記憶體重啟”程式碼塊中,取出tag/下標,進行恢復。

** 2、我的解決方案,9行程式碼解決所有情況的Fragment重疊:傳送門**


Fragment巢狀的那些坑

其實一些小夥伴遇到的很多巢狀的坑,大部分都是由於對巢狀的棧檢視產生混亂,只要理清棧檢視關係,做好恢復相關工作以及正確選擇是使用getFragmentManager()還是getChildFragmentManager()就可以避免這些問題。

這部分內容是我們感覺Fragment非常難用的一個點,我會在下一篇中,詳細介紹使用Fragment巢狀的一些技巧,以及如何清晰分析各個層級的棧檢視。

附:startActivityForResult接收返回問題
在support 23.2.0以下的支援庫中,對於在巢狀子Fragment的startActivityForResult (),會發現無論如何都不能在onActivityResult()中接收到返回值,只有最頂層的父Fragment才能接收到,這是一個support v4庫的一個BUG,不過在前兩天釋出的support 23.2.0庫中,已經修復了該問題,巢狀的子Fragment也能正常接收到返回資料了!


未必靠譜的出棧方法remove()

如果你想讓某一個Fragment出棧,使用remove()在加入回退棧時並不靠譜。

如果你在add的同時將Fragment加入回退棧:addToBackStack(name)的情況下,它並不能真正將Fragment從棧內移除,如果你在2秒後(確保Fragment事務已經完成)列印getSupportFragmentManager().getFragments(),會發現該Fragment依然存在,並且依然可以返回到被remove的Fragment,而且是空白頁面。

如果你沒有將Fragment加入回退棧,remove方法可以正常出棧。

如果你加入了回退棧,popBackStack()系列方法才能真正出棧,這也就引入下一個深坑,popBackStack(String tag,int flags)等系列方法的BUG。


多個Fragment同時出棧的深坑BUG

6月17日更新: 在support-25.4.0版本,google意識到下面的問題,並修復了。 如果你使用25.4.0及以上版本,下面的方法不要再使用,google移除了mAvailIndices屬性

在Fragment庫中如下4個方法是可能產生BUG的:

1、popBackStack(String tag,int flags)
2、popBackStack(int id,int flags)
3、popBackStackImmediate(String tag,int flags)
4、popBackStackImmediate(int id,int flags)

上面4個方法作用是,出棧到tag/id的fragment,即一次多個Fragment被出棧。

1、FragmentManager棧中管理fragment下標位置的陣列ArrayList<Integer> mAvailIndeices的BUG

下面的方法FragmentManagerImpl類方法,產生BUG的罪魁禍首是管理Fragment棧下標的mAvailIndeices屬性:

void makeActive(Fragment f) {
      if (f.mIndex >= 0) {
         return;
      } 
      if (mAvailIndices == null || mAvailIndices.size() <= 0) {
           if (mActive == null) {
              mActive = new ArrayList<Fragment>();
           } 
           f.setIndex(mActive.size(), mParent); 
           mActive.add(f);
       } else {
           f.setIndex(mAvailIndices.remove(mAvailIndices.size()-1), mParent);
           mActive.set(f.mIndex, f);
       } 
      if (DEBUG) Log.v(TAG, "Allocated fragment index " + f);
 }

上面程式碼最終導致了棧內順序不正確的問題,如下圖:

 

上面的這個情況,會一次異常,一次正常。帶來的問題就是“記憶體重啟”後,各種異常甚至Crash。

發現這BUG的時候,我一臉懵比,幸好,stackoverflow上有大神給出了解決方案!hack FragmentManagerImplmAvailIndices,對其進行一次Collections.reverseOrder()降序排序,保證棧內Fragment的index的正確。

public class FragmentTransactionBugFixHack {

  public static void reorderIndices(FragmentManager fragmentManager) {
    if (!(fragmentManager instanceof FragmentManagerImpl))
      return;
    FragmentManagerImpl fragmentManagerImpl = (FragmentManagerImpl) fragmentManager;
    if (fragmentManagerImpl.mAvailIndices != null && fragmentManagerImpl.mAvailIndices.size() > 1) {
      Collections.sort(fragmentManagerImpl.mAvailIndices, Collections.reverseOrder());
    }
  }
}

使用方法就是通過popBackStackImmediate(tag/id)多個Fragment後,呼叫

hanler.post(new Runnable(){
    @Override
     public void run() {
         FragmentTransactionBugFixHack.reorderIndices(fragmentManager));
     }
});

2、popBackStack的坑
popBackStackpopBackStackImmediate的區別在於前者是加入到主線佇列的末尾,等其它任務完成後才開始出棧,後者是佇列內的任務立即執行,再將出棧任務放到佇列尾(可以理解為立即出棧)。

如果你popBackStack多個Fragment後,緊接著beginTransaction() add新的一個Fragment,接著發生了“記憶體重啟”後,你再執行popBackStack(),app就會Crash,解決方案是postDelay出棧動畫時間再執行其它事務,但是根據我的觀察不是很穩定。
我的建議是:如果你想出棧多個Fragment,你應儘量使用popBackStackImmediate(tag/id),而不是popBackStack(tag/id),如果你想在出棧後,立刻beginTransaction()開始一項事務,你應該把事務的程式碼post/postDelay到主執行緒的訊息佇列裡,下一篇有詳細描述。


深坑 Fragment轉場動畫(僅分析v4包下的Fragment)

如果你的Fragment沒有轉場動畫,或者使用setCustomAnimations(enter, exit)的話,那麼上面的那些坑解決後,你可以愉快的玩耍了。

getFragmentManager().beginTransaction()
         .setCustomAnimations(enter, exit)
        // 如果你有通過tag/id同時出棧多個Fragment的情況時,
        // 請謹慎使用.setCustomAnimations(enter, exit, popEnter, popExit)  
        // 在support-25.4.0之前出棧多Fragment時,伴隨出棧動畫,會在某些情況下發生異常
        // 你需要搭配Fragment的onCreateAnimation()臨時取消出棧動畫,或者延遲一個動畫時間再執行一次上面提到的Hack方法,排序

(注意:如果你想給下一個Fragment設定進棧動畫和出棧動畫,.setCustomAnimations(enter, exit)只能設定進棧動畫,第二個引數並不是設定出棧動畫;
請使用.setCustomAnimations(enter, exit, popEnter, popExit),這個方法的第1個引數對應進棧動畫,第4個引數對應出棧動畫,所以是.setCustomAnimations(進棧動畫, exit, popEnter, 出棧動畫))

總結起來就是Fragment沒有出棧動畫的話,可以避免很多坑。
如果想讓出棧動畫運作正常的話,需要使用Fragment的onCreateAnimation中控制動畫。

@Override
public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
    // 此處設定動畫
}

但是用代價也是有的,你需要解決出棧動畫帶來的幾個坑。

1、pop多個Fragment時轉場動畫 帶來的問題

6月17日更新: 在support-25.4.0版本,google意識到下面動畫引起的問題,並修復了。

在使用 pop(tag/id)出棧多個Fragment的這種情況下,將轉場動畫臨時取消或者延遲一個動畫的時間再去執行其他事務;

原因在於這種情景下,可能會導致棧內順序錯亂(上文有提到),同時如果發生“記憶體重啟”後,因為Fragment轉場動畫沒結束時再執行其他方法,會導致Fragment狀態不會被FragmentManager正常儲存下來。

2、進入新的Fragment並立刻關閉當前Fragment 時的一些問題
(1)如果你想從當前Fragment進入一個新的Fragment,並且同時要關閉當前Fragment。由於資料結構是棧,所以正確做法是先pop,再add,但是轉場動畫會有覆蓋的不正常現象,你需要特殊處理,不然會閃屏!

Tip:
如果你遇到Fragment的mNextAnim空指標的異常(通常是在你的Fragment被重啟的情況下),那麼你首先需要檢查是否操作的Fragment是否為null;其次在你的Fragment轉場動畫還沒結束時,你是否就執行了其他事務等方法;解決思路就是延遲一個動畫時間再執行事務,或者臨時將該Fragment設為無動畫

總結

看了上面的介紹,你可能會覺得Fragment有點可怕。

但是我想說,如果你只是淺度使用,比如一個Activity容器包含列表Fragment+詳情Fragment這種簡單情景下,不涉及到popBackStack/Immediate(tag/id)這些的方法,還是比較輕鬆使用的,出現的問題,網上都可以找到解決方案。

但是如果你的Fragment邏輯比較複雜,有特殊需求,或者你的app架構是僅有一個Activity + 多個Fragment,上面說的這些坑,你都應該全部解決。

下一篇中,介紹了一些非常實用的使用技巧,包括如何解決Fragment巢狀、各種環境、元件下Fragment的使用等技巧,推薦閱讀!

還有一些比較隱蔽的問題,不影響app的正常執行,僅僅是一些顯示的BUG,並沒有在上面介紹,在本系列的最後一篇,我給出了我的解決方案,一個我封裝的Fragmentation庫,解決了所有動畫問題,非常適合單Activity+多Fragment 或者 多模組Activity+多Fragment的架構。有興趣的可以看看 :)

小禮物走一走,來簡書



作者:YoKey
連結:https://www.jianshu.com/p/d9143a92ad94
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。