Fragment创建注意事项

1,153 阅读5分钟

Fragment创建Tips

基于开发中遇到的问题,分享几个Fragment在不同场景,创需要注意的问题。

场景1 FragmentContainerView

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/container"
    android:name="com.mihoyo.packaging.fragmentcreatedemo.DemoFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

添加name和id即可,无需特殊操作,FragmentContainerView本身是通过add去添加Fragment的,并且做好了恢复重建,可以浅看下源码

    FragmentContainerView(
            @NonNull Context context,
            @NonNull AttributeSet attrs,
            @NonNull FragmentManager fm) {
        super(context, attrs);

        String name = attrs.getClassAttribute();
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FragmentContainerView);
        if (name == null) {
            name = a.getString(R.styleable.FragmentContainerView_android_name);
        }
        String tag = a.getString(R.styleable.FragmentContainerView_android_tag);
        a.recycle();

        int id = getId();
        Fragment existingFragment = fm.findFragmentById(id);
        // If there is a name and there is no existing fragment,
        // we should add an inflated Fragment to the view.
        if (name != null && existingFragment == null) {
            if (id <= 0) {
                final String tagMessage = tag != null
                        ? " with tag " + tag
                        : "";
                throw new IllegalStateException("FragmentContainerView must have an android:id to "
                        + "add Fragment " + name + tagMessage);
            }
            Fragment containerFragment =
                    fm.getFragmentFactory().instantiate(context.getClassLoader(), name);
            containerFragment.onInflate(context, attrs, null);
            fm.beginTransaction()
                    .setReorderingAllowed(true)
                    .add(this, containerFragment, tag)
                    .commitNowAllowingStateLoss();
        }
        fm.onContainerAvailable(this);
    }

不难发现是先findFragmentById如果没找到才创建新的Fragment再add,之所以这么做是因为在内存不足页面销毁的情况下,再次进入该页面系统会恢复Fragment实例,这个时候如果没find直接新建一个并且add的话就会添加两个Fragment实例,恰巧没设置背景的话就会有重影效果。

场景2 Activity or Fragment中

在Activity和Fragment中创建Fragment实例都是遵循先find,找不到在new,否则add和replace场景下会产生不同bug

add

    //❎ 恢复的时候会有多个实例,导致重叠
    val f = DemoFragment()
    //✅
    val f =
        supportFragmentManager.findFragmentByTag(DemoFragment::class.java.generateDefaultTag())
            ?: DemoFragment()

    supportFragmentManager
        .beginTransaction().apply {
            if (f.isAdded) {
                show(f)
            } else {
                add(R.id.container, f, f.generateDefaultTag())
            }
            commitAllowingStateLoss()
        }

对于add情况下,和FragmentContainerView处理方式类似,要先find,找不到在new,不然就会出现多个Fragment情况,

replace

    //❎ 恢复的时候,系统帮忙恢复Fragment实例,再创建一个replace会导致整个流程重新在走一遍,导致接口、绘制等两次操作
    val f = DemoFragment()
    //✅
    val f =
        supportFragmentManager.findFragmentByTag(DemoFragment::class.java.generateDefaultTag())
            ?: DemoFragment()
    supportFragmentManager
        .beginTransaction().apply {
            replace(R.id.container, f, f.generateDefaultTag())
            commitAllowingStateLoss()
        }

对于replace这个场景下,直接new虽然不会有问题,但是再恢复的时候,系统会帮忙恢复Fragment实例,再创建一个replace会导致整个流程重新在走一遍,导致接口、绘制等两次操作。

场景3 ViewPager2加载Fragment

ViewPager2加载Fragment分为两种情况

  • 数据源Fragment数量固定且顺序不变
  • 数据源Fragment可变

数据源Fragment数量固定且顺序不变

先来看一个常见的写法

class ViewPager2Activity : AppCompatActivity() {
    val list by lazy {
        mutableListOf(DemoFragment(), DemoFragment(), DemoFragment())
    } 

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ActivityViewPagerBinding.inflate(layoutInflater).also { binding ->
            binding.vp.adapter = MyAdapter(list, this)
        }
    }
    

    class MyAdapter(
        private val fragmentList: List<DemoFragment>,
        fragmentActivity: FragmentActivity
    ) :
        FragmentStateAdapter(fragmentActivity) {
        override fun getItemCount() = fragmentList.size

        override fun createFragment(position: Int) = fragmentList[position]
    }
}

