View事件分发机制分析

View 事件分发是很重要的知识点,只有理解其中的原理 在写代码过程中更精准的处理代码逻辑,控制好 api 的调用时机。本文通过阅读SDK 28的源码,在这里做一次输出,深入理解下。

目录

一、实例引申

二、事件分发原理

    1. Activity
    1. ViewGroup
    1. View

三、总结

一、实例引申

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        (Button)findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.d("MainActivity", "click btn");
            }
        });
    }
}
# activity_main.xml

<RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <Button
            android:id="@+id/btn"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
</RelativeLayout>

以上是最简单的点击按钮点击事件,对我们应用层开发来讲就是点击了一个Button,然后回调到了 listener 中的onClick 方法,但其背后的原理要从触摸到屏幕开始讲起。

二、事件分发原理

1. Activity

触摸事件首先会达到 Activity 中的 dispatchTouchEvent 方法内,如果你问我触摸屏幕后是怎么到达 Activity 的,这个问题 I don't know!也并不是本文谈论的范围。

public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}

这里有必要解释一下 MotionEvent 这个对象,这是触摸事件发生后,系统将触摸事件动作(ACTION_DOWN、ACTION_MOVE、ACTION_UP、ACTION_CANCEL)、触摸坐标点、多指触控等信息保存到此对象中,以便传输时操作。而我们触摸屏幕时是一序列的事件,会有按压然后不停的移动,最后会抬起,这些动作和坐标点都是会变化的,也就是说会产生down + 很多 move + up/cancel 事件,多指触控比较复杂不在本文讨论范围内。

onUserInteraction 方法是 Activity 内的一个空实现,如果想在触摸屏幕的最初期做一些操作,可以重写此方法。对于 View 事件分发必须要有一个「消费」的概念,触摸事件到底是在哪一步、哪一个组件里被消费了。在这里,若 getWindow().superDispatchTouchEvent(ev) 返回 true 代表事件被某个组件消费了,此时直接返回 true 结束,如果事件没被消费,那么就继续走到 onTouchEvent 方法,Activity 的 onTouchEvent 基本上都会返回 false, 表示没有消费。

直接跟到 getWindow().superDispatchTouchEvent(ev) 方法,在 Android 系统中 Window 抽象类唯一的实现类就是 PhoneWindow, 而 PhoneWindow 内部调用了 DecorView.superDispatchTouchEvent(event), 此方法内又调用了 super.dispatchTouchEvent(event), 也就是调到 ViewGroup 的 dispatchTouchEvent 方法。

ps: DecorView 就是所有一个页面(也就是setContentView后)的最顶层View。

至此触摸事件从 Activity 传递到了 ViewGroup 中,这里把 Window 和 DecorView 的调用过程都写在 Activity 范畴内,因为这个流程是很简单的,没必要分开。

下图是Activity事件分发调用流程图解:

Activity事件分发

2. ViewGroup

ViewGroup 中有三个关键方法:

  • dispatchTouchEvent 用于触摸事件一开始传递到 ViewGroup 时调用,
  • onInterceptTouchEvent 用于拦截触摸事件,决定是否自己来消费事件。
  • onTouchEvent 用于消费触摸事件。

看源码有些细节是真的看不懂,但是那些细节又不是特别重要,那么就略过好了。。只看重要的调用流程。
由于 dispatchTouchEvent 方法内容很多,因此分几块去看。首先是 ViewGroup 是否需要拦截的部分。

public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    
    final int action = ev.getAction();
    final int actionMasked = action & MotionEvent.ACTION_MASK;

    // Handle an initial down.
    // 当一个ACTION_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();
    }

    // Check for interception.
    // 此标志位代表自己是否要拦截这个事件
    final boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
        // 通过 mGroupFlags 标志位得到是否允许我这个ViewGroup拦截事件
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            // 基本上onInterceptTouchEvent都会返回false,代表不拦截,
            // 除非自定义ViewGroup,重写此方法是解决滑动冲突的重要手段
            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;
    }
    
    ...
}

上述一段源码做了一些注释,解释了其流程的逻辑。这里有几个重要变量需要解释的。

mFirstTouchTarget:此对象是一个单链表结构,存储这一系列的事件(ACTION_DOWN、ACTION_MOVE...、ACTION_UP)发生时所涉及到的子View,因此触摸事件 ACTION_DOWN 发生后如果这个对象还是为null,那么就表示 ViewGroup 没有将事件传递到子View。

