android自定义View学习二之事件分发

android 自定义View学习一之onMeasure和onLayout

这里我们开始第二篇学习:android的事件分发

本文虽然有不少源码,主要看注释的地方就行,一些细节可以忽略,暂时没找到如何让注释高亮的方法,导致有些源码看起来不太友善。

我们这里主要是讲单点触摸:
在这里插入图片描述
关键点:move事件会多次触发


为什么这么说呢?我们往下看,等会会解开这个疑惑?
一般情况下的分发逻辑:

我们在屏幕上点击一下屏幕,首先是activity的dispatchTouchEvent先响应:

public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        	//这是一个空方法,不用理会
            onUserInteraction();
        }
        //android里面window只有一个实现就是PhoneWindow
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        return onTouchEvent(ev);
    }

PhoneWindow的dispaTouchEvent

 @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
    	//mdecor就是我们的decorView
        return mDecor.superDispatchTouchEvent(event);
    }

decorView是继承自FrmaeLayout的,但是FrameLayout并没有实现dispaTouchEvent,所以直接到了ViewGroup里面去了。

class ViewGroup extends View ,但是Viewgroup并没有去重写它的onTouchEvent的,因为一般情况下它只负责分发事件。

我们对同一个View进行onClickView和onTouch监听:

        btn_click.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.e(TAG, "onClick");
            }
        });

        btn_click.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                Log.e(TAG, "onTouch: " + event.getAction());

                return true;
            }
        });

当onTouch返回为false的时候两者都打印了log,onTouch返回为true的时候onclick没打印。

我们直接到View的dispatchTouchEvent方法里面看:

我们是看一个正常流程,一些其他因素暂时抛开:关键代码是:

  if (onFilterTouchEventForSecurity(event)) {
            if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
                result = true;
            }
            //noinspection SimplifiableIfStatement
            **//关键代码:看这里这个判断方法就行
            //正常流程,第一个down事件进来,ENABLED肯定是正常的。
            //所以决定这个if判断成功或者失败主要看li.mOnTouchListener.onTouch(this, event))**
            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;
            }
        }

mListenerInfo肯定不为空,因为我们实现了onTouch方法,如果你不实现result就是默认值fasle

 public void setOnTouchListener(OnTouchListener l) {
        getListenerInfo().mOnTouchListener = l;
    }
  @UnsupportedAppUsage
    ListenerInfo getListenerInfo() {
        if (mListenerInfo != null) {
            return mListenerInfo;
        }
        mListenerInfo = new ListenerInfo();
        return mListenerInfo;
    }

你只要实现了onTouch方法mListenerInfo就一定不为空

(mViewFlags & ENABLED_MASK) == ENABLED,这里我就不过多解释,从字面意思:ENABLED,是否激活,判断你这个是否可点击是否可见等,你是一个正常View这里就通过。

那么也就是这个result是false还是true完全是看你onTouch的返回值了。

//如果resutl是false才会进入onTouchEvent方法
 if (!result && onTouchEvent(event)) {
                result = true;
            }

我们的点击事件是action_up,直接到:

 if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                   
                  ...
                                if (!post(mPerformClick)) {
                                	//直接找它
                                    performClickInternal();
                                }
                            }
                        }

                   ...
 private boolean performClickInternal() {
        // Must notify autofill manager before performing the click actions to avoid scenarios where
        // the app has a click listener that changes the state of views the autofill service might
        // be interested on.
        //点击播放音效
        notifyAutofillManagerOnClick();
		//跟进去
        return performClick();
    }
 public boolean performClick() {
        // We still need to call this method to handle the cases where performClick() was called
        // externally, instead of through performClickInternal()
        notifyAutofillManagerOnClick();

        final boolean result;
        final ListenerInfo li = mListenerInfo;
        //我们上面说了li不过空
        if (li != null && li.mOnClickListener != null) {
            playSoundEffect(SoundEffectConstants.CLICK);
            //点击事件
            li.mOnClickListener.onClick(this);
            //直接给你返回true,所以onclik没有返回值
            result = true;
        } else {
            result = false;
        }

        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);

        notifyEnterOrExitForAutoFillIfNeeded(true);

        return result;
    }

从上面的流程我们知道了为什么onTouch返回false的时候onclick才能执行

接下来进入我们的主菜,事件分发
当我一个布局ViewPager,里面是三个子界面Fragment,界面就是一个ListView。
Viewpager可以左右滑动,ListView可以上下滑动,我们知道即可以上下滑也可以左右滑。

但是当我继承了ViewPager,重写了:

public boolean onInterceptTouchEvent(MotionEvent event) {
	return false}