相信很多人这么写过,并且在开发阶段也没发生什么问题,但这个一旦到了线上则会出现非常难查的bug fragment no attach view一类的错误。

即使告知原因是恢复重建产生的bug,再从代码层面分析,每次重建都新建了list给到adapter,并给vp2设置了新adapter,依旧看不出问题,那只能从vp2的源码进行分析。

vp2是通过RecyclerView实现的,所以fragment实际创建和添加是在adapter中,于是跳到FragmentStateAdapter源码主要看其onBindViewHolder如何添加fragment

    public final void onBindViewHolder(final @NonNull FragmentViewHolder holder, int position) {
        
        ensureFragment(position);
        /** Special case when {@link RecyclerView} decides to keep the {@link container}
         * attached to the window, but not to the view hierarchy (i.e. parent is null) */
        final FrameLayout container = holder.getContainer();
        if (ViewCompat.isAttachedToWindow(container)) {
            if (container.getParent() != null) {
                throw new IllegalStateException("Design assumption violated.");
            }
            container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
                @Override
                public void onLayoutChange(View v, int left, int top, int right, int bottom,
                        int oldLeft, int oldTop, int oldRight, int oldBottom) {
                    if (container.getParent() != null) {
                        container.removeOnLayoutChangeListener(this);
                        placeFragmentInViewHolder(holder);
                    }
                }
            });
        }
    }

删除多余代码,Fragment创建在ensureFragment()方法实现,添加到view在placeFragmentInViewHolder()方法实现

    private void ensureFragment(int position) {
        long itemId = getItemId(position);
        if (!mFragments.containsKey(itemId)) {
            // TODO(133419201): check if a Fragment provided here is a new Fragment
            Fragment newFragment = createFragment(position);
            newFragment.setInitialSavedState(mSavedStates.get(itemId));
            mFragments.put(itemId, newFragment);
        }
    }

可以看到只有itemId不在mFragments中才会调用createFragment获取Fragment实例,也就是说如果id存在则不会调用createFragment

    public long getItemId(int position) {
        return position;
    }

而默认情况下getItemId返回值为position,再来看添加的逻辑

    void placeFragmentInViewHolder(@NonNull final FragmentViewHolder holder) {
        Fragment fragment = mFragments.get(holder.getItemId());
        FrameLayout container = holder.getContainer();

        scheduleViewAttach(fragment, container);
        mFragmentManager.beginTransaction()
                .add(fragment, "f" + holder.getItemId())
                .setMaxLifecycle(fragment, STARTED)
                .commitNow();
    }

通过add的方式添加,并且tag为f+holder.getItemId,而holder#getItemId值就是等于adater#getItemId也就是上面的position。

大体梳理下流程,onBindViewHolder的时候先判断itemId是否存在,如果不存在才会调用adapter#createFragment创建Fragment,然后通过add方式添加,tag为f+position。

既然bug是恢复情况下产生,再来看看恢复逻辑

    @Override
    public final @NonNull Parcelable saveState() {
        /** TODO(b/122670461): use custom {@link Parcelable} instead of Bundle to save space */
        Bundle savedState = new Bundle(mFragments.size() + mSavedStates.size());

        /** save references to active fragments */
        for (int ix = 0; ix < mFragments.size(); ix++) {
            long itemId = mFragments.keyAt(ix);
            Fragment fragment = mFragments.get(itemId);
            if (fragment != null && fragment.isAdded()) {
                String key = createKey(KEY_PREFIX_FRAGMENT, itemId);
                mFragmentManager.putFragment(savedState, key, fragment);
            }
        }
        return savedState;
    }

    @Override
    public final void restoreState(@NonNull Parcelable savedState) {
        Bundle bundle = (Bundle) savedState;

        for (String key : bundle.keySet()) {
            if (isValidKey(key, KEY_PREFIX_FRAGMENT)) {
                long itemId = parseIdFromKey(key, KEY_PREFIX_FRAGMENT);
                Fragment fragment = mFragmentManager.getFragment(bundle, key);
                mFragments.put(itemId, fragment);
                continue;
            }
        }
    }

