《Android开发艺术探索》第3章-View的事件体系读书笔记

目录

1 View 基础知识

1.1 对于 View 的理解

View 是 Android 中所有控件的基类;ViewGroup 继承了 View,这样 View 本身就可以是单个控件也可以是多个控件组成的一组控件,通过这种关系就形成了 View 树的结构。

1.2 View 的位置参数有哪些?

View 的位置由四个顶点来确定,对应 View 的四个属性:left、top、right、bottom。注意,这些坐标都是相对于 View 的父容器的,因此它是一种相对坐标。

在 Android 中,x 轴和 y 轴的正方向为右和下。

View 的这四个参数在 View 的源码中对应于 mLeft,mRight,mTop 和 mBottom 这四个成员变量,获取方式为:

public final int getLeft() {
    return mLeft;
}
public final int getRight() {
        return mRight;
}
public final int getTop() {
    return mTop;
}
public final int getBottom() {
    return mBottom;
}

从 Android 3.0 开始,新增的几个参数:x、y、translationX 和 translationY。x 是 View 左上角的横坐标,y 是 View左上角的纵坐标, translationX 是 View 左上角相对父容器横向的偏移量,translationY 是 View 左上角相对父容器纵向的偏移量。这几个参数也是相对于父容器的坐标,并且 translationX 和 translationY 的默认值是 0。

 public float getX() {
        return mLeft + getTranslationX();
 }
public float getY() {
        return mTop + getTranslationY();
}

View 在平移的过程中,top 和 left 表示的是原始左上角的位置信息,其值不会发生改变,发生改变的是 x、y、translationX 和 translationY。

1.3 MotionEvent 类中的 getX()/getY() 和 getRawX()/getRawY() 这两组方法的区别是什么?

getX()/getY() 返回的是点击事件距离当前 View 左边/顶边的距离,对应于视图坐标系(视图坐标系的原点位于父视图的左上角),是视图坐标;而 getRawX()/getRawY() 返回的是点击事件距离整个屏幕左边/顶边的距离,对应的是 Android 坐标系(Android坐标系的的原点位于屏幕的左上角),是绝对坐标。可以看一下下边的图:

需要注意的是 getLeft()getTop()getRight()getBottom()View 类中的方法。

1.4 如何获取滑动的最小距离?

ViewConfiguration.get(getContext()).getScaledTouchSlop();

当处理滑动时,可以利用这个常量来做一些过滤,用来判断是不是滑动(大于等于这个值,认为是滑动;否则,不认为是滑动),可以有更好的用户体验。在不同的设备上,这个值可能是不同的。

1.5 VelocityTracker、GestureDetector 和 Scroller 怎么使用?

VelocityTracker

VelocityTracker 用于速度追踪,包括水平和竖直方向的速度。使用方式如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    int action = event.getActionMasked();
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            if (velocityTracker == null) {
                velocityTracker = VelocityTracker.obtain();
            } else {
                velocityTracker.clear();
            }
            // 1, 把用户的动作添加给 VelocityTracker 对象
            velocityTracker.addMovement(event);
            break;
        case MotionEvent.ACTION_MOVE:
            // 把用户的动作添加给 VelocityTracker 对象
            velocityTracker.addMovement(event);
            // 2, 获取当前的滑动速度
            // 2.1, 获取速度之前必须先计算速度
            // 传入 1000,表示在 1000ms 内手指所滑过的像素数。
            velocityTracker.computeCurrentVelocity(1000);
            // 2.2 获取计算出来的速度
            float xVelocity = velocityTracker.getXVelocity();
            float yVelocity = velocityTracker.getYVelocity();
            tv.setText("xVelocity:"+xVelocity+",\nyVelocity:"+yVelocity);
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            // 3, 调用 clear 方法来重置并回收内存
            velocityTracker.recycle();
            velocityTracker = null;
            break;
        default:
            break;
    }
    return super.onTouchEvent(event);
}

需要注意的是,

addMovement 方法在哪里调用?文档是这样说的

You should call this for the initial MotionEvent.ACTION_DOWN, the following MotionEvent.ACTION_MOVE events that you receive, and the final MotionEvent.ACTION_UP. You can, however, call this for whichever events you desire.

速度的正负问题:速度 = (终点位置 - 起点位置)/ 时间段。也就是说,手指逆着坐标系的正方向滑动,所产生的速度就为负值。

