背景
在bugly上面收集到项目View.getVisiblity()的空指针异常,这个崩溃总量不大,但是一直存在,所以也没有特别花时间去关注,后面有一期需求,导致这个崩溃量上升,必须要解决了,本文将详细描述这个崩溃的排查过程和问题解决方法。
排查过程
bugly上报的具体堆栈信息
bugly上具体的堆栈如下:
1 | java.lang.NullPointerException |
分析
我们都知道view的绘制从ViewRootImpl的performTraversals方法开始,依次执行Measure、Layout、Draw:
- performMeasure -> measure->onMeasure:测量
- performLayout->layout->onLayout:布局
- performDraw->draw->onDraw:真正的绘制
从异常的堆栈信息来看,当前正在执行View绘制中layout过程,也就是根据子视图的大小以及布局参数将View树放到合适的位置上。具体的过程在这里不做详细描述了,我们直接看到最后崩溃的地方:
1 | android.widget.FrameLayout.layoutChildren(FrameLayout.java:284) |
查看FrameLayout.java源码:
1 | void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) { |
通过上面的代码,可以知道出现空指针的原因是因为getChildAt(index)拿到了一个空的View。
可是为什么会拿到空呢,我们可以先看看getChildCount是如何计算的:
ViewGroup.java部部分源码:
1 | // Child views of this ViewGroup |
从源码上来看,ViewGroup内部通过数组mChildren和mChildrenCount来维护子view的列表。在addView和removeView的时候,数组和mChildrenCount会发生改变,相关代码如下:
1 | public void addView(View child, int index, LayoutParams params) { |
1 | public void removeView(View view) { |
这个过程还是很清晰的,有兴趣的可以去跟踪一下源码。
我们再回到最初的问题上:view = getChildAt(index)为什么为空呢?
1 | void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) { |
一种可能存在的情况:执行到1处,假设mChildrenCount为10,在接下来遍历过程中,执行FrameLayout所有子view的layout方法。假设在执行index为2的view的layout方法过程中,remove了FrameLayout中index为7的view,当遍历到index7时,这时候getChildAt(i)就为空了,导致执行3处时,出现空指针异常。
接下来将进行两个场景的分析,验证以上的猜想。
场景一:
AppBarLayout是我们常用来做吸顶功能的控件,有一个需求功能,在滑动的时候,将页面内某个view移除,在这个场景下,复现概率很大。相关代码如下:
1 | layoutAppbar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, offset -> |
我们通过AppBarLayout源码看下AppBarLayout.onOffsetChanged回调时机:
1 | // onOffsetChanged有两个调用处,很明显是onLayoutChild |
再看看AppBarLayout的onLayoutChild是谁调用的
1 | public class CoordinatorLayout{ |
在CoordinatorLayout的onLayout方法中被调用,behavior.onLayoutChild(this, child, layoutDirection)
,而CoordinatorLayout的onLayout由顶层的FrameLayout调用,刚好符合前面的分析。
我们在梳理一下这种场景下的崩溃过程:
- 前提:被移除的view记为A,且它是被添加到android.R.id.content里面的;
- android.R.id.content执行onLayout,获取当前childCount,开始遍历,调用子View的layout方法;
- CoordinatorLayout为android.R.id.content中一个子view,执行其layout方法,触发AppBarLayout的onLayoutChild方法,从而触发onOffsetChanged方法;
- 业务代码收到OnOffsetChangedListener回调,执行代码removeView操作,导致A被移除,导致childCount-1,view数组大小-1;
- android.R.id.content继续执行遍历,由于childCount是提前获取的,而此时view数组的大小已经小于childCount,遍历到最后一个,出现空指针。
场景二
从场景一可以联想到,RecyclerView滑动的时候是不是会有同样的问题呢?RecyclerView也是我们很常用的控件了,如果在滑动的过程中,也会导致同样的崩溃。
简单跟踪一下RecyclerView的源码:
1 |
|
调用链路是:
1 | RecyclerView.layout()->RecyclerView.onLayout()->dispatchLayout->dispatchLayoutStep3->dispatchOnScrolled |
如果我们在onScrolled中做removeView的操作,同样会导致空指针。
demo验证
在页面添加一个RecyclerView和手动add若干view,在recyclerView.OnScrollListener中removeView,我们放大以上场景,发现是必崩溃的,堆栈也跟bugly一致。
1 | class MainActivity : AppCompatActivity() { |
运行后的崩溃堆栈:
小结
简单总结一下这个崩溃的场景,如下图,是一个页面的View层级示意图。
- 当顶层ViewGroup1进行onLayout时,获取childCount为N,遍历执行子view的layou方法
- 触发viewGroup2的onLayout,从而触发view5的onLayout方法,在其layout过程中,removeView了ViewGroup1下的view3,导致N的值变为N-1
- 当ViewGroup1继续遍历,获取index为N-1的view时,这时候获取的view就是空的,从而导致执行child.getVisibility()出现异常