mGroupFlags:mGroupFlags 可以理解为很多个标志位的组合。mGroupFlags & FLAG_DISALLOW_INTERCEPT != 0 表示这个标志位组合内有「不允许拦截事件」这个标志位(类似于Map中找一个Key是否存在)。对于位运算本人一直很疑惑,虽说这些不一定都需要看懂,但是这些判断逻辑的标志位看不懂就很难受。。反正在看位运算的时候千万不要按一贯的逻辑在脑海里把数值转换成十进制的,就用二进制去理解,这里推荐一篇位操作文章。

    ...
    
    final View[] children = mChildren;
    for (int i = childrenCount - 1; i >= 0; i--) {
        ...
        
        if (!canViewReceivePointerEvents(child)
            || !isTransformedTouchPointInView(x, y, child, null)) {
            ev.setTargetAccessibilityFocus(false);
            continue;
        }
        
        ...

        if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
            // Child wants to receive touch within its bounds.
            // if 代码块内主要保存了一些变量,设置标志位
            // 记录找到的子View,以便之后的事件序列可以直接使用目标View
            ...
            break;
        }
    }
    
    ...
}

这里省略了很多杂七杂八的代码,关键还是在于遍历 ViewGroup 的所有子 View, 通过 isTransformedTouchPointInView 方法找到点击时坐标落在哪个子 View 上,跟进 dispatchTransformedTouchEvent 看看:

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
        View child, int desiredPointerIdBits) {
    ...
    
    final boolean handled;
    // If the number of pointers is the same and we don't need to perform any fancy
    // irreversible transformations, then we can reuse the motion event for this
    // dispatch as long as we are careful to revert any changes we make.
    // Otherwise we need to make a copy.
    final MotionEvent transformedEvent;
    if (newPointerIdBits == oldPointerIdBits) {
        if (child == null || child.hasIdentityMatrix()) {
            if (child == null) {
                // View来处理事件
                handled = super.dispatchTouchEvent(event);
            } else {
                final float offsetX = mScrollX - child.mLeft;
                final float offsetY = mScrollY - child.mTop;
                // 这里就是将触摸点的坐标转换成子视图的坐标
                event.offsetLocation(offsetX, offsetY);
                // 子视图处理事件
                handled = child.dispatchTouchEvent(event);
                // 又将触摸坐标还原,之前转换的坐标只适合那个子视图
                event.offsetLocation(-offsetX, -offsetY);
            }
            return handled;
        }
        transformedEvent = MotionEvent.obtain(event);
    } else {
        transformedEvent = event.split(newPointerIdBits);
    }
    
    ...
    return handled;
}

这里的英文注释解释的很清晰,这个方法的主要作用就是将触摸事件转换成子View相对父容器的坐标,并过滤一些不相关的触摸点(由于不讨论多点触控所以不必纠结),如果没有子视图,那么就会传到 View 的 dispatchTouchEvent 方法(要知道 ViewGroup 就是继承自 View)。最后返回的 handled 代表是否被处理了,也就是事件是否被消费了。

以上几块代码在 ViewGroup.dispatchTouchEvent 方法中是针对 ACTION_DOWN 这个动作所做的处理,因此还需要做其他动作的处理,其实完全是类似的,只是操作更简单了:

...

if (mFirstTouchTarget == null) {
    // No touch targets so treat this as an ordinary view.
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
            TouchTarget.ALL_POINTER_IDS);
} else {
    // Dispatch to touch targets, excluding the new touch target if we already
    // dispatched to it.  Cancel touch targets if necessary.
    TouchTarget predecessor = null;
    TouchTarget target = mFirstTouchTarget;
    while (target != null) {
        final TouchTarget next = target.next;
        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
            handled = true;
        } else {
            final boolean cancelChild = resetCancelNextUpFlag(target.child)
                    || intercepted;
            if (dispatchTransformedTouchEvent(ev, cancelChild,
                    target.child, target.pointerIdBits)) {
                handled = true;
            }
            
            ...
        }

    }
}

...

代码大致意思就是如果没有找到对应的子 View 即 mFirstTouchTarget = null, 那么交给 View.dispatchTouchEvent 处理;如果之前的 ACTION_DOWN 动作已经找到了子 View,那么就继续给它处理。

ViewGroup.onInterceptTouchEvent 方法,这个方法默认基本不做什么事,一般会返回 false;但它是解决滑动冲突的关键方法,遇到滑动冲突时,需要重写此方法。

ViewGroup 的 onTouchEvent 完全是继承了 View 的 onTouchEvent 方法,因此处理方式和 View 完全相同,此方法在 View 小节分析。

ViewGroup事件分发调用流程图解:

ViewGroup事件分发

3. View

