Android 的 FragmentStatePagerAdapter 的笔记

好久没有写开发相关的博客了,老夫最近做了 N 多项目,可是都没有写笔记的习惯,这样的坏处是很快就会忘了。话说最近公司有同事经常来问我 Android 开发的问题,特么老夫只是酱油开发好不好。但是乃看最近 app 那么火,不管啥东西都要做 app,所以做一做总不是坏事。所以老夫要开始写“笔记”,这样以后忘了的话可以直接回过头来看。

话说前两个月某个创业项目的合伙人说,要山寨一下网易新闻首页顶部的那个分类列表。老夫一看,这个简单,把原来的 Toolbar 改成一个 RecyclerView 作为分类选项,用户选择了之后直接刷新 RecyclerView 不就好啦?

不过这样简单粗暴的做法肯定体验不佳,比如不支持左右滑动(虽然可以劫持手势事件,但是会引发很多问题),而且没有缓存。于是我不得不使用 ViewPager 来实现了,但这就相当于把原来的页面基本重新实现,没办法边学边做吧。

首先来分析一下页面的结构:

1

由于有底栏这种在 Android 上堪称奇葩的设计,所以我们原先的列表(下面蓝色框起来的部分)是一个 Fragment(这样用户点了“奇葩底栏”后才能用其他 Fragment 来替代)。用户滑动时更改顶部的分类选项,此时是更改此 Fragment 内展示的内容,因此 ViewPager 要嵌在里面。然而 ViewPager 里的内容又是用 Fragment 来实现的,所以我们的 app 实际上是 Fragment 里面又嵌套了 Fragment,谷歌官方只在 Android 4.2 才提供支持,不过无所谓忽略版本低的设备(因为目测用这些版本的用户大多不会买我们的东西 ^_^)。

ViewPager 只是提供显示的区域,里面具体的内容是依靠 PagerAdapter 来提供。对于 PagerAdapter 的具体实现类,谷歌提供不少,但常用的是 FragmentPagerAdapter 和 FragmentStatePagerAdapter。这两个实现类的区别在于:前者将所有的页面缓存到内存,而后者只缓存一部分页面,其他页面会被系统销毁。而从 State 这个名字来看,系统肯定会保存其状态以便其快速恢复。

我选择了后者,因为用户大部分时间不会有耐心查看所有的页面,为了节约内存,只缓存用户正在看的页面以及旁边一个或两个页面就可以了(目前老夫做的这个 app 会根据机器的内存来决定缓存数量)。

用了这个东东后就很容易做出可以滑动的页面的效果了:

2

可以看到在滑动页面的过程中系统已经自动对相邻的页面做了预加载。虽然进展比较顺利,可是在具体实现时会遇到一些其他问题,我主要遇到了两个:

  • 在用户切换性别之后,所有的页面中的 RecyclerView 都需要重新向服务器获取数据并刷新(原谅有些人设计的这种奇葩交互方式!)。此时我们需要通知所有页面进行重载,并且滚动到顶部。但是并不是所有页面都在内存中,而已经被系统销毁的页面在恢复时 FragmentStatePagerAdapter 会尝试恢复原来的状态,这样就会导致不能回到顶部。
  • 在某些时候,RecyclerView 并不能回到系统保存的状态,具体表现为用户可能向下滑动了一部分内容,但是回到此页面时列表又变成了顶部。

这两个问题要解决的最好方式是先学习一下这个 Adapter 是怎么工作的,那么老夫就要查看一下它的源代码。这个东东的成员变量定义如下:

public abstract class FragmentStatePagerAdapter extends PagerAdapter {
    private static final String TAG = "FragmentStatePagerAdapter";
    private static final boolean DEBUG = false;

    private final FragmentManager mFragmentManager;
    private FragmentTransaction mCurTransaction = null;

    private ArrayList<Fragment.SavedState> mSavedState = new ArrayList<Fragment.SavedState>();
    private ArrayList<Fragment> mFragments = new ArrayList<Fragment>();
    private Fragment mCurrentPrimaryItem = null;
}

很显然 mSavedState 是用来记录各个页面的状态的,在加载某个页面的时候,代码如下:

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        // If we already have this item instantiated, there is nothing
        // to do.  This can happen when we are restoring the entire pager
        // from its saved state, where the fragment manager has already
        // taken care of restoring the fragments we previously had instantiated.
        if (mFragments.size() > position) {
            Fragment f = mFragments.get(position);
            if (f != null) {
                return f;
            }
        }

        if (mCurTransaction == null) {
            mCurTransaction = mFragmentManager.beginTransaction();
        }

        Fragment fragment = getItem(position);
        if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment);
        if (mSavedState.size() > position) {
            Fragment.SavedState fss = mSavedState.get(position);
            if (fss != null) {
                fragment.setInitialSavedState(fss);
            }
        }
        while (mFragments.size() <= position) {
            mFragments.add(null);
        }
        FragmentCompat.setMenuVisibility(fragment, false);
        FragmentCompat.setUserVisibleHint(fragment, false);
        mFragments.set(position, fragment);
        mCurTransaction.add(container.getId(), fragment);

        return fragment;
    }

