Android | View的事件分发源码分析

前言

在Android中,View主要负责界面的绘制和事件的分发、处理,它是所有控件Widgets的基类。通过源码分析View的事件分发,我们可以更加深刻地理解Android系统中View的工作原理。不仅如此,在日常的开发中,当我们遇到View事件冲突、滑动冲突时,处理起来将会游刃有余。

基础知识

当我们的手指触摸手机屏幕时,手机中的应用会对我们的触摸动作做出响应,确切地说是应用里的控件Widgets响应了触摸事件。在Android中,使用MotionEvent来描述触摸事件,我们可以通过getAction()方法来获取当前的事件类型。通常,一次手势动作会产生一系列的事件,下面列举了4个主要事件:

  • ACTION_DOWN事件 当手指第一次触摸到屏幕时将产生此事件。ACTION_DOWN事件表示一系列事件的开始。
  • ACTION_UP事件 当手指离开屏幕时将产生此事件。与ACTION_DOWN事件对应,ACTION_UP事件表示一系列事件的结束。
  • ACTION_MOVE事件 当手指有在屏幕上滑动时将产生此事件。
  • ACTION_CANCEL事件 表示当前的手势被中止了。如果一个View收到了ACTION_CANCEL事件,那么它不会再收到其它任何事件,包括ACTION_UP事件。

通过getX(), getY()方法可以获取到当前事件在屏幕上的坐标。注意,这个坐标是相对于父容器左上角的坐标。通过getRawX(), getRawY()方法可以获取到当前事件在屏幕上的原始坐标。通过前后两个ACTION_MOVE事件的坐标我们就可以知道当前手势动作的方向了。

在具体分析之前,先提一下View的事件分发的3个核心方法:

  • dispatchTouchEvent()方法 主要负责事件的分发。
  • onInterceptTouchEvent()方法 主要负责事件的拦截,ViewGroup专有。
  • onTouchEvent()方法 主要负责事件的处理。

dispatchTouchEvent()和onTouchEvent()方法都有返回值,如果返回值为true,表示当前事件被处理了或者被消费了。另外再提一个ViewGroup的requestDisallowInterceptTouchEvent()方法,子控件通过调用这个方法可以控制是否允许父容器拦截事件,它具体影响了父容器的FLAG_DISALLOW_INTERCEPT标志位。

下面我们开始具体的源码分析。

Activity的事件分发

在Android中,底层的触摸事件最开始是传递到Activity中的,从Activity的dispatchTouchEvent()方法开始分发事件。

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

从上面的代码可以知道,Activity将事件交给Window来负责分发到具体的页面布局中。如果Window的superDispatchTouchEvent()方法返回了true,即事件被消费了,那么直接退出。反之,如果没有任何一个View消费事件,那么最终Activity的onTouchEvent()方法将被调用,即Activity自己来处理事件。

Activity的Window是个抽象类,它的具体实现类是PhoneWindow。下面来看PhoneWindow的superDispatchTouchEvent()方法。

@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

PhoneWindow的superDispatchTouchEvent()方法比较简单,它直接将事件传递给了DecorView。DecorView是Android系统中所有Activity页面布局的顶级父容器。平常我们在Activity的onCreate()方法中调用setContentView()方法来设置页面布局,其实页面布局是被添加到DecorView这个父容器中。下面来看DecorView的superDispatchTouchEvent()方法。

public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

DecorView的superDispatchTouchEvent()方法比较简单,它将事件传递给了父类的dispatchTouchEvent()方法。在Android中,所有的父容器都是继承自ViewGroup,而ViewGroup继承自View。ViewGroup重写了View的dispatchTouchEvent()方法,所以事件开始从ViewGroup中进行分发。

ViewGroup的事件分发

ViewGroup的dispatchTouchEvent()方法比较复杂,我们分段来分析。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    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();
        }
        ...
    }
    ...
    return handled;
}

前面说过,ACTION_DOWN事件表示一次手势动作产生的一系列事件的起始事件。在dispatchTouchEvent()方法的开始,如果是ACTION_DOWN事件,那么ViewGroup会做一些复位、重置操作。

private void cancelAndClearTouchTargets(MotionEvent event) {
    if (mFirstTouchTarget != null) {
        ...
        for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
            resetCancelNextUpFlag(target.child);
            dispatchTransformedTouchEvent(event, true, target.child, target.pointerIdBits);
        }
        clearTouchTargets();
        ...
    }
}

