在使用Fragment和ViewPager2时遇到了一个奇怪的bug,于是顺藤摸瓜学习了一下Fragment和View的状态保存恢复流程,解决方法在最后面。
首先看一下崩溃调用栈
java.lang.IllegalStateException: Expected the adapter to be 'fresh' while restoring state.
at androidx.viewpager2.adapter.FragmentStateAdapter.restoreState(FragmentStateAdapter.java:536)
at androidx.viewpager2.widget.ViewPager2.restorePendingState(ViewPager2.java:350)
at androidx.viewpager2.widget.ViewPager2.dispatchRestoreInstanceState(ViewPager2.java:375)
at android.view.ViewGroup.dispatchRestoreInstanceState(ViewGroup.java:4099)
at android.view.View.restoreHierarchyState(View.java:20357)
at androidx.fragment.app.Fragment.restoreViewState(Fragment.java:639)
at androidx.fragment.app.Fragment.restoreViewState(Fragment.java:3010)
at androidx.fragment.app.Fragment.performActivityCreated(Fragment.java:3001)
接下来描述一下我遇到这个bug的场景,方便大家对号入座:
首先在创建Activity时将MainFragment添加到了Activity中,MainFragment里又会通过FragmentStateAdapter将Fragment添加到MainFragment的ViewPager2中。然后通过消息推送,让activity调用FragmentManager.FragmentTransaction.replace()
移除了MainFragment并添加了SecondFragment(这里还有一行重点代码FragmentManager.FragmentTransaction.addToBackStack()
,后面会讲它为什么会导致这个bug的出现),接着再调用同一个FragmentManager的FragmentManager.popBackStack()
方法,然后程序崩溃。
然后是排查过程:
首先发现是因为MainFragment只调用了onDestroyView()而没有调用onDestroy()(只销毁了视图,但是实例还存在),而我的FragmentStateAdapter是跟随MainFragment对象一起初始化的,因为对象没有被销毁所以只初始化了一次,并且里面的状态(adapter管理的saveStates和fragments也都保存着),所以在Fragment.performActivityCreated时会判断
if (mView != null) {
restoreViewState(mSavedFragmentState);
}
然后会调用到viewpager2的dispatchRestoreInstanceState(),内部最终调用FragmentStateAdapter.restoreState()
if (!mSavedStates.isEmpty() || !mFragments.isEmpty()) {
throw new IllegalStateException(
"Expected the adapter to be 'fresh' while restoring state.");
}
那么肉眼可见的是,这个bug是和fragment的状态销毁和重建有关的,大概的原因是:在使用FragmentManager.replace()
切换fragment时,FragmentManager会将当前将要被销毁的Fragment视图从Activity中移除,并将新的Fragment的视图加载到activity上。因为我们将这个事务加入了返回栈FragmentManager.FragmentTransaction.addToBackStack()
,所以FragmentManager不会销毁或者解绑这个fragment实例,只是把视图销毁了。并且FragmentManager会保存Fragment和Adapter的状态再销毁视图,在这个事务弹出返回栈时,FragmentManager又会控制fragment恢复它的视图状态,接着FragmentStateAdapter发现它自己不干净(mSavedStates不为空),于是自爆了。
接下来详细跟一遍fragment和viewpager2状态保存恢复的流程(已简化)
这段的流程有点长,其实大概流程上面已经讲清楚了,只是看了的话会对理解Fragment和View的状态保存恢复流程更清晰
当我点击/执行了返回操作,触发了FragmentManager.popBackStack()
,就会走一遍下面这个流程
在FragmentStateAdapter准备恢复当前Fragment视图上的ViewPager2的状态时,崩溃就产生了。
一点牢骚
说实话,我觉得官方代码在这里直接抛出异常是很愚蠢的行为,因为通过将Transaction加入返回栈addToBackStack()
,加入返回栈的Fragment就只会被销毁视图onDestroyView()
而实例仍然被FragmentManager持有(fragment不会与activity解绑,也不会执行onDestroy()),并将在弹出返回栈时恢复这个Fragment的状态,所以如果你不做任何特殊处理,FragmentStateAdapter.mSavedStates
必然是不为空的,而且FragmentStateAdapter并没有提供任何方法让我们可以去清除它的缓存(我们甚至都不能重写它的saveState()和restoreState(),太扯淡了),因此看起来就像谷歌让ViewPager2不接受一个复用的adapter。我不明白为什么官方要在这里选择让程序崩溃而不是清空之前的mSavedStates,因为要触发这个崩溃只需要一个很常见的场景和代码。
吐槽完毕接下来就说一下解决方法吧,因为能改动的地方很有限,所以我觉得下面这几个方法都不是很好,而且有利有弊,但是总归是能解决问题。
解决方法
方案1:
将Transaction的replace改成add和hide,避免了fragment重新创建视图,也就不会触发FragmentStateAdapter.restoreState(),所以崩溃的问题就解决了(没有动画的需求用这个方法就行了)。但是通过add和hide,我的mainFragment的渐隐动画没有被触发,mainFragment的视图直接被隐藏了,这样肯定是不能满足我的需求的。
方案2:
既然是视图状态恢复的时候崩溃的,那我禁用掉viewpager2的状态恢复不就可以跳过抛出异常的代码了吗?调用view.setSaveEnabled(false)就可以禁用view的状态保存和恢复。实践结果证明这是可行的,但是我的Fragment消失转场动画也消失了,并且每次返回时都会返回到position 0。
方案3:
不保存adapter的实例,而是在onViewCreated()
里每次都创建一个新的FragmentStateAdapter并赋值给viewpager2.adapter,并且在onDestroyView()
里将viewpager2的adapter移除掉viewpager2.adapter = null
。这个方法的思路和方法2类似,也是通过手动控制避开viewpager2的状态恢复代码。
方案4:
先将MainFragment和SecondFragment都添加到activity中,然后隐藏除了MainFragment以外的其他Fragment
val secondFragment = SecondFragment()
supportFragmentManager.beginTransaction()
.add(
vb.container.id,
MainFragment::class.java,
null,
MainFragment::class.simpleName
)
.add(
vb.container.id,
SecondFragment,
SecondFragment::class.simpleName
)
.hide(pictureDetailsFragment)
.commit()
然后在需要展示SecondFragment的时候使用FragmentManager.FragmentTransaction.show(secondFragment)
和FragmentManager.FragmentTransaction.hide(mainFragment)
来切换fragment。 这是我认为最好的解决方案。因为这样即避免了fragment的状态保存和恢复流程以及fragment各种创建时的回调代码(提高了性能),也能保证过渡动画的正常运作。不过这个方法也有一个弊端,就是我们需要注意SecondFragment刷新界面(加载布局/动画/刷新数据)的时机,因为我们一开始就将fragment都添加到activity上了,所以fragment会跟随activity走完整个启动的生命周期(例如onCreateView()和onResume()
),在切换显示隐藏时SecondFragment只会回调onHiddenChange(isHidden:Boolean)
方法,所以我们要注意在SecondFragment真正准备显示出来的时候再执行对应的界面刷新操作
方案5:
把ViewPager2换成ViewPager和FragmentStatePagerAdapter,虽然听起来很扯但是确实有用 ; )
网友评论