View 中有两个关键方法:

  • dispatchTouchEvent 用于触摸事件传递到 View 时触发。
  • onTouchEvent 用于消费触摸事件。
/**
 * Pass the touch screen motion event down to the target view, or this
 * view if it is the target.
 *
 * @param event The motion event to be dispatched.
 * @return True if the event was handled by the view, false otherwise.
 */
public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }

        if (!result && onTouchEvent(event)) {
            result = true;
        }

    ...
    return result;
}

首先判断 OnTouchListener 是否为空,再判断这个 View 是否可以用(即setEnable属性,默认都是true),然后调用 OnTouchListener.onTouch 方法执行我们自定义的触摸操作,如果此方法返回 true, 则代表事件被消费,接下来不需要执行 onTouchEvent; 如果我们使其返回 false, 那么可以继续传递给 onTouchEvent 去消费。跟进 View.onTouchEvent 看看:

public boolean onTouchEvent(MotionEvent event) {

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

    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                    ...
                    
                    // 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();
                    }
                    
                    ...
        }

        return true;
    }

    return false;
}

其中最关键的部分就是我们最常用的 click 事件,一些长按等事件的逻辑这里就不再分析。 通过属性判断 View 是否可点击,并且在手指抬起时即 ACTION_UP 执行 performClick 方法,其内部就是判断用户是否设置了 OnClickListener 监听器,如果有则调用 onClick 方法。

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        result = false;
    }
    return result;
}

View 的事件分发大致流程就是这样了。其中处理的优先级是:

  • 如果用户设置了 OnTouchListener, 那么就会调用 onTouch 方法,并且如果 onTouch 方法返回true, 那么就不会执行 onTouchEvent 了,也就不会执行 onClick 了;
  • 如果来到 onTouchEvent 方法,那么就有机会去执行 OnClickListener.onClick 方法,除非你执行了长按之类的操作;
  • 最后回调到 onClick;
  • onTouch -> onTouchEvent -> onClick

View 事件分发调用流程图解:

View事件分发

三、总结

事件分发

触摸事件会经过以下几个组件:Activity、Window、DecorView、ViewGroup、View。

  • 当用户点击屏幕时,触摸事件 MotionEvent 最先传递到 Activity.dispatchTouchEvent 方法,然后传递到 PhoneWindow.superDispatchTouchEvent 方法,紧接着传到 DecorView.dispatchTouchEvent 方法,然后直接调用了父类 ViewGroup.dispatchTouchEvent 方法。
  • 在 ViewGroup 的 dispatchTouchEvent 中主要做了以下几件事:当 ACTION_DOWN 事件来的时候,判断现在的 ViewGroup 是否拦截这个事件,而 onInterceptTouchEvent 方法一般返回 false; 同样地,针对 ACTION_DOWN 事件,会遍历一遍 ViewGroup 的所有子 View, 点击如果落在某个子 View 上,那么就将触摸事件传递给子 View 的 dispatchTouchEvent 方法,如果没有找到子 View 那就直接交给父类 View.dispatchTouchEvent 处理事件;当 ACTION_MOVE 或 ACTION_UP 等事件来的时候,依然会传给子 View 或 父类 View 实现的 dispatchTouchEvent, 只是这个过程不用再拦截了,只要 down 的时候拦截了,那么都会交由此 View 拦截,除非调用了 requestDisallowInterceptTouchEvent;
  • 最后事件会来到 View, dispatchTouchEvent 主要去找是否有 OnTouchListener 监听,如果有则调用 onTouch 方法,并根据此方法的返回值决定是否执行 onTouchEvent 方法,onTouchEvent 方法内部会判断是否有 OnClickListener 监听,如果有则调用 onClick。
  • 如果事件达到了子 View,而子 View 并没有去消费它,那么这个事件会抛到上一层,如果每层的父视图都不消费事件,那么最后会交给 Activity 执行 onTouchEvent 方法。

事件分发的理解是通过《Android开发艺术探索》(好书) + View事件分发(好文)。

理解事件分发机制的原理后,突然发现,源码的设计都是很巧妙的,有些业务场景我们也可以采用这种从上到下委托的方式去设计代码不是吗?因此看源码能提高自身的代码质量,这点是毋庸置疑的。后来搜索了下,这就是责任链模式啊。。

其实源码中的注释非常详细、清晰,比我们平时接触的业务代码不知道清晰多少倍,但有一点让大多数人望而却步,那就是英语。英语对编程来说太重要了,因此本人现在已经重新开始学习英语了。。看不懂的注释就配合着翻译强行去看。

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

推荐阅读更多精彩内容