computeCurrentVelocity 方法的参数含义是时间间隔,单位是毫秒,计算速度时得到的速度就是在这个时间间隔内手指在水平或竖直方向上所滑过的像素数。

GestureDectector

GestureDectector 用于手势检测,其中监听双击行为是 onTouchEvent() 方法没有的,自己使用过用于手势切换 Activity

Scroller

Scroller 用于实现 View 的弹性滑动。当使用 ViewscrollTo/scrollBy 方法进行滑动时,是瞬间完成的,没有过渡效果。而使用 Scroller 可以实现有过渡效果的滑动。但是,Scroller 本身无法让 View 弹性滑动,它必须和 View 的 computeScroll 方法配合使用才能完成这个功能。

2 View 的滑动

2.1 滑动在 Android 开发中有什么作用?

在 Android 设备上,滑动几乎是应用的标配,如下拉刷新,侧滑菜单,它们的基础都是滑动。
Android 手机由于屏幕比较小,为了给用户呈现更多的内容,可以使用滑动来隐藏和显示一些内容。

2.2 实现 View 的滑动的方法有哪些?

方式优点缺点
通过 View 本身提供的 scrollTo/scrollBy 方法专门用于 View 的滑动,可以比较方便地实现滑动效果并且不影响内部元素的单击事件只能滑动 View 的内容,并不能滑动 View 本身
使用动画一些复杂的效果必须通过动画才能实现View 动画只是对 View 的影像做操作,不能真正改变 View 的位置参数,而属性动画可以改变 View 本身的属性
改变布局参数,需要使用 MarginLayoutParamsleftMargintopMargin 属性适用于有交互的 View操作稍微麻烦

2.3 View 的 scrollTo、scrollBy 方法的区别是什么?

scrollToscrollByView 专门用于实现 View 的滑动的方法。

scrollBy 内部调用了 scrollTo 方法,

    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }

scrollBy 实现的是基于当前位置的相对滑动,当传入都为负值时会向右下角移动。

scrollTo 实现的是基于所传递参数的绝对滑动,也就是说,让 View 相对于初始的位置滑动某段距离,而 View 的初始位置是不变的。

scrollToscrollBy 都是只能改变 View 内容的位置而不能改变 View 在布局中的位置(View 的位置主要由它的四个顶点来决定,对应于 View 的四个属性:top,left,right,bottom)。这一点可以通过打印验证。

如果要实现 View 本身的滑动,就要调用它父控件的 scrollTo/scrollBy 方法。

2.4 View 内部的两个属性 mScrollX 和 mScrollY 的改变规则

mScrollX 指的是 View 的内容在横向滑动的距离,即 View 左边缘和 View 内容左边缘在水平方向的距离;
mScrollY 指的是 View 的内容在纵向滑动的距离,即 View 上边缘和 View 内容上边缘在竖直方向的距离;
mScrollXmScrollY 的单位是像素,可以分别通过 getScrollXgetScrollY 来获取;
View 左边缘在 View 内容左边缘的右边时,mScrollX 的值为正,反之,为负;
View 上边缘在 View 内容上边缘的下边时,mScrollY 的值为正,反之,为负。

2.5 实现一个跟手滑动效果,你有几种思路?

  1. layout() 方法;
  2. setTranslationX() 和 setTranslationY() 方法;
  3. ((View) getParent()).scrollBy() 方法;
  4. setLayoutParams() 方法;
  5. offsetLeftAndRight() 和 offsetTopAndBottom() 方法;
  6. ViewDragHelper。

3 弹性滑动

3.1 实现弹性滑动的共同思想是什么?

将一次大的滑动分成若干次小的滑动并在一定时间内完成,实现渐进式滑动,或者说有过渡效果的滑动。

3.2 实现弹性滑动的方法有哪些?

1,使用 Scroller
2,通过动画
3,使用延时策略

3.3 Scroller 的工作原理是什么?

这里写一个使用 Scroller实现弹性滑动的例子:

public class ScrollerLayout extends LinearLayout {
    private Scroller mScroller;
    public ScrollerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        mScroller = new Scroller(context);
    }

    public void smoothScrollTo(int destX, int destY) {
        int scrollX = getScrollX();
        int deltaX = destX - scrollX;
        // 1000 ms 内滑向 destX, 效果就是慢慢滑动
        mScroller.startScroll(scrollX, 0, deltaX, 0, 1000);

        invalidate();

    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }
}

