Android 触摸反馈与事件分发原理解析

前言

自定义触摸反馈其实就是去重写 onTouchEvent() 方法,在方法内部定制触摸反馈算法。

例如,我们可以重写该方法,来粗略地判断用户的点击。

kotlin 复制代码
class TouchView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    override fun onTouchEvent(event: MotionEvent): Boolean {
        // 如果是抬起事件, 认为一次点击
        if (event.actionMasked == MotionEvent.ACTION_UP) {
            // 触发点击事件
            performClick()
        }
        return true
    }
}

在上述代码中,getActionMasked() 方法用于获取触摸事件的类型。如果是抬起,就会调用 performClick() 方法触发点击,在方法内部会执行设置点击监听器时传入的 OnClickListener 实例的 onClick(view) 方法。

我们也可以通过 getAction() 方法来获取触摸事件的类型。两者的区别在于 getAction() 返回值混合 了事件类型和进行操作的手指索引,就比如 event.getAction() == MotionEvent.ACTION_POINTER_DOWN 的比较永远为 false。而 getActionMasked() 在内部会返回真正的事件类型,用于多点触控。

方法的返回值为 true,表示当前 View 消费了该组事件。注意:触摸事件是一个序列,是以组来划分的,序列以按下事件开始,以抬起或取消事件结束。

为什么要这样?

将此次点击事件 (一组触摸事件) 标记为已消费,可以阻止事件向上传递给父 View 的 onTouchEvent(),避免了一次点击触发多个 View 响应的问题。

但事件还是会传递给父 View 的 dispatchTouchEvent(),父 View 在分发事件时,会直接传给消费了事件的子 View,而不会调用自己的 onTouchEvent()

另外,决定是否消费只有在按下事件返回时才有效,之后的事件中返回无意义。所以,我们可以用下面这行代码替换之前的 return true,效果是等价的。

kotlin 复制代码
return event.actionMasked == MotionEvent.ACTION_DOWN

View.onTouch 源码解析

我们来看看 View 的 onTouchEvent() 方法。

java 复制代码
final float x = event.getX();
final float y = event.getY();
final int viewFlags = mViewFlags;
final int action = event.getAction();

这部分是获取触摸的坐标、当前视图的标记位,以及触摸事件的类型。

java 复制代码
final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
        || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
        || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;

clickable 表示当前 View 是否可被点击,CLICKABLE 表示点击,LONG_CLICKABLE 表示长按,CONTEXT_CLICKABLE 表示上下文点击,就是鼠标右键或是手指长按空白区域,通常会弹出一个上下文菜单,而这种点击就是上下文点击。

java 复制代码
// 如果是禁用状态且在当前状态下不可点击
if ((viewFlags & ENABLED_MASK) == DISABLED
        && (mPrivateFlags4 & PFLAG4_ALLOW_CLICK_WHEN_DISABLED) == 0) {
    // 防御性编程:如果是抬起事件且当前为按下状态,设为未按下状态
    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.
    // 返回 clickable
    return clickable;
}

这段代码用于让处于禁用状态下的 View 被点击后,能够消费触摸事件。让其下层的 View 不能获取到触摸事件,也就是不能被点击。

java 复制代码
if (mTouchDelegate != null) {
    if (mTouchDelegate.onTouchEvent(event)) {
        return true;
    }
}

上述代码中设置 mTouchDelegate (触摸代理),mTouchDelegate 用于增大可点击区域。

前面的代码就看完了,接着看核心代码,首先是最外层的 if 判断。

java 复制代码
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
    switch (action) {
        // ...
    }
}

其中 TOOLTIP 是工具提示的标志,我们可以给 View 设置工具提示,这样长按该 View 会显示一段提示,解释该 View 的作用。

只要满足任一条件,可点击或是设置了工具提示,就能够进入触摸事件的处理逻辑。

然后看看每一条分支,按照触摸事件的顺序来,先看 MotionEvent.ACTION_DOWN

MotionEvent.ACTION_DOWN

java 复制代码
if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
    mPrivateFlags3 |= PFLAG3_FINGER_DOWN;
}

这一段代码用于获取是否手指触摸到了屏幕,因为在早期的安卓手机和电视上,是可以通过实体按键来操作的。它的作用是当手指触摸时,让提示文字隔开一段距离,不至于手指会挡住提示。