如果页面被有被创建,会调用 getItem 方法来创建页面,这个方法是抽象的,需要用户自己实现。创建了这个 Fragment 后,会尝试找这个页面有没有被保存过,如果有就调用 Fragment.setInitialSavedStates 尝试恢复。至于中间为什么会有个 while,那是因为最初时系统之创建了前两个页面,而此时用户可能直接点了后面的页面,中间的页面都还没有被创建。

在 ViewPager 尝试销毁此页面时,会调用 destroyItem:

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        Fragment fragment = (Fragment) object;

        if (mCurTransaction == null) {
            mCurTransaction = mFragmentManager.beginTransaction();
        }
        if (DEBUG) Log.v(TAG, "Removing item #" + position + ": f=" + object
                + " v=" + ((Fragment)object).getView());
        while (mSavedState.size() <= position) {
            mSavedState.add(null);
        }
        mSavedState.set(position, fragment.isAdded()
                ? mFragmentManager.saveFragmentInstanceState(fragment) : null);
        mFragments.set(position, null);

        mCurTransaction.remove(fragment);
    }

所以看来看去 FragmentStatePagerAdapter 做的事情很简单,在销毁页面时使用 FragmentManager 的 saveFragmentInstanceState 方法保存在内存中。FragmentManager 在 Activity 和 Fragment 里面都可以 get 到,也就是说从 Android 4.2 开始支持 Fragment 里面嵌套 Fragment,所以在具体保存时需要递归遍历。绕了一大圈,最终会调用 FragmentManager 的这个方法:

    void saveFragmentViewState(Fragment f) {
        if (f.mView == null) {
            return;
        }
        if (mStateArray == null) {
            mStateArray = new SparseArray<Parcelable>();
        } else {
            mStateArray.clear();
        }
        f.mView.saveHierarchyState(mStateArray);
        if (mStateArray.size() > 0) {
            f.mSavedViewState = mStateArray;
            mStateArray = null;
        }
    }

在这里要插一些题外话说明一下 Android 里的 Parcelable 和  SparseArray。

Parcelable 是 Android 提供的序列化机制,这个东西随处可见,比如你现在看到的 ViewState 以及 Activity 间通信的 Bundle 都实现了这个接口。Java 本身提供了序列化机制,而 Parcelable 性能更高、构造时生成的对象更少,这样可以减少垃圾回收的次数。听起来是不是很牛逼?实际上它的想法相当简单,就是要用户自己去实现 writeToParcelable 方法。比如我有 a、b、c 三个字段需要序列化,在写入时,我按照 a、b、c 的顺序依次写,读取时按照这个顺序以及数据类型来读取(因为数据类型你是知道的),这样就避免了调用 Java 通用的序列化方法产生很多不必要的内容(比如它肯定要记录你的属性名、类型等等很多信息)。内存的节省以及程序的性能在移动设备上尤为重要(虽然现在手机的内存已经很多了)。

而 SparseArray 的想法类似,进一步压榨 Hashmap 的内存(只提供 Integer 到 Object 的映射),具体实现老夫准备过一段时间再仔细研究一下。

View 的 saveHierarchyState 最终会调用下面这个方法:

    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
        if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
            mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
            Parcelable state = onSaveInstanceState();
            if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
                throw new IllegalStateException(
                        "Derived class did not call super.onSaveInstanceState()");
            }
            if (state != null) {
                // Log.i("View", "Freezing #" + Integer.toHexString(mID)
                // + ": " + state);
                container.put(mID, state);
            }
        }
    }

由于 View 是一个抽象类,界面上的元素都给出了其具体实现,则每个 UI 组件都需要覆盖此方法以保存自己需要保存的状态。对于哪些内容是需要保存的,在官方文档里已经写得很清楚了,那就是不能够被重新创建的内容(例如文本框的内容、游标位置等),而类似于该 View 在窗口中的坐标等内容则不应保存,因为它可以通过重新计算得出。我们的重点是找到 RecyclerView 的 onSaveInstanceState 方法:

    @Override
    protected Parcelable onSaveInstanceState() {
        SavedState state = new SavedState(super.onSaveInstanceState());
        if (mPendingSavedState != null) {
            state.copyFrom(mPendingSavedState);
        } else if (mLayout != null) {
            state.mLayoutState = mLayout.onSaveInstanceState();
        } else {
            state.mLayoutState = null;
        }

        return state;
    }

