Android事件分发-来龙去脉

阅读原文

  1. 情境(Situation)
  2. 冲突(Complication)
  3. 疑问(Question)
  4. 答案(Answer)
    1. 剖析
      1. 论点
        1. 约法三章
      2. 论据
        1. 人机交互
        2. View树
        3. 类图
        4. 注释
        5. DecorView
        6. WindowCallbackWrapper
        7. Activity
        8. PhoneWindow
        9. ViewGroup
        10. View
      3. 事件流
    2. 论证
  5. 一张图
  6. 标准
    1. 常见错误
    2. 最佳实践
    1. 方法论
    2. 利器
  7. 进阶
  8. 参考
  9. 长歌

情境(Situation)

1. 专注于移动互联网数年,作为高P的我【鼓掌】竟然对事件分发机制见招拆招,似懂非懂。不专业,没法忍。

2. View树的递归嵌套逻辑让广大一线同行云里雾里,手足无措。

冲突(Complication)

1. 网上好多相关主题的博客,描述信息点非常多(但是ACTION_CANCEL描述很少),看完后不明觉厉。

2. 事件分发主要用于解决自定义炫酷控件以及滑动嵌套引发的冲突问题(程序傻傻分不清是横滑还是竖滑),发现同行各种写法都有,雷无处不在【人在家中坐,锅从天上来】。


我的机会来了


image

疑问(Question)

1. 有没有体系化剖析套路?

2. 指出常见错误,给出最佳实践?

3. 清晰明了的给出一张图,便于查阅?

4. “鱼”和“渔”可以兼得?

答案(Answer)

剖析

论点

约法三章

1. 限于个人水平,本文只包含单点触控事件(ACTION_DOWN,ACTION_MOVE,ACTION_UP,ACTION_CANCEL)。

2. Window类相关的我不会,肤浅的认为和事件分发关系不大(求大牛点拨),直接跳过。

3. 一家之言,姑妄言之,姑妄听之。

1. 事件流一致性保证(Consistency Guarantees):按下开始,中间可能伴随着移动,松开或者取消结束。ACTION_DOWN -> ACTION_MOVE(*) -> ACTION_UP/ACTION_CANCEL。

2. View类的dispatchTouchEvent方法完成事件的消费处理,ViewGroup的dispatchTouchEvent方法完成事件的分发处理。正常情况下不建议重写该方法改变系统事件分发机制。

3. ViewGroup类的onInterceptTouchEvent方法完成事件的拦截处理。事件分发路径上的ViewGroup,在ACTION_DOWN或者不是自己直接消费事件时一定会调用onInterceptTouchEvent方法。

4. View类的onTouchEvent方法完成具体处理事件消费,即触发点击监听(OnClickListener)和长时间点击监听(OnLongClickListener)以及按键状态、焦点相关处理。

  1. 如果设置了OnTouchListener,会先调用OnTouchListener,如果该监听onTouch返回true,则不会调用onTouchEvent,直接返回已消费;

  2. 如果设置了TouchDelegate ,onTouchEvent中会先调用TouchDelegate,如果该类onTouchEvent返回true,则直接返回已消费;

  3. 如果View 可点击,执行onTouchEvent中事件处理,并返回true;

    1. ACTION_DOWN:置按键标志位为按下状态,并触发延时(500ms)执行长按点击事件。

    2. ACTION_MOVE:如果按键坐标超出该控件区域,则置按键标志位为非按下状态,并且移除ACTION_DOWN触发的延时执行长按点击事件。

    3. ACTION_UP:如果按键标志位为按下状态,并且ACTION_DOWN触发的长按点击事件还未执行,则移除长按点击事件,执行点击事件。

    4. ACTION_CANCEL:置按键标志位为非按下状态,移除ACTION_DOWN触发的延时执行长按点击事件。

  4. 否则不可点击,返回false;

论据

基于 Android 8.0 (API Level 28) 源码解析

人机交互

人机交互流程图
事件分发Java栈

赏析

用户的按键行为->手机传感器->ViewRootImpl->DecorView->WindowCallbackWrapper->Activity->PhoneWindow->DecorView->ViewGroup*->View->程序员的代码逻辑->硬件(显示器、扬声器等)响应输出->用户感知

View树

AndroidView树布局结构

赏析

1. View是由树形结构组织,节点为ViewGroup或者View。ViewGroup可以包含多个子节点,View没有子节点。

2. Android中View树的根节点为DecorView(父View为FrameLayout,属于ViewGroup)。

