Android的Tween动画的实现框架

在写程序的时候遇到了Tween动画几个问题:

1,  执行动画的时候点击事件仍然在动画开始的位置?

2,  XXXAnimation的构造参数里面的值具体是什么意思?

3,  平移动画中fromXValue和toXValue旋转动画中fromDegrees和toDegrees取负值有什么不同??(相信很多人也有疑惑)

4,  RotateAnimation的int pivotXType, float pivotXValue, int pivotYType, float pivotYValue四个参数是怎么确定旋转原点的?确定的旋转原点在哪里?

Android动画分为:

Tween Animation  View动画也叫补间动画

Drawable Animation  也叫Frame 帧动画

Property Animation(3.0以后加入)

主要研究Tween动画

我在写程序的时候经常由于参数设置不当(主要是从多少度旋转为多少度,有时是负的度数)得不到想要的效果。因此打算把动画的实现框架研究一下。

研究之前请看这篇文章:Android中图像变换Matrix的原理  了解一下Matrix矩阵的相关知识。明白图形的各种转换就是要得到对应的变换矩阵。


首先说一下动画的大概绘制框架过程,不然由于我写的比较乱可能看晕了。

调用startAnimation会设置与View关联的animation,然后会重绘视图,重绘视图的时候调用到drawChild,这时获取与View绑定的Animation,不为null了,只要动画时间没有结束就会通过绘制的时间获得变换矩阵,然后将画布原点平移到视图的左上角?(是不是这样?)绘制新的一帧。绘制完又会重绘,然后获取新的一帧的转换矩阵…..循环下去,直到动画结束就不再重绘视图了。

回到View的onDraw函数里面,onDraw函数做了如下工作。

1. Draw the background

2. If necessary, save the canvas' layers toprepare for fading

3. Draw view's content

4. Draw children

5. If necessary, draw the fading edges andrestore layers

6. Draw decorations (scrollbars forinstance)

当是ViewGroup的时候会执行第四步,dispatchDraw(canvas); 

[java]  view plain copy
  1. @Override  
  2. protected void dispatchDraw(Canvas canvas) {  
  3. // LayoutAnimationController比较熟悉,是让ViewGroup的子控件有动画效果,以前没发现竟然也是在这里发生的。  
  4. final LayoutAnimationController controller = mLayoutAnimationController;  
  5.         ...  
  6.        // We will draw our child's animation, let's reset the flag  
  7. //下面对子View动画进行处理。  
  8.         mPrivateFlags &= ~DRAW_ANIMATION;  
  9.         mGroupFlags &= ~FLAG_INVALIDATE_REQUIRED;  
  10.         boolean more = false;  
  11.         final long drawingTime = getDrawingTime();  
  12.         if ((flags & FLAG_USE_CHILD_DRAWING_ORDER) == 0) {  
  13.             for (int i = 0; i < count; i++) {  
  14.                 final View child = children[i];  
  15.                 if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {  
  16.                     more |= drawChild(canvas, child, drawingTime);  
  17.                 }  
  18.             }  
  19.         } else {  
  20.             for (int i = 0; i < count; i++) {  
  21.                 final View child = children[getChildDrawingOrder(count, i)];  
  22.                 if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {  
  23.                     more |= drawChild(canvas, child, drawingTime);  
  24.                 }  
  25.             }  
  26.         }  
  27.         ...  
  28.     }  

肯定会执行到drawChild(canvas, child, drawingTime); 在该函数顾名思义就是绘制子控件。

