Android View的主要工作流程

View 的流程主要是指 measure、layout、draw 这三大流程,其中 measure 确定 View 的测量宽/高,layout 确定 View 的最终宽/高和四个顶点位置,而 draw 则将 View 绘制到屏幕上

MeasureSpec

我们先来简单了解一下 MeasureSpec ,MeasureSpec 在很大程度上决定了一个 View 的尺寸规格,之所以很大程度上是因为这个过程还受父容器影响,因为父容器影响 View 的 MeasureSpec 的创建过程。再测量过程中,系统会将 View 的 LayoutParam 根据父容器所施加的规格转换成对应的 MeasureSpec ,然后根据这个 MeasureSpec 来测量出 VIew 的宽高,但是 测量宽高 不一定等于 最终宽高,至于为什么?看后面的分析。

MeasureSpec 是一个 32 位 int 值,高两位代表 SpecMode,底 30 位代表 SpecSize;SpecMode 是值测量模式(AT_MOST、EXACTLY、UNSPECIFIED),SpecSize 是指在某种测量模式下的规格大小(如:AT_MOST 模式的 wrap_content、EXACTLY 模式的 match_parent 和具体数值)。看一下 measureSpec 的相关代码

 private static final int MODE_SHIFT = 30;
 private static final int MODE_MASK  = 0x3 << MODE_SHIFT;
 ...
 public static final int UNSPECIFIED = 0 << MODE_SHIFT;
 public static final int EXACTLY     = 1 << MODE_SHIFT;
 public static final int AT_MOST     = 2 << MODE_SHIFT;
 
 public static int makeMeasureSpec(int size, int mode) {
    if (sUseBrokenMakeMeasureSpec) {
        return size + mode;
    } else {
        return (size & ~MODE_MASK) | (mode & MODE_MASK);
    }
 }

 public static int getMode(int measureSpec) {
    //noinspection ResourceType
    return (measureSpec & MODE_MASK);
 }

 public static int getSize(int measureSpec) {
    return (measureSpec & ~MODE_MASK);
 }
 ...

MeasureSpec 通过将 SpecMode 和 SpecSize 打包成一个 int 值来避免过多的对象内存分配,为了方便操作,将提供了打包和解包方法。SpecMode 和 SpecSize 也是一个 int 值,一组 SpecMode 和 SpecSize 可以打包成一个 MeasureSpec,而一个 MeasureSpec 还可以解包还原出原来的 SpecMode 和 SpecSize。

SpecMode 有三类,其含义如下:

MeasureSpec.UNSPECIFIED: 在此模式下,父容器不对子 view 的大小做限制,一般用于系统内部,或者 ListView ScrollView 等滑动控件。

MeasureSpec.AT_MOST: 在此模式下,父容器未能检测出子 view 的大小,但指定了一个最大大小 spec size,子view的大小不能超过此值。具体是什么值要看不同的 View 的具体实现。它对应于 LayoutParams 中的 wrap_content。

MeasureSpec.EXACTLY: 在此模式下,父容器已经检测出子 view 所需要的精确大小,这个时候,view 的测量大小就是通过 getSize 得到的数值。它对应于 LayoutParams 中的 match_parent 和 具体的数值这两种模式。

measure 过程

measure 过程要分情况来看,如果只是一个原始的 View, 那么通过 measure 方法就完成其测量过程,如果是一个 ViewGroup,除了完成自己的测量外,还会遍历去调用所有子元素的 measure 方法,各个子元素再递归去执行这个流程。

对于普通的 view 来说,这里指的是我们布局中的 View,View 的 measure 过程由 ViewGroup 传递而来,先看一下 ViewGroup 的 mrasureChildWithMargins 方法:

	protected void measureChildWithMargins(View child,
            int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                        + heightUsed, lp.height);
		//调用子类的measure
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