第一步,先构造一个 Scroller 对象;

第二步,调用 ScrollerstartScroll(int startX, int startY, int dx, int dy, int duration) 方法,传入的参数:startXstartY 表示滑动的起点,dxdy 表示要滑动的距离,duration 表示滑动的时间。但是仅调用 startScroll方法并不能实现滑动,可以看 startScroll 方法的内部只是保存了传入的参数而已。

第三步,在startScroll后面,调用 invalidate 方法,这样会导致 View 重绘,在 Viewdraw 方法中又会去调用 computeScroll 方法,computeScroll 方法在 View 里是空实现的。

第四步,重写 computeScroll 方法,在里面调用 ScrollercomputeScrollOffset 方法,这个方法的作用是判断滑动是否结束了,根据经过的时间计算出要滑动到的位置,即根据时间的流逝来计算出当前的 mCurrXmCurrY(这两个值可以通过 ScrollergetCurrX()getCurrY() 方法获取到)。如果这个方法返回 false,表示弹性滑动结束了。

第五步,调用 scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); 滑动到要滑动的位置。

第六步,调用 postInvalidate 方法,再次导致 View 重绘,会继续走第四步。

总之,Scroller 正是将一次大的滑动分成若干次小的滑动并在一定时间内完成,实现渐进式滑动这一思想的代码实现。

4 View 的事件分发机制

4.1 什么是事件分发?

首先要知道,Android 的视图是由一个个 View 构成的层级视图,也就是说一个 View 里可以包含多个子 View,而每个子 View 又可以包含更多的子 View;当用户触摸屏幕产生一系列事件时,事件会由高到低,由外向内依次传递,最终把事件传递给一个具体的 View,这个传递的过程就叫做事件分发。

4.2 当一个点击事件产生后,它的传递过程遵循什么顺序?

Activity -> Window -> View,即事件总是先传给 Activity,Activity 再传递给 Window,最后 Window 再传递给顶级 View。顶级 View 接收到事件后,就会按照事件分发机制去分发事件。如果一个 View 的 onTouchEvent 返回 false,那么它的父容器的 onTouchEvent 将会被调用,依此类推。如果所有的元素都不处理这个事件,那么这个事件最终会传递给 Activity 处理,即 Activity 的 onTouchEvent 方法会被调用。

4.3 当 View 需要处理事件时,它的 OnTouchListener,OnTouchEvent 和 OnClickListener 的优先级是怎样的?

可以阅读 View 类的 dispatchTouchEvent(MotionEvent event) 方法得到答案:如果这个 View 设置了 OnTouchListener

    public interface OnTouchListener {
        boolean onTouch(View v, MotionEvent event);
    }

那么 OnTouchListeneronTouch 方法就会被回调。这时如果 onTouch 方法返回 true,那么 onTouchEvent 方法就不会被调用;如果 onTouch 方法返回 false,那么 onTouchEvent 方法会被调用。所以,View 设置的 OnTouchListener,其优先级比 onTouchEvent 方法要高。这样做的好处是方便在外界处理 View 的点击事件。具体可以看这段源码:

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

ViewonTouchEvent 方法中,如果当前设置了 OnClickListener,那么它的 onClick 方法会被调用。所以,onTouchEvent 方法的优先级比 OnCLickListener 要高。具体可以看下面的源码:

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

4.4 事件分发的三个重要方法是什么,以及它们在 Activity,ViewGroup 和 View 中的存在状态是怎样的?

三个重要方法是

public boolean dispatchTouchEvent(MotionEvent ev);
public boolean onInterceptTouchEvent(MotionEvent ev) ;
public boolean onTouchEvent(MotionEvent event);

dispatchTouchEvent 方法的作用是分发点击事件,对于 Activity 来说,这个方法总是会被调用;对于 ViewViewGroup 来说,只有当点击事件能够传递给它们时,这个方法才会被调用。需要说明的是,对于 ActivityViewGroup 来说,这个方法的作用是分发点击事件,对于 View 来说,这个方法的作用是处理点击事件。

onInterceptTouchEvent 方法的作用是用于判断是否拦截点击事件,在 ViewGroupdispatchTouchEvent 方法内部调用;这是 ViewGroup 独有的方法。需要说明的是这个方法并非每次事件到达 dispatchTouchEvent 方法都会被调用。