[java]  view plain copy
  1. protected boolean drawChild(Canvas canvas, View child, long drawingTime) {  
  2.         boolean more = false;  
  3.   
  4.         final int cl = child.mLeft;  
  5.         final int ct = child.mTop;  
  6.         final int cr = child.mRight;  
  7.         final int cb = child.mBottom;  
  8.   
  9.         final int flags = mGroupFlags;  
  10.   
  11.         Transformation transformToApply = null;  
  12. //取得该View绑定的动画  
  13.         final Animation a = child.getAnimation();  
  14.         boolean concatMatrix = false;  
  15. //如果该View有了动画那么就会进入if判断执行,没有动画就仅仅绘制该控件。  
  16.         if (a != null) {  
  17.             if (mInvalidateRegion == null) {  
  18.                 mInvalidateRegion = new RectF();  
  19.             }  
  20.             final RectF region = mInvalidateRegion;  
  21.   
  22.             final boolean initialized = a.isInitialized();  
  23.             if (!initialized) {  
  24. //调用Animation的初始化函数,在这面会解析Animation的各个参数。对不同的xy类型和值进行转换。  
  25.                 a.initialize(cr - cl, cb - ct, getWidth(), getHeight());  
  26.                 a.initializeInvalidateRegion(00, cr - cl, cb - ct);  
  27.                 child.onAnimationStart();  
  28.             }  
  29.   
  30.             if (mChildTransformation == null) {  
  31.                 mChildTransformation = new Transformation();  
  32.             }  
  33. //取得变换(平移,旋转或缩放等)信息,传进去的drawingTime代表了绘制的时间毫秒值,取得的结果放进mChildTransformation里面。  
  34. //mChildTransformation是一个图形转换信息的类,包含了一个矩阵Matrix,和alpha值。Matrix就是图形转换矩阵。  
  35. //more是该函数的返回值,查看代码很容易分析出来:如果动画没有结束就一只返回true,知道动画结束返回false。  
  36.             more = a.getTransformation(drawingTime, mChildTransformation);  
  37.             transformToApply = mChildTransformation;  
  38. //默认返回true  
  39.             concatMatrix = a.willChangeTransformationMatrix();  
  40. //more==true进入循环  
  41.             if (more) {  
  42.             //more==true when the animation is not over  
  43.                 if (!a.willChangeBounds()) {  
  44.                     if ((flags & (FLAG_OPTIMIZE_INVALIDATE | FLAG_ANIMATION_DONE)) ==  
  45.                             FLAG_OPTIMIZE_INVALIDATE) {  
  46.                         mGroupFlags |= FLAG_INVALIDATE_REQUIRED;  
  47.                     } else if ((flags & FLAG_INVALIDATE_REQUIRED) == 0) {  
  48.                         mPrivateFlags |= DRAW_ANIMATION;  
  49. //动画没有结束就会不停调用invalidate函数对动画view进行重绘  
  50.                         invalidate(cl, ct, cr, cb);  
  51.                     }  
  52.                 } else {  
  53.                     a.getInvalidateRegion(00, cr - cl, cb - ct, region, transformToApply);  
  54.                     mPrivateFlags |= DRAW_ANIMATION;  
  55.                     final int left = cl + (int) region.left;  
  56.                     final int top = ct + (int) region.top;  
  57. //动画没有结束就会不停调用invalidate函数对动画view进行重绘  
  58.                     invalidate(left, top, left + (int) region.width(), top + (int) region.height());  
  59.                 }  
  60.             }  
  61.         } else if ((flags & FLAG_SUPPORT_STATIC_TRANSFORMATIONS) ==  
  62.                 FLAG_SUPPORT_STATIC_TRANSFORMATIONS) {  
  63.             if (mChildTransformation == null) {  
  64.                 mChildTransformation = new Transformation();  
  65.             }  
  66.             final boolean hasTransform = getChildStaticTransformation(child, mChildTransformation);  
  67.             if (hasTransform) {  
  68.                 final int transformType = mChildTransformation.getTransformationType();  
  69.                 transformToApply = transformType != Transformation.TYPE_IDENTITY ?  
  70.                         mChildTransformation : null;  
  71.                 concatMatrix = (transformType & Transformation.TYPE_MATRIX) != 0;  
  72.             }  
  73.         }  
  74.         ...  
  75.         child.computeScroll();  
  76. //分析简单情况下视图都是在可视范围内,sx和sy应该等于0??  
  77.         final int sx = child.mScrollX;  
  78.         final int sy = child.mScrollY;  
  79.         ...  
  80.         final boolean hasNoCache = cache == null;  
  81.         final int restoreTo = canvas.save();  
  82.         if (hasNoCache) {  
  83.             canvas.translate(cl - sx, ct - sy);  
  84.         } else {  
  85.         //here translate the canvas's zuobiao???? @auth:qhyuan  
  86. //将画布平移到(cl,ct)点,cl和ct是childView的左上角到屏幕(0,0)点的距离。这点非常重要,在重绘动画的时候画布的左边在发生变化!!!并不是一直在屏幕的(0,0)点。  
  87. //平移后的坐标体系和最初不一样,一般情况下坐标的原点会移动至View的左上角。  
  88.             canvas.translate(cl, ct);  
  89.             if (scalingRequired) {  
  90.                 // mAttachInfo cannot be null, otherwise scalingRequired == false  
  91.                 final float scale = 1.0f / mAttachInfo.mApplicationScale;  
  92.                 canvas.scale(scale, scale);  
  93.             }  
  94.         }  
  95.         float alpha = 1.0f;  
  96.         if (transformToApply != null) {  
  97.             if (concatMatrix) {  
  98.                 int transX = 0;  
  99.                 int transY = 0;  
  100.                 if (hasNoCache) {  
  101.                     transX = -sx;  
  102.                     transY = -sy;  
  103.                 }  
  104. //两个参数为0  
  105.                 canvas.translate(-transX, -transY);  
  106. // transformToApply是从Animation取得的转换信息类,取得变换矩阵。这个变换矩阵在不同时刻都不一样,因为传过去的drawingTime不一样。  
  107. //对画布进行变换矩阵转换,实现动画效果。  
  108.                 canvas.concat(transformToApply.getMatrix());  
  109.                 canvas.translate(transX, transY);  
  110.                 mGroupFlags |= FLAG_CLEAR_TRANSFORMATION;  
  111.             }  
  112.         ...  
  113.             if (alpha < 1.0f && hasNoCache) {  
  114.                 final int multipliedAlpha = (int) (255 * alpha);  
  115.                 if (!child.onSetAlpha(multipliedAlpha)) {  
  116.                     canvas.saveLayerAlpha(sx, sy, sx + cr - cl, sy + cb - ct, multipliedAlpha,  
  117.                             Canvas.HAS_ALPHA_LAYER_SAVE_FLAG | Canvas.CLIP_TO_LAYER_SAVE_FLAG);  
  118.                 } else {  
  119.                     child.mPrivateFlags |= ALPHA_SET;  
  120.                 }  
  121.             }  
  122.         } else if ((child.mPrivateFlags & ALPHA_SET) == ALPHA_SET) {  
  123.             child.onSetAlpha(255);  
  124.         }  
  125.         ...  
  126.         return more;  
  127.     }  