3. Android中用户可自定义的View子树根节点id为“android:id/content”。
{:.info}

类图

View和ViewGroup类图

赏析

1. ViewRootImpl是Android层逻辑起始点,用于接收来自系统底层的事件消息。相当于View管理类,本身不是View。(BTW:View绘制流程的三部曲(measure、layout、draw)也由该类触发的。)

2. DecorView是Android View树的根节点,持有window对象。本身能够直接进行真正事件分发能力(继承了父类ViewGroup和View的事件分发处理功能),但是事件分发会直接调用window,间接传递到Activity的事件分发,后续会由Activity回调DecorView的真正事件分发能力。对应图中的环形依赖。

3. Activity是Android中的页面,真正的事件分发由该类的dispatchTouchEvent触发。(Easter Eggs:如果你想让用户操作不了你的界面,蒙一层透明的View是不是有点low,直接重写该方法就可以控制。)

4. ViewGroup负责事件分发和拦截处理。按下事件和后续事件(移动、释放或者取消)处理不相同。

  1. 按下事件,先判断是否拦截。

    1. 如果不拦截的话,分发事件寻找目标消费子View(逆序遍历子View,递归调用子View的事件分发,判断是否有子View消费。mFirstTouchTarget存储目标消费子View对象)。

      1. 如果有子View消费,则目标子View消费事件。

      2. 否则自己尝试消费事件。

    2. 否则直接自己尝试消费事件。

  2. 后续事件

    1. 如果按下事件找到了目标消费子View,则判断是否拦截,否则不拦截。

    2. 如果有目标消费子View,则根据是否拦截。

      1. 如果没有拦截,正常传送后续事件;

      2. 如果有拦截,则当前事件转换为取消事件发送给目标消费子View,并且重置目标消费子View为空,接下来的后续事件直接自己尝试消费事件(不管是否消费,后续事件都会接收到&尝试处理事件分发);

    3. 否则自己尝试消费事件。(不会调用是否拦截,其实拦截或者不拦截,都是自己消费事件。)

5. View负责事件消费事件处理。

  1. 调用mOnTouchListener的onTouch。

    1. 如果消费,直接返回true;

    2. 否则,继续调用onTouchEvent方法;

      1. 如果为启用的(enable),返回可点击(clickable)。

      2. 否则,调用mTouchDelegate的onTouchEvent。

        1. 如果消费,直接返回true;

        2. 否则,

          1. 如果可点击(clickable)

            1. 进行事件流(ACTION_DOWN,ACTION_MOVE,ACTION_UP,ACTION_CANCEL)处理(包含焦点、按键状态、按键和长时间按键);

            2. 返回true。

          2. 否则返回false;

注释

DecorView
/**
  * Decor的意思是:装饰,布置。
  * View树的根节点。
  * 事件分发的启点,ViewRootImpl最先调用dispatchPointerEvent(实现在父类View里面)。
  * 事件调用在DecorView里面形成了一个环。(先通过Window交由Activity分发,Activity再调用DecorView中的真正事件分发方法)
  */
public class DecorView extends FrameLayout  {
    private PhoneWindow mWindow;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // DecorView直接覆盖ViewGroup的事件分发实现,其实这只是饶了个圈,
        // 正真的事件分发会由Activity回调到superDispatchTouchEvent(ViewGroup的事件分发处理)。
        // 调用Window的WindowCallbackWrapper对象继续分发。
        final Window.Callback cb = mWindow.getCallback();
        return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
                ? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
    }
    
    public boolean superDispatchTouchEvent(MotionEvent event) {
        // 调用父类ViewGroup进行事件分发处理。
        return super.dispatchTouchEvent(event);
    }
}
WindowCallbackWrapper
/**
  * Wrapper的意思是包装材料。
  * 实实在在的一个壳,包裹着Activity。
  */
public class WindowCallbackWrapper implements Window.Callback   {
    final Window.Callback mWrapped;

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        // 交给Callback(具体对象为Activity)接力事件分发。
        return mWrapped.dispatchTouchEvent(event);
    }
}
Activity
/**
  * Activity和View不一样,Activity就是一个壳,没有事件分发机制,View树如果没有消费,Activity捡个漏。
  */