这里我返回true或者false,都会有问题,要不抢了listView要不抢了viewPager的滑动事件,很明显,google帮我们处理了,这里我们就要学习如何自己处理

当我们的手机点击屏幕的时候,事件最开始是在decorView进行分发,它是一个ViewGroup
所以我们直接找ViewGroup的dispatchTouchEvent

正常流程还是来到这个if判断:

//进入if
if (onFilterTouchEventForSecurity(ev)){
	//如果是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;
            //mFirstTouchTarget 这个时候为空,什么时候赋值等会会说
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                    //第一次down进来 disallowIntercept:
                    //1、判断事件是否拦截,如果拦截intercepted == true
                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;
            }
}

我们先看第一种情况:拦截
拦截:相当于你就是最后一个View了,分发或者处理。

	//如果是拦截,上面的if进不去,直接来到这里
 	// Dispatch to touch targets.
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                //进来这里,我们看一下dispatchTransformedTouchEvent
                //第三个参数null,因为拦截不传给子View
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {
                ...
            }
    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        final boolean handled;

		...
        if (newPointerIdBits == oldPointerIdBits) {
            if (child == null || child.hasIdentityMatrix()) {
                if (child == null) {
                	//看这里,调用它的super,也就是View,View刚才我们分析过了
                	//如果View的onTouch和onClick都不处理,则返回给Activity
                    handled = super.dispatchTouchEvent(event);
                } else {
                  ...
                }
                return handled;
            }
            transformedEvent = MotionEvent.obtain(event);
        } else {
            transformedEvent = event.split(newPointerIdBits);
        }
		...
        return handled;
    }

所以为什么onInterecepTouchEnvet返回true的时候listView不能滑动了

下面我们继续跟进不拦截的情况:

//走一个正常流程,那么你的down的时候cancled肯定是false,跟着我的代码注释走
if (!canceled && !intercepted) {
                // If the event is targeting accessibility 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;
                 //如果是down事件才分发      
                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;
					//newTouchTarget 局部变量,刚定义的为null,如果你有子View则进去
                    if (newTouchTarget == null && childrenCount != 0) {
                    	**//解释一下下面的代码逻辑:拿出每一个子View的x、y,判断点击范围是否在
                    	//你的响应范围,同时判断你的这个View是否是可点击的,且判断animation动画
                    	//中的平移你的原来的x、y,判断的是你原来的位置,一直循环递归下去,直到最底层
                    	//符合要求的View才返回,同时给newTouchTarget 赋值,记住你这个View
                    	//本来这里还有一个Y,就是我们在xml里面可以设置它在这里的循环优先级
                    	//我的这个30的源码里面已经没有了**

                        final float x =
                                isMouseEvent ? ev.getXCursorPosition() : ev.getX(actionIndex);
                        final float y =
                                isMouseEvent ? ev.getYCursorPosition() : 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;
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            final int childIndex = getAndVerifyPreorderedIndex(
                                    childrenCount, i, customOrder);
                            final View child = getAndVerifyPreorderedView(
                                    preorderedList, children, childIndex);
                            if (!child.canReceivePointerEvents()
                                    || !isTransformedTouchPointInView(x, y, child, null)) {
                                continue;
                            }
							//赋值 第一个符合要求的View,它也可能是一个ViewGrop
                            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进行再次递归循环
                            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真正给newTouchTarget 赋值
                                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();
                    }

newTouchTarget 赋值过程,这个很重要,下面要用到。

  private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
        final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
        target.next = mFirstTouchTarget;
        mFirstTouchTarget = target;
        return target;
    }

mFirstTouchTarget = target; != null
target.next = mFirstTouchTarget; == null
alreadyDispatchedToNewTouchTarget = true;

 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;
                //target现在不为空了
                while (target != null) {
               		//next  == null,记住这里因为 target.next == null
                    final TouchTarget next = target.next;
					//alreadyDispatchedToNewTouchTarget  == true
					//target == newTouchTarget == true 所以down事件到这里就结束了
                    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;
                    //next == null,所以target现在是为空了,这个循环只会走一次,
                    //指的单点触控,如果是多点的话会执行多次
                    target = next;
                }
            }

流程走到这里,我们的down事件也找到真正的消费的View了。