在saveState的时候存储了itemId和Fragment到FragmentManager,restoreState读取出了itemId和Fragment恢复到了mFragments中。

那再来分析下之前那个demo,假设一进来加载了第一个页面,那么在mFragments中会存在[{0,DemoFragment}]实例,这个时候app退到后台,内存不足杀死了app,再次打开系统会帮忙恢复,给mFragments中添加[{0,DemoFragment}],然后ensureFragment的时候判断itemId等于0已经存在则不会调用adapter#createFragment,而是直接使用系统恢复的Fragment添加到了视图上,我们自己创建的Fragment并没有附着到视图上,此时如果在给Fragment做对应的操作,修改到了ui则会出现crash。

这里你可能会问,我新setAdapter了啊,不应该会自动恢复才对,那看到vp2#setAdapter方法

    public void setAdapter(@Nullable @SuppressWarnings("rawtypes") Adapter adapter) {
        mRecyclerView.setAdapter(adapter);
        mCurrentItem = 0;
        restorePendingState();
    }

    private void restorePendingState() {
        Adapter<?> adapter = getAdapter();
        if (adapter instanceof StatefulAdapter) {
            ((StatefulAdapter) adapter).restoreState(mPendingAdapterState);
        } 
    }

会发现恢复的时机正好就是在setAdapter中,这样也就串起来了,默认情况下itemId为position,在恢复重建的时候系统会帮忙恢复Fragment实例,这个时候即使新设置一个Adapter,显示在界面上的Fragment也是系统恢复的,所以正确写法也是先find没有才new,find的tag则为f+position

class ViewPager2Activity : AppCompatActivity() {
    val list by lazy {
        mutableListOf(
            supportFragmentManager.findFragmentByTag("f${0}") ?: DemoFragment(),
            supportFragmentManager.findFragmentByTag("f${1}") ?: DemoFragment(),
            supportFragmentManager.findFragmentByTag("f${2}") ?: DemoFragment()
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ActivityViewPagerBinding.inflate(layoutInflater).also { binding ->
            binding.vp.adapter = MyAdapter(list, this)
        }
    }
    
    class MyAdapter(
        private val fragmentList: List<DemoFragment>,
        fragmentActivity: FragmentActivity
    ) :
        FragmentStateAdapter(fragmentActivity) {
        override fun getItemCount() = fragmentList.size

        override fun createFragment(position: Int) = fragmentList[position]
    }
}

数据源Fragment可变

前面说道itemId默认是position,在Fragment数据可变的情况下无法做到position和Fragment一一对应,则会出现问题,所以adapter需要重写getItemId

    class MyAdapter2(
        private val fragmentList: List<Pair<Long, DemoFragment>>,
        fragmentActivity: FragmentActivity
    ) :
        FragmentStateAdapter(fragmentActivity) {
        override fun getItemCount() = fragmentList.size

        override fun createFragment(position: Int) = fragmentList[position].second

        override fun getItemId(position: Int): Long {
            return fragmentList[position].first
        }
    }

但如果仅仅重写getItemId,在恢复重建的时候依旧会crash

    java.lang.IllegalStateException: Design assumption violated.
        at androidx.viewpager2.adapter.FragmentStateAdapter.placeFragmentInViewHolder(FragmentStateAdapter.java:287)
        at androidx.viewpager2.adapter.FragmentStateAdapter.onViewAttachedToWindow(FragmentStateAdapter.java:276)

原因是因为在恢复重建的时候,系统会尝试移除不新鲜的Fragment。类似于我们这种会变的情况,上次显示的是a、b、c重建的时候恢复了a、b、c但这次实际要展示d、e、f那恢复的数据是没有意义的,系统会尝试移除