public class Activity implements Window.Callback  {
    private Window mWindow;

    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }

        // 交给Window(具体对象为PhoneWindow)接力事件分发。
        if (getWindow().superDispatchTouchEvent(ev)) {
            // View树消费掉事件
            return true;
        }

        // 如果View树没有消费事件,Activity消费事件的机会来了。
        // 启示:如果View树消费事件,在按下事件的后续事件中,如果父ViewGroup进行拦截,
        // 虽然后续返回的消费状态对整个事件流没有影响,但是会对Activity有影响(View数不消费,Activity有机会消费)。
        return onTouchEvent(ev);
    }
    
    public boolean onTouchEvent(MotionEvent event) {
        // 事件消费处理,系统默认基本不干啥
        if (mWindow.shouldCloseOnTouch(this, event)) {
            finish();
            return true;
        }

        return false;
    }
}
PhoneWindow
/**
  * PhoneWindow也是一个壳,将事件转回给DecorView分发处理。
  */
public class PhoneWindow extends Window  {
    private DecorView mDecor;

    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        // 交给DecorView接力事件分发(自此,环形结束,开始ViewGroup和View中事件分发和消费闪亮登场)。
        return mDecor.superDispatchTouchEvent(event);
    }
}
ViewGroup
/**
  * ViewGroup,View容器的意思。
  * dispatchTouchEvent完成时间分发逻辑。
  * onInterceptTouchEvent:为事件拦截接口,父控件可以主动截留事件自己消费,否则只能等子Viwe树都不消费才能捡漏。【有控制权就是爸爸】
  */
