Android 保存 Fragment 引用及 getActivity() 為空問(wèn)題

問(wèn)題

做 Android 應(yīng)用開(kāi)發(fā)的小伙伴們大多都被 Fragment 坑過(guò). 最近研究了其中常見(jiàn)的一種坑, 記錄下來(lái), 以免遺忘. 問(wèn)題大體是這樣的:
有時(shí)我們希望在 Activity 中保存所創(chuàng)建的 Fragment 的引用, 以便后續(xù)邏輯中做界面更新等操作. 如果頁(yè)面中的 Fragment 都是靜態(tài)的 (不會(huì)被 remove, hide 等), 則一般不會(huì)出啥問(wèn)題. 如果是多個(gè) Fragment 切換的場(chǎng)景, 就容易出現(xiàn) getActivity() 為 null 等問(wèn)題. 這種問(wèn)題在使用 FragmentPagerAdapter 時(shí)尤其容易出現(xiàn).
這里涉及兩個(gè)問(wèn)題: Fragment 的創(chuàng)建和 Fragment 引用的保存. 兩個(gè)問(wèn)題都有坑.

先放結(jié)論 (編程建議):

  1. 不要在 Activity.onCreate() 中直接 new Fragment(). Fragment 的創(chuàng)建應(yīng)盡量納入 FragmentManager 的管理.
  2. 盡量不要保存 Fragment 的引用. 在需要直接調(diào)用 Fragment 時(shí), 使用 FragmentManager.findFragmentByTag() 等方法獲取相關(guān) Fragment 的引用.
  3. 如果一定要保存 Fragment 引用, 則要謹(jǐn)慎選擇獲取引用的節(jié)點(diǎn).

原因分析

以一段實(shí)際代碼說(shuō)明.
遇到主頁(yè)需要左右滑動(dòng)切換標(biāo)簽頁(yè)的需求, 最常用的就是 ViewPager + FragmePagerAdapter 方案了. 很多小伙伴可能會(huì)這樣寫(xiě) (示例代碼1):

public class TabChangeActivity extends AppCompatActivity {

    private ArrayList<Fragment> mFragmentList;
    private ViewPager mViewPager;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_tab_fragment_sample);
        mFragmentList = new ArrayList<>(3);
        mFragmentList.add(new Fragment1());
        mFragmentList.add(new Fragment2());
        mFragmentList.add(new Fragment3());
        mViewPager = (ViewPager) findViewById(R.id.view_pager);
        mViewPager.setAdapter(new SlidePagerAdapter(getSupportFragmentManager()));
    }

    private class SlidePagerAdapter extends FragmentPagerAdapter {

        public SlidePagerAdapter(FragmentManager fm) {
            super(fm);
        }

        @Override
        public Fragment getItem(int position) {
            return mFragmentList.get(position);
        }

        @Override
        public int getCount() {
            return mFragmentList.size();
        }
    }
}

上例是一個(gè)最簡(jiǎn)單的標(biāo)簽頁(yè)切換界面寫(xiě)法, 布局中只有一個(gè) ViewPager, 就不再貼出了.
但這段代碼是存在隱患的.
這里首先復(fù)習(xí)一下 Activity 管理 Fragment 的方式. 在代碼中動(dòng)態(tài)顯示 Fragment 時(shí), 大體流程如下:

private void showFragment1() {
    FragmentManager fragmentManager = getSupportFragmentManager();
    FragmentTransaction transaction = fragmentManager.beginTransaction();
    // 查看 fragment1 是否已經(jīng)被添加
    Fragment1 fragment1 = (Fragment1) fragmentManager.findFragmentByTag("fragment1");
    if (fragment1 == null) {
        // fragment1 尚未被添加, 則創(chuàng)建并添加
        fragment1 = new Fragment1();
        transaction.add(R.id.submitter_fragment_container, fragment1, "fragment1");
    } else {
        // fragment1 已被添加, 則調(diào)用 show() 方法讓其顯示
        transaction.show(fragment1);
    }
    transaction.commit();
}

但 示例代碼1 中并沒(méi)有類(lèi)似邏輯. 其實(shí)是被 FragmentPagerAdapter 封裝了, 但邏輯依然是一樣的:
FragmentPagerAdapter 在需要展示 fragment1 時(shí), 會(huì)首先嘗試通過(guò) FragmentManager.findFragmentByTag() 找到它. 如果找不到, 才會(huì)調(diào)用 FragmentPagerAdapter.getItem() 來(lái)創(chuàng)建它.