接下来看一下Animation类:动画类里面有两个重要的函数protected void applyTransformation(float interpolatedTime,Transformation t)

和public boolean getTransformation(long currentTime, TransformationoutTransformation);

Transformation类包含了一个变换矩阵和alpha值。

applyTransformation函数:传入一个差值时间,会填充一个Transformation类。会在getTransformation函数里面调用,Animation类的applyTransformation是个空实现,具体的XXXAnimation在继承自Animation时会实现applyTransformation函数。

getTransformation函数:会在drawChild函数里面调用。

[java]  view plain copy
  1. public boolean getTransformation(long currentTime, Transformation outTransformation) {  
  2. // currentTime 会在drawChild函数中通过getDrawTime传过来  
  3.         if (mStartTime == -1) {  
  4.             mStartTime = currentTime;  
  5.         }  
  6.   
  7.         final long startOffset = getStartOffset();  
  8.         final long duration = mDuration;  
  9.         float normalizedTime;  
  10.         if (duration != 0) {  
  11. //归一化时间,这样normalizedTime是介于0和1之间的值。  
  12.             normalizedTime = ((float) (currentTime - (mStartTime + startOffset))) /  
  13.                     (float) duration;  
  14.         } else {  
  15.             // time is a step-change with a zero duration  
  16.             normalizedTime = currentTime < mStartTime ? 0.0f : 1.0f;  
  17.         }  
  18. // expired表示“过期”,如果归一化时间大于1 ,expired == true,即expire表示动画结束了  
  19.         final boolean expired = normalizedTime >= 1.0f;  
  20.         mMore = !expired;  
  21.   
  22.         if (!mFillEnabled) normalizedTime = Math.max(Math.min(normalizedTime, 1.0f), 0.0f);  
  23.   
  24.         if ((normalizedTime >= 0.0f || mFillBefore) && (normalizedTime <= 1.0f || mFillAfter)) {  
  25.             if (!mStarted) {  
  26.                 if (mListener != null) {  
  27. //记录开始动画  
  28.                     mListener.onAnimationStart(this);  
  29.                 }  
  30.                 mStarted = true;  
  31.             }  
  32.   
  33.             if (mFillEnabled) normalizedTime = Math.max(Math.min(normalizedTime, 1.0f), 0.0f);  
  34.   
  35.             if (mCycleFlip) {  
  36.                 normalizedTime = 1.0f - normalizedTime;  
  37.             }  
  38. //通过归一化的时间得到插值时间,类似于一个函数f(t)根据归一化的时间得到插值时间。  
  39. //插值时间的作用就是得到变化速率改变的效果,例如线性插值就是f(t)=t  
  40.             final float interpolatedTime = mInterpolator.getInterpolation(normalizedTime);  
  41. //调用applyTransformation函数,具体实现在继承自Animation的类中实现  
  42. //简单地说就是传入插值时间,然后该函数根据插值时间填充具体的转换矩阵,不同的时刻对应不同的转换矩阵,通过该转换矩阵就会绘制出在不同位置的图形。  
  43.             applyTransformation(interpolatedTime, outTransformation);  
  44.         }  
  45. //如果动画结束了会执行下面  
  46.         if (expired) {  
  47.             if (mRepeatCount == mRepeated) {  
  48.                 if (!mEnded) {  
  49.                     mEnded = true;  
  50.                     if (mListener != null) {  
  51.                         mListener.onAnimationEnd(this);  
  52.                     }  
  53.                 }  
  54.             } else {  
  55.                 if (mRepeatCount > 0) {  
  56.                     mRepeated++;  
  57.                 }  
  58.   
  59.                 if (mRepeatMode == REVERSE) {  
  60.                     mCycleFlip = !mCycleFlip;  
  61.                 }  
  62.   
  63.                 mStartTime = -1;  
  64.                 mMore = true;  
  65.   
  66.                 if (mListener != null) {  
  67.                     mListener.onAnimationRepeat(this);  
  68.                 }  
  69.             }  
  70.         }  
  71.   
  72.         if (!mMore && mOneMoreTime) {  
  73.             mOneMoreTime = false;  
  74.             return true;  
  75.         }  
  76. //通过分析发现总是返回true,除非动画结束了。  
  77. //always return true until the expired==true(the animation is over) @auth:qhyuan  
  78.         return mMore;  
  79.     }  