public abstract class ViewGroup extends View implements ViewParent  {
    
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
        }

        // If the event targets the accessibility focused view and this is it, start
        // normal event dispatch. Maybe a descendant is what will handle the click.
        if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) {
            ev.setTargetAccessibilityFocus(false);
        }

        boolean handled = false;
        if (onFilterTouchEventForSecurity(ev)) {
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;

            // 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();
            }

            // Check for interception.
            // 是否拦截判断
            final boolean intercepted;
            // 拦截条件1,要么是按下事件,要么自己不直接消费事件。
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                // 拦截条件2,允许拦截开关打开。
                //(默认状态是打开的,其他View可以调用requestDisallowInterceptTouchEvent进行控制,
                // 多为子View掉父View,滑动冲突外部拦截法就是靠调用这个接口控制父View拦截)。【爸爸的权利也不是绝对的】
                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 {
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                // 这种场景我没有遇到过,可能多点触控会调到【说错了当我放屁】
                intercepted = true;
            }

            // If intercepted, start normal event dispatch. Also if there is already
            // a view that is handling the gesture, do normal event dispatch.
            if (intercepted || mFirstTouchTarget != null) {
                ev.setTargetAccessibilityFocus(false);
            }

            // Check for cancelation.
            final boolean canceled = resetCancelNextUpFlag(this)
                    || actionMasked == MotionEvent.ACTION_CANCEL;

            // Update list of touch targets for pointer down, if needed.
            final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
            TouchTarget newTouchTarget = null;
            boolean alreadyDispatchedToNewTouchTarget = false;
            // 递归查找目标消费子View条件1:事件没有被取消,也没有被拦截
            if (!canceled && !intercepted) {

                // If the event is targeting accessiiblity focus we give it to the
                // view that has accessibility focus and if it does not handle it
                // we clear the flag and dispatch the event to all children as usual.
                // We are looking up the accessibility focused host to avoid keeping
                // state since these events are very rare.
                View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
                        ? findChildWithAccessibilityFocus() : null;
                // 递归查找目标消费子View条件2:事件必须是按下事件。【多点触控的不讨论,关键是我也不会】
                if (actionMasked == MotionEvent.ACTION_DOWN
                        || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                    final int actionIndex = ev.getActionIndex(); // always 0 for down
                    final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
                            : TouchTarget.ALL_POINTER_IDS;

                    // Clean up earlier touch targets for this pointer id in case they
                    // have become out of sync.
                    removePointersFromTouchTargets(idBitsToAssign);

                    final int childrenCount = mChildrenCount;
                    if (newTouchTarget == null && childrenCount != 0) {
                        final float x = ev.getX(actionIndex);
                        final float y = ev.getY(actionIndex);
                        // 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资格1:事件的坐标在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);
                            // 消费事件View资格2:自己或者子View树消费事件。进入递归事件分发。
                            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();
                                // 标记当前View为目标消费子View,消费路径上都是父View标记直接子View(下发分发不用再找了)。不存在跨级。
                                // 我也没有搞明白为啥整一个链式结构存目标消费子View。我没有遇到多余1个目标消费子View的情况。【看逻辑,如果有子View消费,则跳出循环,不会继续分发】
                                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);
                        }
                        if (preorderedList != null) preorderedList.clear();
                    }

                    if (newTouchTarget == null && mFirstTouchTarget != null) {
                        // Did not find a child to receive the event.
                        // Assign the pointer to the least recently added target.
                        newTouchTarget = mFirstTouchTarget;
                        while (newTouchTarget.next != null) {
                            newTouchTarget = newTouchTarget.next;
                        }
                        newTouchTarget.pointerIdBits |= idBitsToAssign;
                    }
                }
            }

            // Dispatch to touch targets.
            // 没有目标子View消费,自己消费。(要么自己拦截了,要么子View树没有消费)
            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;
                    // 如果是按下事件,则已消费,直接置消费状态为true
                    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                        handled = true;
                    } else {
                        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                || intercepted;
                        // 非按下事件,要么持续正常处理消费,要么被拦截(事件转成取消事件,还是继续分发给目标View)
                        if (dispatchTransformedTouchEvent(ev, cancelChild,
                                target.child, target.pointerIdBits)) {
                            handled = true;
                        }
                        if (cancelChild) {
                            // 如果是取消事件(要么被拦截,要么传过来的就是取消事件),则清空目标消费子View。
                            if (predecessor == null) {
                                mFirstTouchTarget = next;
                            } else {
                                predecessor.next = next;
                            }
                            target.recycle();
                            target = next;
                            continue;
                        }
                    }
                    predecessor = target;
                    target = next;
                }
            }

            // Update list of touch targets for pointer up or cancel, if needed.
            if (canceled
                    || actionMasked == MotionEvent.ACTION_UP
                    || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                resetTouchState();
            } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
                final int actionIndex = ev.getActionIndex();
                final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
                removePointersFromTouchTargets(idBitsToRemove);
            }
        }

        if (!handled && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
        }
        // 返回消费状态
        return handled;
    }

    // 拦截处理
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
                && ev.getAction() == MotionEvent.ACTION_DOWN
                && ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
                && isOnScrollbarThumb(ev.getX(), ev.getY())) {
            return true;
        }
        return false;
    }

    // 事件分发处理封装部分逻辑的子方法,实现取消事件转换
    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        final boolean handled;

        // Canceling motions is a special case.  We don't need to perform any transformations
        // or filtering.  The important part is the action, not the contents.
        final int oldAction = event.getAction();
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
            // 转换成取消事件
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }

        // Calculate the number of pointers to deliver.
        final int oldPointerIdBits = event.getPointerIdBits();
        final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;

        // If for some reason we ended up in an inconsistent state where it looks like we
        // might produce a motion event with no pointers in it, then drop the event.
        if (newPointerIdBits == 0) {
            return false;
        }

        // 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) {
                    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);
        }

        // 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());
            }

            handled = child.dispatchTouchEvent(transformedEvent);
        }

        // Done.
        transformedEvent.recycle();
        return handled;
    }
}
事件DispatchTouchEvent流程图
View
public class View  {
    public final boolean dispatchPointerEvent(MotionEvent event) {
        // View树接收事件的起点,由ViewRootImpl调用DecorView的该方法开始,
        // 接下来会调用到DecorView的dispatchTouchEvent方法。
        if (event.isTouchEvent()) {
            return dispatchTouchEvent(event);
        } else {
            return dispatchGenericMotionEvent(event);
        }
    }

    // 事件消费处理
    public boolean dispatchTouchEvent(MotionEvent event) {
        // If the event should be handled by accessibility focus first.
        if (event.isTargetAccessibilityFocus()) {
            // We don't have focus or no virtual descendant has it, do not handle the event.
            if (!isAccessibilityFocusedViewOrHost()) {
                return false;
            }
            // We have focus and got the event, then use normal event dispatch.
            event.setTargetAccessibilityFocus(false);
        }

        boolean result = false;

        if (mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onTouchEvent(event, 0);
        }

        final int actionMasked = event.getActionMasked();
        if (actionMasked == MotionEvent.ACTION_DOWN) {
            // Defensive cleanup for new gesture
            stopNestedScroll();
        }

        if (onFilterTouchEventForSecurity(event)) {
            if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
                result = true;
            }
            //noinspection SimplifiableIfStatement
            ListenerInfo li = mListenerInfo;
            // 优先mOnTouchListener消费处理,如果消费,直接返回已消费
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {
                result = true;
            }

            // 自己处理消费,封装在onTouchEvent内
            if (!result && onTouchEvent(event)) {
                result = true;
            }
        }

