ViewPager anr,页面空白问题完全解析

96
mandypig
0.9 2019.05.20 18:17* 字数 5085

关键词 viewpager页面空白根源 viewpager刷新anr异常

首先装个B,这可能是网上能找到的最为详细解释anr和页面空白的文章了,如果你正好遇到了viewpager的这两个问题,那么这篇问题对你帮助应该蛮大,即使你原来不知道这两个问题,看完之后你不就知道了吗(手动滑稽)。

基于viewpager的无限自动轮播功能,这个功能实在是太常见了,做android的几乎没有不知道这个功能的,可能很多人觉得就这么个烂大街的功能如今还有必要写一篇文章专门讨论如何实现是不是有点过时,但是 相信我,如果你自己动手封装过无限自动轮播效果,那么你是很有可能会遇到我所说的问题,但是也不排除一种可能,就是你非常侥幸绕过了这个坑,指不定哪天就踩坑了。

什么坑

要想踩坑,你需要满足的一个条件就是你的viewpager能够无限轮播,这个条件很多app都会满足,一般app的首页都会有这么个无限轮播的广告位,如果自己封装过那么页面空白问题,anr问题简直让你欲罢不能啊。有些人可能没遇到过这个问题,具体原因嘛我会在文章最后给出一种不会有问题的情况,指不定就是你们采用的方案,但这种方案性能上差了点意思,不太可取。

无限轮播实现思路

非常老掉牙的一个问题了,但是还是有必要说一下,因为稍不同的实现思路,就有极大可能是你产生anr的根源。无限轮播思路蛮简单,相信大家都知道简单说一下即可。
1:adater的getcount方法返回MAX

@Override
    public int getCount() {
        if (views.size() > 2) {
            return Integer.MAX_VALUE;
        } else {
            return views.size();
        }
    }

2:如何获取对应的view数据

public Object instantiateItem(ViewGroup container, int position) {
        View view;
        int pos = position % views.size();
        view = views.get(pos);
        container.addView(view);
        return view;
    }

关于上述代码应该也没什么异议,常规操作,在第三点可能就存在一点差异了,这也是导致是否会anr的关键
3:如何确定首个展示页面的index
比如我们需要加载的页面有5个,那么可以知道0,5,10,15....都可以作为首个展示页面的index,为了实现无限轮播的效果这个值当然选择一个比较大的更加合适,那么问题来了,选择多大对轮播库有影响吗,踩过坑的都知道还真有区别!选个1000或者2000够大了吧,但是你要知道getcount返回的可是Integer.MAX_VALUE,那么首个页面展示的index大约应该在Integer.MAX_VALUE的中间值才对,根据这种想法可以得到首个index的代码为

            pageIndex = (Integer.MAX_VALUE >> 1) - (Integer.MAX_VALUE >> 1) % size;

这里的size就是数据的实际大小。可能有些人觉得没有这个必要非要选个max的中间值,选个几千的首个页面index也没人会无聊到真的滑到头,但是我要说的是虽然选个比较小的首页index不会触发anr,但是anr问题其实一直存在,只不过当选择Integer.MAX_VALUE中间值时把这个问题放到到了最大并不代表选个小的起始index就没问题,问题的关键是为什么会引起这个问题,如何解决,下文会详细说明。

viewpager无限轮播会引起的另一个问题就是页面空白问题,这个问题和anr问题相比我认为才是最为头疼的,它的表现形式就是当viewpager刷新完数据后,会出现展示的页面是空白的情况,这里说到一个比较重要的操作就是刷新了,如果只是纯粹手滑viewpager不涉及到刷新操作,那么既不会有anr也不会有页面空白问题,但是作为app首页广告展示位,viewpager刷新数据这种操作太常见了,无法避免这个问题。

viewpager刷新操作

