View.getVisiblity()出现NullPointerException的详细分析

背景

在bugly上面收集到项目View.getVisiblity()的空指针异常,这个崩溃总量不大,但是一直存在,所以也没有特别花时间去关注,后面有一期需求,导致这个崩溃量上升,必须要解决了,本文将详细描述这个崩溃的排查过程和问题解决方法。

排查过程

bugly上报的具体堆栈信息

bugly上具体的堆栈如下:

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
java.lang.NullPointerException
Attempt to invoke virtual method 'int android.view.View.getVisibility()' on a null object reference

android.widget.FrameLayout.layoutChildren(FrameLayout.java:284)
android.widget.FrameLayout.onLayout(FrameLayout.java:270)
android.view.View.layout(View.java:22958)
android.view.ViewGroup.layout(ViewGroup.java:6433)
android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
android.view.View.layout(View.java:22958)
android.view.ViewGroup.layout(ViewGroup.java:6433)
android.widget.FrameLayout.layoutChildren(FrameLayout.java:332)
android.widget.FrameLayout.onLayout(FrameLayout.java:270)
android.view.View.layout(View.java:22958)
android.view.ViewGroup.layout(ViewGroup.java:6433)
android.widget.LinearLayout.setChildFrame(LinearLayout.java:1829)
android.widget.LinearLayout.layoutVertical(LinearLayout.java:1673)
android.widget.LinearLayout.onLayout(LinearLayout.java:1582)
android.view.View.layout(View.java:22958)
android.view.ViewGroup.layout(ViewGroup.java:6433)
android.widget.FrameLayout.layoutChildren(FrameLayout.java:332)
android.widget.FrameLayout.onLayout(FrameLayout.java:270)
com.android.internal.policy.DecorView.onLayout(DecorView.java:789)
android.view.View.layout(View.java:22958)
android.view.ViewGroup.layout(ViewGroup.java:6433)
android.view.ViewRootImpl.performLayout(ViewRootImpl.java:3547)
android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3015)
android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:2029)
android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:8354)
android.view.Choreographer$CallbackRecord.run(Choreographer.java:972)
android.view.Choreographer.doCallbacks(Choreographer.java:796)
android.view.Choreographer.doFrame(Choreographer.java:731)
android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:957)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loop(Looper.java:223)
android.app.ActivityThread.main(ActivityThread.java:7986)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:603)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

分析

我们都知道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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
final int count = getChildCount();

final int parentLeft = getPaddingLeftWithForeground();
final int parentRight = right - left - getPaddingRightWithForeground();

final int parentTop = getPaddingTopWithForeground();
final int parentBottom = bottom - top - getPaddingBottomWithForeground();

for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child.getVisibility() != GONE) {
...
child.layout(childLeft, childTop, childLeft + width, childTop + height);
}
}
}

通过上面的代码,可以知道出现空指针的原因是因为getChildAt(index)拿到了一个空的View。

可是为什么会拿到空呢,我们可以先看看getChildCount是如何计算的:
ViewGroup.java部部分源码:

1
2
3
4
5
6
7
8
// Child views of this ViewGroup
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private View[] mChildren;

// Number of valid children in the mChildren array, the rest should be null or not
// considered as children
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
private int mChildrenCount;

从源码上来看,ViewGroup内部通过数组mChildren和mChildrenCount来维护子view的列表。在addView和removeView的时候,数组和mChildrenCount会发生改变,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void addView(View child, int index, LayoutParams params) {
...
addViewInner(child, index, params, false);
}

private void addViewInner(View child, int index, LayoutParams params,
boolean preventRequestLayout) {
...
addInArray(child, index);
...
}

private void addInArray(View child, int index) {
...
children[index] = child;
mChildrenCount++;
...
}
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
public void removeView(View view) {
if (removeViewInternal(view)) {
requestLayout();
invalidate(true);
}
}

private boolean removeViewInternal(View view) {
final int index = indexOfChild(view);
if (index >= 0) {
removeViewInternal(index, view);
return true;
}
return false;
}

private void removeViewInternal(int index, View view) {
...
removeFromArray(index);
...
}

// This method also sets the child's mParent to null
private void removeFromArray(int index) {
...
System.arraycopy(children, index + 1, children, index, count - index - 1);
children[--mChildrenCount] = null;
...
}

这个过程还是很清晰的,有兴趣的可以去跟踪一下源码。

我们再回到最初的问题上:view = getChildAt(index)为什么为空呢?