ViewGroup使用mFirstTouchTarget变量来存储消费了事件的子控件。mFirstTouchTarget变量将所有消费了事件的子控件以链表的形式存储在一起。但是,通常要么没有子控件消费事件,要么只有一个子控件消费了事件。在cancelAndClearTouchTargets()方法中,如果之前有子控件消费了事件,那么ViewGroup将通过dispatchTransformedTouchEvent()方法向它们分发ACTION_CANCEL中止事件以便开始一轮新的事件传递。接着在clearTouchTargets()方法中将mFirstTouchTarget变量重置为null。

private void resetTouchState() {
    clearTouchTargets();
    resetCancelNextUpFlag(this);
    mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    mNestedScrollAxes = SCROLL_AXIS_NONE;
}

resetTouchState()方法中复位了一些标志位,包括了不允许父容器拦截事件的标志位FLAG_DISALLOW_INTERCEPT。接着往下看dispatchTouchEvent()方法。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
        ...
        // Check for interception.
        final boolean intercepted;
        if (actionMasked == MotionEvent.ACTION_DOWN
                || mFirstTouchTarget != null) {
            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;
        }
        ...
        // Check for cancelation.
        final boolean canceled = resetCancelNextUpFlag(this)
                || actionMasked == MotionEvent.ACTION_CANCEL;
        ...
}

这段代码的主要作用是检查ViewGroup是否拦截了事件、是否中止了事件传递。当ACTION_DOWN事件发生或者mFirstTouchTarget变量不为null,即之前有子控件消费了事件时,检查ViewGroup是否拦截事件。如果子控件没有调用ViewGroup的requestDisallowInterceptTouchEvent()方法来设置FLAG_DISALLOW_INTERCEPT标志位,那么ViewGroup将调用onInterceptTouchEvent()方法来决定是否拦截事件。

ViewGroup的onInterceptTouchEvent()方法默认返回false,即父容器默认是不拦截事件的。在平常的开发中,我们可以根据需要重写ViewGroup的onInterceptTouchEvent()方法来决定是否拦截事件。

如果ViewGroup拦截了ACTION_DOWN事件,那么mFirstTouchTarget变量将为null。根据上面的代码可以知道,当后续的ACTION_MOVE、ACTION_UP等其它事件到来时,intercepted直接为true,ViewGroup拦截事件。这种情况下所有的事件都将由ViewGroup自己处理,子控件一个事件也接收不到。所以,在平常的开发中一般不会让ViewGroup拦截ACTION_DOWN事件。

如果ViewGroup不拦截ACTION_DOWN事件,但是没有子控件消费ACTION_DOWN事件,那么mFirstTouchTarget变量将为null。同上,当后续的ACTION_MOVE、ACTION_UP等其它事件到来时,intercepted直接为true,ViewGroup拦截事件。这种情况下子控件只接收到一个ACTION_DOWN事件,不会接收到后续的其它事件。

接着往下看dispatchTouchEvent()方法。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
        ...
        // 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;
        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) {
                    ...
                    final View[] children = mChildren;
                    for (int i = childrenCount - 1; i >= 0; i--) {
                        ...
                        if (!canViewReceivePointerEvents(child)
                                || !isTransformedTouchPointInView(x, y, child, null)) {
                            ev.setTargetAccessibilityFocus(false);
                            continue;
                        }
                        ...
                        resetCancelNextUpFlag(child);
                        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();
                            newTouchTarget = addTouchTarget(child, idBitsToAssign);
                            alreadyDispatchedToNewTouchTarget = true;
                            break;
                        }
                        ...
                    }
                    if (preorderedList != null) preorderedList.clear();
                }
                ...
            }
        }
        ...
}

这段代码的主要作用是将事件分发到可以处理事件的子控件。当事件没有被中止和拦截时,如果是ACTION_DOWN事件,那么ViewGroup开始遍历子控件进行事件分发。ViewGroup主要通过两个方法来判断子控件是否可以接收事件,canViewReceivePointerEvents()方法判断子控件的可见性和是否有动画,isTransformedTouchPointInView()方法判断事件是否落在子控件的布局区域中。

当子控件满足条件时,ViewGroup将调用dispatchTransformedTouchEvent()方法将事件传递给子控件。如果dispatchTransformedTouchEvent()方法返回了true,即子控件消费了事件,那么将调用addTouchTarget()方法将子控件设置给mFirstTouchTarget变量,然后退出循环。如果没有一个子控件消费了事件,那么mFirstTouchTarget变量仍然为null。