applyTransformation函数在Animation里面默认是空实现,需要在子类中实现,也就是说自定义动画需要实现applyTransformation函数。

插值类也很简单,是一个接口,只有一个函数。

[java]  view plain copy
  1. public interface Interpolator {  
  2.    float getInterpolation(float input);  
  3. }  

常用的子类有:

AccelerateDecelerateInterpolator 在动画开始与结束的地方速率改变比较慢,在中间的时候加速

AccelerateInterpolator  在动画开始的地方速率改变比较慢,然后开始加速

AnticipateInterpolator 开始的时候向后然后向前甩

AnticipateOvershootInterpolator 开始的时候向后然后向前甩一定值后返回最后的值

BounceInterpolator   动画结束的时候弹起

CycleInterpolator 动画循环播放特定的次数,速率改变沿着正弦曲线

DecelerateInterpolator 在动画开始的地方快然后慢

LinearInterpolator   以常量速率改变

OvershootInterpolator    向前甩一定值后再回到原来位置

也可以自定义interpolator,只需要实现getInterpolation函数就可以了。


前面简单的介绍了一下动画绘制所涉及的一些函数,接下来以执行平移动化为例将动画的执行过程走一遍:

1.定义完XXXAnimation后执行View的startAnimation

[java]  view plain copy
  1. public void startAnimation(Animation animation) {  
  2.         animation.setStartTime(Animation.START_ON_FIRST_FRAME);  
  3. //设置Animation域  
  4.         setAnimation(animation);  
  5. //请求重绘视图  
  6.         invalidate();  
  7. }  

setAnimation函数如下:

[java]  view plain copy
  1. public void setAnimation(Animation animation) {  
  2. //将animation设置给View的mCurrentAnimation属性  
  3.         mCurrentAnimation = animation;  
  4.         if (animation != null) {  
  5.             animation.reset();  
  6.         }  
  7. }  