        if (!result && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
        }

        // Clean up after nested scrolls if this is the end of a gesture;
        // also cancel it if we tried an ACTION_DOWN but we didn't want the rest
        // of the gesture.
        if (actionMasked == MotionEvent.ACTION_UP ||
                actionMasked == MotionEvent.ACTION_CANCEL ||
                (actionMasked == MotionEvent.ACTION_DOWN && !result)) {
            stopNestedScroll();
        }

        return result;
    }

    // 针对完整事件流(ACTION\_DOWN -> ACTION\_MOVE(*) -> ACTION\_UP/ACTION\_CANCEL)完成按键监听、长时间按键监听、焦点以及按键状态处理。
    public boolean onTouchEvent(MotionEvent event) {
        final float x = event.getX();
        final float y = event.getY();
        final int viewFlags = mViewFlags;
        final int action = event.getAction();

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

        if ((viewFlags & ENABLED_MASK) == DISABLED) {
            // 按键未启用,直接返回点击状态。
            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.
            return clickable;
        }
        // 有效触摸代理消费事件,可用于扩大点击热点控制。如果消费,直接返回已消费。
        if (mTouchDelegate != null) {
            if (mTouchDelegate.onTouchEvent(event)) {
                return true;
            }
        }

        if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            // 可点击情况下进行按键处理。
            switch (action) {
                case MotionEvent.ACTION_UP:
                    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                    if ((viewFlags & TOOLTIP) == TOOLTIP) {
                        handleTooltipUp();
                    }
                    if (!clickable) {
                        removeTapCallback();
                        removeLongPressCallback();
                        mInContextButtonPress = false;
                        mHasPerformedLongPress = false;
                        mIgnoreNextUpEvent = false;
                        break;
                    }
                    boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                    // 检查按键标志位状态,只有为按下状态才接着处理。
                    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                        // take focus if we don't have it already and we should in
                        // touch mode.
                        boolean focusTaken = false;
                        if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                            focusTaken = requestFocus();
                        }

                        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);
                        }

                        if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                            // This is a tap, so remove the longpress check
                            // ACTION_DOWN触发的长按点击事件还未执行,则移除长按点击事件,
                            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)) {
                                    performClick();
                                }
                            }
                        }

                        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();
                        }

                        removeTapCallback();
                    }
                    mIgnoreNextUpEvent = false;
                    break;

                case MotionEvent.ACTION_DOWN:
                    if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
                        mPrivateFlags3 |= PFLAG3_FINGER_DOWN;
                    }
                    mHasPerformedLongPress = false;

                    if (!clickable) {
                        checkForLongClick(0, x, y);
                        break;
                    }

                    if (performButtonActionOnTouchDown(event)) {
                        break;
                    }

                    // 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.
                    // 置按键标志位为按下状态,并触发延时(500ms)执行长按点击事件。
                    // 以下为滚动和非滚动下的处理。
                    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(0, x, y);
                    }
                    break;

                case MotionEvent.ACTION_CANCEL:
                    if (clickable) {
                        setPressed(false);
                    }
                    // 置按键标志位为非按下状态,移除ACTION_DOWN触发的延时执行长按点击事件。
                    removeTapCallback();
                    removeLongPressCallback();
                    mInContextButtonPress = false;
                    mHasPerformedLongPress = false;
                    mIgnoreNextUpEvent = false;
                    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                    break;

                case MotionEvent.ACTION_MOVE:
                    if (clickable) {
                        drawableHotspotChanged(x, y);
                    }

                    // Be lenient about moving outside of buttons
                    // 检查按键坐标是否超出该View区域。
                    if (!pointInView(x, y, mTouchSlop)) {
                        // Outside button
                        // Remove any future long press/tap checks
                        // 置按键标志位为非按下状态,并且移除ACTION\_DOWN触发的延时执行长按点击事件。
                        removeTapCallback();
                        removeLongPressCallback();
                        if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                            setPressed(false);
                        }
                        mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                    }
                    break;
            }

            return true;
        }

        return false;
    }
}
事件OnTouchEvent流程图

事件流

DemoParentInterceptTouchEventActivity页面git仓库

使用MECE(Mutually Exclusive Collectively Exhaustive,相互独立,完全穷尽)法则