可以看出 RecyclerView 保存的全部信息都是 LayoutManager 需要保存的内容,自己信息全部不会保存,例如里面的列表元素,所以如果需要保存这类信息需要自己继承 RecyclerView 并覆盖此方法。不过需要注意的是从上面的代码可以看出覆盖 onSaveInstanceState 时必须先调用基类中的此方法,否则会报错(这很好理解)。

在此界面中我们给 RecyclerView 指定的是 LinearLayoutManager,那我们赶紧看它的代码:

    @Override
    public Parcelable onSaveInstanceState() {
        if (mPendingSavedState != null) {
            return new SavedState(mPendingSavedState);
        }
        SavedState state = new SavedState();
        if (getChildCount() > 0) {
            ensureLayoutState();
            boolean didLayoutFromEnd = mLastStackFromEnd ^ mShouldReverseLayout;
            state.mAnchorLayoutFromEnd = didLayoutFromEnd;
            if (didLayoutFromEnd) {
                final View refChild = getChildClosestToEnd();
                state.mAnchorOffset = mOrientationHelper.getEndAfterPadding() -
                        mOrientationHelper.getDecoratedEnd(refChild);
                state.mAnchorPosition = getPosition(refChild);
            } else {
                final View refChild = getChildClosestToStart();
                state.mAnchorPosition = getPosition(refChild);
                state.mAnchorOffset = mOrientationHelper.getDecoratedStart(refChild) -
                        mOrientationHelper.getStartAfterPadding();
            }
        } else {
            state.invalidateAnchor();
        }
        return state;
    }

这个东东返回的是一个 Parcelable 接口的对象,但是实际实现是 LinearLayoutManager 里面的一个内部类,该类只定义了三个成员变量:

    public static class SavedState implements Parcelable {

        int mAnchorPosition;

        int mAnchorOffset;

        boolean mAnchorLayoutFromEnd;
    }

也就是说它只会保存 mAchorPosition、mAchorOffset 以及 mAnchorLayoutFromEnd 这三个字段(在此顺带一提,Parcelable 保存内容的最小单位是 4 个字节,因此 boolean 类型也会占用 4 个字节)。

OrientationHelper 一共有两种实现,用于支持内容的横向与纵向排列(在本文最初的 app 中,顶部的标签栏就是 RecyclerView 元素的横向排列,底下的内容则是纵向排列),具体的代码就在这里不多说。总而言之,mAchorPosition 用于记录当前显示的最后一个元素在适配器中的位置,mAchorOffset 用于记录该元素对应的 View 在界面上的偏移量,mAchorLayoutFromEnd 用于表示 RecyclerView 是否是反向排列列表内容。所以,这三个字段就是标识当前 RecyclerView 滑动到了什么位置!

偶们具体运行一下:

untitled

可以看出,在这个页面被系统干掉的时候,除了保存 ReyclerView 里用户看到了哪个位置,其他毛都没保存!按照 Google 的想法,onSaveInstanceState 要保存尽可能少的内容,这样当内存不足时,系统可以花费最少的内存开销来保存当前的状态以便今后恢复。RecyclerView 里面的内容实在是太多了,即使是把 Adapter 序列化出来也会有不少内容,所以干脆全都不要好了,恢复时候乃可以重新生成一个 Adapter 嘛,只要我把位置记住了,用户的感觉就像是直接回到了当时的状态。

可惜我个人认为这样做似乎不是一个很好的选择。因为假如我们想要保存列表里的内容,把 Adapter 整个序列化出来其实不会有多大。例如对于我这个 app 而言,假设用户看了 100 条数据(多数用户并不会有耐心看这么多),而每条数据包括标题、图片(只是图片的 URL)、作者、是否收藏、收藏数等,保守估计每条数据占 1K 的内存吧(大部分不会有这么多),那 100 条也仅仅 100KB 而已。Android 的老用户都知道,应用被 kill 后再打开恢复的速度并不快,由此可见瓶颈并不在于恢复 Adapter 的数据。

不过对于操作系统的设计来说是能省则省,再说乃完全可以手工把 Adapter 保存起来呀。

现在问题都弄明白了,来解决开始提出的两个问题。

