Android Touch事件分发与MotionEvent详解

QuibblerQuibbler 2021-12-30 约 28 分钟 182 次阅读

Android Touch事件分发与MotionEvent详解

Android开发中Touch事件的处理无处不在,从简单的OnClickListener到复杂的自定义View手势识别,理解Touch事件的分发机制是解决滑动冲突、优化交互体验的基础。本文将从MotionEvent的数据结构出发,系统梳理Touch事件从Activity到View的完整分发链路。

1、MotionEvent数据结构

MotionEvent是Touch事件的载体,采用"时间片"方式管理所有触摸行为的数据。可以从纵向和横向两个维度理解:
       • 纵向(批量采样):MotionEvent尤其是Move事件会在一段时间内多次采样合成为一个事件进行处理。每一次采样对应一份Touch数据,该事件中包含多份采样数据,将最近采样的数据作为当前数据,其他存储为历史数据。这种批处理方式主要出于效率考虑。
       • 横向(多Pointer):一个时间片对应特定时间点的采样数据,其中可能包含多个触摸点。MotionEvent用Pointer标记每一个触摸点,每个Pointer的激活周期为从Down事件到Up或Cancel事件,期间分配一个唯一的PointerId。但Pointer在不同MotionEvent中的排序会变化,因此PointerIndex也会变化。通过PointerId可以找到该Pointer在某一MotionEvent中的PointerIndex,再通过PointerIndex获取相关Touch数据。

每个Pointer包含以下数据:
       • rawX/rawY:相对于屏幕坐标系的原始坐标,通过getRawX/getRawY获取
       • x/y:相对于事件消费View坐标系的坐标,通过getX(int index)/getY(int index)获取
       • size:按压区域大小,通过getSize(int index)获取
       • pressure:按压压力,通过getPressure(int index)获取
       • orientation:按压时屏幕方向,通过getOrientation(int index)获取
       • touchMajor/touchMinor:按压椭圆区域长边/短边长

MotionEvent通常将触发当前事件的Pointer作为主要Pointer(PointerIndex为0),并提供getX()这类不带index参数的便捷接口来操作主要Pointer的数据。

1.1、事件类型

MotionEvent通过getAction获取事件Action。Action的低8位存储事件类型,高8位存储PointerId(PointerDown/PointerUp时有效)。主要事件类型包括:
       • ACTION_DOWN:手指按下,一个完整事件流的起点
       • ACTION_MOVE:手指滑动,一个事件流中可能包含大量Move事件
       • ACTION_UP:手指抬起,最后一个触摸点离开屏幕
       • ACTION_CANCEL:当前手势被取消,通常由父View拦截事件时触发
       • ACTION_POINTER_DOWN:多指触摸时,有新的手指按下
       • ACTION_POINTER_UP:多指触摸时,其中一个非最后的手指抬起

事件流总是以ACTION_DOWN开始,以ACTION_UP或ACTION_CANCEL结束。对于多指场景,完整的事件流为:ACTION_DOWN -> ACTION_POINTER_DOWN -> 多个ACTION_MOVE -> ACTION_POINTER_UP -> 多个ACTION_MOVE -> ACTION_UP。

1.2、getAction与getActionMasked的区别

当MotionEvent只包含一个触摸点时,getAction()和getActionMasked()返回值相同。但当包含多个触摸点时,getAction()返回的int值由pointer的index值和事件类型值组合而成,而getActionMasked()只返回事件类型值。

// getAction() 返回 0x0105,高8位是pointer index,低8位是事件类型
// getActionMasked() 返回 0x0005,仅事件类型

// 等价操作
int action = event.getAction() & MotionEvent.ACTION_MASK; // 等同于 getActionMasked()
int pointerIndex = event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK; // 等同于 getActionIndex()

1.3、坐标转换机制

getX()/getY()返回相对于消费该事件的View左上角的坐标,getRawX()/getRawY()返回相对于屏幕的绝对坐标。ViewGroup在将事件分发给子View时,会通过offsetLocation调整坐标值:

// ViewGroup.dispatchTransformedTouchEvent 中的坐标转换
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
event.offsetLocation(offsetX, offsetY);       // 转换为子View坐标系
handled = child.dispatchTouchEvent(event);
event.offsetLocation(-offsetX, -offsetY);     // 恢复为父View坐标系

1.4、历史数据批处理

Android系统为提高效率,会将连续的多个Move事件打包到一个MotionEvent对象中。通过getHistoricalX/getHistoricalY获取历史采样数据,应先处理历史数据再处理当前数据:

void processMoveEvent(MotionEvent ev) {
    final int historySize = ev.getHistorySize();
    final int pointerCount = ev.getPointerCount();
    // 先处理历史采样数据
    for (int h = 0; h < historySize; h++) {
        for (int p = 0; p < pointerCount; p++) {
            float x = ev.getHistoricalX(p, h);
            float y = ev.getHistoricalY(p, h);
            // 处理历史坐标...
        }
    }
    // 再处理当前采样数据
    for (int p = 0; p < pointerCount; p++) {
        float x = ev.getX(p);
        float y = ev.getY(p);
        // 处理当前坐标...
    }
}

2、事件分发链路

Touch事件在Framework Java层的传递链路为:
       Activity.dispatchTouchEvent -> Window.superDispatchTouchEvent -> DecorView(ViewGroup).dispatchTouchEvent -> ... -> 子ViewGroup.dispatchTouchEvent -> View.dispatchTouchEvent

Activity将事件传递给Window(实际为PhoneWindow),PhoneWindow再传递给DecorView。DecorView继承自FrameLayout,最终调用ViewGroup.dispatchTouchEvent,这是事件分发的核心逻辑。

2.1、ViewGroup.dispatchTouchEvent -- Down事件

Down事件是整个事件周期的起点,决定了后续Move、Up、Cancel事件的处理主体。处理步骤如下:
       • 第一步(清空状态):给所有之前选择出的Target发送Cancel事件,确保之前Target能收到完整的事件周期;清除已有Target,复位所有标志位(如PFLAG_CANCEL_NEXT_UP_EVENT、FLAG_DISALLOW_INTERCEPT等)。
       • 第二步(判断拦截):调用onInterceptTouchEvent确定当前ViewGroup是否拦截该Down事件。若拦截,事件不向下传递给子View,而是调用super.dispatchTouchEvent判断ViewGroup本身是否消费。若不拦截,则遍历所有子View寻找消费者。
       • 第三步(遍历子View):若存在子View消费该事件,则设定该子View为事件处理Target。若无子View消费,则调用super.dispatchTouchEvent判断ViewGroup本身是否处理。
       • 第四步(返回结果):若Target不为空或ViewGroup消费了事件,返回true;否则返回false。返回值决定上级ViewGroup是否继续询问其他子View。

2.2、ViewGroup.dispatchTouchEvent -- Move/Up/Cancel事件

Down事件分发完成后,后续Move、Up、Cancel事件的处理逻辑:
       • 若Down事件中找到了Target:调用onInterceptTouchEvent判断是否拦截。拦截则直接由ViewGroup本身处理(先给Target发Cancel);不拦截则传递事件至所有已有Target。
       • 若Down事件中未找到Target:直接调用super.dispatchTouchEvent由ViewGroup本身处理。
       • 若事件为Up或Cancel,表明完整事件周期结束,清除所有Target,复位标志位。

2.3、ViewGroup.dispatchTouchEvent -- PointerDown事件

在支持多Pointer的环境下(FLAG_SPLIT_MOTION_EVENTS置位),新Pointer按下时产生PointerDown事件。该事件会重新遍历View层级寻找可以处理新Pointer事件的Target,流程类似Down事件。若未找到,则将新Pointer的处理权分配给已有Target中最早被添加的那个。找到Target后,通过dispatchTransformedTouchEvent将事件传递至所有已有Target。

2.4、PointerUp事件处理

PointerUp与Up的区别在于:Up标记整个以Down起始的事件周期结束,会清除所有状态;而PointerUp仅标记以PointerDown起始的某个Pointer的事件周期结束,只从已有Target中移除与该Pointer相关的部分,不清除全部状态。

3、关键方法分析

3.1、onInterceptTouchEvent

