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() 的核心逻辑就讲完了。

相关推荐
relis5 小时前
解密大语言模型推理:Prompt Processing 的内存管理与计算优化
android·语言模型·prompt
CYRUS STUDIO7 小时前
FART 自动化脱壳框架优化实战:Bug 修复与代码改进记录
android·自动化·逆向·fart
2501_915909068 小时前
uni-app iOS 上架常见问题与解决方案,实战经验全解析
android·ios·小程序·https·uni-app·iphone·webview
如此风景8 小时前
Compose 多平台UI开发的基本原理
android
CYRUS_STUDIO9 小时前
静态分析根本不够!IDA Pro 动态调试 Android 应用的完整实战
android·逆向
CYRUS_STUDIO9 小时前
攻防 FART 脱壳:实现 AJM 壳级别的对抗功能 + 绕过全解析
android·安全·逆向
灿烂阳光g9 小时前
JAVA层的权限与SELinux的关系
android·linux
wayne21410 小时前
「原生 + RN 混合工程」一条命令启动全攻略:解密 react-native.config.js
android·react native
一个CCD12 小时前
MySQL主从复制之进阶延时同步、GTID复制、半同步复制完整实验流程
android·mysql·adb