2.然后请求重绘视图会执行onDraw函数,最后必然会执行到dispatchDraw函数,又会执行到drawChild(canvas,child, drawingTime); drawingTime函数是这次绘制的时间毫秒值。drawChild函数前面解释过。

3.首先获取View所关联的Animation,然后调用Animation的初始化函数,在这面会解析Animation的各个参数。对不同的xy类型和值进行转换,initialize函数也会在Animation的子类中实现。TranslateAnimation中的initialize函数如下,在该函数里面直接调用resolveSize函数解析构造参数中的值。

[java]  view plain copy
  1. public void initialize(int width, int height, int parentWidth, int parentHeight) {  
  2.         super.initialize(width, height, parentWidth, parentHeight);  
  3.         mFromXDelta = resolveSize(mFromXType, mFromXValue, width, parentWidth);  
  4.         mToXDelta = resolveSize(mToXType, mToXValue, width, parentWidth);  
  5.         mFromYDelta = resolveSize(mFromYType, mFromYValue, height, parentHeight);  
  6.         mToYDelta = resolveSize(mToYType, mToYValue, height, parentHeight);  
  7.     }  

resolveSize函数如下,根据是绝对坐标、相对控件自身还是相对父控件和具体的值解析出解析后的值,很容易看出绝对坐标,直接返回对应值,相对自身和相对父控件就是用相对值结余0到1之间的值乘以子控件或者父视图宽高的值。

[java]  view plain copy
  1. protected float resolveSize(int type, float value, int size, int parentSize) {  
  2.         switch (type) {  
  3.             case ABSOLUTE:  
  4.                 return value;  
  5.             case RELATIVE_TO_SELF:  
  6.                 return size * value;  
  7.             case RELATIVE_TO_PARENT:  
  8.                 return parentSize * value;  
  9.             default:  
  10.                 return value;  
  11.         }  
  12. }  

4.然后就根据重绘的时间毫秒值通过getTransformation函数获得对应的转换矩阵。在这个函数里面会先调用interpolatedTime = mInterpolator.getInterpolation(normalizedTime);根据时间获得插值时间。

然后调用applyTransformation函数,TranslateAnimation的该函数具体如下:

[java]  view plain copy
  1. protected void applyTransformation(float interpolatedTime, Transformation t) {  
  2.         float dx = mFromXDelta;  
  3.         float dy = mFromYDelta;  
  4. //开始X坐标值和结束值不一样  
  5.         if (mFromXDelta != mToXDelta) {  
  6. //某个时刻(插值时间)对应的dx,如果是线性插值interpolatedTime和normalizedTime是一样的。  
  7.             dx = mFromXDelta + ((mToXDelta - mFromXDelta) * interpolatedTime);  
  8.         }  
  9.         if (mFromYDelta != mToYDelta) {  
  10.             dy = mFromYDelta + ((mToYDelta - mFromYDelta) * interpolatedTime);  
  11.         }  
  12. //对转换矩阵进行重新设置,将位移差设置进转换矩阵中。平移动化其实也是变换矩阵和原来的坐标点的相乘。  
  13.         t.getMatrix().setTranslate(dx, dy);  
  14. }  

只要该动画没有结束getTransformation函数会一直返回true。然后判断返回true又会调用invalidate函数,接下来就是重复2,3,4的步骤了,但是重复执行的时候绘制时间不一样,于是获得的转换矩阵不一样,得到的新的视图的位置就不一样。如果返回false说明动画执行完成了,就不在重绘视图了。绘制控件时前面说过在绘制视图的时候会调用canvas.translate(cl - sx, ct - sy);简单地说就是将画布的坐标体系从屏幕左上角移动至动画视图的左上角。然后每次动画的时候canvas.concat(transformToApply.getMatrix());对话不的矩阵转换操作,然后绘图就实现了对动画的转换。

整个过程稍微有点复杂,有些函数我还没看的很明白,不过大致的思路就是这样。


回头看开始提出的几个问题:

1,  执行动画的时候其实并不是该控件本身在改变,而是他的父View完成的。startAnimation(anim)其实是给这个View设置了一个animation,而不是进行实际的动画绘制。他的位置其实根本没有改变,还是有layout所指定的位置决定的。