ViewGroup 的 mrasureChildWithMargins 方法会调用子元素的 measure,但在调用子元素前会通过getChildMeasureSpec 方法获取创建子元素的 MeasureSpec,并且子元素的 MeasureSpec 创建和父容器的 MeasureSpec 和 子元素本身的 LayoutParams 有关;这个可以看一下 getChildMeasureSpec 方法就知道了:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        //父容器的 specMode 和 specSize
    	int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);
		//获取子元素的可用size,即:父容器size - 已使用的空间size
        int size = Math.max(0, specSize - padding);

        int resultSize = 0;
        int resultMode = 0;
		
        switch (specMode) {
        // Parent has imposed an exact size on us
        case MeasureSpec.EXACTLY:
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size. So be it.
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent has imposed a maximum size on us
        case MeasureSpec.AT_MOST:
            if (childDimension >= 0) {
                // Child wants a specific size... so be it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size, but our size is not fixed.
                // Constrain child to not be bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        //noinspection ResourceType
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

根据上述方法不难理解,它的主要作用是根据父容器的 MeasureSpec 同时结合子元素 View 本身的 LayoutParams 来确定子元素的 MeasureSpec。

接着上面的 measureChildWithMargins 方法带着创建好的子元素的 MeasureSpec 调用 子元素 的measure 方法,接着再调用子元素的 onMeasure 方法,我们来看一下 onMeasure 方法的实现:

 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(),widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
 }

setMeasuredDimension 方法会设置 View 宽高的测量值,因此我们只需要看 getDefaultSize 方法即可:

 public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        switch (specMode) {
        //这种情况,一般用于系统内部的测量过程,我们一般用不到
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
 }

可以看出这个方法很简单,对于我们来说,我们只需要看 AT_MOST 和 EXACTLY 这两种情况,简单的讲,就是 getDefaultSize 方法返回的大小就是 measureSpec 中的 specSize,而这个 specSize 就是 View 测量后的大小,这里要区分一下 View 的最终大小是在 Layout 阶段确定的,但是几乎大部分情况下 View 的测量大小和最终的大小是一致的。

从 getDefaultSize 方法的实现来看,View 的宽高是由 specSize 决定的,所以我们可以得出如下结论:直接继承 View 的自定义控件需要重写 onMeasure 方法并需要设置 wrap_content 情况下的自身大小,否则在布局中使用 wrap_content 和设置 match_content 的情况下是一样的;为什么呢?从上述 ViewGroup 的measureChildWithMargins 方法中创建子元素的 MeasureSpec 的 getChildMeasureSpec 方法中知道,如果使用 wrap_content,那它的 specMode 就是 AT_MOST 模式,在这种模式下,它的宽高等于父容器当前剩余空间的大小。如下代码所示:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
	...
	//获取子元素的可用size,即:父容器剩余空间
    int size = Math.max(0, specSize - padding);
    ...
    else if (childDimension == LayoutParams.WRAP_CONTENT) {
       // Child wants to determine its own size. It can't be
       // bigger than us.
       resultSize = size;
       resultMode = MeasureSpec.AT_MOST;
    }
    ...
    //noinspection ResourceType
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

很显然,这和 match_parent 的效果完全一致。那如何解决这个问题呢?如下所示:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);

        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

            if(widthSpecMode == MeasureSpec.AT_MOST 
               && heightSpecMode == MeasureSpec.AT_MOST){
                setMeasuredDimension(defaultWidth, defaultHeight);
            }else if(widthSpecMode == MeasureSpec.AT_MOST){
                setMeasuredDimension(defaultWidth, heightSpecSize);
            }else if(heightSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(widthSpecSize, defaultHeight);
            }
    }

只需要给 View 指定一个默认的宽高(defaultWidth、defaultHeight),并在 wrap_content 时设置此宽高即可。对于非 wrap_content 情形,我们沿用系统的测量值即可。

ViewGroup 有两个测量 View 的方法,一个是刚刚我们说的 measureChildWithMargins 方法,一个是measureChild 方法,这两个方法区别是,前者考虑了 padding 和 margin,而后者只考虑了 padding。

