Android事件分发机制理解
前言:
最近花了一周的时间看了Android事件分发原理方面的知识,我就把自己所学到的和自己的理解整理出来,如果有理解不当的地方希望有朋友指出来,公共成长。Android事件指的Touch
事件的相关细节(发生触摸的位置、时间等)被封装成MotionEvent
对象。
一.Android事件分发的核心方法。
1.disPatchTouchEvent方法
用来进行事件的分发,如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受当前View的OnTouchEvent和下级View的dispatchTouchEvent方法的影响表示是否消耗当前事件。
2.onInterceptTouchEvent方法
此方法表示的是否拦击当前事件,如果当前的view一旦拦截了事件,在同一个事件序列中,该方法不会被再次调用。
3.onTouchEvent方法
该方法用来处理点击事件,在dispatchTouchEvent方法中被调用。返回的结果表示是否消耗当前事件,如果不消耗,当前view将无法再接收到当前事件
二.事件分发的顺序
事件传递的顺序:Activity->ViewGroup->view
1.Activity事件传递源码解析
当一个点击事件触发,一定会调用dispatchTouchEvent方法
/**
* 源码分析:Activity.dispatchTouchEvent()
*/
public boolean dispatchTouchEvent(MotionEvent ev) {
// 一般事件列开始都是DOWN事件 = 按下事件,故此处基本是true
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
// ->>分析1
}
// ->>分析2
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
// 若getWindow().superDispatchTouchEvent(ev)的返回true
// 则Activity.dispatchTouchEvent()就返回true,则方法结束。即 :该点击事件停止往下传递 & 事件传递过程结束
// 否则:继续往下调用Activity.onTouchEvent
}
// ->>分析4
return onTouchEvent(ev);
}
/**
* 分析1:onUserInteraction()
* 作用:实现屏保功能
* 注:
* a. 该方法为空方法
* b. 当此activity在栈顶时,触屏点击按home,back,menu键等都会触发此方法
*/
public void onUserInteraction() {
}
// 回到最初的调用原处
/**
* 分析2:getWindow().superDispatchTouchEvent(ev)
* 说明:
* a. getWindow() = 获取Window类的对象
* b. Window类是抽象类,其唯一实现类 = PhoneWindow类;即此处的Window类对象 = PhoneWindow类对象
* c. Window类的superDispatchTouchEvent() = 1个抽象方法,由子类PhoneWindow类实现
*/
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
// mDecor = 顶层View(DecorView)的实例对象
// ->> 分析3
}
/**
* 分析3:mDecor.superDispatchTouchEvent(event)
* 定义:属于顶层View(DecorView)
* 说明:
* a. DecorView类是PhoneWindow类的一个内部类
* b. DecorView继承自FrameLayout,是所有界面的父类
* c. FrameLayout是ViewGroup的子类,故DecorView的间接父类 = ViewGroup 所以将事件传递到了ViewGroup
*/
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
// 调用父类的方法 = ViewGroup的dispatchTouchEvent()
// 即 将事件传递到ViewGroup去处理,详细请看ViewGroup的事件分发机制
}
// 回到最初的调用原处
/**
* 分析4:Activity.onTouchEvent()
* 定义:属于顶层View(DecorView)
* 说明:
* a. DecorView类是PhoneWindow类的一个内部类
* b. DecorView继承自FrameLayout,是所有界面的父类
* c. FrameLayout是ViewGroup的子类,故DecorView的间接父类 = ViewGroup
*/
public boolean onTouchEvent(MotionEvent event) {
// 当一个点击事件未被Activity下任何一个View接收 / 处理时
// 应用场景:处理发生在Window边界外的触摸事件
// ->> 分析5
if (mWindow.shouldCloseOnTouch(this, event)) {
finish();
return true;
}
return false;
// 即 只有在点击事件在Window边界外才会返回true,一般情况都返回false,分析完毕
}
/**
* 分析5:mWindow.shouldCloseOnTouch(this, event)
*/
public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
// 主要是对于处理边界外点击事件的判断:是否是DOWN事件,event的坐标是否在边界内等
if (mCloseOnTouchOutside && event.getAction() == MotionEvent.ACTION_DOWN
&& isOutOfBounds(context, event) && peekDecorView() != null) {
return true;
}
return false;
// 返回true:说明事件在边界外,即 消费事件
// 返回false:未消费(默认)
}
// 回到分析4调用原处
总结:事件分发首页会传递到Activity的dispatchTouchEvent上,然后会根据getWindow.superDispatchTouchEvent()是否返回true,返回true代表消费了事件,然后调用Activity的TouchEvent()方法。getWindow.superDispatchTouchEvent()是调用window的DispatchTouchEvent,应为window类是一个抽象类,DispatchTouchEvent也是window的抽象方法,所以我们必须找到window的实现类才行,window的实现类只有一个就是PhoneWindow。通过源码我们知道PhoneWindow将事件传递给了DecorView,我们可以通过(ViewGroup)getWindow.getDecorView().findViewById(android.R.id.content)).getAt()(这个获取的其实就是我们setContentView()中的view)获取当前的view,而DecorView是继承自frameLayout其父类也是View,所以最终还是会传递到setContentView中的view里面去,这个view一般都是*view也是根view,*view一般都是ViewGroup。所以事件就由Activity传递到了ViewGroup。
2.ViewGroup事件传递源码解析
上面部分我详细阐述了Activity事件分发的过程,这里我们介绍ViewGroup的事件分发。当事件到达*View(ViewGroup)后首页调用的也是dispatchTouchEvent方法,然后会调用View的OnInterceptTouchEvent如果返回true,这代表拦截事件,事件就交给了ViewGroup处理,否则事件交到下级View处理。在ViewGroup拦截事件过程中,如果ViewGroup的mOntouchListener被是设置了将调用ViewGroup的OnTouch,否则才会调用View的OnTouchEvent方法。这里说明OnTOuch的优先级要比OntouchEvent方法优先级高。在整个以下的层级View中都会重复上面的传递过程,直到整个事件传递完成。
// Check for interception.这里我截取的是部分代码,因为ViewGroup的DispatchTouchEvent方法很长。从下面代码可以看出是否拦截出现了两种情况
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
//1.mFirstTouchTarget在第一步就决定来了是否拦截,全篇搜索发现他是在当ViewGroup的子view成功处理时被赋值,
并指向子view,换句话意思就是ViewGroup不拦截事件交给子View处理mFirstTouchTarget 就不会为null,
反过来如果ViewGroup拦截了事件就会为null,所以就不会再掉用onInterceptTouchEvent(ev)
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
这里还要着重说下FLAG__DISALLOW_INTERCEPT,可以通过requestDisallowInterceptTouchEvent方法设置,一般用于子view中,一旦设置成功后ViewGroup将无法拦截除了Action_Down以外的其他事件。在滑动事件冲突解决中我们可以在子view中调requestDisallowInterceptTouchEvent方法,让ViewGroup不在拦截事件。最下面额View的滑动冲突中我还会详细介绍。
从上面源码可以看出,ViewGroup一旦拦截了事件,将不会在调用OnInterceptTouchEvent方法。
3.View事件传递源码解析
我们也是直接看view的dispatchTouchEvent方法。
public boolean onTouchEvent(MotionEvent event) {
final float x = event.getX();
final float y = event.getY();
final int viewFlags = mViewFlags;
final int action = event.getAction();
final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
//这里可以看出虽然view处于不可点击的状态,但是他还是会消耗事件
if ((viewFlags & ENABLED_MASK) == DISABLED) {
if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
// A disabled view that is clickable still consumes the touch
// events, it just doesn't respond to them.
return clickable;
}
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
switch (action) {
case MotionEvent.ACTION_UP:
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
if ((viewFlags & TOOLTIP) == TOOLTIP) {
handleTooltipUp();
}
if (!clickable) {
removeTapCallback();
removeLongPressCallback();
mInContextButtonPress = false;
mHasPerformedLongPress = false;
mIgnoreNextUpEvent = false;
break;
}
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
// take focus if we don't have it already and we should in
// touch mode.
boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
focusTaken = requestFocus();
}
if (prepressed) {
// The button is being released before we actually
// showed it as pressed. Make it show the pressed
// state now (before scheduling the click) to ensure
// the user sees it.
setPressed(true, x, y);
}
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
// This is a tap, so remove the longpress check
removeLongPressCallback();
// Only perform take click actions if we were in the pressed state
if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClick();
}
}
}
if (mUnsetPressedState == null) {
mUnsetPressedState = new UnsetPressedState();
}
if (prepressed) {
postDelayed(mUnsetPressedState,
ViewConfiguration.getPressedStateDuration());
} else if (!post(mUnsetPressedState)) {
// If the post failed, unpress right now
mUnsetPressedState.run();
}
removeTapCallback();
}
mIgnoreNextUpEvent = false;
break;
case MotionEvent.ACTION_DOWN:
if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
mPrivateFlags3 |= PFLAG3_FINGER_DOWN;
}
mHasPerformedLongPress = false;
if (!clickable) {
checkForLongClick(0, x, y);
break;
}
if (performButtonActionOnTouchDown(event)) {
break;
}
// Walk up the hierarchy to determine if we're inside a scrolling container.
boolean isInScrollingContainer = isInScrollingContainer();
// For views inside a scrolling container, delay the pressed feedback for
// a short period in case this is a scroll.
if (isInScrollingContainer) {
mPrivateFlags |= PFLAG_PREPRESSED;
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
mPendingCheckForTap.x = event.getX();
mPendingCheckForTap.y = event.getY();
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
} else {
// Not inside a scrolling container, so show the feedback right away
setPressed(true, x, y);
checkForLongClick(0, x, y);
}
break;
case MotionEvent.ACTION_CANCEL:
if (clickable) {
setPressed(false);
}
removeTapCallback();
removeLongPressCallback();
mInContextButtonPress = false;
mHasPerformedLongPress = false;
mIgnoreNextUpEvent = false;
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
break;
case MotionEvent.ACTION_MOVE:
if (clickable) {
drawableHotspotChanged(x, y);
}
// Be lenient about moving outside of buttons
if (!pointInView(x, y, mTouchSlop)) {
// Outside button
// Remove any future long press/tap checks
removeTapCallback();
removeLongPressCallback();
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
}
break;
}
return true;
}
return false;
}
从代码可以看出,只要View的CLICKABLE和LONG_CLICKABLE有一个为True,那么他就会消耗事件,onTouchEvent就返回true。然后在ACTION_UP生成时就会调用performClick方法,这里就出现了我们平时给一个控件设置OnClickListener事件,那么performClick方法内部就会调用onClick方法,代码如下所示:
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);//这里就是我们传的mOnClickListener然后出发的点击事件
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
notifyEnterOrExitForAutoFillIfNeeded(true);
return result;
}
三.View的滑动冲突处理
我们开发中一般会遇到的滑动冲突有三种:
1.ViewPage+fragment方式的上下,和左右的冲突
2.ScllowView+内部放一个RecycleView,内外上下的滑动冲突
3.上面一二的结合,比如我们的商品详情页
一.滑动冲突的处理规则
1.根据滑动的线与水平之间的夹角
2.根据滑动水平方向与垂直方向的距离差
3.根据滑动水平方向与垂直方向的速度差来判断
二.滑动冲突的解决方式
1.外部拦截法
通过上面的原理分析我们知道我们可以在dispatchTouchEvent的时候不分发事件或者onInterceptTouchEvent时候拦截事件,实际上onInterceptTouchEvent方法是一个空方法,是android专门提供给我们处理touch事件拦截的方法,所以这里我们在onInterceptTouchEvent方法中拦截touch事件。
具体做法就是当你不想把事件传递给子控件的时候在onInterceptTouchEvent方法中返回true即可拦截事件,这时候子控件将不会再接收到这一次的touch事件流(所谓touch事件流是以ACTION_DOWN开始,中间包含若干个ACTION_MOVE,以ACTION_UP结束的一连串事件)。伪代码如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//down事件不拦截,否则无法传给子元素
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
//水平滑动则拦截
if (Math.abs(deltaX) > Math.abs(deltaY) + 5) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
//不拦截,否则子元素无法收到
intercepted = false;
break;
}
//因为当ViewGroup中的子View可能消耗了down事件,在onTouchEvent无法获取,
// 无法对mLastX赋初值,所以在这里赋值一次
mLastX = x;
mLastY = y;
mLastYIntercept = y;
mLastXIntercept = x;
return intercepted;
}
在down事件不需要拦截,返回false,否则的话子view无法收到事件,将全部会由父容器处理,这不是希望的;up事件也要返回false,否则最后子view收不到看看move事件,当水平滑动距离大于竖直距离时,代表水平滑动,返回true,由父类来进行处理,否则交由子view处理。这里move事件就是主要的拦截条件判断,如果你遇到的不是水平和竖直的条件这么简单,就可以在这里进行改变,比如,ScrollView嵌套了RecycleView,条件就变成,当RecycleView滑动到底部或顶部时,返回true,交由父类滑动处理,否则自身RecycleView动。
在onTouchEvent中主要是做的滑动切换的处理
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (getScrollX() < 0) {
scrollTo(0, 0);
}
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocityTracker = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocityTracker) > 50) {//速度大于50则滑动到下一个
mChildIndex = xVelocityTracker > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWith / 2) / mChildWith;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWith - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
}
mLastY = y;
mLastX = x;
return true;
}
在这个嵌套一个普通的ListView,这样就可以解决水平和竖直滑动冲突的问题了。2.内部拦截法
内部拦截法是父容器不拦截任何事件,所有事件都传递给子view,如果需要就直接消耗掉,不需要再传给父容器处理
下面重写一个ListView,只需要重写一个dispatchTouchEvent方法就OK
public class ListViewEx extends ListView {
private static final String TAG = "lzy";
private int mLastX;
private int mLastY;
public ListViewEx(Context context) {
super(context);
}
public ListViewEx(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ListViewEx(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//子View的所有父ViewGroup都会跳过onInterceptTouchEvent的回调
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (Math.abs(deltaX) > Math.abs(deltaY) + 5) {//水平滑动,使得父类可以执行onInterceptTouchEvent
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(ev);
}
}
在down事件调用getParent().requestDisallowInterceptTouchEvent(true),这句代码的意思是使这个view的父容器都会跳过onInterceptTouchEvent,在move中判断如果是水平滑动就由父容器去处理,父容器只需要把之前的onInterceptTouchEvent改为下面那样,其他不变
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
mLastX = x;
mLastY = y;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
return true;
}
return false;
} else {
//如果是非down事件,说明子View并没有拦截父类的onInterceptTouchEvent
//说明该事件交由父类处理,所以不需要再传递给子类,返回true
return true;
}
}
推荐使用外部拦截法,因为内部拦截法比较复杂,更难操作。
四.总结
事件分发全部过程已经总结完了,关于事件分发在原理的基础上去理解。在此特别感谢任玉刚的安卓开发艺术,还有 Carson_Ho的点击打开链接 。