java 复制代码
if (!clickable) {
    checkForLongClick(
            ViewConfiguration.getLongPressTimeout(),
            x,
            y,
            TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
    break;
}

如果当前 View 不可点击,就去检测长按,给长按设置一个等待器(延迟触发操作)。方法内部会在长按检测时间到达后,执行长按操作(performLongClick(x,y))。

java 复制代码
if (performButtonActionOnTouchDown(event)) {
    break;
}

performButtonActionOnTouchDown() 方法用于处理鼠标右键操作,方法内部会弹出上下文菜单。

java 复制代码
// 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(
            ViewConfiguration.getLongPressTimeout(),
            x,
            y,
            TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);
}
break;

其中 isInScrollingContainer() 方法用于判断当前 View 是否处于可滚动的容器中。

java 复制代码
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public boolean isInScrollingContainer() {
    ViewParent p = getParent();
    while (p != null && p instanceof ViewGroup) {
        if (((ViewGroup) p).shouldDelayChildPressedState()) {
            return true;
        }
        p = p.getParent();
    }
    return false;
}

方法内部会不断递归调用父 ViewGroup 的 shouldDelayChildPressedState() 来获取是否延迟子 View 的按下状态值,只要有任何一个父 ViewGroup 返回 true,就直接返回。

ViewGroup 的 shouldDelayChildPressedState() 方法默认实现总是返回 true。

获取到该值后,就会进行判断:

  1. 不在滚动容器中,将当前设为按下状态,并开启长按检测。

  2. 在滚动容器中 ,会先将当前 View 标记设为预按下状态,然后设置一个按下的等待器。等待时间到达后,才会取消预按下状态,设置为按下状态 (会显示按下效果,比如颜色会变深),并开始检测长按。

这样可以防止与父 ViewGroup 的滚动发生冲突。一个典型的例子是,滑动QQ的聊天列表时,列表项并不会有点击效果,只有当缓慢滑动时,列表项才会出现按下状态效果,这是因为按下的时长超过了 TAP_TIMEOUT(100ms)。

因此,如果我们自定义一个不支持滚动的 ViewGroup,需要去重写 shouldDelayChildPressedState() 方法并返回 false,这样可以避免子 View 在点击时的延迟。

MotionEvent.ACTION_MOVE

再来看看 MotionEvent.ACTION_MOVE,它的代码会更复杂一些,我们分段来看。

java 复制代码
// Be lenient about moving outside of buttons
if (!pointInView(x, y, touchSlop)) {
    // Outside button
    // Remove any future long press/tap checks
    removeTapCallback();
    removeLongPressCallback();
    if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
        setPressed(false);
    }
    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
}

这段代码用于判断当前触摸点是否超出 View 的边界。如果超出,就会移除点击和长按的延迟回调,并取消按下状态。其中 touchSlop 是一个溢出的阈值,作用是扩大 View 的可触摸边界,提供容错率。

java 复制代码
final int motionClassification = event.getClassification();
// ... 省略模糊手势处理
    
final boolean deepPress =
        motionClassification == MotionEvent.CLASSIFICATION_DEEP_PRESS;
if (deepPress && hasPendingLongPressCallback()) {
    // process the long click action immediately
    removeLongPressCallback();
    checkForLongClick(
            0 /* send immediately */,
            x,
            y,
            TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__DEEP_PRESS);
}

break;

另外,还会处理 Android 10 开始支持的深度按压(Deep Press)和模糊手势(Ambiguous Gesture),通过复杂的超时和边界判断,让手势识别更加精准。

MotionEvent.ACTION_UP

java 复制代码
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
    // ...
}
// 是否忽略下次抬起事件
mIgnoreNextUpEvent = false;
break;

ACTION_UP 的核心逻辑只有在当前是按下 或是预按下状态时,才会进入。

java 复制代码
// take focus if we don't have it already and we should in
// touch mode.
boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
    focusTaken = requestFocus();
}

首先,如果需要,会处理焦点获取的逻辑。

java 复制代码
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);
}

如果当前为预按下状态(在滚动容器中快速点击并抬手),为了用户能够看到效果,会立即设置为按下状态。

java 复制代码
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)) { // 异步执行
            performClickInternal(); // 同步执行
        }
    }
}

在这之后,没有触发长按操作,就会移除长按检测,并异步触发点击操作。

java 复制代码
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();
}

最后取消按下状态。如果是当前是预按下状态,会延迟取消,让用户能感知到短暂的按下效果。

MotionEvent.ACTION_CANCEL