事件流MEMC图
条件 结果
1.父控件ACTION_DOWN拦截
2.父控件消费事件
1. 接收按下事件 -DOWN-> Parent.dispatchTouchEvent -DOWN-> Parent.onInterceptTouchEvent -true-> Parent.dispatchTouchEvent -DOWN-> Parent.(super)dispatchTouchEvent{Parent处理消费} -DOWN-> Parent.onTouchEvent -true-> Parent.dispatchTouchEvent-true-> 返回消费状态true
2. 接收移动事件 -MOVE-> Parent.dispatchTouchEvent -MOVE-> Parent.(super)dispatchTouchEvent{Parent处理消费} -MOVE-> Parent.onTouchEvent -消费状态-> Parent.dispatchTouchEvent -true-> 返回消费状态true
3. 接收释放事件 -UP-> Parent.dispatchTouchEvent -UP-> Parent.(super)dispatchTouchEvent{Parent处理消费} -UP-> Parent.onTouchEvent -true-> Parent.dispatchTouchEvent -true-> 返回消费状态true
1.父控件ACTION_DOWN拦截
2.父控件不消费事件
4. 接收按下事件 -DOWN-> Parent.dispatchTouchEvent -DOWN-> Parent.onInterceptTouchEvent -true-> Parent.dispatchTouchEvent -DOWN-> Parent.(super)dispatchTouchEvent{Parent处理消费} -DOWN-> Parent.onTouchEvent -false-> Parent.dispatchTouchEvent-false-> 返回消费状态false
5. 接收不到移动事件
6. 同5
1.父控件ACTION_MOVE拦截
2.子控件消费事件
7. 接收按下事件 -DOWN-> Parent.dispatchTouchEvent -DOWN-> Parent.onInterceptTouchEvent -false-> Parent.dispatchTouchEvent -DOWN-> Child.dispatchTouchEvent{Parent分发,遍历调用Child分发消息,Child内部递归分发消息} -DOWN-> TargetChild(目标子控件,区别Child,子控件消费事件,要么是自己消费了,要么是自己的后代或者后代的后代消费了).onTouchEvent{存在调用多个Child该方法,前提是前面的Child均返回false} -true-> Child.dispatchTouchEvent -true-> Parent.dispatchTouchEvent{记录目标消费Child为该View}-true-> 返回消费状态true
8. 接收移动事件 -MOVE-> Parent.dispatchTouchEvent -MOVE-> Parent.onInterceptTouchEvent -true-> Parent.dispatchTouchEvent -CANCEL-> Child(目标消费Child).dispatchTouchEvent{Child处理消费} -CANCEL->Child.onTouchEvent -消费状态-> Child.dispatchTouchEvent-消费状态-> 返回消费状态
9. 接收释放事件 -UP-> Parent.dispatchTouchEvent -UP-> Parent.(super)dispatchTouchEvent{Parent处理消费} -UP-> Parent.onTouchEvent -消费状态-> Parent.dispatchTouchEvent -消费状态-> 返回消费状态
1.父控件ACTION_MOVE拦截
2.子控件不消费事件
3.父控件消费事件
10. 接收按下事件 -DOWN-> Parent.dispatchTouchEvent -DOWN-> Parent.onInterceptTouchEvent -false-> Parent.dispatchTouchEvent -DOWN-> Child.dispatchTouchEvent{Parent分发,遍历调用Child分发消息,Child内部递归分发消息} -DOWN-> TargetChild(目标子控件,区别Child,子控件处理消费事件).onTouchEvent{满足事件坐标在控件内的子View或者子View的后代均会调用到} -false-> Child.dispatchTouchEvent -false-> Parent.dispatchTouchEvent{没有目标消费Child} -DOWN-> Parent.(super)dispatchTouchEvent{Parent处理消费} -DOWN-> Parent.onTouchEvent -true-> 返回消费状态true
11. 同2
12. 同3
1.父控件ACTION_MOVE拦截
2.子控件不消费事件
3.父控件不消费事件
13. 接收按下事件 -DOWN-> Parent.dispatchTouchEvent -DOWN-> Parent.onInterceptTouchEvent -false-> Parent.dispatchTouchEvent -DOWN-> Child.dispatchTouchEvent{Parent分发,遍历调用Child分发消息,Child内部递归分发消息} -DOWN-> TargetChild(目标子控件,区别Child,子控件处理消费事件).onTouchEvent{满足事件坐标在控件内的子View或者子View的后代均会调用到} -false-> Child.dispatchTouchEvent -false-> Parent.dispatchTouchEvent{没有目标消费Child} -DOWN-> Parent.(super)dispatchTouchEvent{Parent处理消费} -DOWN-> Parent.onTouchEvent -false-> 返回消费状态false
14. 同5
15. 同5
1.父控件ACTION_UP拦截
2.子控件消费事件
16. 同7
17. 接收移动事件 -MOVE-> Parent.dispatchTouchEvent -MOVE-> Parent.onInterceptTouchEvent -false-> Parent.dispatchTouchEvent -MOVE-> Child(目标消费Child).dispatchTouchEvent -MOVE-> Child.onTouchEvent -true-> Child.dispatchTouchEvent -true-> Parent.dispatchTouchEvent -true-> 返回消费状态true
18. 接收释放事件 -UP-> Parent.dispatchTouchEvent -UP-> Parent.onInterceptTouchEvent -true-> Parent.dispatchTouchEvent -CANCEL-> Child(目标消费Child).dispatchTouchEvent{Child处理消费} -CANCEL-> Child.onTouchEvent -消费状态-> Child.dispatchTouchEvent -true-> Parent.dispatchTouchEvent -true-> 返回消费状态true
1.父控件ACTION_UP拦截
2.子控件不消费事件
3.父控件消费事件
19. 同10
20. 同2
21. 同3
1.父控件ACTION_UP拦截
2.子控件不消费事件
3.父控件不消费事件
22. 同13
23. 同5
24. 同5
1. 父控件不拦截
2. 子控件消费事件
25. 同7
26. 同17
27. 接收释放事件 -UP-> Parent.dispatchTouchEvent -UP-> Parent.onInterceptTouchEvent -false-> Parent.dispatchTouchEvent -UP-> Child(目标消费Child).dispatchTouchEvent -UP-> Child.onTouchEvent -true-> Child.dispatchTouchEvent -true-> Parent.dispatchTouchEvent -true-> 返回消费状态true
1. 父控件不拦截
2. 子控件不消费事件
3. 父控件消费事件
28. 同10
29. 同2
30. 同3
1. 父控件不拦截
2. 子控件不消费事件
3. 父控件不消费事件
31. 同13
32. 同5
33. 同5