viewpager和listview或者recyclerview不太一样的地方在于刷新操作,虽然三者都提供了adapter,并且具有notifydatechange方法,但是viewpager的notifydatechange有点不太好用,刷新完后不会引起数据变化,网上查资料需要复写一些其他方法,总之个人认为比较麻烦,所以自己采用的刷新方法就是最简单暴力的重新设置一次setadapter,然而坑就是从这个方法开始的

viewpager加载页面源码解析

要想解决anr和页面空白问题,阅读viewpager加载页面的源码是一个绕不过的坎,不然你无法从根源上去解决这个问题,viewpager大家都知道有预加载功能,默认会加载当前页面以及左一和右一3个页面,当手指滑动的时候会触发

    public Object instantiateItem(ViewGroup container, int position) ;
    public void destroyItem(ViewGroup container, int position, Object object) ;

这两个非常熟悉的方法,上述就是viewpager最为关键的加载页面流程,但是viewpager如何实现的是导致anr以及页面空白的关键所在,和这个过程相关的核心代码在viewpager的populate方法内部

 void populate() {
        populate(mCurItem);
    }

mCurItem就是当前页的位置,内部代码较多,直接分开看重点的部分

    void populate(int newCurrentItem) {
        ...
        final int pageLimit = mOffscreenPageLimit;
        final int startPos = Math.max(0, mCurItem - pageLimit);
        final int N = mAdapter.getCount();
        final int endPos = Math.min(N - 1, mCurItem + pageLimit);
        ...

mOffscreenPageLimit默认值为1,就是预加载页面的单边个数,总共需要加载2*mOffscreenPageLimit+1个页面。startPos和endPos分别表示加载的起始和终止位置。继续往下看

        ItemInfo curItem = null;
        for (curIndex = 0; curIndex < mItems.size(); curIndex++) {
        final ItemInfo ii = mItems.get(curIndex);
        if (ii.position >= mCurItem) {
            if (ii.position == mCurItem) curItem = ii;
            break;
        }
    }

        if (curItem == null && N > 0) {
        curItem = addNewItem(mCurItem, curIndex);
    }

获取当前展示的页面是否在mItems当中有缓存,如果有则直接赋值给curItem,否则通过addNewItem创建一个新的。mItems是viewpager用来缓存页面的列表,元素ItemInfo的数据结构如下

static class ItemInfo {
        Object object;
        int position;
        boolean scrolling;
        float widthFactor;
        float offset;
    }

对里面的变量有个大致印象,看具体代码的时候会涉及到这些变量的使用。除了widthFactor,其他变量都很关键,正确理解才是解决anr和页面空白的关键,现在看下addNewItem具体做了什么事情

ItemInfo addNewItem(int position, int index) {
        ItemInfo ii = new ItemInfo();
        ii.position = position;
        ii.object = mAdapter.instantiateItem(this, position);
        ii.widthFactor = mAdapter.getPageWidth(position);
        if (index < 0 || index >= mItems.size()) {
            mItems.add(ii);
        } else {
            mItems.add(index, ii);
        }
        return ii;
    }

这里涉及到了ItemInfo中三个变量的使用,object应该是大家最熟悉的,通过instantiateItem赋值得到,在轮播viewpager这种情景中,object本质上就是当前页面的view。position就是该页面的位置信息了,很好理解。widthFactor一般使用默认值1,不太关心具体作用。最后通过add方法将创建的ItemInfo插入到相应位置中。首次展示页面的时候mitems必然会空,所以会进入到addNewItem创建当前页面的ItemInfo,创建完当前ItemInfo,接下来要做的事情就是创建左一和右一这两个页面。左一页面创建和右一页面创建代码类似,先看下左一页面创建的代码

if (curItem != null) {
           ...
            ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
           ...
            for (int pos = mCurItem - 1; pos >= 0; pos--) {
                if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {
                    if (ii == null) {
                        break;
                    }
                    if (pos == ii.position && !ii.scrolling) {
                        mItems.remove(itemIndex);
                        mAdapter.destroyItem(this, pos, ii.object);
                        if (DEBUG) {
                            Log.i(TAG, "populate() - destroyItem() with pos: " + pos
                                    + " view: " + ((View) ii.object));
                        }
                        itemIndex--;
                        curIndex--;
                        ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                    }
                } else if (ii != null && pos == ii.position) {
                    extraWidthLeft += ii.widthFactor;
                    itemIndex--;
                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                } else {
                    ii = addNewItem(pos, itemIndex + 1);
                    extraWidthLeft += ii.widthFactor;
                    curIndex++;
                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                }
            }

pos取mCurItem - 1表示左一位置,for中有3个大分支逻辑,第一个分支有一个很熟悉的api destroyItem,所以很明白这个分支的作用就是用来将超出mOffscreenPageLimit的部分移除mitems,看进入条件pos < startPos就应该明白。

第二个分支条件ii != null && pos == ii.position,它的作用就是判断从mitems中得到的ii是否 pos == ii.position,是则继续取下一个ii继续for循环,直到满足pos < startPos。最后一个分支就是从mitems取左一页面失败才会进入该分支,再次进入到熟悉的addNewItem创建新页面并插入到mItems当前页面左边,curIndex表示当前页面index,既然左边插入了页面所以需要执行自增操作。上述就是左一页面创建的整个过程,当然还包括了超过范围的销毁页面代码就在第一个if分支中,右一的创建逻辑类似不再累述。会发生anr的根源就在这上述代码里面。

anr问题产生分析

要想找到根源还需要看另一个关键api的源码,刷新操作setadapter

public void setAdapter(PagerAdapter adapter) {
        if (mAdapter != null) {
            ...
            mItems.clear();
            ...
            mCurItem = 0;
            ...
        }
        ...
        if (mAdapter != null) {
           ...
            final boolean wasFirstLayout = mFirstLayout;
            mFirstLayout = true;
            ...
            if (mRestoredCurItem >= 0) {
               ...
            } else if (!wasFirstLayout) {
                populate();
            } else {
                requestLayout();
            }
        }
    }

去掉了很多无关的代码只留下最关键的部分,轮播viewpager进入app一般会被调用两次setadapter,第一次使用缓存中的数据,防止无网的情况,第二次使用从服务器获取到的最新数据进行刷新。关键就是第二次调用的时候会进入到mAdapter!=null的分支
对原先mItems进行清空操作,然后执行了一行关键代码mCurItem=0,最终调用到populate。

来看一下刷新操作的完整过程,每个人的写法应该都大同小异

        customViewPager.setAdapter(bannerViewPagerAdapter);
        int size = bannerViewPagerAdapter.getRealCount();
        if (size > 2) {
            pageIndex = (Integer.MAX_VALUE >> 1) - (Integer.MAX_VALUE >> 1) % size;
        } else {
            pageIndex = 0;
        }
        customViewPager.setCurrentItem(pageIndex, true);

先调用setadapter然后调用setCurrentItem定位到首页的index,结合setadapter来分析下会有什么问题。首先第一次调用刷新操作后mCurItem是一个Integer.MAX_VALUE的中间值,这并不会有任何问题。第二次调用刷新操作后那么问题就来了,setadapter内部会将mCurItem设置到0然后调用populate方法,populate源码上面已经分析过了,会先调用展示当前页面,然后初始化左一和右一两个页面,当前页面是0,左一没有页面,所以只能初始化右一页面,执行完毕之后mItems中就存在两个itemInfo,这时还没出现任何问题,接着调用setCurrentItem方法,该方法最终调用了setCurrentItemInternal

 void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {
        if (mAdapter == null || mAdapter.getCount() <= 0) {
            setScrollingCacheEnabled(false);
            return;
        }
        if (!always && mCurItem == item && mItems.size() != 0) {
            setScrollingCacheEnabled(false);
            return;
        }

        if (item < 0) {
            item = 0;
        } else if (item >= mAdapter.getCount()) {
            item = mAdapter.getCount() - 1;
        }
        final int pageLimit = mOffscreenPageLimit;
        if (item > (mCurItem + pageLimit) || item < (mCurItem - pageLimit)) {
            // We are doing a jump by more than one page.  To avoid
            // glitches, we want to keep all current pages in the view
            // until the scroll ends.
            for (int i = 0; i < mItems.size(); i++) {
                mItems.get(i).scrolling = true;
            }
        }
        final boolean dispatchSelected = mCurItem != item;

        if (mFirstLayout) {
            // We don't have any idea how big we are yet and shouldn't have any pages either.
            // Just set things up and let the pending layout handle things.
            mCurItem = item;
            if (dispatchSelected) {
                dispatchOnPageSelected(item);
            }
            requestLayout();
        } else {
            populate(item);
            scrollToItem(item, smoothScroll, velocity, dispatchSelected);
        }
    }

关键代码在于scrolling标志位的设置,会将mitems中的元素给设置为true,mFirstLayout变量在setadapter已经设置为true,所以进入if分支,最终调用requestLayout,在onmeasure方法内部会调用到populate方法。

下面就是见证奇迹的时刻,盼望已久的anr问题马上就要出现了,在正式调用populate之前回顾一下此时mitems中已经有两个元素了,position分别为0,1,这个很关键和anr,页面空白都脱不了关系。现在正式进入populate内部,该方法前文已经分析过了,现在直接看引起anr部分的重点代码即可,注意此时的mcuritem已经是一个Integer.MAX_VALUE;的中间值

for (int pos = mCurItem - 1; pos >= 0; pos--) {
                if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {
                    if (ii == null) {
                        break;
                    }
                    if (pos == ii.position && !ii.scrolling) {
                        mItems.remove(itemIndex);
                        mAdapter.destroyItem(this, pos, ii.object);
                        if (DEBUG) {
                            Log.i(TAG, "populate() - destroyItem() with pos: " + pos
                                    + " view: " + ((View) ii.object));
                        }
                        itemIndex--;
                        curIndex--;
                        ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                    }
                } else if (ii != null && pos == ii.position) {
                    extraWidthLeft += ii.widthFactor;
                    itemIndex--;
                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                } else {
                    ii = addNewItem(pos, itemIndex + 1);
                    extraWidthLeft += ii.widthFactor;
                    curIndex++;
                    ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
                }
            }

这部分代码就是populate当中加载左一页面的代码逻辑,它有两个作用一个是加载mOffscreenPageLimit内的页面,另一个作用销毁超过mOffscreenPageLimit范围的页面,在正式执行销毁页面逻辑之前此时mitems中保存了4个元素,分别是0,1,mCurItem-1,mCurItem。现在要销毁的是哪几个页面,很明显是0,1,因为 pos < startPos。如何销毁页面是引起anr问题的关键,mitems中总共才4个页面,删除两个页面还能引起anr?然而事实就是这样的,populate删除页面是通过iteminfo中的position来判断,执行删除的条件pos == ii.position && !ii.scrolling而pos是通过for循环的pos--操作来得到的,要想从mCurItem-1遍历到pos=1,是一个极其耗时的过程,因为我们的mCurItem是一个 Integer.MAX_VALUE;的中间值,大约在10亿多!!!这种遍历方式是不可能不引起anr问题的,这也很好解释了为什么将首页展示的index设置为几千为什么不会引起anr问题。

到这里就分析完了引起anr问题的根源所在,相信认真思考看完的老铁自己也能动手去解决这个问题了,这里就先不放上自己解决的思路,待解释完页面空白问题后一并给出。

viewpager页面空白问题

这是另一个在实现无限轮播库时遇到的非常另人头疼的问题,通过setadapter刷新完数据后viewpager出现了空白的情况,产生该问题的根源需要在上述分析anr的基础上继续分析。先抛开anr问题,假设你的机子牛B到从10亿遍历到1速度也很快,这时就会判断 if (pos == ii.position && !ii.scrolling) ,只有满足了才会执行内部删除iteminfo操作。

难道删除iteminfo在超过mOffscreenPageLimit的情况下还有不满足条件的情况?嗯,确实是这样的,虽然条件pos == ii.position是成立了,可是ii.scrolling它不答应啊,position 0,1的scrolling此时是true!!,该值默认为false什么时候被改成了true这是一个关键的地方,其实答案在分析setCurrentItemInternal已经给出了,也就是说虽然页面0,1已经超过了mOffscreenPageLimit范围,但是就是无法被删除掉,这也是引起页面空白的根源所在!!

那么问题来了,为什么不删除掉0,1就会引起页面空白,下面要开始我的表演,请注意看分析,首先执行完populate之后由于0,1无法被删除所以mitems中存在5个iteminfo,分别为0,1,mCurItem-1,mCurItem,mCurItem+1,这里虽然有5个iteminfo但是实际的object只有3个对象,0和mCurItem的成员object是同一个对象,1和mCurItem+1的成员object是同一个对象,看着是不是有点绕,看一下下面的代码应该就能明白了

public Object instantiateItem(ViewGroup container, int position) {
        View view;
        int pos = position % views.size();
        view = views.get(pos);
        container.addView(view);
        return view;
    }

这是adapter中实现的instantiateItem代码,views中对展示过的页面都进行了缓存,好处很明显,虽然是无限轮播但是来来回回展示的都是这么几个页面,复用是最好的选择,结合上面的代码应该就能明白为什么0和mCurItem的成员object是同一个对象这句话了吧。

接着上面的分析,执行完populate方法之后,viewpager看着有5个iteminfo但实际上却只有3个子view,有了子view之后肯定要进行layout布局才能真正展示出来,看下onlayout里面的操作,马上就能发现端倪了

for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() != GONE) {
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
                ItemInfo ii;
                if (!lp.isDecor && (ii = infoForChild(child)) != null) {
                    int loff = (int) (childWidth * ii.offset);
                    int childLeft = paddingLeft + loff;
                    int childTop = paddingTop;
                    if (lp.needsMeasure) {
                        // This was added during layout and needs measurement.
                        // Do it now that we know what we're working with.
                        lp.needsMeasure = false;
                        final int widthSpec = MeasureSpec.makeMeasureSpec(
                                (int) (childWidth * lp.widthFactor),
                                MeasureSpec.EXACTLY);
                        final int heightSpec = MeasureSpec.makeMeasureSpec(
                                (int) (height - paddingTop - paddingBottom),
                                MeasureSpec.EXACTLY);
                        child.measure(widthSpec, heightSpec);
                    }
                    if (DEBUG) {
                        Log.v(TAG, "Positioning #" + i + " " + child + " f=" + ii.object
                                + ":" + childLeft + "," + childTop + " " + child.getMeasuredWidth()
                                + "x" + child.getMeasuredHeight());
                    }
                    child.layout(childLeft, childTop,
                            childLeft + child.getMeasuredWidth(),
                            childTop + child.getMeasuredHeight());
                }
            }
        }