回到 示例代碼1, 在正常情況下, 這段代碼是可以完美運(yùn)行的. 但如果我們的界面被系統(tǒng)回收掉了, 當(dāng)用戶(hù)再次返回這個(gè)界面時(shí), 問(wèn)題就來(lái)了. 在這種情況下:

  • 因?yàn)?Activity 被銷(xiāo)毀了, 因此 onCreate() 會(huì)被調(diào)用, 我們的三個(gè) Fragment 會(huì)被重新創(chuàng)建并裝入 mFragmentList 數(shù)組.
  • 又因?yàn)?Activity 被銷(xiāo)毀了, 因此系統(tǒng)會(huì)自動(dòng)恢復(fù)界面狀態(tài), 包括之前已經(jīng)被添加的 Fragment. 恢復(fù)完成后, 輪到 FragmentPagerAdapter 顯示 fragment1. FragmentPagerAdapter 通過(guò) FragmentManager.findFragmentByTag(), 發(fā)現(xiàn) fragment1 已經(jīng)被添加了 (被添加的為老 Fragment, 即被系統(tǒng)恢復(fù)的那個(gè)). 因此不會(huì)再去調(diào)用 FragmentPagerAdapter.getItem(), 因此 FragmentPagerAdapter 直接顯示了被系統(tǒng)恢復(fù)出來(lái)的 fragment1.

沒(méi)錯(cuò), 這種情況下, Fragment1 在 Activity 中其實(shí)有兩個(gè)實(shí)例:
一個(gè)是真正的被 Activity 添加并顯示的實(shí)例;
一個(gè)是在 onCreate() 中被創(chuàng)建, 并保存在 mFragmentList 中的沒(méi)有什么卵用的實(shí)例.

可以想見(jiàn), 這種狀態(tài)下肯定會(huì)出現(xiàn)很多莫名其妙的問(wèn)題, 其中就包括 getActivity() 返回 null 的問(wèn)題.

吐槽: FragmentPagerAdapter.getItem() 方法明明就是 FragmentPagerAdapter 用來(lái)內(nèi)部創(chuàng)建 Fragment 用的啊, 根本不是用來(lái)供外部獲取 Fragment 用的. 如果改名叫 createItem() 或者 createFragment() 之類(lèi)的, 估計(jì)可以防止不少人掉坑的.

代碼修正

基于以上分析可知, 在 Activity.onCreate() 中創(chuàng)建 Fragment 是不恰當(dāng)?shù)? 應(yīng)該把 Fragment 的創(chuàng)建放在 FragmentPagerAdapter.getItem() 中. 經(jīng)過(guò)改進(jìn)的 示例代碼1 如下:

public class TabChangeActivity extends AppCompatActivity {

    private ViewPager mViewPager;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_tab_fragment_sample);
        mViewPager = (ViewPager) findViewById(R.id.view_pager);
        mViewPager.setAdapter(new SlidePagerAdapter(getSupportFragmentManager()));
    }

    private class SlidePagerAdapter extends FragmentPagerAdapter {

        public SlidePagerAdapter(FragmentManager fm) {
            super(fm);
        }

        @Override
        public Fragment getItem(int position) {
            switch (position) {
                case 0:
                    return new Fragment1();
                case 1:
                    return new Fragment2();
                case 2:
                    return new Fragment3();
                default:
                    return null; // unlikely to happen
            }
        }

        @Override
        public int getCount() {
            return 3;
        }
    }
}

即: 不再用 mFragmentList 保存各個(gè) Fragment 的引用了, Fragment 的創(chuàng)建完全交給 FragmentPagerAdapter 去做.
其實(shí)在其他的使用 Fragment 的場(chǎng)景中, 也會(huì)出現(xiàn)上述問(wèn)題, 也應(yīng)該遵循同樣的原則, 即文章開(kāi)頭所列的 建議1 和 建議2 .

這樣是解決了上面提到的 Activity 銷(xiāo)毀恢復(fù)的問(wèn)題, 但如果我們?cè)?Activity 邏輯中, 一定要取到 Fragment 引用, 該怎么辦呢. (比如, 點(diǎn)擊 ActionBar 上的按鈕則改變 Fragment 中的某段文字).
有兩種方法可以解決保存 Fragment 引用的問(wèn)題.

保存引用

如前所述, 肯定不能用 FragmentPagerAdapter.getItem() 方法來(lái)獲取!
要找到合適的方法, 需要瞄一眼源碼. FragmentPagerAdapter 的源碼相當(dāng)?shù)亩?