接着往下看dispatchTouchEvent()方法。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
        ...
        // Dispatch to touch targets.
        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;
                    }
                    if (cancelChild) {
                        if (predecessor == null) {
                            mFirstTouchTarget = next;
                        } else {
                            predecessor.next = next;
                        }
                        target.recycle();
                        target = next;
                        continue;
                    }
                }
                predecessor = target;
                target = next;
            }
        }
        ...
}

当ViewGroup一开始就拦截了ACTION_DOWN事件或者没有子控件消费ACTION_DOWN事件时,mFirstTouchTarget变量为null,ViewGroup将通过dispatchTransformedTouchEvent()方法将事件传递给自己处理。反之,如果有子控件消费了ACTION_DOWN事件,并且后续事件没有被ViewGroup拦截,那么ViewGroup将直接通过mFirstTouchTarget变量进行事件分发。

如果有子控件消费了ACTION_DOWN事件,即mFirstTouchTarget变量不为null,但是后续事件被ViewGroup拦截了,此时cancelChild为true,ViewGroup将通过dispatchTransformedTouchEvent()方法向子控件分发ACTION_CANCEL事件,之后mFirstTouchTarget将被置为null。当后续事件到来时,ViewGroup将自己处理拦截的事件了。

接着看下dispatchTransformedTouchEvent()方法。

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;
    }
    ...
    // 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;
}

当参数cancel为true或者是ACTION_CANCEL事件时,ViewGroup通过dispatchTransformedTouchEvent()方法传递ACTION_CANCEL事件给子控件或者ViewGroup自身。反之,将传递其它事件。

如果参数child为null,那么将调用ViewGroup父类的dispatchTouchEvent()方法,即ViewGroup自己处理事件。如果child不为null,那么将调用child,即View的dispatchTouchEvent()方法,即子控件处理事件。此时,事件分发由ViewGroup传入了View。

View的事件分发

因为ViewGroup也继承自View,所以要特别说明一下这部分提到的View特指子控件,不包括ViewGroup。View的事件处理相对来说就比较简单了,来看下View的dispatchTouchEvent()方法。

public boolean dispatchTouchEvent(MotionEvent event) {
    ...
    boolean result = false;
    ...
    if (onFilterTouchEventForSecurity(event)) {
        ...
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo;
        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;
}

当View是enabled状态并且设置了OnTouchListener时,View将先调用OnTouchListener的onTouch()方法。如果onTouch()方法返回了true,那么将不再调用View的onTouchEvent()方法。可见,View的OnTouchListener的优先级高于onTouchEvent()方法。

如果View没有设置OnTouchListener,那么onTouchEvent()方法将被调用。最后我们来看下onTouchEvent()方法。

public boolean onTouchEvent(MotionEvent event) {
    ...
    if ((viewFlags & ENABLED_MASK) == DISABLED) {
        if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
            setPressed(false);
        }
        // A disabled view that is clickable still consumes the touch
        // events, it just doesn't respond to them.
        return (((viewFlags & CLICKABLE) == CLICKABLE
                || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
    }
    ...
    if (((viewFlags & CLICKABLE) == CLICKABLE ||
            (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
            (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                    ...
                    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)) {
                                performClick();
                            }
                        }
                    }
                    ...
                }
                mIgnoreNextUpEvent = false;
                break;
            ...
        }

        return true;
    }

    return false;
}

当View是disabled状态时,只要View是clickable的,onTouchEvent()方法将返回true。如果View是enabled状态并且是clickable的,onTouchEvent()方法默认也返回true。这说明,默认情况下只要有事件传递到了View并且View是clickable的,那么事件就会被消费。

阅读View的源码可以发现,默认情况下View不是clickable的,即默认情况下View没有消费事件。ViewGroup继承自View,但是ViewGroup没有重写View的onTouchEvent()方法,所以默认情况下ViewGroup也没有消费事件。

通过View的setClickable()、setLongClickable()和setContextClickable()方法可以设置相应的clickable状态。特别要提一下的是,平常我们通过View的setOnClickListener()方法设置监听器时其实也设置了View的clickable状态。

最后,在ACTION_UP事件时View将调用performClick()方法。

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

    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    return result;
}

在performClick()方法中,如果View设置了OnClickListener,那么将调用OnClickListener的onClick()方法。

到这里View的事件分发源码分析就结束了。

总结