ViewGroup在事件分发过程中调用此方法确定是否拦截事件。返回true时事件被拦截,不进行正常的View层级传递,直接由该ViewGroup处理。拦截后的行为取决于事件类型:
       • 拦截Down事件:该事件及后续所有事件均不传递至子View。ViewGroup尝试消费Down事件,若消费则后续事件均由ViewGroup消费,若不消费则后续事件均不传递至该ViewGroup的子层级。
       • 拦截PointerDown事件:若存在已有Target,先传递Cancel事件给所有Target;该事件不传递至子View,直接由ViewGroup处理。
       • 拦截Move/Up/PointerUp/Cancel事件:若存在已有Target,先传递Cancel事件给所有Target;该事件及后续事件均由ViewGroup接手消费(不管是否实际消费)。

3.2、dispatchTransformedTouchEvent

在将事件传递给Target之前调用此方法对MotionEvent进行处理:
       • cancel为true时:将事件Action转换为ACTION_CANCEL,根据child是否为null分别调用ViewGroup.super.dispatchTouchEvent或child.dispatchTouchEvent。
       • cancel为false时:对于Down/Move/Up事件,child为null则调用super.dispatchTouchEvent,child不为null则转换坐标后调用child.dispatchTouchEvent。对于PointerDown/PointerUp事件,先调用MotionEvent.split分离出该Pointer的事件,再进行分发。

3.3、MotionEvent.split

用于从多Pointer事件中分离出指定PointerId的事件。参数idBits为需要分离的pointerId(Pointer最大32个,用整数唯一标记)。分割步骤:
       • 遍历当前事件中所有Pointer,找出与idBits对应的Pointer
       • 计算新的pointerCount和pointerIndex
       • 根据情况转换Action:若新事件仅一个Pointer,PointerDown转为Down,PointerUp转为Up;若无匹配Pointer则转为Move
       • 创建新事件并添加历史数据

4、View事件处理

4.1、View.dispatchTouchEvent

View.dispatchTouchEvent用于寻找事件的最终消费者,处理逻辑:
       • 首先将事件传递给mTouchListener.onTouch,若返回true则消费事件
       • 若返回false或未设置TouchListener,则继续传递给View.onTouchEvent
       • View.dispatchTouchEvent的返回值决定上级ViewGroup是否需要继续询问其他子View

// View.dispatchTouchEvent 核心逻辑(简化)
public boolean dispatchTouchEvent(MotionEvent event) {
    // ...
    if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            && li.mOnTouchListener.onTouch(this, event)) {
        result = true;  // onTouch消费了事件
    }
    if (!result && onTouchEvent(event)) {
        result = true;  // onTouchEvent消费了事件
    }
    return result;
}

4.2、View.onTouchEvent

当未设置TouchListener或未消费事件时,由View.onTouchEvent决定是否消费。通用处理逻辑如下:
       • Disabled状态:若View可点击(Clickable、LongClickable、ContextClickable任一为true),则消费事件但不执行具体逻辑。
       • Enabled状态下,若存在touchDelegate(用于调整View可点击区域),则将事件转发给touchDelegate。
       • Enabled状态下,若可点击:
              - ACTION_DOWN:在Scroll容器内设为PrePressed状态并延迟判定点击;不在Scroll容器则设为Pressed状态并延迟判定长按。
              - ACTION_MOVE:若移出View区域,取消点击及长按判定,设为非Pressed状态。
              - ACTION_CANCEL:取消点击长按判定,设为非Pressed状态,清除状态。
              - ACTION_UP:若为PrePressed则设为Pressed;若为Pressed且未执行长按则执行点击逻辑;清除Pressed状态和所有判定。
       • 若不可点击:不消费事件。

从这里可以清楚看到onClick和onLongClick事件的产生逻辑:Down时设置状态并延迟判定长按,Up时若未触发长按则执行点击。

5、多指触摸处理示例

处理多指触摸时,需要通过PointerId跟踪每个触摸点,因为PointerId在整个事件流中不变,而PointerIndex会变化:

private int mActivePointerId = INVALID_POINTER_ID;
private float mLastX, mLastY;

