二、View Animation动画源码简析——动画的启动运行

不知道大伙有没有想过,当我们调用了 View.startAnimation(animation) 之后,动画是不是马上就开始执行了?
——我们先来看看 View.startAnimation(animation) 方法里都做那那些事情。

public void startAnimation(Animation animation) {
    animation.setStartTime(Animation.START_ON_FIRST_FRAME);
    setAnimation(animation);
    invalidateParentCaches();
    invalidate(true);
}

首先一个的animation.setStartTime(Animation.START_ON_FIRST_FRAME)方法:

public void setStartTime(long startTimeMillis) {
    mStartTime = startTimeMillis;
    mStarted = mEnded = false;
    mCycleFlip = false;
    mRepeated = 0;
    mMore = true;
}

这里setStartTime()方法主要就是完成动画相关参数的赋值操作。
再来看setAnimation(animation)方法:

public void setAnimation(Animation animation) {
    mCurrentAnimation = animation;
    if (animation != null) {
        // If the screen is off assume the animation start time is now instead of
        // the next frame we draw. Keeping the START_ON_FIRST_FRAME start time
        // would cause the animation to start when the screen turns back on
        if (mAttachInfo != null && mAttachInfo.mDisplayState == Display.STATE_OFF && animation.getStartTime() == Animation.START_ON_FIRST_FRAME) {
            animation.setStartTime(AnimationUtils.currentAnimationTimeMillis());
        }
        animation.reset();
    }
}

三行注释的翻译:如果屏幕是关闭的,假设动画开始时间现在,而不是未来我们画。保持START_ON_FIRST_FRAME开始时间将导致动画开始当屏幕。

if条件语句里面有三个条件,依次是:
mAttachInfo != null————当AttachInfo对象 mAttachInfo不为空,这个好说。
mAttachInfo.mDisplayState == Display.STATE_OFF————AttachInfo.class下的mDisplayState = Display.STATE_UNKNOWN,我们可以查询到:Display.class下的STATE_UNKNOWN变量状态:

public static final int STATE_UNKNOWN = 0;

而Display.class下的STATE_OFF变量状态为:

public static final int STATE_OFF = 1;

所以二者永远不可能相等,直接为false,那么后面的一个条件可以不用判断了。这样if条件语句经“&&”后卫false,里面的语句不会执行。那就只剩下animation.reset();一条了。他的源码如下:

public void reset() {
    mPreviousRegion.setEmpty();
    mPreviousTransformation.clear();
    mInitialized = false;
    mCycleFlip = false;
    mRepeated = 0;
    mMore = true;
    mOneMoreTime = true;
    mListenerHandler = null;
}

可以看得出就是重置animation动画所需要的各种参数。

然后我们再看startAnimation()方法的invalidateParentCaches()方法:

protected void invalidateParentCaches() {
    if (mParent instanceof View) {
        ((View) mParent).mPrivateFlags |= PFLAG_INVALIDATED;
    }
}

这里对mPrivateFlags 标记做了一次位或运算。

最后,我们再来看看invalidate(true);方法:

public void invalidate(boolean invalidateCache) {
    invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
}

这里直接调用了invalidateInternal方法,从字面意思可以看得出他就是刷新UI的。其中,(0, 0)为View的坐标原点,(mRight - mLeft, mBottom - mTop)分别为View的宽度和高度,二者合起来就是确定要刷新的View的坐标位置。

我们进入invalidateInternal()方法的源码看看:

void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache, boolean fullInvalidate) {
    if (mGhostView != null) {
        mGhostView.invalidate(true);
        return;
    }
    if (skipInvalidate()) {
        return;
    }
    if ((mPrivateFlags & (PFLAG_DRAWN | PFLAG_HAS_BOUNDS)) == (PFLAG_DRAWN | PFLAG_HAS_BOUNDS)
            || (invalidateCache && (mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == PFLAG_DRAWING_CACHE_VALID)
            || (mPrivateFlags & PFLAG_INVALIDATED) != PFLAG_INVALIDATED
            || (fullInvalidate && isOpaque() != mLastIsOpaque)) {
        if (fullInvalidate) {
            mLastIsOpaque = isOpaque();
            mPrivateFlags &= ~PFLAG_DRAWN;
        }
        mPrivateFlags |= PFLAG_DIRTY;
        if (invalidateCache) {
            mPrivateFlags |= PFLAG_INVALIDATED;
            mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
        }
        // Propagate the damage rectangle to the parent view.
        final AttachInfo ai = mAttachInfo;
        //mParent 一般是 ViewGroup,下面其实是调用了 ViewGroup 的 invalidateChild()
        final ViewParent p = mParent;
        if (p != null && ai != null && l < r && t < b) {
            final Rect damage = ai.mTmpInvalRect;
            damage.set(l, t, r, b);
            p.invalidateChild(this, damage);
        }
        // Damage the entire projection receiver, if necessary.
        if (mBackground != null && mBackground.isProjected()) {
            final View receiver = getProjectionReceiver();
            if (receiver != null) {
                receiver.damageInParent();
            }
        }
    }
}

代码量稍大,不过我们捡紧要的看:

if (mGhostView != null) {
    mGhostView.invalidate(true);
    return;
}
if (skipInvalidate()) {
    return;
}

这两个条件语句,在方法开始的时候就执行不满足条件就return。当真是return不要钱不值钱啊,还没执行什么代码任务就return了。所以我们也无关痛痒的忽略它的存在。然后继续往下看。
可以发现真个invalidateInternal方法的核心就是调用了parent(ViewGroup )的 invalidateChild()方法和receiver.damageInParent()方法。我们先跟进invalidateChild()去看看:

//ViewGroup#invalidateChild()  
public final void invalidateChild(View child, final Rect dirty) {
    //1. 注意这里,下面的do{}while()循环会用到
    ViewParent parent = this;
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        ...
        do {
        ...  
        //2.第一次循环的时候 parent 是 this 即 ViewGroup 本身,循环终止条件是 parent == null,所以可以猜测这个方法会返回当前ViewGroup的parent,跟进确认一下
        parent = parent.invalidateChildInParent(location, dirty);
        ...
        } while (parent != null);
    }
}

这里有一个 do{}while() 的循环操作,第一次循环的时候 parent 是 this,即 ViewGroup 本身,所以接下去就是调用 ViewGroup 本身的 invalidateChildInParent() 方法,然后循环终止条件是 patent == null,所以这个方法返回的应该是 ViewGroup 的 parent,跳进去看看其源码:

//ViewGroup#invalidateChildInParent()  
public ViewParent invalidateChildInParent(final int[] location, final Rect dirty) {
    //1.当满足if条件时,就会返回 mParent,否则返回 null。
    if ((mPrivateFlags & PFLAG_DRAWN) == PFLAG_DRAWN ||
            (mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == PFLAG_DRAWING_CACHE_VALID) {
        if (...) {
            ...
            return mParent;	
        } else {
            ...
            return mParent;
        }
    }
    return null;
}

发现关键是 PFLAG_DRAWN 和 PFLAG_DRAWING_CACHE_VALID 这两个是什么时候赋值给 mPrivateFlags,因为只要有两个标志中的一个时,该方法就会返回 mParent,一个具体的 View 的 mParent 是 ViewGroup,ViewGroup 的 mParent 还是是 ViewGoup,所以在 do{}while() 循环里会一直不断的寻找 mParent,而一个 View 树最顶端的 mParent 是 ViewRootImpl,所以最终是会走到了 ViewRootImpl 的 invalidateChildInParent() 里去。

至于一个界面的 View 树最顶端为什么是 ViewRootImpl,这个就跟 Activity 启动过程有关了。我们都清楚,在 onCreate 里 setContentView() 的时候,是将我们自己写的布局文件添加到以 DecorView 为根布局的一个 ViewGroup 里,也就是说 DevorView 才是 View 树的根布局,那为什么又说 View 树最顶端其实是 ViewRootImpl 呢?

这是因为在 onResume() 执行完后,WindowManager 将会执行 addView(),然后在这里面会去创建一个 ViewRootImpl 对象,接着将 DecorView 跟 ViewRootImpl 对象绑定起来,并且将 DecorView 的 mParent 设置成 ViewRootImpl,而 ViewRootImpl 是实现了 ViewParent 接口的,所以虽然 ViewRootImpl 没有继承 View 或 ViewGroup,但它确实是 DecorView 的 parent。这部分内容应该属于 Activity 的启动过程相关原理的,这里不深入了。

那么我们继续返回到寻找动画执行的地方,我们跟到了 ViewRootImpl 的 invalidateChildInParent() 里去了,看看它做了些什么:

//ViewRootImpl#invalidateChildInParent()  
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
    ...
    if (dirty == null) {
        invalidate();
        return null;
    } else if (dirty.isEmpty() && !mIsAnimating) {
        return null;
    }
    ...
    invalidateRectOnScreen(dirty);
    return null;
}