2,  参数的意思在代码里面的注释解释过了。

3,  fromXValue和toXValue旋转动画中fromDegrees和toDegrees取正负值是有区别的,具体要看代码里面,转换矩阵是怎么生成的。比如平移动化里面:

[java]  view plain copy
  1. dx = mFromXDelta + ((mToXDelta - mFromXDelta) *interpolatedTime);  

插值时间从0变到1,假设现在时相对自身的类型,控件本身宽度为100,mFromXDelta这些值在初始化的时候已经解析成了实际值(从0~1.0这种相和相对自身还是相对父View变成了像素宽高度)

从0变到1.0f的效果是:从当前位置向右平移100,原因是第一帧图像的dx为0,最后一帧dx为100

从-1.0f变到0的效果是:从当前位置的左边100处向右平移到当前位置,原因是第一帧图像的dx为-100,最后一帧的dx为0

旋转动画中通过指定开始结束角度的正负实现顺时针和逆时针旋转是类似的道理。大家可以自行感悟一下下面四个动画的区别,来看一下正负始末值对旋转的影响。

[java]  view plain copy
  1. rotateAnimation= new RotateAnimation(090, Animation.RELATIVE_TO_PARENT, -0.5f, Animation.RELATIVE_TO_PARENT,0);  
  2. rotateAnimation= new RotateAnimation(900, Animation.RELATIVE_TO_PARENT, -0.5f,Animation.RELATIVE_TO_PARENT, 0);  
  3. rotateAnimation= new RotateAnimation(-900, Animation.RELATIVE_TO_PARENT, -0.5f,Animation.RELATIVE_TO_PARENT, 0);  
  4. rotateAnimation = new RotateAnimation(0, -90,Animation.RELATIVE_TO_PARENT, -0.5f, Animation.RELATIVE_TO_PARENT, 0);  

4,  RotateAnimation的int pivotXType, float pivotXValue, int pivotYType, float pivotYValue四个参数的问题:

由于前面说过了,动画的时候,画布平移到了View的左上角,因此对于旋转动画来说参考的坐标原点始终是View左上角。而旋转的旋转点是可以任意指定的,该旋转点参考的坐标应该是View左上角。

手机屏幕中心处有一个点,现在我们想以屏幕最左边的那一边的中点为圆心旋转360度,代码应该是:

[java]  view plain copy
  1. new RotateAnimation(0360, Animation.RELATIVE_TO_PARENT,-0.5f, Animation.RELATIVE_TO_PARENT, 0);//注意是-0.5f  

以屏幕左上角旋转:

[java]  view plain copy
  1. new RotateAnimation(0360, Animation.RELATIVE_TO_PARENT,-0.5f, Animation.RELATIVE_TO_PARENT, -0.5f);//注意是负的。  

以自己中心旋转:

[java]  view plain copy
  1. new RotateAnimation(0360, Animation.RELATIVE_TO_SELF, 0.5f,Animation.RELATIVE_TO_SELF, 0.5f);  

以自己最下面的中点旋转:

[java]  view plain copy
  1. new RotateAnimation(0360, Animation.RELATIVE_TO_SELF, 0.5f,Animation.RELATIVE_TO_SELF, 1.0f);  

其他旋转点以此类推。我以前只是大致看过这些参数,所以在写围绕屏幕左上角旋转的代码时,想当然写成了:

new RotateAnimation(0, 360, Animation.RELATIVE_TO_PARENT, 0,Animation.RELATIVE_TO_PARENT, 0);

我本来想相对于父View来说(0,0)就是屏幕的左上角那一个点。结果当然不正确,测试一下也可以看出这个时候的旋转点仍然是View的左上角,是不是和RELATIVE_TO_PARENT没有半点关系?

总结一下就是说:后面的四个参数只是能算出来相对于画布坐标的距离,仅此而已,并不能看到是相对父View的值就忘了画布的原点在哪。


我在分析的时候只是大致的将动画的绘制过程研究了一下,实际的代码很复杂的。如果那里解释的不对的地方欢迎大家和我交流。


测试动平移和旋转动画的参数Demo:点此下载

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值