public boolean onTouchEvent(MotionEvent event) {
    int action = MotionEventCompat.getActionMasked(event);

    switch (action) {
        case MotionEvent.ACTION_DOWN:
            int index = event.getActionIndex();
            mActivePointerId = event.getPointerId(index);
            mLastX = event.getX(index);
            mLastY = event.getY(index);
            break;

        case MotionEvent.ACTION_POINTER_DOWN:
            // 新手指按下,记录新Pointer
            index = event.getActionIndex();
            mActivePointerId = event.getPointerId(index);
            mLastX = event.getX(index);
            mLastY = event.getY(index);
            break;

        case MotionEvent.ACTION_MOVE:
            // 通过PointerId找到当前PointerIndex(因为index会变化)
            index = event.findPointerIndex(mActivePointerId);
            float x = event.getX(index);
            float y = event.getY(index);
            float dx = x - mLastX;
            float dy = y - mLastY;
            // 处理滑动...
            mLastX = x;
            mLastY = y;
            break;

        case MotionEvent.ACTION_POINTER_UP:
            // 非最后手指抬起,切换到剩余的Pointer
            int pointerIndex = event.getActionIndex();
            int pointerId = event.getPointerId(pointerIndex);
            if (pointerId == mActivePointerId) {
                // 当前活跃Pointer抬起了,选择另一个
                int newIndex = event.getPointerCount() - 1 - pointerIndex;
                if (newIndex < 0) newIndex = 0;
                mActivePointerId = event.getPointerId(newIndex);
                mLastX = event.getX(newIndex);
                mLastY = event.getY(newIndex);
            }
            break;

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            mActivePointerId = INVALID_POINTER_ID;
            break;
    }
    return true;
}

6、总结

Android Touch事件分发机制的核心流程可以概括为:
       • 分发链路:Activity -> Window -> DecorView -> ViewGroup -> View,每一层通过dispatchTouchEvent进行分发。
       • 三个核心方法:dispatchTouchEvent负责分发,onInterceptTouchEvent负责拦截判断(仅ViewGroup有),onTouchEvent负责消费处理。
       • 事件周期:以Down开始,Up或Cancel结束。Cancel在父View拦截事件时产生,确保子View能收到完整的事件周期。
       • 多指处理:通过PointerId(不变)和PointerIndex(可变)区分不同触摸点,使用findPointerIndex进行转换。
       • 消费判断优先级:OnTouchListener.onTouch优先于View.onTouchEvent。View.onTouchEvent中根据View的可点击状态和Pressed状态决定是否消费,并在此产生onClick和onLongClick回调。

理解这套机制后,就能清晰分析"为什么事件有时候传到某个View有时候不传"、"为什么只传了前面几个事件后面不传了"等常见问题,为解决滑动冲突和实现复杂手势交互打下基础。

相关推荐

精选
ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager
Android

ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager

ViewPager1、ViewPagerandroidx.viewpager.widget.ViewPager,Android中使用非常广泛的控件,可以说是APP必备:首次打开引导页、页面Banner广告等。常用方法:setAdapter() 设置适配器setOffscreenPageLimit() 设置缓存的页面个数,默认是 1setCurrentItem() 跳转到特定的页面setOnPage

1.1k
获取Android内置WebView内核版本
Android

获取Android内置WebView内核版本

获取Android内置WebView内核版本竟然能遇到这样奇葩的事情,网页用的技术过于新颖,以至于只支持高版本Chromium内核的,低版本安卓系统中内置的内核版本较低,无法加载前端页面。1、设置查看在系统设置里 > 应用 > 应用管理 > 显示系统应用,查看WebView组件:2、页面查看通过WebView发起的网络请求,都会带上浏览器的UA,通常页面都可以通过UA判断浏览器的内核版本。这里有两

1.1w
Android 14适配总结
Android

Android 14适配总结

Android 14适配总结毕业工作至今已经适配了三个Android大版本,从Android 11到Android 12、再到Android 13。2023年,Google即将推出的Android 14,上半年已经开始第一批适配。现在,第四个Android版本已经适配完,总结记录一下。1、Android 14计划Google一般会在2月份对外发布预告,同时放出开发者预览版。“拉通”各大平台、厂商以

1.1w