接下来我们分析move事件,由于我们不拦截,newTouchTarget 不为空,move事件不再进入分发的判断if里面了,最终还是跑到上面的代码里,我把上面的代码赋值一份下来:

 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;
                //target现在不为空了
                while (target != null) {
               		//next  == null,记住这里因为 target.nextnull
                    final TouchTarget next = target.next;
					//alreadyDispatchedToNewTouchTarget  == true
					//target == newTouchTarget == true 所以down事件到这里就结束了


					**//move事件alreadyDispatchedToNewTouchTarget局部变量又变成fasle**
                    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                        handled = true;
                    } else {
                    	**//move事件走这里  cancelChild 正常情况是fasle**
                        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                || intercepted;
                        **//target.child就是刚才down事件的view,全局变量存着呢
                        //递归找到最终的子View传递给它处理**
                        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;
                    //next == null,所以target现在是为空了,这个循环只会走一次,
                    //指的单点触控,如果是多点的话会执行多次
                    target = next;
                }
            }

move事件不拦截的话没什么好说的,之前的down事件是被谁消费了,直接又指给它,下面我们来分析一下move事件的拦截。

move事件–>处理事件冲突

处理事件冲突我们分为两种方法:内部拦截法和外部拦截法对应子View和父View谁处理

我们先学习内部拦截法:子View处理

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
			//父View拦截,子View处理
	      return true;

我们看一下这个代码:

 final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {
                	//intercepted你要拦截还得看disallowIntercept允许你拦截不
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                    intercepted = false;
                }
   @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
		
        if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
            // We're already in this state, assume our ancestors are too
            return;
        }
		//下面可以看出是false或者ture,我们对disallowIntercept的返回直接决定
        if (disallowIntercept) {
            mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
        } else {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        }

        // Pass it up to our parent
        if (mParent != null) {
            mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
        }
    }

所以子View调用requestDisallowInterceptTouchEvent方法是可以让父View拦截不了你

public class MyListView extends ListView {

    public MyListView(Context context) {
        super(context);
    }

    public MyListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

//     内部拦截法:子view处理事件冲突
    private int mLastX, mLastY;

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
            	//down的时候不拦截,那么down就到了listView
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
            	//记录滑动的x和y
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                //如果是竖直方向则不进去,如果是水平方向取消请求不拦截,让父View正常去拦截
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;

            }
            default:
                break;
        }
		//滑动事件有多个,所以一开始是0,之后是从listView原来的x、y点开始计算
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }
}

实际上上面这样处理还是解决不了问题:

 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.
                //down事件触发的时候会把所有参数清零
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }

            // Check for interception.
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                //down事件触发的时候这里if百分百会进去,所以父View拦截成功了
                if (!disallowIntercept) {
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                    intercepted = false;
                }

所以我们还需要额外的处理:

    public boolean onInterceptTouchEvent(MotionEvent event) {
    	//down的时候不拦截,让listView拿到焦点
        if (event.getAction() == MotionEvent.ACTION_DOWN){
            super.onInterceptTouchEvent(event);
            return false;
        }
        return true;

还是之前的代码,我把之前的注释去掉便于关注核心代码:

//上一次move事件已经mFirstTouchTarget 设置 == null
 if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                //直接给自己的View进行分发,跟down事件一样
                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 {
                    	//listView不请求父View不拦截了之后父View拦截intercepted == true
                        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                || intercepted;
                         //上面代码走完cancelChild == true
                         //dispatchTransformedTouchEvent方法里面的处理看下面我重新贴代码
                        if (dispatchTransformedTouchEvent(ev, cancelChild,
                                target.child, target.pointerIdBits)) {
                            //handle == true,有人处理事件不会返回给activity    
                            handled = true;
                        }
                        if (cancelChild) {
                        	//局部变量predecessor == null
                            if (predecessor == null) {
                            	//将mFirstTouchTarget 设置为空,next等于空上面分析过了
                                mFirstTouchTarget = next;
                            } else {
                                predecessor.next = next;
                            }
                            //又将target也设置为空
                            target.recycle();
                            target = next;
                            continue;
                        }
                    }
                    predecessor = target;
                    target = next;
                }
            }

dispatchTransformedTouchEvent方法

//保存旧的action
 final int oldAction = event.getAction();
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
        	//cancel == true进入这里 将event设置为cancle
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            //上面肯定会成功进行分发,所以handle返回true
            return handled;
        }

流程走到这里子View处理就结束了,我们就可以左右滑也可以上下滑了。

父view可以抢子View的事件,而子View不可以抢父View的事件,这也是我们上下滑的时候可以进行左右滑,左右滑的时候你不能上下滑。

下面我们来看父View处理的拦截法,跟刚才子View的处理几乎一摸一样

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mLastX = (int) event.getX();
                mLastY = (int) event.getY();
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                //左右滑的话就拦截,上下滑就不拦截
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    return true;
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }

        return super.onInterceptTouchEvent(event);

    }

看起来父View处理就是比子View给力啊,省事。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值