它的所有返回值都是 null,所以之前那个 do{}while() 循环最终就是执行到这里后肯定就会停止了。然后参数 dirty 是在最初 View 的 invalidateInternal() 里层层传递过来的,可以肯定的是它不为空,也不是 isEmpty,所以继续跟到 invalidateRectOnScreen() 方法里看看:

private void invalidateRectOnScreen(Rect dirty) {
    ...
    if (!mWillDrawSoon && (intersected || mIsAnimating)) {
        //1.跟到这里看到这个方法就可以停住了
        scheduleTraversals();
    }
}

scheduleTraversals() 作用是将 performTraversals() 封装到一个 Runnable 里面,然后扔到 Choreographer 的待执行队列里,这些待执行的 Runnable 将会在最近的一个屏幕刷新信号到来的时候被执行。而 performTraversals() 是 View 的三大操作:测量、布局、绘制的发起者。

View 树里面不管哪个 View 发起了布局请求、绘制请求,统统最终都会走到 ViewRootImpl 里的 scheduleTraversals(),然后在最近的一个屏幕刷新信号到了的时候再通过 ViewRootImpl 的 performTraversals() 从根布局 DecorView 开始依次遍历 View 树去执行测量、布局、绘制三大操作。这也是为什么一直要求页面布局层次不能太深,因为每一次的页面刷新都会先走到 ViewRootImpl 里,然后再层层遍历到具体发生改变的 View 里去执行相应的布局或绘制操作。

我们从 View.startAnimation() 开始跟进源码分析的这一过程中,也可以看出,执行动画,其实内部会调用 View 的重绘请求操作 invalidate() ,所以最终会走到 ViewRootImpl 的 scheduleTraversals(),然后在下一个屏幕刷新信号到的时候去遍历 View 树刷新屏幕。

现在可以得出结论:
1、当调用了 View.startAniamtion() 之后,动画并没有马上就被执行,这个方法只是做了一些变量初始化操作,然后接着将 View 和 Animation 绑定起来,最后调用View的重绘请求操作,内部层层寻找 mParent,最终走到 ViewRootImpl 的 scheduleTraversals 里,在这里发起遍历 View 树的请求,这个请求会在最近的一个屏幕刷新信号到来的时候被执行,调用 performTraversals 从根布局 DecorView 开始遍历 View 树。
2、动画其实真正执行的地方应该是在 ViewRootImpl 发起的遍历 View 树的这个过程中。

那么Animation动画到底是从哪里开始执行的呢?

View的绘制流程中,测量、布局、绘制,View 显示到屏幕上的三个基本操作都是由 ViewRootImpl 的 performTraversals() 来控制,而作为 View 树最顶端的 parent,要控制这颗 Veiw 树的三个基本操作,只能通过层层遍历。所以,测量、布局、绘制三个基本操作的执行都会是一次遍历操作。

绘制流程的开始是由 ViewRootImpl 发起的,然后从 DecorView 开始遍历 View 树。而遍历的实现,是在 View的draw() 方法里的。