public abstract class FragmentPagerAdapter extends PagerAdapter {

    ......

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        if (mCurTransaction == null) {
            mCurTransaction = mFragmentManager.beginTransaction();
        }

        final long itemId = getItemId(position);

        // Do we already have this fragment?
        String name = makeFragmentName(container.getId(), itemId);
        Fragment fragment = mFragmentManager.findFragmentByTag(name);
        if (fragment != null) {
            if (DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
            mCurTransaction.attach(fragment);
        } else {
            fragment = getItem(position);
            if (DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
            mCurTransaction.add(container.getId(), fragment,
                    makeFragmentName(container.getId(), itemId));
        }
        if (fragment != mCurrentPrimaryItem) {
            fragment.setMenuVisibility(false);
            fragment.setUserVisibleHint(false);
        }

        return fragment;
    }

    ......

    private static String makeFragmentName(int viewId, long id) {
        return "android:switcher:" + viewId + ":" + id;
    }
}

上面只列出了其中的兩個(gè)關(guān)鍵方法:
instantiateItem() 方法是負(fù)責(zé)創(chuàng)建 pager 頁(yè)的方法, 其邏輯就是先判斷 Fragment 是否存在, 存在則顯示, 不存在則調(diào)用 getItem(position) 創(chuàng)建.
makeFragmentName() 方法用來(lái)為一個(gè)特定位置的 fragment 生成一個(gè) tag, 規(guī)則就是容器 ViewGroup 的 id 和 Fragment 位置的組合. 其中 ViewGroup 的 id 就是 ViewPager 在 Activity 界面中的 id.

因此取到 Fragment 引用的方法也就找到了:

方法一

既然我們都知道 tag 的生成規(guī)則了, 找到 Fragment 那還不是 so easy.
還是以上面的 示例代碼1 為例, 獲取 fragment1 的引用, 這么做就可以了:

private void changeFragment1Text() {
    String tag = "android:switcher:" + R.id.view_pager + ":" + 0;
    Fragment1 fragment1 = (Fragment1) getSupportFragmentManager().findFragmentByTag(tag);
    // 一定要做判空, 因?yàn)槟阋业?Fragment 這時(shí)可能還沒(méi)有加入 Activity 中.
    if (fragment1 != null) {
        fragment1.setText("Laziness is a programmer's feature.");
    } else {
        Log.e("lyux", "fragment not added yet.");
    }
}

這種方法有兩個(gè)缺點(diǎn):
一是, tag 的規(guī)則依賴(lài)一個(gè)源碼中的私有方法, 谷歌大大哪天不爽要改了這條規(guī)則, 我們的程序就會(huì)出錯(cuò)了.
二是, 對(duì)于另一個(gè)裝載 Fragment 的 PagerAdapter, 即 FragmentStatePagerAdapter, 這個(gè)方法是不適用的.

FragmentStatePagerAdapter 是為了懶加載及頁(yè)面回收的目的而編寫(xiě)的, 即不把每個(gè) page 頁(yè)的內(nèi)容都保存在內(nèi)存里. 因此它在創(chuàng)建了 Fragment 后, 沒(méi)有給其附加 tag. 所以由它創(chuàng)建的 Fragment 無(wú)法用 FragmentManager.findFragmentByTag() 方法找到. 具體見(jiàn)其源碼, 也不長(zhǎng).

方法二

還有一種思路, 是重載 FragmentPagerAdapter 類(lèi)中的 instantiateItem() 方法, 得到 Fragment 引用. 依然以 示例代碼1 為例, 將 SlidePagerAdapter 做如下改寫(xiě)即可:

public class TabChangeActivity extends AppCompatActivity {

    private ViewPager mViewPager;
    private Fragment1 mFragment1;
    private Fragment2 mFragment2;
    private Fragment3 mFragment3;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_tab_fragment_sample);
        mViewPager = (ViewPager) findViewById(R.id.view_pager);
        mViewPager.setAdapter(new SlidePagerAdapter(getSupportFragmentManager()));