通过对View的事件分发的源码进行分析,我们可以总结出以下一些结论:

  • 触摸事件的传递顺序是:Activity -> Window -> DecorView -> 具体的页面布局容器 -> 具体的子控件。如果没有View消费事件,那么事件将逐级返回,最终Activity的onTouchEvent()方法会被调用。
  • ViewGroup的onInterceptTouchEvent()方法默认返回false,即父容器默认是不拦截事件的。
  • 如果父容器拦截了ACTION_DOWN事件,那么它的子控件一个事件也接收不到。
  • 如果一个View没有消费ACTION_DOWN事件,那么后续的ACTION_MOVE、ACTION_UP等其它事件它都接收不到了。
  • 如果一个View消费了ACTION_DOWN事件,并且后续事件没有被父容器拦截,那么父容器会将后续事件直接传递给此View。
  • 如果一个View消费了ACTION_DOWN事件,但是后续事件被父容器拦截了,那么这个View只会再收到一个ACTION_CANCEL事件。
  • 默认情况下,ViewGroup和View都是不消费事件的。
  • OnTouchListener的onTouch()方法优先级高于onTouchEvent()方法。

例子

这里举了两个简单的例子。例子代码地址:https://github.com/chongyucaiyan/ViewDemo

第一个例子主要用来了解正常情况下触摸事件的传递顺序。

demo01页面.png

如上图所示,布局很简单,垂直方向的LinearLayout布局里放置了一个TextView和一个Button。代码里主要是在View的事件分发核心方法里加了日志打印,PhoneWindow和DecorView没办法加日志就没加了。首先,在TextView上触发一次手势,打印的日志如下图所示:

demo01页面TextView日志.png

如上图所示,触摸事件从Demo01Activity传递到MyLinearLayout01父容器,最后传递到MyTextView01子控件。同时我们可以看到,默认情况下,父容器不拦截事件,父容器和子控件不消费事件。下面简要分析一下。

ACTION_DOWN事件发生后,事件先被传递到Demo01Activity的dispatchTouchEvent()方法。接着事件被传递到MyLinearLayout01的dispatchTouchEvent()方法。MyLinearLayout01的onInterceptTouchEvent()方法返回false,即父容器默认不拦截事件。接着事件被传递到MyTextView01的dispatchTouchEvent()方法。此时,MyTextView01调用onTouchEvent()方法来处理事件。MyTextView01的onTouchEvent()方法返回false,即子控件默认不消费事件。这时事件返回,MyLinearLayout01调用onTouchEvent()方法来处理事件。MyLinearLayout01的onTouchEvent()方法返回false,即父容器默认不消费事件。事件接着返回,最终Demo01Activity的onTouchEvent()方法被调用。

因为MyTextView01和MyLinearLayout01都没有消费ACTION_DOWN事件,所以后续的ACTION_MOVE、ACTION_UP事件它们都接收不到了。

然后,在Button上触发一次手势,打印的日志如下图所示:

demo01页面Button日志.png

如上图所示,默认情况下MyButton01消费了事件,代码里并没有给MyButton01设置OnClickListener。这是因为Android应用默认使用的theme之中设置了Button的clickable属性为true,造成Button默认消费事件。

MyButton01消费了ACTION_DOWN事件,所以MyLinearLayout01和Demo01Activity的onTouchEvent()方法都不会被调用了。并且MyButton01可以正常接收到后续的ACTION_MOVE、ACTION_UP事件。

第二个例子主要用来了解拦截情况下触摸事件的传递情况。

布局更简单,FrameLayout里放置了一个Button。代码里在MyFrameLayout02中重写了onInterceptTouchEvent()方法,对ACTION_MOVE事件进行拦截。

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted;
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            intercepted = false;
            break;

        case MotionEvent.ACTION_MOVE:
            intercepted = true;
            break;

        case MotionEvent.ACTION_UP:
            intercepted = false;
            break;

        default:
            intercepted = super.onInterceptTouchEvent(event);
            break;
    }
    Log.i(TAG, "onInterceptTouchEvent(), " + Utils.getActionString(event) + ", intercepted = " + intercepted);
    return intercepted;
}

然后,在Button上触发一次手势,打印的日志如下图所示:

demo02页面Button日志.png

如上图所示,ACTION_DOWN事件被MyButton02正常消费,当ACTION_MOVE事件发生时,MyFrameLayout02对事件进行拦截。此时,MyButton02只再接收到一个ACTION_CANCEL事件,其它事件都接收不到了。

参考

  • Android 7.1.1 (API level 25)
  • https://developer.android.com/reference/android/view/View.html
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值