public void draw(Canvas canvas) {
	 ...
	//Draw traversal performs several drawing steps which must be executed in the appropriate order:
	//1. Draw the background //绘制背景
	//2. If necessary, save the canvas' layers to prepare for fading 
	//3. Draw view's content //调用onDraw()绘制自己
	//4. Draw children       //调用dispatchDraw()绘制子View
	// 5. If necessary, draw the fading edges and restore layers
	// 6. Draw decorations (scrollbars for instance)
	
	1. Draw the background
	//Step 1, draw the background, if needed
	if (!dirtyOpaque) {    
		drawBackground(canvas);
	}
	2. If necessary, save the canvas' layers to prepare for fading
	// skip step 2 & 5 if possible (common case)
	final int viewFlags = mViewFlags;
	boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
	boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
	if (!verticalEdges && !horizontalEdges) {
		3. Draw view's content
		// Step 3, draw the content
		if (!dirtyOpaque) {
			 onDraw(canvas); 
		}
		4. Draw children
		// Step 4, draw the children
		dispatchDraw(canvas);       drawAutofilledHighlight(canvas);
		// Overlay is part of the content and draws beneath Foreground
		if (mOverlay != null && !mOverlay.isEmpty()) {
		    mOverlay.getOverlayView().dispatchDraw(canvas);
		}
		5. If necessary, draw the fading edges and restore layers
		6. Draw decorations (scrollbars for instance)
		
		// Step 6, draw decorations (foreground, scrollbars)
		onDrawForeground(canvas);
		
		// Step 7, draw the default focus highlight
		drawDefaultFocusHighlight(canvas);
		if (debugDraw()) {    
			debugDrawFocus(canvas);  
		 }
	}
}

这个方法里主要做了上述六件事,大体上就是如果当前 View 需要绘制,就会去调用自己的 onDraw(),然后如果有子 View,就会调用dispatchDraw() 将绘制事件通知给子 View。ViewGroup 重写了 dispatchDraw(),调用了 drawChild(),而 drawChild() 调用了子 View 的 draw(Canvas, ViewGroup, long),而这个方法又会去调用到 draw(Canvas) 方法,层层循环遍历。

在draw(Canvas, ViewGroup, long) 里面,发现了与动画相关的代码:

boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
    ...
    boolean more = false;
    ...
    //1.获取View绑定的动画
    final Animation a = getAnimation();
    if (a != null) {
        //2.如果View有绑定动画,执行动画相关逻辑
        more = applyLegacyAnimation(parent, drawingTime, a, scalingRequired);
        ...
    } 
    ...
    return more;
}

而在前面我们提到了startAnimation(animation)方法里面的的操作,其中第二个方法就是赋值:

setAnimation(animation);

setAnimation(animation)赋值操作中,其中一个赋值操作是:

mCurrentAnimation = animation;

而这里的getAnimation()则是获取前面赋值进去的animation对象——mCurrentAnimation:

public Animation getAnimation() {
        return mCurrentAnimation;
}

这验证了我们上面的结论。所以紧随其后的if判断语句就是动画的执行开始的地方:

if (a != null) {
	//这里开始执行动画。
	more = applyLegacyAnimation(parent, drawingTime, a, scalingRequired);
	...
} 

我们进入applyLegacyAnimation()方法 IM去看看:

private boolean applyLegacyAnimation(ViewGroup parent, long drawingTime, Animation a, boolean scalingRequired) {
    Transformation invalidationTransform;
    final int flags = parent.mGroupFlags;
    final boolean initialized = a.isInitialized();
    if (!initialized) {
        //动画初始化
        a.initialize(mRight - mLeft, mBottom - mTop, parent.getWidth(), parent.getHeight());
        a.initializeInvalidateRegion(0, 0, mRight - mLeft, mBottom - mTop);
        if (mAttachInfo != null) a.setListenerHandler(mAttachInfo.mHandler);
        //动画开始执行
        onAnimationStart();
    }
    final Transformation t = parent.getChildTransformation();
    //获取返回值来查看动画是否已经结束
    boolean more = a.getTransformation(drawingTime, t, 1f);
    if (scalingRequired && mAttachInfo.mApplicationScale != 1f) {
        if (parent.mInvalidationTransformation == null) {
            parent.mInvalidationTransformation = new Transformation();
        }
        invalidationTransform = parent.mInvalidationTransformation;
        //调用动画的getTransfomation()计算动画
        a.getTransformation(drawingTime, invalidationTransform, 1f);
    } else {
        invalidationTransform = t;
    }
    //动画还没结束时
    if (more) {
        //除了 Alpha 动画默认返回 false,其余基础动画都返回 true
        if (!a.willChangeBounds()) {
            if ((flags & (ViewGroup.FLAG_OPTIMIZE_INVALIDATE | ViewGroup.FLAG_ANIMATION_DONE)) == ViewGroup.FLAG_OPTIMIZE_INVALIDATE) {
                parent.mGroupFlags |= ViewGroup.FLAG_INVALIDATE_REQUIRED;
            } else if ((flags & ViewGroup.FLAG_INVALIDATE_REQUIRED) == 0) {
                parent.mPrivateFlags |= PFLAG_DRAW_ANIMATION;
                parent.invalidate(mLeft, mTop, mRight, mBottom);
            }
        } else {
            if (parent.mInvalidateRegion == null) {
                parent.mInvalidateRegion = new RectF();
            }
            final RectF region = parent.mInvalidateRegion;
            a.getInvalidateRegion(0, 0, mRight - mLeft, mBottom - mTop, region, invalidationTransform);
            parent.mPrivateFlags |= PFLAG_DRAW_ANIMATION;

            final int left = mLeft + (int) region.left;
            final int top = mTop + (int) region.top;
            //调用mParent的请求重绘操作,这个方法在开头分析startAnimation时分析过了
            parent.invalidate(left, top, left + (int) (region.width() + .5f), top + (int) (region.height() + .5f));
        }
    }
    return more;
}

代码量又开始大起来了,我们还是捡紧要的看。

首先我们就能注意到第一个if条件语句里面就有这么一句:onAnimationStart(),不用我说你也能猜到他是干什么的了。
然后Transformation对象的获取以及动画的核心代码:animation.getTransformation(drawingTime, t, 1f);

public boolean getTransformation(long currentTime, Transformation outTransformation, float scale) {
    mScaleFactor = scale;
    return getTransformation(currentTime, outTransformation);
}

这里先是做了赋值,然后调用getTransformation方法:

public boolean getTransformation(long currentTime, Transformation outTransformation) {
	//记录动画第一帧时间
    if (mStartTime == -1) {
        mStartTime = currentTime;
    }
    //动画延迟开始时间,默认为0
    final long startOffset = getStartOffset();
    //动画持续时间
    final long duration = mDuration;
    float normalizedTime;
    if (duration != 0) {
        //计算动画的进度:(当前时间 - 动画第一帧时间mStartTime - 动画延迟开始时间startOffset ) / 动画持续时间duration 
        normalizedTime = ((float) (currentTime - (mStartTime + startOffset))) / (float) duration;
    } else {
        normalizedTime = currentTime < mStartTime ? 0.0f : 1.0f;
    }
    //expired = true 表示动画已经结束或者被取消了
    final boolean expired = normalizedTime >= 1.0f || isCanceled();
    mMore = !expired;
    //确保动画进度在 0.0 ---- 1.0 之间
    if (!mFillEnabled) {
        normalizedTime = Math.max(Math.min(normalizedTime, 1.0f), 0.0f);
    }
    if ((normalizedTime >= 0.0f || mFillBefore) && (normalizedTime <= 1.0f || mFillAfter)) {
        if (!mStarted) {
            fireAnimationStart();
            mStarted = true;
            if (NoImagePreloadHolder.USE_CLOSEGUARD) {
                guard.open("cancel or detach or getTransformation");
            }
        }
        if (mFillEnabled) normalizedTime = Math.max(Math.min(normalizedTime, 1.0f), 0.0f);
        if (mCycleFlip) {
            normalizedTime = 1.0f - normalizedTime;
        }
        //根据插值器计算实际的动画进度
        final float interpolatedTime = mInterpolator.getInterpolation(normalizedTime);
        //根据动画进度匹配动画效果
        applyTransformation(interpolatedTime, outTransformation);
    }
    //如果设置了动画循环的次数,那么当动画结束的时候判断一下循环次数是否已经达到设定的次数,没有的话,重置第一帧时间
    if (expired) {
        if (mRepeatCount == mRepeated || isCanceled()) {
            if (!mEnded) {
                mEnded = true;
                guard.close();
                fireAnimationEnd();
            }
        } else {
            if (mRepeatCount > 0) {
                mRepeated++;
            }
            if (mRepeatMode == REVERSE) {
                mCycleFlip = !mCycleFlip;
            }
            mStartTime = -1;
            mMore = true;
            fireAnimationRepeat();
        }
    }
    //综上,mMore = true 表示动画进度还没超过 1.0,也就是动画还没结束;false 表示动画已经结束或者被取消了
    if (!mMore && mOneMoreTime) {
        mOneMoreTime = false;
        return true;
    }
    return mMore;
}