选取了onlayout中的关键部分代码,遍历子view然后调用child的layout方法,0和mcuritem是同一个对象,两者只会执行一次layout操作,但是问题的关键在于虽然0和mcuritem元素的object是同一个对象但是offset却是两个不同的值!!offset表示的就是页面距离屏幕的偏移量,当前页面offset为0,左一页面offset为-1,右一页面offset为1,以此类推,所以0页面上的offset是一个非常恐怖的值大约在负10亿左右!,在执行layout操作时还被用上了,正确的mcuritem上的offset反而没有机会使用,最终就导致了页面空白的问题!!

如何解决anr和页面空白问题

写到这里关于anr和页面空白已经解释的很清楚了,现在剩下的就是如何去解决这个问题,其实认真思考过的各位老铁应该都能找到上述两个问题的根源所在,那就是viewpager自作主张给我们生成了两个多余的iteminfo,0和1,想办法删除掉这两个iteminfo就能很好避免这个问题。如何删除需要同时修改adapter中instantiateItem代码以及viewpager源码,先看一下instantiateItem中的处理

@Override
    public Object instantiateItem(ViewGroup container, int position) {
        if (views.size() > 2 && (position == 0 || position == 1)) {
            return null;
        }
        View view;
        int pos = position % views.size();
        view = views.get(pos);
       ...
        container.addView(view);
        return view;
    }