onTouchEvent 方法的作用是处理点击事件,在 ViewdispatchTouchEvent 方法内部调用。

对应的存在状态如下:

方法ActivityViewGroupView
dispatchTouchEvent
onInterceptTouchEvent××
onTouchEvent

可以看到,只有 ViewGroup 具有 onInterceptTouchEvent 方法,而在 ActivityView 中是没有这个方法的。

4.5 Activity 对点击事件的分发过程是什么?

看一下 ActivitydispatchTouchEvent 方法:

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

a. 当点击事件传递给 Activity 时,会通过这个方法进行分发。

b. getWindow().superDispatchTouchEvent(ev) 中的 getWindow() 的真正实现是 PhoneWindow
PhoneWindow 内部,会调用 DecorViewsuperDispatchTouchEvent 方法 (DecorViewPhoneWindow 的内部类,继承于 FrameLayout,到这里就实现了事件从 ActivityViewGroup 的传递。);

c. 若 mDecor.superDispatchTouchEvent(event) 返回 true,则 getWindow().superDispatchTouchEvent(ev) 也返回 true,则事件分发结束;

d. 若 mDecor.superDispatchTouchEvent(event) 返回 false,则 getWindow().superDispatchTouchEvent(ev) 也返回 false,会继续调用 ActivityonTouchEvent 方法,然后事件分发结束。

4.6 ViewGroup 对点击事件的分发过程是什么?

a, 当点击事件传递给ViewGroup 时,就会调用ViewGroupdispatchTouchEvent 方法;

b, 若 ViewGroup 拦截事件,即它的 onInterceptTouchEvent 方法返回 true,那么点击事件就由 ViewGroup 自己处理,和 View 对点击事件的处理过程是一样的;

c, 若 ViewGroup 不拦截事件,那么点击事件会传递给点击事件链上的子 View,这时子 ViewdispatchTouchEvent 方法会被调用。这样,事件就从 ViewGroup 传递到了下一级 View

4.7 View 对点击事件的处理过程是什么?

a, 当点击事件传递给 View 时,就会调用 View 的 dispatchTouchEvent 方法;

b, 若 View 设置了 OnTouchListener 监听事件并且 OnTouchListener 的 onTouch 方法返回 true,那么就不会调用 View 的 onTouchEvent 方法,若没有设置 OnTouchListener 或者设置了 OnTouchListener 但 onTouch 方法返回 false,则会调用 View 的 onTouchEvent 方法;

c, 若设置了 OnClickListener 事件,在 onTouchEvent 方法中,会调用 onClick 方法。

4.8 当前 View 是 DISABLED 状态,可以消耗点击事件吗?

查看 View 类的 onTouchEvent 方法:

if ((viewFlags & ENABLED_MASK) == DISABLED) {
    if (event.getAction() == 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));
}

可以得出结论:当 View 处于 DISABLED 状态时,只要它可点击(CLICKABLE 或者 LONG_CLICKABLE),那么它仍然可以消耗点击事件。

4.9 View 的 setOnLongClickListener 和 setOnClickListener 是否只能执行一个?

可以同时执行两个。当 OnLongClickListeneronLongClick 返回 false时,进行长按会执行两个方法;当返回 true 时,则只会执行长按方法。

4.10 ViewGroup 的 dispatchTouchEvent 方法的返回值如何确定?

返回值受当前 ViewonTouchEvent 方法和下级 ViewdispatchTouchEvent 方法的影响。

4.11 ViewGroup 是如何处理事件拦截的?

a, 当 ACTION_DOWN 事件到达 ViewGroup 时,ViewGroup 总是会调用自己的 onInterceptTouchEvent 方法来询问自己是否要拦截事件,从源码中可以看出:面对 ACTION_DOWN 事件时,会清除 mFirstTouchTarget(它会指向处理事件的子 View) 的值并且重置 FLAG_DISALLOW_INTERCEPT 标记。

b, 当其他事件到达 ViewGroup 时,如果 mFirstTouchTargetnull,那么 ViewGroup 会拦截该事件;如果 mFirstTouchTarget 不为 null,看一下是否设置了 FLAG_DISALLOW_INTERCEPT 标记,如果设置了,则 ViewGroup 不拦截该事件,如果没有设置,则 ViewGroup 调用 onInterceptTouchEvent(ev),这个方法返回 true,表示 ViewGroup 拦截该事件,反着,不拦截。

