View.getVisibility()出现NullPointerException的排查和解决

背景

在App发版后的某一个版本后,崩溃收集后台收到了crash告警。出现了一个量级较大的空指针问题。 空指针问题在android开发中非常常见,不过在看到我们的崩溃收集后台上的崩溃堆栈,却显得无从下手解决。 下面先看下具体的崩溃堆栈:

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在绘制流程中View.getVisiblity()出现了空指针。堆栈信息中没有我们业务相关的类和方法,我们没有办法像解决其他空指针问题一样简单加一个判空来解决问题。 既然要解决这个空指针问题,那查看源码肯定是必不可少的了。 我们一起从源码层面上来看看为什么会出现这个空指针问题。

我们都知道view的绘制从ViewRootImpl的performTraversals方法开始,依次执行Measure、Layout、Draw:

  • performMeasure -> measure->onMeasure:测量
  • performLayout->layout->onLayout:布局
  • performDraw->draw->onDraw:真正的绘制

从异常的堆栈信息来看,当前正在执行View绘制中layout过程,也就是根据子视图的大小以及布局参数将View树放到合适的位置上。具体的过程在这里不做详细描述了,我们直接看到最后崩溃的地方:

android.widget.FrameLayout.layoutChildren(FrameLayout.java:284)
复制代码

接着看看FrameLayout的layoutChildren方法:

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);
        }
    }
}
复制代码

也就是说,在child.getVisibility() 出现了空指针,这个child为null,也就意味着getChildAt(i) 获取出来了一个null的View。这个方法内的childCount是提前计算好的,在方法最前面通过getChildCount获取出当前这一时刻的child数量,然后通过这个count遍历layout子View。 我们有理由怀疑在layoutChild的过程中,这个提前计算好的count值和实际的子View数量对不上了。我们有两种猜想:

  1. 存在异步调用移除子View,导致当前Layout获取异常
  2. 在子view的layout的过程中,删除了当前FrameLayout的Child

为了验证上面哪种猜想是正确的,我们需要先看看在ViewGroup中是如何来维护count和child列表的。

// 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会发生改变,相关代码如下:

private void addInArray(View child, int index) {
    ...
    children[index] = child;
    mChildrenCount++;
    ...
}

// 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都不能在子线程去完成,和绘制操作在同一个线程。所以可以排除多线程导致的问题。 那么答案就只剩下第二种猜想了:在子view的layout的过程中,删除了当前FrameLayout的Child

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. 执行到1处,假设mChildrenCount为10,在接下来遍历过程中,执行FrameLayout所有子view的layout方法。
  2. 假设在执行index为2的view的layout方法过程中,remove了FrameLayout中index为7的view
  3. 当遍历到最后一个index时,此时实际count已经小于方法前获取出来的count,导致最后一个index会获取到null的view

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

场景一:

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

layoutAppbar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, offset ->
    val parent = activity.findViewById(android.R.id.content)
    parent.removeView(guideView)
})
复制代码

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

// 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是谁调用的

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调用,刚好符合前面的猜想。

我们在梳理一下这种场景下的崩溃过程:

  1. 被移除的view记为A,且它是被添加到android.R.id.content里面的;
  2. android.R.id.content执行onLayout,获取当前childCount,开始遍历,调用子View的layout方法;
  3. CoordinatorLayout为android.R.id.content中一个子view,执行其layout方法,触发AppBarLayout的onLayoutChild方法,从而触发onOffsetChanged方法;
  4. 业务代码收到OnOffsetChangedListener回调,执行代码removeView操作,导致A被移除,导致childCount-1,view数组大小-1;
  5. android.R.id.content继续执行遍历,由于childCount是提前获取的,而此时view数组的大小已经小于childCount,遍历到最后一个,出现空指针。

场景二

从场景一可以联想到,RecyclerView滑动的时候是不是会有同样的问题呢?RecyclerView也是我们很常用的控件了,如果在滑动的过程中,也会导致同样的崩溃。 简单跟踪一下RecyclerView的源码

@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);
        }
    }
    ...
}
复制代码

调用链路是:

RecyclerView.layout()->RecyclerView.onLayout()->dispatchLayout->dispatchLayoutStep3->dispatchOnScrolled
复制代码

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

demo验证

我们写一个RecyclerView的Demo来复现这个问题。在recyclerView.OnScrollListener中removeView。

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()出现异常

解决方案

前面说的都是这个问题产生的原因,目前暂时没有想到一个比较好的全局解决方案,目前比较简单的解决方案就是在执行removeView的时候,通过view.post延迟解决。 但是有一个问题,想找到具体是哪一个地方的调用导致的空指针是比较复杂的。

目前可以通过AOP的方式,全局Hook住removeView方法。 记录当前removeView的历史记录,同时记录removeView的调用者。 在出现Crash时,把当前removeView的历史记录作为额外信息传到崩溃后台。 最后一个remove就是就是导致View空指针崩溃的操作。例如下面的携带的日志:

removeViewHistory: [{"android.widget.RelativeLayout- remove - com.xx.xxx.biz.floatad.xxView":"xxxxx.View$1 : run : ()V"}]
复制代码

本文感谢程同学。

猜你喜欢

转载自juejin.im/post/7077510110800510984