4

问题

做 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 虚拟机中弱引用等于没引用, 会很快被回收掉. (这句是听一位虚拟机大牛说的)

Lyux
571 声望328 粉丝