Android onClick 事件无法响应,疑似被缓存

bug场景:

2017年11月初的某个下午,大家都很开心的在测试自己写的代码,因为明天就要提测了,突然组内有一位同事说:不会吧,现在出现这样的bug,他说RecyclerView里面包裹的HeaderView点击没有反应,点击事件没反应!!!不会吧,刚开始写android第一行代码就已经会写的点击事件不响应了;虽然我们很快的规避了这个bug,但是规避毕竟只是规避,并没有找出问题的根本,但是作为程序员怎么能知其然,而不知其所以然呢?闲暇之日,我开启了寻找bug之路,为了复现bug,我模拟了代码场景:我使用的RecyclerView是recyclerview-v7:25.2.0。

代码如下:

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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
Activity如下:
RecyclerView mRecyclerView;
MyAdapter mAdapter;
List<String> mData = new ArrayList<>();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mRecyclerView = (RecyclerView) findViewById(R.id.rv_container);
mRecyclerView.setLayoutManager(new LinearLayoutManager(this));
mAdapter = new MyAdapter();
mAdapter.setHeaderView(View.inflate(this, R.layout.recyclerview_header, null));
mRecyclerView.setAdapter(mAdapter);
//request network
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
//network response
mAdapter.setData(mData);
mAdapter.notifyDataSetChanged();
}
}, 80);
//模拟服务端返回数据
for (int i = 0; i < 6; i++) {
mData.add("" + i);
}
}
MyAdapter代码如下:
public class MyAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private List<String> mData = new ArrayList<>();
private View mHeaderView;
public static final int TYPE_NORMAL = 0;
public static final int TYPE_HEADER = 1;

public void setHeaderView(View headerView) {
mHeaderView = headerView;
}

@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
switch (viewType) {
case TYPE_NORMAL:
return new WeeklyHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout
.me_item_my_weekly_list, parent, false));
case TYPE_HEADER:
return new HeaderView(mHeaderView);
default:
break;
}
return null;
}

@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
if (holder.getItemViewType() == TYPE_NORMAL) {
if (mHeaderView != null) {
position--;
}
final WeeklyHolder courseHolder = (WeeklyHolder) holder;
courseHolder.mTextView.setText(mData.get(position));
}
}

class WeeklyHolder extends RecyclerView.ViewHolder implements View
.OnClickListener {
TextView mTextView;
public WeeklyHolder(View itemView) {
super(itemView);
mTextView = (TextView) itemView.findViewById(R.id.tv_test);
itemView.setOnClickListener(this);
}

@Override
public void onClick(View v) {
Log.e("单元测试", "点击" + mData.get(getLayoutPosition() - 1));

}
}

class HeaderView extends RecyclerView.ViewHolder {

public HeaderView(View itemView) {
super(itemView);
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Log.e("单元测试", "点击头部");
}
});
}
}

@Override
public int getItemViewType(int position) {
if (position == 0) {
if (mHeaderView != null) {
return TYPE_HEADER;
}
}
return TYPE_NORMAL;
}

@Override
public int getItemCount() {
if (mHeaderView == null) {
return mData.size();
} else {
return mData.size() + 1;
}
}

public void setData(List<String> data) {
this.mData = data;
}

}

xml如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>

<android.support.v7.widget.RecyclerView
android:id="@+id/rv_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
android:layoutAnimation="@anim/layoutanimation"
/>

</LinearLayout>

layoutanimation如下:
<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:delay="50%"
android:animation="@anim/alpha_item_anim"></layoutAnimation>

alpha_item_anim如下:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<alpha
android:duration="200"
android:fromAlpha="0.0"
android:toAlpha="1.0" />
</set>

以上代码就是我们常规使用RecyclerView的方式,可是当网络数据返回,页面刷新后却发现:头布局的点击事件无法响应,但是只要稍微滑动一下RecyclerView,之前所有的onClick事件都会响应。