在 ViewGroup 里有个 measureChildren 方法通过遍历子 View 调用 measureChild ,而 measureChild 方法测量生成 MeasureSpec ,再调用子 View 的 measure;如下代码所示:

    protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        //遍历所有的子View,如果不为 GONE 的话就调用 measureChild 创建子View 的 MeasureSpec 后调用子 View 的measure
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
	//创建子View 的 MeasureSpec 后调用子 View 的 measure 方法
    protected void measureChild(View child, int parentWidthMeasureSpec,
            int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();
		//这里的 getChildMeasureSpec 方法上面已经分析过了
        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

ViewGroup 是个抽象类,没有重写 onMeasure 方法,其测量过程的 onMeasure 需要各个子类根据情况去具体实现,比如 LinearLayout、RelativeLayout等等。为什么 ViewGroup 不像 View 一样对其 onMeasure 方法做统一实现呢?那是因为不同的 ViewGroup 子类有不同的布局特性,这就导致他们的测量细节不同,因此 ViewGroup 无法做统一实现。

LinearLayout 的测量过程大致如下:(竖直情况)

系统会遍历子元素并对每个子元素执行 measureChildBeforeLayout 方法,这个方法内部会调用子元素的 measure 方法,这样各个子元素就开始依次进入 measure 过程,并且系统会通过 mTotalLength 变量存储 LinearLayout 在竖直方向的初步高度,每测量一个子元素,mTotalLength 就会增加,增加的部分主要包括子元素的高度以及子元素在竖直方向上的 margin 等,当子元素测量完毕后,LinearLayout 会测量自己的大小,测量的方式和 View 的测量方式差不多,因为总的高度都确定了。但要记得的是如果 ViewGroup 布局中采用的是 wrap_content,那么它的高度是所有子元素所占的高度总和,但是,仍然不能超过它的父容器的剩余空间。

需要注意的是,在某种情况下,系统可能需要多次 measure 才能确定最终的测量宽高,在这种情形下,在 onMeasure 方法中拿到的测量宽高很可能是不准确的,一个比较好的习惯是在onLayout 方法中去取 View 的宽高。

Layout 过程

Layout 的作用是 ViewGroup 用来确定子元素的位置的,当 ViewGroup 的位置被确定后,它在 onLayout 中会遍历所有的子元素并调用其 Layout 方法, 在 layout 方法中 onLayout 方法又会被调用。

即 layout 方法确定 View 本身的位置,而 onLayout 方法则会确定所有子 View 的位置

先看 View 的 layout 方法:

    public void layout(int l, int t, int r, int b) {
        if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
            onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
            mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
        }

        int oldL = mLeft;
        int oldT = mTop;
        int oldB = mBottom;
        int oldR = mRight;

        //调用 setFrame 方法确定该 view 的位置
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            //调用该方法确定子 View 的位置
            onLayout(changed, l, t, r, b);
        ...
    }

layout 方法大致流程如下:

  1. 通过 setFrame 方法来设定 View 的四个顶点的位置,即初始化 mLeft、mRight、mTop 和 mBottom这四个值,View 的四个顶点一旦确定,那么 View 在容器中的位置也就确定了。
  2. 接着会调用 onLayout 方法,这个方法的主要用途是父容器确定子元素的位置,和 onMeasure 方法类似,onLayout 的具体实现同样和具体的布局有关,所以 View 和 ViewGroup 均没有真正实现 onLayout 方法

我们来看一下View的 setFrame 方法和 onLayout 方法:

	protected boolean setFrame(int left, int top, int right, int bottom) {
        boolean changed = false;
     	...
        if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
            changed = true;
            
			...
            // 确定 View 的四个顶点    
            mLeft = left;
            mTop = top;
            mRight = right;
            mBottom = bottom;
            mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);

            ...
        }
        return changed;
    }

	// 没错,View 并没有实现该方法
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    }

我们来分析一下 LinearLayout 的 onLayout 方法:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (mOrientation == VERTICAL) {
            layoutVertical(l, t, r, b);
        } else {
            layoutHorizontal(l, t, r, b);
        }
    }
	// 竖直布局
    void layoutVertical(int left, int top, int right, int bottom) {
        ...
        final int count = getVirtualChildCount();
        ...
        for (int i = 0; i < count; i++) {
            final View child = getVirtualChildAt(i);
            if (child == null) {
                childTop += measureNullChild(i);
            } else if (child.getVisibility() != GONE) {
                //获取子 view 的宽高,记住这里
                final int childWidth = child.getMeasuredWidth();
                final int childHeight = child.getMeasuredHeight();

                final LinearLayout.LayoutParams lp =
                        (LinearLayout.LayoutParams) child.getLayoutParams();
               ...
                // 是否添加分割线的高度
                if (hasDividerBeforeChildAt(i)) {
                    childTop += mDividerHeight;
                }
				//确定当前 View 的top位置,即将前面的所有 View 的总高度 加 上当前子 View 的 topMargin
                childTop += lp.topMargin;
                // 设置该 View 的最终位置,该方法直接调用 View#setFrame 方法
                setChildFrame(child, childLeft, childTop + getLocationOffset(child),
                        childWidth, childHeight);
                // 累加总高度,为下一个 View 做准备
                childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child);

                i += getChildrenSkipCount(child, i);
            }
        }
    }

	// 该方法直接调用 View#setFrame 方法
    private void setChildFrame(View child, int left, int top, int width, int height) {
        child.layout(left, top, left + width, top + height);
    }