代码量稍大,还是老规矩,捡紧要的看。
该方法主要做了两件事情,一是计算动画所需要的时间数据;二是调用applyTransformation()方法匹配动画效果。而applyTransformation() 最终是在绘制流程中的 draw() 过程中执行到的,在每一帧的屏幕刷新信号来的时候,遍历 View 树并重新渲染View界面,动画只是在这个过程中顺便执行的。

而getTransformation() 的返回值代表的是动画是否完成。他在applyLegacyAnimation() 方法里面被调用,这个前面已经提及到,就不再展示代码了。getTransformation()方法和 applyLegacyAnimation()方法关键地方都有注释。

当动画如果还没执行完,就会再调用 invalidate() 方法,层层通知到 ViewRootImpl 再次发起一次遍历请求,当下一帧屏幕刷新信号来的时候,再通过 performTraversals() 遍历 View 树绘制时,该 View 的 draw 收到通知被调用时,会再次去调用 applyLegacyAnimation() 方法去执行动画相关操作,包括调用 getTransformation() 计算动画进度,调用 applyTransformation() 应用动画。
————简单来说就是:正常情况下,UI是以帧/16.6ms的频率刷新UI,执行一次 applyTransformation(),直到动画完成。所以 applyTransformation() 将会被回调多次,且调用次数是由动画的持续时间以及重复执行次数来决定的,无法人为进行设定。

总结一下:
首先,当调用了 View.startAnimation() 时动画并没有马上就执行,而是通过 invalidate() 层层通知到 ViewRootImpl 发起一次遍历 View 树的请求,而这次请求会等到接收到最近一帧到了的信号时才去发起遍历 View 树绘制操作。

其次,从 DecorView根目录 开始遍历,遍历时会调用到 View 的 draw() 方法,如果 View 有绑定动画,那么会去调用applyLegacyAnimation()方法,该方法是专门处理动画相关逻辑的。

然后,在 applyLegacyAnimation() 这个方法里,如果动画对象znimation还没有执行过初始化,先调用动画的初始化方法 initialized(),同时调用 onAnimationStart() 通知动画开始了,然后调用 getTransformation() 来根据当前时间计算动画进度,紧接着调用 applyTransformation() 并传入动画进度来应用动画。

再后,根据getTransformation() 方法的返回值,判断动画是否已经结束:如果没结束就返回 true,已经结束或者被取消了就返回 false。所以 applyLegacyAnimation() 会根据 getTransformation() 的返回值来决定是否通知 ViewRootImpl 再发起一次遍历请求,返回值是 true 表示动画没结束,那么就去通知 ViewRootImpl 再次发起一次遍历请求。然后当下一帧到来时,再从 DecorView 开始遍历 View 树绘制,重复上面的步骤,这样直到动画结束。

最后,最重要的一点:动画并不是单独执行的,是在View的每一帧的绘制流程里被执行,也就是说,如果这一帧里有一些 View 需要重绘,那么这些工作同样是在这一帧里的这次遍历 View 树的过程中完成的。每一帧只会发起一次 perfromTraversals() 操作。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值