针对bug现象,提出疑问:

onClick事件不能被响应,是否被拦截?不,肯定不是,因为在某个时刻,之前的onClick事件一次性释放出来。所以,事件没有并被消费,而是被保存起来了,那么:

  • 1.onClick事件的触发及响应流程是什么呢?
  • 2.在什么场景下,onClick才会被保存起来?
  • 3.onClick事件是因为什么原因而被保存起来?

只要解决上面三个疑问,答案自然揭晓,带着这些疑问,开始寻找:

为了清楚onClick事件的触发及响应流程,我们需要对android sdk 源码进行debug调试,以往的经验都是对自己写的代码进行debug调试,还从没对sdk源码debug调试,带着好奇心使用手机去debug源码,第一个拦路虎出现了:源码错行,因为手机的Android版本和编译的sdk版本不同,解决这个问题有俩种方案:

  • 将手机刷成和编译sdk相同的版本,然后进行调试。

  • 使用同版本的模拟器调试。

    为了快捷,我使用了模拟器方式,android studio自带的模拟器有点卡,于是下载了一个genymotion。自此,sdk源码的调试便可方便自如,调试环境如下:

    Android SDK 25 Android模拟7.1

在View源码中的dispatchTouchEvent可以发现onTouch是被最先回调的,然后会在onTouchEvent的MotionEvent.ACTION_UP事件中post一条消息,代码如下:

1
2
3
4
5
6
7
8
public boolean post(Runnable action) {
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
return attachInfo.mHandler.post(action);
}
getRunQueue().post(action);
return true;
}

从上面代码可以很直观看出:如果attachInfo != null,会用handler向主线程的looper发一条message,经测试得知正常的一次onClick事件的触发及响应流程如下图:

根据上图流程,调试RecyclerView头布局的onClick事件时发现:attachInfo == null,所以造成点击事件没有被及时回调,而是调用getRunQueue().post(action),将action缓存HandlerActionQueue里面,而在dispatchAttachedToWindow时会调用mRunQueue.executeActions;也就是说:之所以出现点击事件无法响/应,是因为attachInfo == null,而造成点击事件缓存是因为getRunQueue().post(action),至于为什么会在某个时刻一次性释放onClick,某个时刻指的是:dispatchAttachedToWindow时,一次性释放是因为调用mRunQueue.executeActions。

截止目前为止,上面提到的三个疑问,解决了俩个,还剩最后一个没有解决;而最后一个也是最重要的一个:onClick事件是因为什么原因而被保存起来?是因为attachInfo = null被保存起来,那么View的attachInfo为什么会为null?翻开View的源码:attachInfo是在dispatchAttachedToWindow时赋值的,只有在dispatchDetachedFromWindow方法内才会将attachInfo = null,而View的dispatchDetachedFromWindow会在ViewGourp调用,可是在ViewGroup中多处都会调用View的dispatchDetachedFromWindow,为了更准确的寻找调用地方,只能在每一个可能发生的位置break point,经过调试得知是在ViewGroup的finishAnimatingView中调用View的dispatchDetachedFromWindow,只不过在ViewGroup的finishAnimatingView中调用VIew的dispatchDetachedFromWindow是有条件的:mDisappearingChildren != null。

####接下来我们寻找mDisappearingChildren是在什么时候被赋值的?
上面当网络数据返回,调用Adapter的notityDataSetChanged时,会调用mObservable.notifyChanged(),接着调用mObservers.get(i).onChanged(),接着调用requestLayout:requestLayout是我们非常熟悉的方法,它的执行流程如下图:

当调用RecyclerView的onMeasure时,会调用RecyclerView的dispatchLayoutStep2,接着调用mLayout.onLayoutChildren,而我使用的LinearLayoutManager,即调用LinearLayoutManager的onLayoutChildren,接着调用RecyclerView的detachAndScrapAttachedViews,接着调用RecyclerView的scrapOrRecycleView,接着调用RecyclerView的removeViewAt,接着调用mChildHelper.removeViewAt,然后调用RecyclerView.this.removeViewAt,而RecyclerView extends ViewGourp 即调用ViewGroup的removeViewInternal,此时view.getAnimation() != null,也就是动画还未执行完,会执行ViewGroup的addDisappearingView,在addDisappearingView方法内会为mDisappearingChildren赋值,到此为止,整个流程大概跑通了,通俗将就是:

当网络请求回来之后,刷新RecyclerView时,因为头布局item已经存在,并且正在执行动画,所以导致mDisappearingChildren != null,当动画执行完成之后,却因为mDisappearingChildren != null ,而执行view.dispatchDetachedFromWindow()从而将mAttachInfo置为null。

以上就是为什么点击RecyclerView的头布局item无法响应点击事件的全部流程分析。

既然原因都已经如此透彻了,要想解决这个bug,自然轻而易举:比如在网络返回需要刷新RecyclerView时,可以清除RecyclerView内Item的Animation;至此,bug已经被正面解决了。

可是我还是有一个疑问:
我始终觉得这属于RecyclerView的bug,毕竟大多数Android Developer并不清楚以上整个流程,也不能轻而易举的在众多业务代码中找到:Item点击事件无法响应,是因为RecyclerView里面Item的Animation还未执行完时,我们主动调用Adapter的notityDataSetChanged去刷新RecyclerView导致的,带着这个疑问我继续寻找:终于,皇天不负有人心,最终发现:
Google Android官方在recyclerview-v7:25.4.0上修复了这个bug(其实25.4.0发布也没多久),而我使用的还是recyclerview-v7:25.2.0,Google修复的办法其实我们的方法一模一样,它是在:mChildHelper的removeViewAt时调用了child.clearAnimation(),也就是说:当RecyclerView重新刷新的时候,如果包裹的View有Animation,它会清除此Animation。正如注释:
Clear any android.view.animation.Animation that may prevent the item from detaching when being removed. If a child is re-added before the lazy detach occurs, it will receive invalid attach/detach sequencing.
这样的话:因为View的Animation被清除了,当再次调用draw方法绘制当前View的时候,便不会调用parent.finishAnimatingView(this, a),也就不存在使得mAttachInfo置为null的情况了。

经此一战之后,得出一个重要结论:

Android sdk太过庞大,虽然被很多人使用,但也难免会因为使用不当出现bug,遇到问题,要敢于质疑,小心论证;还有就是一定要及时更新Android最新发布的sdk版本,时刻紧跟google官方节奏。


后续:

关于上面view.getAnimation() != nul分析:

onCreat执行setContentView之后然后初始化xml,在ViewGroup的构造方法中调用initFromAttributes,如果发现ViewGroup中有layoutAnimation属性,会在ViewGroup中创建LayoutAnimationController对象;

onResume执行时依次会遍历执行child view的 layout,measure,draw(Activity中有ActivityThread,ActivityThread中的handleResumeActivity调用WindowManagerGlobal的addView,WindowManagerGlobal中的addView调用ViewRootImpl的setView方法,而setView中调用了requestLayout,requestLayout中调用了scheduleTraversals方法,scheduleTraversals中使用Choreographer发送一个message,最后会回调doTraversal,执行performTraversals。然后进行measure,layout,draw)

View的draw方法中会调用dispatchDraw,而View中的dispatchDraw是一个空实现,具体的实现在ViewGroup当中,当dispatchDraw执行时,如果mLayoutAnimationController != null,会遍历子View执行bindLayoutAnimation,在bindLayoutAnimation中调用View的setAnimation方法。

通俗讲就是:如果你在xml为某个View或者ViewGroup设置layoutAnimation属性,当它绘制的时候便会调用View的setAnimation方法,或者遍历里面所有的View调用它的setAnimation方法。