Android 保存 Fragment 引用及 getActivity() 为空问题

问题

做 Android 应用开发的小伙伴们大多都被 Fragment 坑过. 最近研究了其中常见的一种坑, 记录下来, 以免遗忘. 问题大体是这样的:
有时我们希望在 Activity 中保存所创建的 Fragment 的引用, 以便后续逻辑中做界面更新等操作. 如果页面中的 Fragment 都是静态的 (不会被 remove, hide 等), 则一般不会出啥问题. 如果是多个 Fragment 切换的场景, 就容易出现 getActivity() 为 null 等问题. 这种问题在使用 FragmentPagerAdapter 时尤其容易出现.
这里涉及两个问题: Fragment 的创建和 Fragment 引用的保存. 两个问题都有坑.

先放结论 (编程建议):

  1. 不要在 Activity.onCreate() 中直接 new Fragment(). Fragment 的创建应尽量纳入 FragmentManager 的管理.
  2. 尽量不要保存 Fragment 的引用. 在需要直接调用 Fragment 时, 使用 FragmentManager.findFragmentByTag() 等方法获取相关 Fragment 的引用.
  3. 如果一定要保存 Fragment 引用, 则要谨慎选择获取引用的节点.

原因分析

以一段实际代码说明.
遇到主页需要左右滑动切换标签页的需求, 最常用的就是 ViewPager + FragmePagerAdapter 方案了. 很多小伙伴可能会这样写 (示例代码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();
        }
    }
}

上例是一个最简单的标签页切换界面写法, 布局中只有一个 ViewPager, 就不再贴出了.
但这段代码是存在隐患的.
这里首先复习一下 Activity 管理 Fragment 的方式. 在代码中动态显示 Fragment 时, 大体流程如下:

private void showFragment1() {
    FragmentManager fragmentManager = getSupportFragmentManager();
    FragmentTransaction transaction = fragmentManager.beginTransaction();
    // 查看 fragment1 是否已经被添加
    Fragment1 fragment1 = (Fragment1) fragmentManager.findFragmentByTag("fragment1");
    if (fragment1 == null) {
        // fragment1 尚未被添加, 则创建并添加
        fragment1 = new Fragment1();
        transaction.add(R.id.submitter_fragment_container, fragment1, "fragment1");
    } else {
        // fragment1 已被添加, 则调用 show() 方法让其显示
        transaction.show(fragment1);
    }
    transaction.commit();
}

但 示例代码1 中并没有类似逻辑. 其实是被 FragmentPagerAdapter 封装了, 但逻辑依然是一样的:
FragmentPagerAdapter 在需要展示 fragment1 时, 会首先尝试通过 FragmentManager.findFragmentByTag() 找到它. 如果找不到, 才会调用 FragmentPagerAdapter.getItem() 来创建它.

回到 示例代码1, 在正常情况下, 这段代码是可以完美运行的. 但如果我们的界面被系统回收掉了, 当用户再次返回这个界面时, 问题就来了. 在这种情况下:

  • 因为 Activity 被销毁了, 因此 onCreate() 会被调用, 我们的三个 Fragment 会被重新创建并装入 mFragmentList 数组.
  • 又因为 Activity 被销毁了, 因此系统会自动恢复界面状态, 包括之前已经被添加的 Fragment. 恢复完成后, 轮到 FragmentPagerAdapter 显示 fragment1. FragmentPagerAdapter 通过 FragmentManager.findFragmentByTag(), 发现 fragment1 已经被添加了 (被添加的为老 Fragment, 即被系统恢复的那个). 因此不会再去调用 FragmentPagerAdapter.getItem(), 因此 FragmentPagerAdapter 直接显示了被系统恢复出来的 fragment1.

没错, 这种情况下, Fragment1 在 Activity 中其实有两个实例:
一个是真正的被 Activity 添加并显示的实例;
一个是在 onCreate() 中被创建, 并保存在 mFragmentList 中的没有什么卵用的实例.

可以想见, 这种状态下肯定会出现很多莫名其妙的问题, 其中就包括 getActivity() 返回 null 的问题.

