概述
最近发现线上会报下面的 Crash:
1 | E AndroidRuntime: FATAL EXCEPTION: main |
但是在发布测试的时候没有测出来这样的问题,对于这样的偶现问题,我的一贯认为是:偶现bug一定有其必现的路径,为了找到问题的原因,对于上面的问题,就有必要从源码来分析了。
其实对于类似的问题,以前在实现动画过程中如果遇到 View.mViewFlags on null object refrence
的问题,后面发现是在 onAnimationEnd
里面做了一些View操作,比如 remove view,导致的,这个问题应该比较类似。
源码分析
这个问题在 Android 7 机型上报的比较多,那么就从 Android 7 的源码入手。
Crash 发生的 resetCancelNextUpFlag
如下:
1 | private static boolean resetCancelNextUpFlag(@NonNull View view) { |
很明显,发生崩溃的原因是传入的 view 参数为 null 导致的。
虽然这里有调用的堆栈和对应的行数,但是我这里的源码和崩溃机型的定制过的源码肯定是不一样的,所以要先从 resetCancelNextUpFlag
入手找出实际的调用堆栈。resetCancelNextUpFlag
在 dispatchTouchEvent
的调用有三处,我们逐一来分析一下。
第一处:
1 | // Check for cancelation. |
这里传入的是 this,是肯定不为空的。
第二处是
1 | for (int i = childrenCount - 1; i >= 0; i--) { |
如果这里的 child 为空的话,在执行 canViewReceivePointerEvents
方法时早就奔溃了,因此也不是这里的问题。
那么就是下面的第三处调用了:
1 | TouchTarget predecessor = null; |
target.child
为 null,造成了崩溃。
原因分析
在 ContainerView 中有两个 RecycleView,在每个 RecycleView 的 onItemClick
方法中写入了对该 ContainerView 的 mWindowManager.removeViewImmediate(mContainerView)
操作。
当长按其中一个 RecycleView 的 ItemView 时,点击另外一个 RecycleView 的 ItemView,就必现这个崩溃。
为什么会出现崩溃呢?
再看一下我们 removeView
的时机,
为了方便调试,我们模拟了一个环境,布局如下:
1 | <com.example.heqiang.testsomething.event.LinearLayoutA xmlns:android="http://schemas.android.com/apk/res/android" |
然后在 ViewC 和 ViewD 的 onTouchEvent 中收到 MotionEvent.ACTION_DOWN 时返回 true,表示可以处理事件。在 ViewC 的 收到 MotionEvent.ACTION_UP 时把根布局从根布局的父布局中 remove 掉。
1 | // ViewC |
1 | // ViewD |
然后用一根手指按住 ViewD,此时用另外一根手指点击 ViewC,ViewC 上的手指抬起时发生崩溃。日志和上面是一样一样的。
前面的博客中我们介绍过,当当调用 ViewGroup#removeView 移除某个子 View 时,ViewGroup 会调用 cancelTouchTarget,该方法不仅从链表中删除了该 View 对应的 TouchTarget,调用其 recycle 方法,还给它对应的 View 发了一个 ACTION_CANCEL 事件,使得View能清理各类状态。
1 | // Update list of touch targets for pointer up or cancel, if needed. |
那么主要问题就出现在这,LinearLayoutA 向 LinearLayoutB 和 ViewD 分发 ACTION_CANCEL 事件,那么就会调用它们的 TouchTarget 链表里面 TouchTarget 的 recycle 操作,当然就包括了 ViewD:
ViewGroup.dispatchTouchEvent() -> ViewGroup.resetTouchState() -> ViewGroup.clearTouchTargets()
1 | private void clearTouchTargets() { |
看一下下面的调用日志:
RRRRRRRRRRRR 这里表示 removeView 操作。
1 | Event : Activity dispatchTouchEvent ACTION_DOWN |
LinearLayoutA 在处理 ACTION_POINTER_UP 过程中,首先向它的子 View LinearLayoutB 派发事件,派发前,先把该事件转换为 ACTION_UP,当该事件分发到 ViewC 时,触发 removeView LinearLayoutA 操作,然后 LinearLayoutA 就会向它的所有子 View 分发 ACTION_CANCEL,这样就会触发所有的 TouchTarget 链表的 recycle 操作。
然后 LinearLayoutA 的 dispatchTouchEvent 方法会继续处理 ACTION_POINTER_UP 事件,当它继续分发给 LinearLayoutB 所在的 TouchTarget 的下一个 TouchTarget 也就是 ViewD 时,由于 ViewD 所在的 TouchTarget 已经执行了 recycle 操作,因此就会出现调用 resetCancelNextUpFlag(target.child) 时传入了 null 参数。
这里有个问题,为什么 ACTION_POINTER_UP 是先分发给 LinearLayoutB ,然后分发给 ViewD 呢?这是因为我们是最后触摸 LinearLayoutB 上的 ViewC,那么它所对应的 TouchTarget 就位于 TouchTarget 链表的表头。
来看一下到 LinearLayoutA 开始分发ACTION_CANCEL 之前的流程。
1 | LinearLayoutA.dispatchTouchEvent ACTION_POINTER_UP |
1 | // Dispatch to touch targets. |
1 | // while循环,向 LinearLayoutB 分发ACTION_POINTER_UP |
所以我们看到,派发 ACTION_POINTER_UP 事件的事件循环中,在传递给 LinearLayoutB 时,先转换 ACTION_UP 事件,然后执行 removeView 操作,之后派发 ACTION_CANCEL 操作,然后再派发给 ViewD ACTION_POINTER_UP 事件, 这些都是在一次事件派发循环中进行的,于是就出现了逻辑上的问题导致了崩溃,因此,稳妥的做法是在这次事件循环进行完毕后再进行 removeView 操作,这样派发 ACTION_CANCEL 操作以及EventTarget的recycle都是在另外的事件循环中进行的了,这样就能避免这个逻辑错误。
解决办法
不要在事件分发时进行 removeView
操作,把这些 removeView
的操作都放 Handler 中执行,在下一次的 Loop 循环中处理 removeView
操作,这样就可以计算出正确的事件分发逻辑,可以避免这个问题。
或者是把上面的 mWindowManager.removeViewImmediate(mContainerView)
改成 mWindowManager.removeView(mContainerView)
应该也能避免这个问题,因为 mWindowManager.removeView(mContainerView)
也是通过 Handler 在下一次消息循环执行的 removeView 操作。
1 | recyclerView.setOnItemClickListener(new RecyclerView.OnItemClickListener() { |