ACTION_CANCEL 分支的代码全在做收尾工作。用于在事件被中断时,取消按下状态、移除所有回调、重置标记位。

ViewGroup.onIntercreptTouchEvent()

前面我们说,当一个子 View 消费过触摸事件后,它的父 View 就不会在 onTouchEvent() 中获取到这一组触摸事件。但有时,父 View 需要强行获取触摸事件的控制权。最典型的场景就是滑动容器:它会先将触摸事件给子 View,让子 View 能够响应点击;然后在用户滑动时,夺取事件的控制权,让自己滚动。

父 ViewGroup 是通过 onIntercreptTouchEvent() 方法来完成这个决策的。在一个事件序列传递过程中,每一个事件都会经过父 ViewGroup 的这个方法。

  1. 如果方法返回 false,表示不拦截,会将事件传递给子 View;

  2. 否则,决定 拦截事件。后续的所有事件将不会传递给子 View,父 ViewGroup 将会调用 onTouchEvent() 方法进行处理,且不再经过 onInterceptTouchEvent()

同时,之前正在处理事件的子 View 将会接收到 MotionEvent.ACTION_CANCEL 事件,告知子 View 事件序列已被父 View 接收。

一般自定义 ViewGroup 的策略:在 ACTION_DOWN 事件到来时不拦截,让子 View 有机会处理事件。在 ACTION_MOVE 中,判断如果需要时,才会返回 true 进行拦截。

通常在父 View 拦截之前,ACTION_DOWN 事件已经传给子 View,所以需要在 onIntercreptTouchEvent() 方法中完成准备工作,比如保存初始按下的坐标。

另外,onIntercreptTouchEvent()onTouchEvent(),都是由 dispatchTouchEvent() 来调度的,Activity 获取到触摸事件后,也是通过 dispatchTouchEvent() 将事件传递下来的。这个过程是这样的:

dispatchTouchEvent() 源码分析

View

View 的 dispatchTouchEvent() 的核心逻辑就一行,调用 onTouchEvent(event) 方法来处理事件,其余都是些前置检查。

ViewGroup

ViewGroup 的 dispatchTouchEvent() 代码很长,我们先梳理一下流程和伪代码:

会判断是否需要进行拦截,是的话,就调用 onTouchEvent() 方法处理触摸事件,并消费该组事件;反之,调用子 View 的 dispatchTouchEvent() 方法,传递该组事件。

java 复制代码
public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;

    // 检查是否要进行拦截
    if (onInterceptTouchEvent(event)) {
        // 如果拦截,事件交给自己处理
        result = onTouchEvent(event); // 实际上通过super.dispatchTouchEvent(event)来调用
    } else {
        // 如果不拦截,将事件分发给子View
        result = child.dispatchTouchEvent(event);
    }
    return result;
}

注意:在分发事件之前,会遍历子 View,判断触摸点位于哪个子 View 上;还会将相对于 ViewGroup 的坐标转为相对于子 View 的坐标,这个过程会通过 dispatchTransformedTouchEvent() 方法来完成。

接着我们来看源码的关键部分。

第一段为:

java 复制代码
// 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();
}

如果是 ACTION_DOWN 事件,会清除之前所有的触摸目标,重置触摸状态。

resetTouchState() 方法中,会清除 FLAG_DISALLOW_INTERCEPT 标记,与之相关的方法为 requestDisallowInterceptTouchEvent()

requestDisallowInterceptTouchEvent() 用于解决嵌套滑动冲突,请求父 View 不要拦截事件。重置这个标记,能够让父 View 在下一次事件序列中,恢复拦截能力。

第二段代码为:

java 复制代码
// Check for interception.
final boolean intercepted;
ViewRootImpl viewRootImpl = getViewRootImpl();
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    final boolean isBackGestureInProgress = (viewRootImpl != null
            && viewRootImpl.getOnBackInvokedDispatcher().isBackGestureInProgress());
    if (!disallowIntercept || isBackGestureInProgress) {
        // Allow back to intercept touch
        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;
}

这部分是拦截的核心逻辑。

  • 外层判断:如果当前是 ACTION_DOWN 事件(事件序列开端),或是 mFirstTouchTarget 不为空(已有子 View 在处理这个事件序列),ViewGroup 才会考虑是否拦截,否则直接拦截。

  • 内层判断:会检查子 View 是否请求 ViewGroup 不要拦截事件和系统是否正在处理全局返回手势,如果子 View 没有禁止拦截,或是系统在处理返回手势,ViewGroup 就会判断是否拦截,否则,听从子 View 的请求,不进行拦截。