        // 延遲5秒改變文字. 如果立刻執(zhí)行, mFragment1 肯定是 null.
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mFragment1 != null) {
                    mFragment1.setText("Every program must have a purpose. If not, it is deleted. -- The Matrix");
                }
            }
        }, 5000);
    }

    private class SlidePagerAdapter extends FragmentPagerAdapter {

        public SlidePagerAdapter(FragmentManager fm) {
            super(fm);
        }

        @Override
        public Fragment getItem(int position) {
            switch (position) {
                case 0:
                    return new Fragment1();
                case 1:
                    return new Fragment2();
                case 2:
                    return new Fragment3();
                default:
                    return null; // unlikely to happen
            }
        }

        @Override
        public int getCount() {
            return 3;
        }

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            Fragment fragment = (Fragment) super.instantiateItem(container, position);
            switch (position) {
                case 0:
                    mFragment1 = (Fragment1) fragment;
                    break;
                case 1:
                    mFragment2 = (Fragment2) fragment;
                    break;
                case 2:
                    mFragment3 = (Fragment3) fragment;
                    break;
            }
            return fragment;
        }
    }
}

因?yàn)?instantiateItem() 方法管理了 Fragment 的創(chuàng)建及重用, 因此無(wú)論其是新創(chuàng)建的, 還是被恢復(fù)的, 都可以正確取到引用.

注意: 不要在 FragmentStatePagerAdapter 場(chǎng)景中使用該方法. 因?yàn)槲覀儽4媪嗣恳豁?yè)的 Fragment 的引用, 就會(huì)阻止其被回收, 那 FragmentStatePagerAdapter 就白用了: 不就是為了可以回收頁(yè)面才用它的嘛.
真要用的話(huà)就用 WeakReference<Fragment> 保存其弱引用.
但據(jù)說(shuō) 4.0 后的 Android 虛擬機(jī)中弱引用等于沒(méi)引用, 會(huì)很快被回收掉. (這句是聽(tīng)一位虛擬機(jī)大牛說(shuō)的)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末尉桩,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子贪庙,更是在濱河造成了極大的恐慌蜘犁,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,590評(píng)論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件止邮,死亡現(xiàn)場(chǎng)離奇詭異这橙,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)导披,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,157評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門(mén)析恋,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人盛卡,你說(shuō)我怎么就攤上這事≈欤” “怎么了滑沧?”我有些...
    開(kāi)封第一講書(shū)人閱讀 169,301評(píng)論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)巍实。 經(jīng)常有香客問(wèn)我滓技,道長(zhǎng),這世上最難降的妖魔是什么棚潦? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 60,078評(píng)論 1 300
  • 正文 為了忘掉前任令漂,我火速辦了婚禮,結(jié)果婚禮上丸边,老公的妹妹穿的比我還像新娘叠必。我一直安慰自己,他們只是感情好妹窖,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,082評(píng)論 6 398
  • 文/花漫 我一把揭開(kāi)白布纬朝。 她就那樣靜靜地躺著,像睡著了一般骄呼。 火紅的嫁衣襯著肌膚如雪共苛。 梳的紋絲不亂的頭發(fā)上判没,一...
    開(kāi)封第一講書(shū)人閱讀 52,682評(píng)論 1 312
  • 那天,我揣著相機(jī)與錄音隅茎,去河邊找鬼澄峰。 笑死,一個(gè)胖子當(dāng)著我的面吹牛辟犀,可吹牛的內(nèi)容都是我干的俏竞。 我是一名探鬼主播,決...
    沈念sama閱讀 41,155評(píng)論 3 422
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼踪蹬,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼胞此!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起跃捣,我...
    開(kāi)封第一講書(shū)人閱讀 40,098評(píng)論 0 277
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤漱牵,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后疚漆,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體酣胀,經(jīng)...
    沈念sama閱讀 46,638評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,701評(píng)論 3 342
  • 正文 我和宋清朗相戀三年娶聘,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了闻镶。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,852評(píng)論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡丸升,死狀恐怖铆农,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情狡耻,我是刑警寧澤墩剖,帶...
    沈念sama閱讀 36,520評(píng)論 5 351
  • 正文 年R本政府宣布,位于F島的核電站夷狰,受9級(jí)特大地震影響岭皂,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜沼头,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,181評(píng)論 3 335
  • 文/蒙蒙 一爷绘、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧进倍,春花似錦土至、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,674評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至毡庆,卻和暖如春坑赡,著一層夾襖步出監(jiān)牢的瞬間烙如,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,788評(píng)論 1 274
  • 我被黑心中介騙來(lái)泰國(guó)打工毅否, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留亚铁,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 49,279評(píng)論 3 379
  • 正文 我出身青樓螟加,卻偏偏與公主長(zhǎng)得像徘溢,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子捆探,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,851評(píng)論 2 361

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