启示

1. ACTION_DOWN执行事件分发查找(遍历子View,递归分发查找,如果子View未消费,则回退到自己消费,依次向上回溯,找到目标消费View为止)找到目标消费子View。后续事件不再需要查找,直接发送给目标消费子View,如果没有,则自己消费。

事件分发路径


2. 事件已消费路径上(终点为目标消费View),如果有父控件拦截事件,则第一次拦截后,会将当前事件转为ACTION_CANCEL传递给目标消费子View,后续事件则直接自己处理消费,不论是否消费,均能收到后续事件流
事件分发拦截路径

论证

1. 从事件流可证明事件一致性保证(Consistency Guarantees):

  1. ViewGroup在ACTION_DOWN的事件分发返回false(不消费事件),则不再会收到后续事件(ACTION_MOVE、ACTION_UP/ACTION_CANCEL)。

  2. ViewGroup在ACTION_DOWN的事件分发返回true(消费事件),则会收到后续事件(ACTION_MOVE、ACTION_UP/ACTION_CANCEL),如果ViewGroup拦截后续事件,则第一次拦截会将事件转为ACTION_CANCEL传递给目标消费子View(终止子View接收后续事件),接下来的后续事件自己消费。

  3. ViewGroup在非ACTION_DOWN的事件分发返回消费状态对整体事件流没有影响。

2. 从注释可证明:
View.dispatchTouchEvent方法完成事件的消费处理;
ViewGroup.dispatchTouchEvent方法完成事件的分发处理;
ViewGroup.onInterceptTouchEvent方法完成事件的拦截处理;
事件分发路径上的ViewGroup,在ACTION_DOWN或者不是自己直接消费事件时一定会调用onInterceptTouchEvent方法。
以及View类的onTouchEvent方法完成具体处理事件消费。

一张图

事件分发一张图

赏析

1. ACTION_DOWN会触发查找目标消费View,优先子View尝试消费,如果子View仍然没有消费,则依次回溯到父控件尝试消费(直至DecorView,然后Activity尝试消费),如果找到了,则回溯返回true。

2. ACTION_DOWN后续事件执行的前提是事件分发路径的终点就是目标消费View,目标消费View的父控件均会调用到事件拦截(让父控件有机会拦截下来,改变事件流),如果目标消费View的父控件拦截,拦截时的事件会转换为ACTION_CANCEL继续按原路径分发,后续的事件则不再分发给目标消费View,而是拦截的父控件自己消费。