关键代码在于if判断,当views.size() > 2且position==0或者1直接返回null,这里需要特殊说明一下的就是我封装的无限轮播viewpager,如果你的viewpager没有轮播这里返回null是会出现npe问题的,没有轮播的viewpager直接使用原生viewpager即可,或者有时间这块逻辑稍微修改下也能支持普通的viewpager。

然后看下如何修改viewpager中的源码,修改点在于populate方法内部,直接上图就能明白
图片1.png

原理就是移除mitems中0,1两个无用页面,就是这么简单,然后世界就清净了,anr没了,空白也没了。viewpager是support包中的代码,所以完全可以拷贝过来使用,就一个类而已非常方便,在封装轮播库的时候记得使用自己库中的viewpager即可。

为什么有些人在实现无限轮播的时候没有anr,空白问题

我在文章开头就说了,有些人可能在实现无限轮播的时候一帆风顺,这里我可以试着解释下,因为公司项目的轮播就是这种情况没有问题,但是个人认为这是一种不太可取的方式。

没有anr我文章已经反复强调过了,只是设置的首页index不够大,并不是没有问题,你设置个5000,代码照样要遍历5000次这显然是有问题的,可能有些app进入首页卡顿和这块有很大的关系(公司一个app项目就是通过这种方式)。

公司一个项目轮播没有出现页面空白问题,简单看了下实现发现没有对0,1页面做特殊处理,也没有修改viewpager源码,让我有点好奇是如何做到的,跟踪了下代码发现问题出在instantiateItem内部,直接上图
图片2.png

