
很久没有写 UI 相关的程序,感觉都生疏了。最近用 FragmentPagerAdapter,配合 TabLayout,感觉还不错。不过很快就遇到了一个问题,我把 Adapter 里面的数据清空之后,再换一批进去,发现展示的 fragment总是有问题。为什么呢?
我们做 Android 这么多年,大家肯定比较熟悉这个套路,就是返回 item 的位置嘛。如果没有这个 item 呢?我们通常也习惯性的返回一个 -1,表示没有,这样做,也与很多 api 的设计的习惯一致:
public int indexOf(Object o) {
...
return -1;
}例如 ArrayList.indexOf 这个方法,如果找不到这个元素,那么就返回一个 -1。
所以我们在实现 PagerAdapter 的 getItemPosition 时很自然的想到这么写:
override fun getItemPosition(item : Any?): Int {
return items.indexOf(item)
}看上去没有什么,一副人畜无害的样子。如果是在 ListView 或者 RecyclerView 当中这么写,应该没有什么问题,可偏偏这里是 PagerAdapter。
我们来看下这个方法的注释:
/**
* @return object's new position index from [0, {@link #getCount()}),
* {@link #POSITION_UNCHANGED} if the object's position has not changed,
* or {@link #POSITION_NONE} if the item is no longer present.
*/
public int getItemPosition(Object object) {
return POSITION_UNCHANGED;
}好像有点儿不一样的地方,注释说如果这个元素没有发生变化,那么就返回 POSITION_UNCHANGED,没有的话就返回 POSITION_NONE —— 聪明的我们一下就能想到这二位是俩整数常量,对应的值依次是 -1 和 -2。是的,你没看错, -1 表示的是位置没有发生变化,而在我们的 indexOf 中, -1 恰恰表示没有。
所以这个方法如果按照上面的写法,就算你清空了 PagerAdapter 的数据,就算你调用了 notifyDataSetChanged,你看到的绝对跟你想要的不一样。
所以这个方法一定要正确实现,哪怕我们不去理会位置是不是发生了变化,至少像下面这样:
override fun getItemPosition(item : Any?): Int {
val index = items.indexOf(item)
return if(index == -1)
PagerAdapter.POSITION_NONE
else index
}就是这么尴尬,也不知道设计 PagerAdapter 的人到底是怎么想的,非要给我们添加点儿麻烦。
解决了一个问题,只是一个解决问题的开始罢了。
我在项目中的 adapter 实际上继承自 FragmentPagerAdapter,修改之后我发现清空数据也好、移除页面也好,有了较为正常的效果。不过,很快我就发现如果我清空了数据,并且重新添加了一个上去,显示出来的 Fragment 总是清空之前正在显示的那一个。
进过一番排查,原来在 FragmentPageAdapter 当中实例化 Fragment 的时候,会先通过 findFragmentByTag 查找当前 FragmentManager 当中是否已经存在了与之 name 相同的 Fragment,这个 name 的生成规则如下:
private static String makeFragmentName(int viewId, long id) {
return "android:switcher:" + viewId + ":" + id;
}具体调用如下:
final long itemId = getItemId(position);
String name = makeFragmentName(container.getId(), itemId);也就是说,只要 itemId 相同的 fragment 在 FragmentManager 当中,下次显示的时候总是会被复用。
那么问题来了, getItemId 这个方法返回的是什么呢?
public long getItemId(int position) {
return position;
}它的默认实现就直接把位置返回了,所以如果你在清空数据之前所在的页面的位置和你添加新数据之后的位置如果恰好相同,那么这个新页面就不会被加载。
所以这个默认实现也是要小心的。其实最简单的做法就是返回一个与该 fragment 实例一一对应的 id,跟别人重了的怎么能叫 id 呢。
override fun getItemId(position: Int): Long {
return items.getOrNull(position)
?.hashCode()?.toLong()?: -1L
}其实前面说了这么多,如果 FragmentPagerAdapter 能在 destroyItem 的时候把对应的 fragment 移除掉,那么也就没有这么多事儿了。
不巧的是,它老人家用的是 detach,而不是 remove:
public void destroyItem(ViewGroup container, int position, Object object) {
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction();
}
mCurTransaction.detach((Fragment)object);
}这意味着,这个 fragment 虽然从 UI 上被移除了,但生命周期还没有结束,如果需要的话,还是可以立马回来。
通过阅读代码不难发现, FragmentManagerImpl 当中有两个数组:
ArrayList<Fragment> mAdded;
SparseArray<Fragment> mActive;前者存放的是添加到 UI 当中的 fragment,后者则还会保存 UI 已经销毁(调用 onDestroyView 之后)或者尚未初始化(尚未调用 onCreateView 之前)的 fragment。 detach 了之后这个 fragment 就会从 mAdded 中移除,但还会保留在 mActive 当中,这时候我们如果通过 findFragmentByTag 查找的话还是会把它给揪出来的:
public Fragment findFragmentByTag(String tag) {
if (mAdded != null && tag != null) {
for (int i=mAdded.size()-1; i>=0; i--) {
...
}
}
if (mActive != null && tag != null) {
for (int i=mActive.size()-1; i>=0; i--) {
...
}
}
return null;
}到现在为止,我们其实已经把所有存在的问题都搞清楚了,不过有个细节还需要交代一下,为什么用 detach而不是 remove?
用 detach 对于后续召回这个页面比较有用,这种情况出现的概率也比较高,应该说绝大多数情况下使用 ViewPager 时,页面基本上是固定的,需要彻底 remove 的情况较少。
那么只用 detach 对于需要彻底 remove 的情况会造成内存泄露吗?
其实基本不会,毕竟 detach 的时候,UI 已经销毁, fragment 这时候也只会保留最基本的数据而已。