无限循环 ViewPager setCurrentItem 导致 ANR 分析

1. 无限循环 ViewPager

通过 adapter 的 getCount 返回一个足够大的数字,再初始化显示的item在中间位置,那么用户在左右滑动能够模拟出一个循环的显示界面。

2. 调用 setCurrentItem 卡顿

当我们设置的 getCount 是一个较小的数字时,调用该方法总能快速跳转到目标位置,但是 getCount 是一个大数,如 Integer.MAX_VALUE,那么在调用跳转时,很容易触发 anr。

2.1 源码分析

设置显示的item角标,最终调用 void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) 这个函数,看下这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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;
//========================================================
//这个设置是最后铺满画面的判定,但如果是大数,这里就是一个隐藏的 ANR
//========================================================
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 {
//重要方法,添加移除item!ANR的分析重点
populate(item);
//滑动到目标点
scrollToItem(item, smoothScroll, velocity, dispatchSelected);
}
}

上面可以看到,首先会先根据当前位置和目标位置距离判断是否需要滑动item,如果是滑动一页,不会触发设置scrolling,假如超过 pageLimit 泽一定会设置 scrolling = true。

第二个方法是 populate(item),用于移除看不见的item,添加新的item,下面部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
void populate(int newCurrentItem) {

//...省略很多代码

// Fill 3x the available width or up to the number of offscreen
// pages requested to either side, whichever is larger.
// If we have no current item we have no work to do.
if (curItem != null) {
float extraWidthLeft = 0.f;
int itemIndex = curIndex - 1;
ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
final int clientWidth = getClientWidth();
final float leftWidthNeeded = clientWidth <= 0 ? 0 :
2.f - curItem.widthFactor + (float) getPaddingLeft() / (float) clientWidth;

//===========================
//curIndex 是 mItem 的最后一个item的位置,经过上面处理,已经在原来基础上增加了一个
//ii 是我们现在页面显示的item,这里处理当前页面之前的item是否需要销毁
//这里也很明显看出运算次数为 pos 次,当设置是一个大数是,2^32 ≈ 8^10 约等于 10^10
//那么这里将执行总数的一半次数,估计 10^9 次
//===========================
for (int pos = mCurItem - 1; pos >= 0; pos--) {
if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {

//当 ii 为null时,能够跳出循环,ii的更新在下面的判断块中
if (ii == null) {
break;
}

//只有条件成立才会更新ii,但上面说到,如果更新的位置在pageLimit之内,
//scrolling 为false,超出则是true,超出的时候为了保证界面能完全填充
//也就是说无法跳出循环,所以在大数的时候,这里才是导致 ANR 的根本原因
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;
}
}

float extraWidthRight = curItem.widthFactor;
itemIndex = curIndex + 1;
if (extraWidthRight < 2.f) {
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
final float rightWidthNeeded = clientWidth <= 0 ? 0 :
(float) getPaddingRight() / (float) clientWidth + 2.f;

//这里同理,上面是判断设置的新页面在右边时,对当前页的左边进行处理
//下面代码是对当前页面右边的处理,一般执行次数为 pageLimit

//如果新页面位置在当前页的右边,下面只会执行 pageLimit 次就跳出循环,因为pos+pageLimit 后的ii是null
//同理,如果新页面在当前页的左边,上面也只会执行1次就跳出,因为 pos-pageLimit 的ii是null
for (int pos = mCurItem + 1; pos < N; pos++) {
if (extraWidthRight >= rightWidthNeeded && pos > endPos) {
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));
}
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
}
} else if (ii != null && pos == ii.position) {
extraWidthRight += ii.widthFactor;
itemIndex++;
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
} else {
ii = addNewItem(pos, itemIndex);
itemIndex++;
extraWidthRight += ii.widthFactor;
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
}
}
}

calculatePageOffsets(curItem, curIndex, oldCurInfo);

mAdapter.setPrimaryItem(this, mCurItem, curItem.object);
}

//...省略很多代码
}

上面注释说明非常清楚,通过对 posscrolling判断,来决定是否销毁当前页的前/后数据,这里程序只会循环 currentItem 次,原本我猜测即使空转,那应该也会很快处理完成,但在大数面前,任何的空转都应当理性对待。

假设 1w 次循环耗时为0.04ms,那么被放大10^5,也会达到4s,当设置为大数时,这个循环的时间不可忽视。

为了更加严谨,我在void populate(int newCurrentItem) 前后加入时间埋点,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Aspect
public class AspectVp {
private long time = 0;

@Before("call(void populate(..))")
public void beforePopulate(JoinPoint joinPoint) {
if (joinPoint.getArgs().length > 0) {
Log.d("zhou", "AspectVp [before populate >> " + (time = System.currentTimeMillis()));
}
}

@After("call(void populate(..))")
public void afterPopulate(JoinPoint joinPoint) throws Throwable {
if (joinPoint.getArgs().length > 0) {

long t = System.currentTimeMillis();
Log.i("zhou", "AspectVp [after populate >> " + t + " === spend = "
+ (t - time) + " ms");
}
}
}