让我有点尴尬,居然是通过每次inflate一个新的view实现轮播没有采用复用的机制,这方法显然不可取,那么这种方案为什么就不会引起页面空白了呢,上述已经分析过了由于mitems中存在0,1,mcuritem-1,mcuritem,mcuritem+15个元素但是实际上object只有三个,但是使用每次inflate的方式显然会导致这5个元素上的object都不相同,从而碰巧绕开了这个坑。

所以说如果各位的轮播库没有经过任何特殊处理既没有出现anr也没有出现页面空白的话不妨回头看看是不是我说的上述原因。

总结

无限轮播这种最常见的效果其实内部隐藏着巨坑,当时自己封装的时候也未曾想到会有这些问题,所说网络很发达,但当自己遇到上述问题后救助网络发现讲述viewpager无限轮播的文章那么多,既然没有一篇文章是说anr和页面空白问题的,这其实是一个封装轮播库非常常见的问题,只要你封装过,这两个问题就是你绕不开的坑,除非你一直都在使用别人的东西。可惜的是网上找不到解决方案。

退而求其次,只能自己硬着头皮啃代码,把自己分析的整个过程都完整记录了下来,也是方便有缘人看到这篇文章能避开这两个大坑。可能也是google知道viewpager有不少坑,所以前段时间据说推出了viewpager2是通过recyclerview来实现的,我个人的观点反正不想花太多时间去掌握这种新控件,你永远不知道有什么坑等着你来踩,踩viewpager的坑就够了,学一个东西重要的是掌握其原理,纯粹学学api调用没什么意义。

坚持写文章实属不易,对大家有帮助的话希望点个赞
随笔