其实这两个问题是一个问题,重点是这个 SavedState 恢复的时机。当用户离开某个页面后,系统将会回收与它“距离”比较远的页面,例如默认情况下当我们看第三个页面时,与它“距离”为 2 的第一个页面将被回收。在回收之前,系统调用 onSaveInstanceState 方法给此页面写下“遗书”的机会,对于我们的 app 而言则是 RecyclerView 的当前位置。

当用户回到此页面或者系统进行页面的预加载时,执行完 onCreate 方法后系统在某个时候恢复完页面后会调用 onViewStateRestored 给应用发通知。问题就在这里,我们在 onCreate 执行完后会向服务器发起请求去加载列表的内容,加载完后系统调用 onViewStateRestored 方法,此时列表就回到了原先浏览的位置。

可是,网络请求是一个异步的过程,如果它在系统恢复完页面后我们才收到数据开始构造 Adapter,这样 RecyclerView 的位置就丢失了,于是我们又回到了顶部(但是某些时候构造完 Adapter 之后页面才进行恢复,这目前对于我来说是个“未解之谜”,有待以后进一步研究,如果乃知道可以告诉我 ^_^)。

现在我们考虑第一个问题,在用户更改性别后对所有页面的内容进行重新加载,此时所有页面要回到最顶端。此时最简单的做法就是不让 FragmentStatePagerAdapter 去保存页面的内容(即列表的位置),或者把已经保存的状态全删掉。可是坑爹之处在于这个类没有提供任何方法操作其保存的状态,除非我们 copy 一个 FragmentStatePagerAdapter 然后把其中的某些方法改掉,否则完全没有办法访问里面的字段!

我不想去重新实现这个类,于是老夫就想到了另外一个办法:在每个页面里面加入一个 mVersion 的字段,用户每更改一次性别选项后就把容器 Fragment 的版本号加 1,在页面加载完数据后检查一下自己的 mVersion 是否小于容器的 Version,如果小于就回到顶部并把版本号设置成和容器一样的就行啦。

对于第二个问题,在系统回调 onViewStateRestored 方法后,我们把 LayoutManager 保存的信息取出来,在 Adapter 创建好后去恢复其位置:

    @Override
    public void onViewStateRestored(Bundle savedInstanceState) {
        super.onViewStateRestored(savedInstanceState);

        if (savedInstanceState == null)
            return;

        ......
  
        if (savedInstanceState.containsKey(SAVED_BUNDLE_POSITION)) {
            mPositionToRestore = savedInstanceState.getParcelable(SAVED_BUNDLE_POSITION);
        }

    }

LayoutManager 提供了一个 onRestoreInstanceState 方法,我们直接把这个 Parcelable 对象传给它就可以了。

    private void restoreRecyclerViewPosition() {
        if (mPositionToRestore != null) {
            mRecyclerView.getLayoutManager().onRestoreInstanceState(mPositionToRestore);
            mPositionToRestore = null;
        }
    }

先试一下水,我把第一个页面滑动到下面,然后把第二个页面也向下浏览一下,之后一次性跳到最后一个页面,然后又回到第一、二个页面,好像位置就恢复过来了。

untitled

但是这样做还有一个问题,那就是我们的列表不是一次性加载完了。比如目前的实现是每次加载 10 条数据,当用户看到第 8 条时,向服务器请求接下来的 10 条数据填充到 Adapter 的末尾。因此如果用户看到了比如说第 3 页,而在页面加载时我们只请求了第一页,那么尝试恢复位置的时候第三页的数据还没有,这样恢复的位置就不对,那就又回到顶部了。

解决方案似乎是直接把列表加载到当前位置,比如用户在第三页时直接就取 30 条数据再恢复。但这就意味着需要改服务器的接口,而且一次加载那么多数据好像也不太好。

所以我目前就把这个 bug 放在这里,在以后的版本再想想怎么解决。

Android 的 FragmentStatePagerAdapter 的笔记”的一个响应

    1. 像我这样用原生的类库写,或者像你说的用 html 实现都是可以的。
      目前后者比较火,也有很多库(比如 facebook 的 react)。
      但是老夫强烈【不推荐】用 html 来写,首先用户体验差一大截,其次 html 依赖 System WebView,很多机器并不升级这个,会有一大堆兼容性问题。而用原生的类库来做界面很多兼容性问题谷歌已经帮你处理好了。

      1. Android 的 UI 是 Java 写的,有一些系统级的类库也是 Java 写的。这个和 C# 类似。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com 徽标

You are commenting using your WordPress.com account. Log Out /  更改 )

Google+ photo

You are commenting using your Google+ account. Log Out /  更改 )

Twitter picture

You are commenting using your Twitter account. Log Out /  更改 )

Facebook photo

You are commenting using your Facebook account. Log Out /  更改 )

Connecting to %s