当设置个数为 20000,当前页为10000,跳转到 cur+10 位置,单次执行耗时 1127 ms,如图1所示;
当设置个数为 2^32,当前页为 2^31 ,跳转到 cur+10 位置,单次执行耗时 3267
4193 ms,如图2所示:

图1:个数 20000

vp_small.gif

图2:个数 2^32

vp_big.gif

而且,从日志也可以看出,一次超缓存数的跳转,会触发四次 populate(item) 的调用。

1.setCurrentItem -> 触发 populate(item)
2.populate(item) 有新item加入 -> 触发 onMeasure -> populate()
3.populate() -> populate(item)
| — 有可能再次判定加入新 item,跳转2,但下一次肯定不会有新的item需要添加
| — 没有新增的 item
4.最后滑动结束,发出一个 populate() 保证页面覆盖完全

所以,3再会触发一次 populate(),但3-2-3不会成为死循环,总共有4次调用

2.2 解决思路

处理这个问题,有简单的方法,因为设置大数 2^32 真的太大了,修改为小一点、用户感知不强的数字,如10000,而5千次的滑动对用户也算是大操作,并且这个循环耗时在一个可接受范围,也不会造成页面的卡顿甚至 ANR。

或者,当设置的item超过pageLimit,我们强制把 isSrolling 设置为false,那么在遍历缓存 mItem 时能够及时更新 ii,使我们及时打破循环,跳出无用的循环时间。

3. 具体方案:打破循环

打破循环,让 for (int pos = mCurItem - 1; pos >= 0; pos--)for (int pos = mCurItem + 1; pos < N; pos++) 尽快结束循环

3.1 设置有限的小数(相对 2^32 来说)

adapter 设置 getCount 为小数值,让循环基数降低,即使执行次数多,所等待的时间也处于可接受范围

1
2
3
4
5
6
7
8
9
10
11
class Adapter extends PagerAdapter {

//...省略其他代码

@Override
public int getCount() {
return 10000;//自行设置一个合理的数值
}

//...省略其他代码
}

3.2 不触发设置 scrolling 条件

在 setCurrentItem 后,只要设置的 newIndex 在区间 (currentItem-pageLimit,currentItem+pageLimit),就不会触发设置该条件,那么在调用设置之前,把 pageLimit 设置为 Math.abs(newIndex - currentItem),调用设置位置之后,再重置回去,同样可以达到秒跳转效果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//原调用 viewPager.setCurrentItem(newIndex, true); 修改如下

int tmp = viewPager.getOffscreenPageLimit();
int newIndex = viewPager.getCurrentItem() + 10;

int newLimit = Math.abs(newIndex-viewPager.getCurrentItem());
if(newLimit>tmp) {
viewPager.setOffscreenPageLimit(newLimit);
viewPager.setCurrentItem(newIndex, true);
viewPager.setOffscreenPageLimit(tmp);
}else{
viewPager.setCurrentItem(newIndex, true);
}

3.3 重置 scrolling 为 false

在设置完 setCurrentItem 后,由于跳转距离问题会将 scrolling 置为 true,所以在执行 void populate(int newCurrentItem) 之前把 scrolling 重置为 false,但是 mItems 是私有变量,需通过反射获取,再通过 AspectJ 埋点在执行之前遍历重置 scrolling,这么看来,无疑方法二是最快解决问题的方式。

以下代码仅供参考,请不要随意应用于生产环境,确定使用请仔细评估性能消耗,完成覆盖测试

!注意:这里的注入对象是所有的 ViewPager!!

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Aspect
public class AspectVp {
private long time = 0;
private static ArrayList<Object> items = null;

//反射获取
public static void setupViewPager(ViewPager viewPager) {
try {
Field field = viewPager.getClass().getDeclaredField("mItems");
field.setAccessible(true);
items = (ArrayList<Object>) field.get(viewPager);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
Log.i("zhou", "setupViewPager " + (items == null ? "null" : items.size()));
}
//及时销毁
public static void destroyItems() {
items = null;
Log.i("zhou", "destroyItems ");
}

@Before("call(void populate(..))")
public void beforePopulate(JoinPoint joinPoint) {
if (joinPoint.getArgs().length > 0) {
Log.d("zhou", "AspectVp [before populate >> " + (time = System.currentTimeMillis()));
}
}

@After("call(void populate(..))")
public void afterPopulate(JoinPoint joinPoint) throws Throwable {
if (joinPoint.getArgs().length > 0) {

long t = System.currentTimeMillis();
Log.i("zhou", "AspectVp [after populate >> " + t + " === spend = "
+ (t - time) + " ms");
}
}

@Before("execution(void populate(..))")
public void executionBefore(JoinPoint joinPoint) throws Throwable {
if (joinPoint.getArgs().length > 0 && items != null) {
for (Object obj : items) {//强制重置为false
Field scrolling = obj.getClass().getDeclaredField("scrolling");
scrolling.setAccessible(true);
scrolling.set(obj, false);
}
Log.i("zhou", "executionBefore [" + items.size() + "]");
}
}

}

//调用
class MainActivity extent Activity{

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//省略其他

AspectVp.setupViewPager(viewPager);
}

@Override
protected void onStop() {
super.onStop();
//销毁
AspectVp.destroyItems();
}
}

运行效果如下:

vp_asept.gif

完。