理解 TouchTarget 的文章:ViewGroup事件分发总结-TouchTargetAndroid View 事件处理流程 – TouchTargetmFirstTouchTarget相关设计的理解

4.12 当 ViewGroup 不拦截点击事件时,事件会向下分发交给它的子 View进行处理,那么如何判断子元素能够接收点击事件呢?

查看 dispatchTouchEvent 方法:

if (!canViewReceivePointerEvents(child)
        || !isTransformedTouchPointInView(x, y, child, null)) {
    continue;
}

条件一:子元素是否可见或者正在执行动画;
条件二:点击事件的坐标是否落在子元素的区域内。
这两个条件需要同时满足,子元素才可以接收点击事件。

相关面试题

1. 一个 LinearLayout 里放置两个 Button,说一下在这种情况下,事件是如何分发的?

5 View 的滑动冲突

5.1 什么情况下会出现滑动冲突?

在界面中只要内外两层同时可以滑动,这个时候就会出现滑动冲突。

5.2 滑动冲突的场景

场景一:外部滑动方向和内部滑动方向不一致,如 ViewPager 嵌套 ListViewScrollView
场景二:外部滑动方法和内部滑动方法一致,如 ScrollView 嵌套 ListViewScrollView
场景三:上面两种情况的嵌套,如 SlideMemu + ViewPager + ListView

5.3 解决滑动冲突的方式有哪些?

外部拦截法和内部拦截法。

5.4 什么是外部拦截法?

外部拦截法是指点击事件都会先经过父容器的拦截处理,如果父容器需要此事件就拦截,不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这种方法比较符合点击事件的分发机制。外部拦截法需要重写父容器的 onInterceptTouchEvent 方法,在内部做相应的拦截即可。

外部拦截法的典型逻辑如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            // down 事件到达时,父容器必须返回 false,即不拦截 down 事件。
            // 因为一旦父容器拦截了 down 事件,那么后续的 move 和 up 事件都会直接交由父容器处理,
            // 而没有办法传递给子元素了。
            intercepted = false;
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            if (父容器需要当前点击事件) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;
            break;
        }
        default:
        	break;
    }
    mLastInterceptX = x;
    mLastInterceptY = y;
    mLastX = x;
    mLastY = y;
    return intercepted;
}

5.5 什么是内部拦截法?

内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理。

这种方法和 Android 中的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent 方法才能正常工作,使用起来比外部拦截法稍显复杂。

所以,推荐使用外部拦截法来解决常见的滑动冲突

伪代码如下:

需要重写子元素的 dispatchTouchEvent 方法:

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if (父容器需要此类点击事件) {
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
        default:
            break;
    }
    return super.dispatchTouchEvent(event);
}

父元素需要默认拦截除了 ACTION_DOWN 以外的其他事件,在父元素中修改的代码如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        // 父容器不能拦截 down 事件,因为 down 事件不受 FLAG_DISALLOW_INTERCEPT 这个标记位的控制。
        // 如果父容器拦截了 down 事件,那么所有的事件都无法传递到子元素中去了,这样内部拦截就无从谈起了。
        return false;
    } else {
        return true;
    } 
}

参考

1.Android事件分发机制详解:史上最全面、最易懂
2.Android View 事件分发机制 源码解析 (上)- 鸿洋
对 View 的事件分发,状态处理做了详尽的说明。
3.Android ViewGroup事件分发机制 - 鸿洋
这篇文章里分析使用的源码比较旧了,但是作者分析的流程比较清晰。
4.这次,我把Android事件分发机制翻了个遍
这篇文章使用的dispatchTouchEvent 伪代码比开发艺术探索上写的更全。
5.Android事件分发机制-袁辉辉
大佬的文章,值得研读。
6.Android事件分发机制一:事件是如何到达activity的?- 一只修仙的猿
这篇文章算是一种补充了,开发艺术探索没有说这个问题。作者写了四篇文章,好好学习吧。
7.图解 Android 事件分发机制
之前找工作就是看的这篇吧。
8.Android事件分发机制抽象–钓钩模型
通过一个一个case来说明事件分发机制,非常具体。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

willwaywang6

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值