这样父元素在 layout 方法中完成自己的定位后,就通过 onLayout 方法去调用子元素的 layout 方法,然后子元素又会通过 layout 方法来确定自己的位置,这样一层一层地传递下去就完成了整个 View 树的 Layout 过程

前面我们提到 : onMeasure 方法中拿到的测量宽高很可能是不准确的,一个比较好的习惯是在onLayout 方法中去取 View 的宽高。 为什么这么说呢?我们先来看一下 getMeasuredWidth() 、getMeasuredHeight() 和 getWidth()、getHeight() 代码:


	public final int getMeasuredWidth() {
        return mMeasuredWidth & MEASURED_SIZE_MASK;
    }
	
    public final int getMeasuredHeight() {
        return mMeasuredHeight & MEASURED_SIZE_MASK;
    }	

    public final int getWidth() {
        return mRight - mLeft;
    }

    public final int getHeight() {
        return mBottom - mTop;
    }

这里结合上面 LinearLayout 的 layoutVertical 方法代码来看:

 	//获取子 view 的宽高,记住这里
	final int childWidth = child.getMeasuredWidth();
    final int childHeight = child.getMeasuredHeight();

可以知道getWidth() 和 getHeight() 方法的 mLeft、mRight、mTop、mBottom 这四个变量的由来,它们返回的宽高刚好是 View 的测量高度。经上述分析,我们可以回答这个问题了:在 View 的默认实现中, View 的 测量宽高 和 最终宽高 是相等的,只不过 测量宽高 形成于 View 的 measure 过程, 而 最终宽高 形成于 View 的Layout 过程,就是两者的时机不同,就因为两者的时机不同才会在某种情况下导致这两个宽高不同,不过在日常开发中,我们可以认为 View 的 测量宽高 就等于 最终宽高。

下面举例说明导致这两个宽高不同的一个特殊例子:

public void layout(int l, int t, int r, int b){
    super.layout(l, t, r+100, b+100);
}

上述的例子会导致在任何情况下 View 的最终宽高 总是比 测量宽高大 100px。

draw 过程

draw 过程就比较简单了,它的作用是将 View 绘制到屏幕上。View 的绘制过程遵循如下几步:

  1. 绘制背景 background.draw (canvas)
  2. 绘制自己(onDraw)
  3. 绘制 children (dispatchDraw)
  4. 绘制装饰(onDrawScrollBars)

这通过 draw 源码可以看出来:

    public void draw(Canvas canvas) {
        final int privateFlags = mPrivateFlags;
        final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
                (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
        mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

        /*
         * 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
         *      4. Draw children
         *      5. If necessary, draw the fading edges and restore layers
         *      6. Draw decorations (scrollbars for instance)
         */

        // Step 1, draw the background, if needed
        int saveCount;

        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

        // 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) {
            // Step 3, draw the content
            if (!dirtyOpaque) onDraw(canvas);

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

            // Step 6, draw decorations (foreground, scrollbars)
            onDrawForeground(canvas);

            // Step 7, draw the default focus highlight
            drawDefaultFocusHighlight(canvas);

            if (debugDraw()) {
                debugDrawFocus(canvas);
            }

            // we're done...
            return;
        }
		...
    }

View 绘制过程的传递是通过 dispatchDraw 来实现的,dispatchDraw 会遍历调用所有子 View 的draw 方法,这样 draw 事件就一层一层地传递下去了。View 有一个特殊的方法 setWillNotDraw,先看一下它的源码:

    /**
     * If this view doesn't do any drawing on its own, set this flag to
     * allow further optimizations. By default, this flag is not set on
     * View, but could be set on some View subclasses such as ViewGroup.
     *
     * Typically, if you override {@link #onDraw(android.graphics.Canvas)}
     * you should clear this flag.
     *
     * @param willNotDraw whether or not this View draw on its own
     */
    public void setWillNotDraw(boolean willNotDraw) {
        setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
    }

从注释中可以看出,如果一个 View 不需要绘制任何内容,那么设置这个标志位为 true 后,系统会进行相应的优化。默认情况下,View 没有启用这个标记,但是 ViewGroup 会默认启用这个优化标记位。这个标记对实际开发的意义是:当我们的自定义控件继承于 ViewGroup 并且本身不具备绘制功能时,就可以开启这个标记位从而便于系统进行后续优化,当然,当明确知道 ViewGroup 通过 onDraw 来绘制内容时,我们也可以显示地关闭 这个标记位。

End…

本文主要来自于《Android 开发艺术探索》和自己的理解

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值