吐槽: FragmentPagerAdapter.getItem() 方法明明就是 FragmentPagerAdapter 用来内部创建 Fragment 用的啊, 根本不是用来供外部获取 Fragment 用的. 如果改名叫 createItem() 或者 createFragment() 之类的, 估计可以防止不少人掉坑的.

代码修正

基于以上分析可知, 在 Activity.onCreate() 中创建 Fragment 是不恰当的. 应该把 Fragment 的创建放在 FragmentPagerAdapter.getItem() 中. 经过改进的 示例代码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 保存各个 Fragment 的引用了, Fragment 的创建完全交给 FragmentPagerAdapter 去做.
其实在其他的使用 Fragment 的场景中, 也会出现上述问题, 也应该遵循同样的原则, 即文章开头所列的 建议1 和 建议2 .

这样是解决了上面提到的 Activity 销毁恢复的问题, 但如果我们在 Activity 逻辑中, 一定要取到 Fragment 引用, 该怎么办呢. (比如, 点击 ActionBar 上的按钮则改变 Fragment 中的某段文字).
有两种方法可以解决保存 Fragment 引用的问题.

保存引用

如前所述, 肯定不能用 FragmentPagerAdapter.getItem() 方法来获取!
要找到合适的方法, 需要瞄一眼源码. FragmentPagerAdapter 的源码相当的短:

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;
    }
}

上面只列出了其中的两个关键方法:
instantiateItem() 方法是负责创建 pager 页的方法, 其逻辑就是先判断 Fragment 是否存在, 存在则显示, 不存在则调用 getItem(position) 创建.
makeFragmentName() 方法用来为一个特定位置的 fragment 生成一个 tag, 规则就是容器 ViewGroup 的 id 和 Fragment 位置的组合. 其中 ViewGroup 的 id 就是 ViewPager 在 Activity 界面中的 id.

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

方法一

既然我们都知道 tag 的生成规则了, 找到 Fragment 那还不是 so easy.
还是以上面的 示例代码1 为例, 获取 fragment1 的引用, 这么做就可以了:

private void changeFragment1Text() {
    String tag = "android:switcher:" + R.id.view_pager + ":" + 0;
    Fragment1 fragment1 = (Fragment1) getSupportFragmentManager().findFragmentByTag(tag);
    // 一定要做判空, 因为你要找的 Fragment 这时可能还没有加入 Activity 中.
    if (fragment1 != null) {
        fragment1.setText("Laziness is a programmer's feature.");
    } else {
        Log.e("lyux", "fragment not added yet.");
    }
}

这种方法有两个缺点:
一是, tag 的规则依赖一个源码中的私有方法, 谷歌大大哪天不爽要改了这条规则, 我们的程序就会出错了.
二是, 对于另一个装载 Fragment 的 PagerAdapter, 即 FragmentStatePagerAdapter, 这个方法是不适用的.

FragmentStatePagerAdapter 是为了懒加载及页面回收的目的而编写的, 即不把每个 page 页的内容都保存在内存里. 因此它在创建了 Fragment 后, 没有给其附加 tag. 所以由它创建的 Fragment 无法用 FragmentManager.findFragmentByTag() 方法找到. 具体见其源码, 也不长.

方法二

还有一种思路, 是重载 FragmentPagerAdapter 类中的 instantiateItem() 方法, 得到 Fragment 引用. 依然以 示例代码1 为例, 将 SlidePagerAdapter 做如下改写即可:

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秒改变文字. 如果立刻执行, 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;
        }
    }
}

因为 instantiateItem() 方法管理了 Fragment 的创建及重用, 因此无论其是新创建的, 还是被恢复的, 都可以正确取到引用.

注意: 不要在 FragmentStatePagerAdapter 场景中使用该方法. 因为我们保存了每一页的 Fragment 的引用, 就会阻止其被回收, 那 FragmentStatePagerAdapter 就白用了: 不就是为了可以回收页面才用它的嘛.
真要用的话就用 WeakReference<Fragment> 保存其弱引用.
但据说 4.0 后的 Android 虚拟机中弱引用等于没引用, 会很快被回收掉. (这句是听一位虚拟机大牛说的)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容