3. 非ACTION_DOWN返回的消费状态对事件流没有影响,如果未消费,会回调给Activity处理。

标准

常见错误

1. 不知道onInterceptTouchEvent和onTouchEvent什么时候会调用,但是知道dispatchTouchEvent每次都会调用,就把逻辑直接写在dispatchTouchEvent的重写方法里面。
问题: 不满足事件流一致性,存在目标消费View没有接收到ACTION_UP/ACTION_CANCEL就结束了,导致焦点、按键状态或者按键事件不符合预期。

2. 发现onInterceptTouchEvent经常调用到,逻辑写在onInterceptTouchEvent里面。
问题: onInterceptTouchEvent在View自己消费情况下或者拦截之后的事件流不再会调用到,会把坑隐藏得更深【不好复现的Bug才是最难解决的Bug】。

3. 鸟枪法,dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent均会调用到逻辑。
问题: 路子太野。。。

4. 觉得自己很牛X,逻辑分散在dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent里面。
问题: 可读性差,逻辑混乱。

5. 事件消息只处理了ACTION_DOWN、ACTION_MOVE、ACTION_UP,没有对ACTION_CANCEL或者其他多点触控事件容错处理。
问题: 总会出现不常见的问题。

最佳实践

1. 明确事件流调用顺序以及拦截后的事件流。

2. dispatchTouchEvent:正常情况下不建议重写dispatchTouchEvent方法改变系统事件分发机制,可以看到,Google就没有几个类重新该方法。最多记下坐标点,但千万调用super. dispatchTouchEvent保证系统事件分发正常调用。

3. onInterceptTouchEvent:只处理拦截逻辑,在合适事件将事件流导到onTouchEvent。

4. onTouchEvent:真正处理逻辑。

5. 除常见事件处理外,一定要上剩余事件容错处理。

方法论

1. MECE法则和金字塔原理

2. SCQA 架构如何理解?

利器

1. AS源码英文翻译,参考AS翻译插件Translation

2. Android源码调试

  1. Android模拟器GenyMotion

  2. GenyMotion创建和App的build.gradle中targetSdkVersion相同API Level模拟器即可Debug对应上源码。进阶参考如何调试Android Framework?

  3. Android Studio你不知道的调试技巧

3. 关键日志输出,使用静态代理,进阶参考Android插件化原理解析——Hook机制之动态代理

4. 绘图工具

  1. ProcessOn

  2. Edraw

5. 个人主页

  1. 将纯文本转化为静态网站和博客

  2. TeXt主题模板

  3. 怎样引导新手使用 Markdown?

1. 随心所欲控制事件流【大权在手,天下我有】

2. 事件分发不再是个事,怕个球

3. 各种酷炫动画和自定义控件燥起来

4. 再也不用担心面试中尬聊事件分发

5. 借鉴上述不成熟的“渔”去爱干嘛干嘛

进阶

1. 滚动控件和按键冲突处理,界面布局滚动

2. 滑动冲突

  1. NestedScrolling机制

  2. Android NestedScrolling机制完全解析 带你玩转嵌套滑动

  3. 外部拦截法&内部拦截法

3. 手势(GestureDecetor)

参考

1. 图解 Android 事件分发机制

2. Android 响应用户屏幕手势操作

3. Android MotionEvent详解

4. android触控,先了解MotionEvent(一)

5. Android多点触控之——MotionEvent(触控事件)

6. 图解Android事件传递之View篇

7. 图解Android事件传递之ViewGroup篇

长歌

念奴娇·天丁震怒

完颜亮(金代)


天丁震怒,掀翻银海,散乱珠箔(bó)。

六出奇花飞滚滚,平填了山中丘壑。(六出:雪花六角,因用为雪花的别名。)

皓虎颠狂,素麟猖獗(chāng jué),掣(chè, 拉)断珍珠索。(皓虎:白色的老虎。素麟:白色的麒麟。)

玉龙酣战,鳞甲满天飘落。


谁念万里关山,征夫僵立,缟(gǎo)带沾旗脚。(僵立:因寒冷而冻得僵硬直立。缟带:白色的衣带。)

色映戈矛,光摇剑戟(jǐ ),杀气横戎幕。(戎幕:行军作战时的营帐。)

貔(pí)虎豪雄,偏裨(pí)英勇,共与谈兵略。(裨:副,偏,小。)

须拼一醉,看取碧空寥廓(liáo kuò)。

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

推荐阅读更多精彩内容