探索View的事件分发机制

可能你遇到过这样的情形,从github上down下来一个开源项目的demo跑到好好的,可是一用到自己项目中就出现各种问题,例如滑动冲突问题,可是不知道从何下手解决?了解View的分发机制也许可以帮助你。在自定义有交互View中,事件分发处于一个比较重的地位,也是面试的常客。

在开始之前呢先啰嗦一点题外话,我们在平时学习工作中经常会遇到一些问题,特别是作为开发人员,通常的做法是google、baidu一些资料,看看别人有没有遇到过类似的问题,借鉴他们的处理方案。这的确是一个有效的方法,可是我们都坚信一点,那就是不管在生活上还是在工作上,总有的时候没有人可以给你参考,你需要独立思考并作出选择,所以养成独立思考的习惯也很重要。

通常来说要去探究一件事,总要有一些线索才行,你比如说警察破案,他要勘察案发现场,搜集一些证据,然后沿着线索一步步侦破案件。那我们现在要分析View的事件分发机制,如何去找这个所谓的线索,事实上程序的“作案”过程会被完整地记录了,那就是方法栈。你是不是联想到了平时程序异常时打出来的异常栈,就是它,现在就案情重演一次,看看它的“作案”过程:

btn.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        //throw new RuntimeException("Touch");
        Thread.dumpStack();
        return false;
    }
});

这里选择给一个Button设置OnTouchListener,然后在onTouch方法中通过Thread.dumpStack()打印出方法的调用栈,当然你也可以抛出一个异常,或者进入debug模式来查看。点一下这个Button打印出了下面这一段内容:

事件传递的方法调用栈

这一段内容记虽然比较长,但是不复杂。它展示了点击事件在整个Android系统中的传递过程,从ZygoteInit.main方法到OnTouchListener.onTouch方法。从严格意义上来讲,这不算是完整的过程,为啥?通常点击事件是从点击屏幕开始,当点击手机屏幕后,驱动程序是如何处理并把这个事件交给Android系统这个过程是没有体现的。

这次我并不打算分析完整个过程,因为其实在应用层,事件的输入通常是在应用的界面上,而我们编写界面基本是开始于Activity,所以从Activity开始分析往下分析,就是下面这一段:

start_from_activity.png

是不是看起来内容少了很多,心理负担一下子就轻了不少。可以看到事件的传递过程是Activity->PhoneWindow->DecorView->ViewGroup->View。下面就沿着这条线索摸索摸索,首先是Activity的dispatchTouchEvent方法:

public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        //这个方法是空的实现,用法可以看看它的注释
        onUserInteraction();
    }
    //将事件传递到Window中
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    
    //如果子View都不消耗该事件,那Activity的onTouchEvent()方法就会被调用
    return onTouchEvent(ev);
}

可以看到,Activity将事件传递给了Window,如果Window的superDispatchTouchEvent()方法返回true,那Activity的onTouchEvent就不会被调用了。

接着就到Window了,这个Window是个抽象类,我们要找的是它的实现类,通过前面的方法调用栈知道它是PhoneWindow,看看它的superDispatchTouchEvent方法:

@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

Window又将事件传递给了DecorView,可见这个Window充当了Activity和DecorView之间的连接纽带。

//DecorView#superDispatchTouchEvent
public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

DecorView的superDispatchTouchEvent方法调用了父类的dispatchTouchEvent,而DecorView的直接父类是FrameLayout,而FrameLayout的dispatchTouchEvent方法是从ViewGroup继承下来的,所以事件就传递到了ViewGroup中,这点从方法栈也可以看出来。

ViewGroup的dispatchTouchEvent方法代码是比较多的,也是View事件分发的核心,搞清楚它基本也就搞清楚了View的事件分发过程,下面分段来看看这个方法的实现:

// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
    // Throw away all previous state when starting a new touch gesture.
    // The framework may have dropped the up or cancel event for the previous gesture
    // due to an app switch, ANR, or some other state change.
    cancelAndClearTouchTargets(ev);
    resetTouchState();
}

从这段代码实现可以看到在DOWN事件的时候会重置一些状态信息。

// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    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 {
    //从这个else条件可以看出,只要不是DOWN事件或者mFirstTouchTarget为null, intercepted直接赋值为true,也就是默认拦截。这个DOWN很好理解,但是这个mFirstTouchTarget是什么现在还不知道
    
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}

从这一段代码可以看到在DOWN事件的时候,会调用onInterceptTouchEvent()方法来询问是否要拦截该事件,并通过intercepted来标记,后面的代码会根据这个标记来选择不同的处理方式,首先看看intercepted为false,即事件会向下传递给子View:

// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
        && isChildrenDrawingOrderEnabled();
final View[] children = mChildren;