1
2
3
4
5
6
7
8
9
10
11
void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
final int count = getChildCount(); // 1.获取当前子view的数量,本质是mChildrenCount
...
for (int i = 0; i < count; i++) {
final View child = getChildAt(i); // 2.get当前index为i的view,本质是mChildren[index]
if (child.getVisibility() != GONE) { //3.当前index为i的view的可见性
...
child.layout(childLeft, childTop, childLeft + width, childTop + height); // 4.执行当前index为i的子view的layout过程
}
}
}

一种可能存在的情况:执行到1处,假设mChildrenCount为10,在接下来遍历过程中,执行FrameLayout所有子view的layout方法。假设在执行index为2的view的layout方法过程中,remove了FrameLayout中index为7的view,当遍历到index7时,这时候getChildAt(i)就为空了,导致执行3处时,出现空指针异常。

接下来将进行两个场景的分析,验证以上的猜想。

场景一:

AppBarLayout是我们常用来做吸顶功能的控件,有一个需求功能,在滑动的时候,将页面内某个view移除,在这个场景下,复现概率很大。相关代码如下:

1
2
3
4
layoutAppbar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, offset ->
val parent = activity.findViewById(android.R.id.content)
parent.removeView(guideView)
})

我们通过AppBarLayout源码看下AppBarLayout.onOffsetChanged回调时机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// onOffsetChanged有两个调用处,很明显是onLayoutChild
void onOffsetChanged(int offset) {
...
for (int i = 0, z = listeners.size(); i < z; i++) {
...
listener.onOffsetChanged(this, offset);
...
}
}

public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull T abl, int layoutDirection) {
...
// Make sure we dispatch the offset update
abl.onOffsetChanged(getTopAndBottomOffset());
...
}

再看看AppBarLayout的onLayoutChild是谁调用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CoordinatorLayout{
@SuppressWarnings("unchecked")
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int layoutDirection = ViewCompat.getLayoutDirection(this);
final int childCount = mDependencySortedChildren.size();
for (int i = 0; i < childCount; i++) {
final View child = mDependencySortedChildren.get(i);
if (child.getVisibility() == GONE) {
// If the child is GONE, skip...
continue;
}

final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final Behavior behavior = lp.getBehavior();

if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
onLayoutChild(child, layoutDirection);
}
}
}
}

在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
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
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
...
dispatchLayout();
...
}


void dispatchLayout() {
...
dispatchLayoutStep3();
...
}


private void dispatchLayoutStep3() {
...
dispatchOnScrolled(0, 0);
...
}


void dispatchOnScrolled(int hresult, int vresult) {
...
if (mScrollListeners != null) {
for (int i = mScrollListeners.size() - 1; i >= 0; i--) {
mScrollListeners.get(i).onScrolled(this, hresult, vresult);
}
}
...
}

调用链路是:

1
RecyclerView.layout()->RecyclerView.onLayout()->dispatchLayout->dispatchLayoutStep3->dispatchOnScrolled

如果我们在onScrolled中做removeView的操作,同样会导致空指针。

demo验证

在页面添加一个RecyclerView和手动add若干view,在recyclerView.OnScrollListener中removeView,我们放大以上场景,发现是必崩溃的,堆栈也跟bugly一致。

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
class MainActivity : AppCompatActivity() {
private val adapter = CommonAdapter()

@BindView(R.id.recyclerView)
lateinit var recyclerView: RecyclerView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
ButterKnife.bind(this)
addCustomViews()
initRecyclerView()
}

private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
removeAllViews()
}
}

private fun initRecyclerView() {
recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(scrollListener)
adapter.setData(mutableListOf("1", "2", "3", "4", "5", "6", "7", "1", "2", "3", "4", "5", "6", "7", "1", "2", "3", "4", "5", "6", "7", "1", "2", "3", "4", "5", "6", "7"))
}

private val customViewList = mutableListOf<View>()
private fun addCustomViews() {
val parent = findViewById<ViewGroup>(android.R.id.content)
for (i in 0..10) {
val view = TextView(this)
customViewList.add(view)
parent.addView(view)
}
}

private fun removeAllViews() {
val parent = findViewById<ViewGroup>(android.R.id.content)
customViewList.forEach {
parent.removeView(it)
}
}
}

运行后的崩溃堆栈:

小结

简单总结一下这个崩溃的场景,如下图,是一个页面的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()出现异常

问题解决方法

-------------本文结束感谢您的阅读-------------
如果本篇文章对你有帮助,请作者喝杯咖啡吧~