补充:一个子 View 消费了事件,就会被 ViewGroup 记录为一个触摸目标,触摸目标使用链表存储,上述的 mFirstTouchTarget 为表头。

第三段代码为:

在确定不拦截事件后,就开始为新的触摸点寻找触摸目标,这个过程只在手势的开始或是多点触控中新手指按下时才会触发。

java 复制代码
if (!canceled && !intercepted) {
    // ...

    if (actionMasked == MotionEvent.ACTION_DOWN
            || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
            || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
        // ...

        final int childrenCount = mChildrenCount;
        if (newTouchTarget == null && childrenCount != 0) {
            // ...
            // 遍历子 View
            for (int i = childrenCount - 1; i >= 0; i--) {
                // ...
                // 判断当前子 View 是否已是触摸目标
                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.
                    // 如果是,新增手指 ID
                    newTouchTarget.pointerIdBits |= idBitsToAssign;
                    // 结束遍历
                    break;
                }

                // ...
                // 尝试分发事件给这个子 View
                if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                    // ...
                    // 如果愿意消费,新增为新的触摸目标
                    newTouchTarget = addTouchTarget(child, idBitsToAssign);
                    alreadyDispatchedToNewTouchTarget = true;
                    // 结束遍历
                    break;
                }

                // ...
            }
            // ...
        }

        // ...
    }
}

这段代码会遍历所有子 View,询问是否愿意处理事件。其中关键在于 dispatchTransformedTouchEvent() 方法,它会将事件分发给子 View,如果方法返回 true,说明子 View 消费了事件。ViewGroup 会调用 addTouchTarget() 将其加入链表中,并停止遍历,因为任务已完成。

如果询问之前,子 View 已经是触摸目标,说明用户的第二根手指点在了同一个 View 上。这时,只需更新手指信息到已有的 TouchTarget 上即可,无需再次分发。

第四段代码为:

如果手势已经开始,ViewGroup 就会将后续的事件分发给已知的触摸目标。

java 复制代码
// Dispatch to touch targets.
// 如果没有找到触摸目标,比如点到 ViewGroup 的空白区域,就自己处理事件 (调用自己的 onTouchEvent())
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 {
            // 是否需要发送 CANCEL 事件
            final boolean cancelChild =
                    (target.child != null && resetCancelNextUpFlag(target.child))
                            || intercepted;
            // 事件分发给目标子 View
            if (target.child != null && dispatchTransformedTouchEvent(ev, cancelChild,
                    target.child, target.pointerIdBits)) {
                handled = true;
            }
            // 发送了 CANCEL,从链表中移除
            if (cancelChild) {
                if (predecessor == null) {
                    mFirstTouchTarget = next;
                } else {
                    predecessor.next = next;
                }
                if (!target.isRecycled()) {
                    target.recycle();
                }
                target = next;
                continue;
            }
        }
        predecessor = target;
        target = next;
    }
}

如果没有触摸目标,ViewGroup 就会将事件分发给自己,调用自己的 onTouchEvent() 方法进行处理。

分发时,会遍历触摸目标链表,通过 dispatchTransformedTouchEvent() 分发事件给链表中的每一个 View。

如果 ViewGroup 在中途决定拦截事件 (interceptedtrue),cancelChild 标记就会生效,事件会被转为 ACTION_CANCEL 发给子 View,然后这个触摸目标就会被移除。

至此,ViewGroup 的 dispatchTouchEvent() 的核心逻辑就讲完了。

相关推荐
踢球的打工仔8 小时前
PHP面向对象(7)
android·开发语言·php
安卓理事人8 小时前
安卓socket
android
安卓理事人13 小时前
安卓LinkedBlockingQueue消息队列
android
万能的小裴同学15 小时前
Android M3U8视频播放器
android·音视频
q***577415 小时前
MySql的慢查询(慢日志)
android·mysql·adb
JavaNoober16 小时前
Android 前台服务 "Bad Notification" 崩溃机制分析文档
android
城东米粉儿16 小时前
关于ObjectAnimator
android
zhangphil17 小时前
Android渲染线程Render Thread的RenderNode与DisplayList,引用Bitmap及Open GL纹理上传GPU
android
火柴就是我18 小时前
从头写一个自己的app
android·前端·flutter
lichong95119 小时前
XLog debug 开启打印日志,release 关闭打印日志
android·java·前端