//遍历所有的子View
for (int i = childrenCount - 1; i >= 0; i--) {
    final int childIndex = getAndVerifyPreorderedIndex(
            childrenCount, i, customOrder);
    final View child = getAndVerifyPreorderedView(
            preorderedList, children, childIndex);

    // If there is a view that has accessibility focus we want it
    // to get the event first and if not handled we will perform a
    // normal dispatch. We may do a double iteration but this is
    // safer given the timeframe.
    if (childWithAccessibilityFocus != null) {
        if (childWithAccessibilityFocus != child) {
            continue;
        }
        childWithAccessibilityFocus = null;
        i = childrenCount - 1;
    }
    
    //判断子View是否可以接收点击事件,点击事件的x,y坐标是否在子View内部
    if (!canViewReceivePointerEvents(child)
            || !isTransformedTouchPointInView(x, y, child, null)) {
        ev.setTargetAccessibilityFocus(false);
        continue;
    }
    
    newTouchTarget = getTouchTarget(child);
    if (newTouchTarget != null) {
        // Child is already receiving touch within its bounds.
        // Give it the new pointer in addition to the ones it is handling.
        newTouchTarget.pointerIdBits |= idBitsToAssign;
        break;
    }

    resetCancelNextUpFlag(child);
    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
        // Child wants to receive touch within its bounds.
        mLastTouchDownTime = ev.getDownTime();
        if (preorderedList != null) {
            // childIndex points into presorted list, find original index
            for (int j = 0; j < childrenCount; j++) {
                if (children[childIndex] == mChildren[j]) {
                    mLastTouchDownIndex = j;
                    break;
                }
            }
        } else {
            mLastTouchDownIndex = childIndex;
        }
        mLastTouchDownX = ev.getX();
        mLastTouchDownY = ev.getY();
        newTouchTarget = addTouchTarget(child, idBitsToAssign);
        alreadyDispatchedToNewTouchTarget = true;
        break;
    }

    // The accessibility focus didn't handle the event, so clear
    // the flag and do a normal dispatch to all children.
    ev.setTargetAccessibilityFocus(false);
}

这段代码也比较简单,遍历所有的子View,首先判断子View是否能接收点击事件(View的可见性为VISIBLE或者view.getAnimation() != null),接着判断事件的x,y坐标是否在View的内部。如果能满足这两个条件,那事件就可以传给该子View处理了。其中dispatchTransformedTouchEvent()方法实际上是调用了子View的dispatchTouchEvent()方法,如下:

View#dispatchTouchEvent

// Perform any necessary transformations and dispatch.
if (child == null) {
    handled = super.dispatchTouchEvent(transformedEvent);
} else {
    final float offsetX = mScrollX - child.mLeft;
    final float offsetY = mScrollY - child.mTop;
    transformedEvent.offsetLocation(offsetX, offsetY);
    if (! child.hasIdentityMatrix()) {
        transformedEvent.transform(child.getInverseMatrix());
    }
    //这里传入的child不是null,所以走这个分支
    handled = child.dispatchTouchEvent(transformedEvent);
}

这里调用dispatchTransformedTouchEvent()传入的child不是null,所以走的是else分支。注意,如果子View的dispatchTouchEvent()方法返回true, 那么 mFirstTouchTarget 就会被赋值并跳出遍历子View的循环,如下:

//addTouchTarget内部会对 mFirstTouchTarget进行赋值操作
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;

addTouchTarget()内部会为mFirstTouchTarget赋值

private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    mFirstTouchTarget = target;
    return target;
}

还记的前面关于拦截是的条件吗?

if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null)

这里就解答了前面的疑问,ViewGroup不拦截DOWN事件,且子View的dispatchTouchEvent()方法返回true时mFirstTouchTarget被赋值。前面我们也提到DOWN事件时会重置一些状态信息,这个mFirstTouchTarget会被置为null。也就是说一旦子View的dispatchTouchEvent()方法返回true,那同一个事件序列中,mFirstTouchTarget != null这个条件就都成立。

如果遍历完所有子View,mFirstTouchTarget为null(有两种情况,子View的dispatchTouchEvent()方法都返回false;或者事件被拦截了,即intercepted为true),则会走下面的逻辑:

// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
            TouchTarget.ALL_POINTER_IDS);
}

注意这里的dispatchTransformedTouchEvent()方法的child是null。

// Perform any necessary transformations and dispatch.
if (child == null) {
    //child为null,走的就是这个分支
    handled = super.dispatchTouchEvent(transformedEvent);
} else {
    final float offsetX = mScrollX - child.mLeft;
    final float offsetY = mScrollY - child.mTop;
    transformedEvent.offsetLocation(offsetX, offsetY);
    if (! child.hasIdentityMatrix()) {
        transformedEvent.transform(child.getInverseMatrix());
    }

    handled = child.dispatchTouchEvent(transformedEvent);
}

可以看到当child为null是调用的super.dispatchTouchEvent()方法,这个super就是View。看到这你可能会问,前面为什么不直接往下分析View的dispatchTouchEvent()方法的实现?当然可以,只是这样方法栈就会变深,而我们记忆是有限的,一味地深入会让自己无法自拔,这点在阅读源码的时候要注意。现在大局观已经明确,再去分析它的实现就比较清晰了。