   public final void restoreState(@NonNull Parcelable savedState) {
        Bundle bundle = (Bundle) savedState;

        for (String key : bundle.keySet()) {
            if (isValidKey(key, KEY_PREFIX_FRAGMENT)) {
                long itemId = parseIdFromKey(key, KEY_PREFIX_FRAGMENT);
                Fragment fragment = mFragmentManager.getFragment(bundle, key);
                mFragments.put(itemId, fragment);
                continue;
            }
        }

        if (!mFragments.isEmpty()) {
            mHasStaleFragments = true;
            mIsInGracePeriod = true;
            gcFragments();
            scheduleGracePeriodEnd();
        }
    }

如果有恢复的mFragments则不为空,mHasStaleFragments和mIsInGracePeriod都设置为true,然后gcFragments()尝试移除不新鲜的Fragment,scheduleGracePeriodEnd()是间隔10秒后再次尝试移除不新鲜的

    void gcFragments() {
        if (!mHasStaleFragments || shouldDelayFragmentTransactions()) {
            return;
        }

        // Remove Fragments for items that are no longer part of the data-set
        Set<Long> toRemove = new ArraySet<>();
        for (int ix = 0; ix < mFragments.size(); ix++) {
            long itemId = mFragments.keyAt(ix);
            if (!containsItem(itemId)) {
                toRemove.add(itemId);
                mItemIdToViewHolder.remove(itemId); // in case they're still bound
            }
        }

        for (Long itemId : toRemove) {
            removeFragment(itemId);
        }
    }

移除这块有个比较关键的判断if (!containsItem(itemId))不包含的item则移除,否则不会移除

    public boolean containsItem(long itemId) {
        return itemId >= 0 && itemId < getItemCount();
    }

默认实现是判断itemId在不在条目数的范围内,前面我们只重写了getItemId(),只要返回值不在条目范围内则会被认为不包含,被移除掉。

    @Override
    public final void onBindViewHolder(final @NonNull FragmentViewHolder holder, int position) {
        //省略创建Fragment代码
        gcFragments();
    }

而在onBindViewHolder创建成功后,会调用gcFragments()尝试移除多余Fragment,在只重写getItemId的情况下大概率会符合if (!containsItem(itemId))被移除,导致刚创建就被移除

    @Override
    public final void onViewAttachedToWindow(@NonNull final FragmentViewHolder holder) {
        placeFragmentInViewHolder(holder);
        gcFragments();
    }

不巧的是在onViewAttachedToWindow()回调中会再次尝试placeFragmentInViewHolder放置Fragment到ViewHodler的View上

    void placeFragmentInViewHolder(@NonNull final FragmentViewHolder holder) {
        Fragment fragment = mFragments.get(holder.getItemId());
        if (fragment == null) {
            throw new IllegalStateException("Design assumption violated.");
        }
    }

由于前面已经被移除了,所以通过itemId获取的Fragment为null就boom了,因此在可变数据源的情况下需要重写itemId和containsItem两个方法

    class MyAdapter2(
        private val fragmentList: List<Pair<Long, DemoFragment>>,
        fragmentActivity: FragmentActivity
    ) :
        FragmentStateAdapter(fragmentActivity) {
        override fun getItemCount() = fragmentList.size

        override fun createFragment(position: Int) = fragmentList[position].second

        override fun getItemId(position: Int): Long {
            return fragmentList[position].first
        }

        override fun containsItem(itemId: Long): Boolean {
            return fragmentList.map { it.first }.contains(itemId)
        }
    }

并且还是遵循先find,没有才new的原则,tag为f+itemId

总结

  1. 无论是add还是replace的方式都遵循先find,找不到才new的原则,否则恢复重建的时候add会添加多个实例,replace会恢复的实例生命周期走一次,替换的再走一次,导致流程执行两次浪费资源的情况
  2. 在Fragment结合ViewPager2时候,依旧是遵循先find,找不到才new的原则,只不过tag为f+itemId,否则恢复重建的时候,系统会帮助恢复展示的Fragment,这个时候如果新的itemId和上次的itemId相同,系统会直接使用恢复的Fragment附着到View上,而自己新创建的Fragment并未真正添加到View,后续对新Fragment的操作就会产生异常
    1. 数据源不变情况下itemId默认为position,find的时候需根据f+position去找
    2. 数据源会变情况下需要重写getItemId和containsItem方法,find时候需要根据f+itemId去找