public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    boolean result = false;
    ...
    if (onFilterTouchEventForSecurity(event)) {
        //点击ScrollBar
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
            result = true;
        }
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo; 
        
        //li.mOnTouchListener就是通过setOnTouchListener设置的OnTouchListener
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }
        //如果 mOnTouchListener.onTouch()返回true,onTouchEvent()就不会再被调用了
        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }
    ...
    return result;
}

从View的dispatchTouchEvent()的实现可以看到,如果有调用setOnTouchListener()设置了OnTouchListener的话,就会先调用OnTouchListener的onTouch()方法,若onTouch()返回false,再调用View的onTouchEvent()方法;若返回true,则直接返回了,这将导致View的onTouchEvent()不会被调用。另外在onTouchEvent()内部会调用OnClickListener.onClick()方法:

public boolean onTouchEvent(MotionEvent event) {
    ...
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                ...
                if (!post(mPerformClick)) {
                    //这个方法的实现会调用OnClickListener.onClick()方法
                    performClick();
                }
                ...
        }
    }
}

在UP事件时调用performClick()内部会调用OnClickListener.onClick(),所以父View不能拦截UP事件,否则点击事件就不会被调用。

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        //onClick()被调用
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    notifyEnterOrExitForAutoFillIfNeeded(true);
    return result;
}

从前面的分析可以看到我们平时常用的onClick()方法的优先级是最低的,调用顺序为:OnTouchListener.onTouch()->onTouchEvent()->OnClickListener.onClick(),而且onTouch()返回true会中断后续的调用。

前面的分析过程中发现,主要的逻辑是下面三个方法:

public boolean dispatchTouchEvent(MotionEvent ev)

用来进行事件分发,当事件传递到当前面View,dispatchTouchEvent()方法会被调用,它的返回值受onTouchEvent()方法的返回值和子View的dispatchTouchEvent()返回值的影响,表示是否消耗事件。

public boolean onInterceptTouchEvent(MotionEvent ev)

这个方法的返回值表示是否拦截事件,如果返回true,同一个事件序列中不会再被调用,同一个事件序列指的是down->move...move->up。

public boolean onTouchEvent(MotionEvent event)

在dispatchTouchEvent()内部调用,用于处理事件,返回值表示是否消耗该事件。如果返回false,同一事件序列中,当前View(不包括ViewGroup)将无法再次接收到。

这三个方法的关系大致可以理解为下面的伪代码:

public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean consume;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    } else {
        consume = child.dispatchTouchEvent(ev);
    }
    return consume;
}

大致的意思是当事件传递给ViewGroup时,ViewGroup的dispatchTouchEvent()方法会被调用,接着调用它的onInterceptTouchEvent()方法询问是否拦截该事件,如拦截则该事件交给这个ViewGroup处理,即它的onTouchEvent()方法被调用;如不拦截,则传递给子View,如此反复直到事件被处理掉。下面给出一些前面分析得到的结论:

(1)当一个View决定拦截某个事件,即onInterceptTouchEvent()返回true,那么同一个事件序列的所有事件都直接交给它处理而不会再调用onInterceptTouchEvent()来询问是否拦截;换句话说就是一个事件序列只能被一个View拦截并消耗。
(2)某个View一旦开始处理事件,如果它不消耗调DOWN事件,即onTouchEvent()返回false,那同一个事件序列的其他事件就不会再交给它处理,事件将重新交由它的父View处理,即父View的onTouchEvent()会被调用。
(3)如果不消耗DOWN以外的事件,同一个事件序列还是会传给当前View,没有消耗的事件会交给Activity的onTouchEvent()来处理。
(4)View的onTouchEvent()默认都消耗事件,即返回true,除非它是不可点击的,即下面的clickable为false。

final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
                || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;

可以看到只要是CLICKABLE、LONG_CLICKABLE、CONTEXT_CLICKABLE这三个有一个为true它就是可点击的,而与ennable无关,换句话说就是如果View的enable为true也是会消耗事件的。

(5)事件总是先传给父View,然后在传给子View,在子View中可以通过调用父View的requestDisallowInterceptTouchEvent()来干预事件的拦截过程,在处理滑动冲突可以利用这一点。

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        // We're already in this state, assume our ancestors are too
        return;
    }

    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}

在这个方法内部修改了mGroupFlags的值,现在再回头看看ViewGroup拦截事件的逻辑:

ViewGroup#dispatchTouchEvent

if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    //这个值会受到干预,进而影响是intercepted的值,也就是干预拦截   
    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 {
    intercepted = true;
}

可以看到如果disallowIntercept为true,intercepted直接置为false。另外DOWN事件是不能干预的,因为在DONW事件时调用会resetTouchState()来重置状态信息,mGroupFlags会被重置。

private void resetTouchState() {
    clearTouchTargets();
    resetCancelNextUpFlag(this);
    mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    mNestedScrollAxes = SCROLL_AXIS_NONE;
}

这些结论看似轻描淡写,要真正理解就必须要仔细看看源码才行,如果你能为这些结论说出有力的论据,那说明你掌握的也差不多了。后面我会运用这些论据来自定义一个具体的ViewGroup,让这些结论变得有用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容