Android——view绘制原理

View的绘制原理

1.前言

View的绘制和View的事件分发是View的两个最为重要的知识点。 
在上一篇中已经完整的分析过了View的事件分发机制,这一篇则是分析View的绘制原理。View的绘制原理是自定义View的基础知识,有了这个基础知识就可以写出五花八门的漂亮的自定义view了。

2.View的绘制流程

一个view要显示在界面上,需要经历一个view树的遍历过程,这个过程又可以分为三个过程,分别是:

  • 测量measure 确定一个View的大小
  • 布局layout 确定view在父节点上的位置
  • 绘制draw 绘制view 的内容

这个过程的启动是由ViewRoot.performTraversals()函数发起的,子view也可以通过一些方法来请求重新遍历view树,但是在遍历过程view树时并不是所有的view都需要重新测量,布局和绘制,在view树的遍历过程中,系统会问view是否需要重新绘制,如果需要才会真的去绘制view。 
其中ViewRoot.performTraversals会依次调用ViewRoot.performMeasure,ViewRoot.performLayout,ViewRoot.performDraw,这三个方法分别完成顶级view的measure、layout、draw三个流程。 
performMeasure里面会对所有的子元素进行measure过程,这个时候measure就从父容器传递到子元素中。这样层层递进,measure过程就完成。ViewRoot.performLayout和ViewRoot.performDraw同理。

3.Measure

在看Measure源码之前我们需要了解一些基础知识

3.1 MeasureSpec

View的测量模式,测量规格等,这些东西都封装在MeasureSpec中,MeasureSpec封装了从父容器传递给子容器的布局要求,现在来学习一下这个类

MeasureSpec {
        //MODE_SHIFT是位偏移数
        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 getMode(int measureSpec) {
            return (measureSpec & MODE_MASK);
        }
        //获取尺寸
        public static int getSize(int measureSpec) {
            return (measureSpec & ~MODE_MASK);
        }
      ......
    }

这里面有用一个int型变量来表示一个测量规格,我们知道int型有32位,而MODE_SHIFT=30,三种模式分别是:

  • UNSPECIFIED:表示父容器不对子view的大小做任何要求
  • EXACTLY:父容器已经为子容器设置了尺寸,子容器应当服从这些边界,不论子容器想要多大的空间。
  • AT_MOST:表示父容器不能确定子view大小,但是要求子view的大小不能超过测量规格中指定的尺寸。

3.2 LayoutParams

接着来看看LayoutParams。LayoutParams描述了View的大小,对其方式等信息,而每个ViewGroup都可以根据自身的layout特性来定制自己的LayoutParams。 
之前提到系统内部是通过MeasureSpec来进行View的测量,但是我们也可以通过View设置LayoutParams来设置view的测量。在测量的时候系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpec,然后再根据这个MeasureSpec来确定View测量后的宽高。 
需要注意的是MeasureSpec不是唯一由LayoutParams决定的,而是父View的MeasureSpec和子View自己的LayoutParams共同决定的,而子View的LayoutParams其实就是我们在xml写的时候设置的layout_width和layout_height 转化而来的。 
对于顶级View则有些不同,其MeasureSpec是由窗口的尺寸和自身的LayoutParams决定的。不用在意父容器,毕竟没有父容器。

protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { 

// 子View的LayoutParams,你在xml的layout_width和layout_height,
// layout_xxx的值最后都会封装到这个个LayoutParams。
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();   

//根据父View的测量规格和父View自己的Padding,
//还有子View的Margin和已经用掉的空间大小(widthUsed),就能算出子View的MeasureSpec,具体计算过程看getChildMeasureSpec方法。
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);  

//通过父View的MeasureSpec和子View的自己LayoutParams的计算,算出子View的MeasureSpec,然后父容器传递给子容器的
// 然后让子View用这个MeasureSpec(一个测量要求,比如不能超过多大)去测量自己,如果子View是ViewGroup 那还会递归往下测量。
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);

}

// spec参数   表示父View的MeasureSpec 
// padding参数    父View的Padding+子View的Margin,父View的大小减去这些边距,才能精确算出
//               子View的MeasureSpec的size
// childDimension参数  表示该子View内部LayoutParams属性的值(lp.width或者lp.height)
//                    可以是wrap_content、match_parent、一个精确指(an exactly size),  
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {  
    int specMode = MeasureSpec.getMode(spec);  //获得父View的mode  
    int specSize = MeasureSpec.getSize(spec);  //获得父View的大小  

   //父View的大小-自己的Padding+子View的Margin,得到值才是子View的大小。
    int size = Math.max(0, specSize - padding);   

    int resultSize = 0;    //初始化值,最后通过这个两个值生成子View的MeasureSpec
    int resultMode = 0;    //初始化值,最后通过这个两个值生成子View的MeasureSpec

    switch (specMode) {  
    // Parent has imposed an exact size on us  
    //1、父View是EXACTLY的 !  
    case MeasureSpec.EXACTLY:   
        //1.1、子View的width或height是个精确值 (an exactly size)  
        if (childDimension >= 0) {            
            resultSize = childDimension;         //size为精确值  
            resultMode = MeasureSpec.EXACTLY;    //mode为 EXACTLY 。  
        }   
        //1.2、子View的width或height为 MATCH_PARENT/FILL_PARENT   
        else if (childDimension == LayoutParams.MATCH_PARENT) {  
            // Child wants to be our size. So be it.  
            resultSize = size;                   //size为父视图大小  
            resultMode = MeasureSpec.EXACTLY;    //mode为 EXACTLY 。  
        }   
        //1.3、子View的width或height为 WRAP_CONTENT  
        else if (childDimension == LayoutParams.WRAP_CONTENT) {  
            // Child wants to determine its own size. It can't be  
            // bigger than us.  
            resultSize = size;                   //size为父视图大小  
            resultMode = MeasureSpec.AT_MOST;    //mode为AT_MOST 。  
        }  
        break;  

    // Parent has imposed a maximum size on us  
    //2、父View是AT_MOST的 !      
    case MeasureSpec.AT_MOST:  
        //2.1、子View的width或height是个精确值 (an exactly size)  
        if (childDimension >= 0) {  
            // Child wants a specific size... so be it  
            resultSize = childDimension;        //size为精确值  
            resultMode = MeasureSpec.EXACTLY;   //mode为 EXACTLY 。  
        }  
        //2.2、子View的width或height为 MATCH_PARENT/FILL_PARENT  
        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;                  //size为父视图大小  
            resultMode = MeasureSpec.AT_MOST;   //mode为AT_MOST  
        }  
        //2.3、子View的width或height为 WRAP_CONTENT  
        else if (childDimension == LayoutParams.WRAP_CONTENT) {  
            // Child wants to determine its own size. It can't be  
            // bigger than us.  
            resultSize = size;                  //size为父视图大小  
            resultMode = MeasureSpec.AT_MOST;   //mode为AT_MOST  
        }  
        break;  

    // Parent asked to see how big we want to be  
    //3、父View是UNSPECIFIED的 !  
    case MeasureSpec.UNSPECIFIED:  
        //3.1、子View的width或height是个精确值 (an exactly size)  
        if (childDimension >= 0) {  
            // Child wants a specific size... let him have it  
            resultSize = childDimension;        //size为精确值  
            resultMode = MeasureSpec.EXACTLY;   //mode为 EXACTLY  
        }  
        //3.2、子View的width或height为 MATCH_PARENT/FILL_PARENT  
        else if (childDimension == LayoutParams.MATCH_PARENT) {  
            // Child wants to be our size... find out how big it should  
            // be  
            resultSize = 0;                        //size为0! ,其值未定  
            resultMode = MeasureSpec.UNSPECIFIED;  //mode为 UNSPECIFIED  
        }   
        //3.3、子View的width或height为 WRAP_CONTENT  
        else if (childDimension == LayoutParams.WRAP_CONTENT) {  
            // Child wants to determine its own size.... find out how  
            // big it should be  
            resultSize = 0;                        //size为0! ,其值未定  
            resultMode = MeasureSpec.UNSPECIFIED;  //mode为 UNSPECIFIED  
        }  
        break;  
    }  
    //根据上面逻辑条件获取的mode和size构建MeasureSpec对象。  
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);  
}

其实计算原理很简单: 
- 如果我们在xml 的layout_width或者layout_height 把值都写死,那么上述的测量完全就不需要了,之所以要上面的这步测量,是因为 match_parent 就是充满父容器,wrap_content 就是自己多大就多大, 我们写代码的时候特别爽,我们编码方便的时候,google就要帮我们计算你match_parent的时候是多大,wrap_content的是多大,这个计算过程,就是计算出来的父View的MeasureSpec不断往子View传递,结合子View的LayoutParams 一起再算出子View的MeasureSpec,然后继续传给子View,不断计算每个View的MeasureSpec,子View有了MeasureSpec才能更测量自己和自己的子View。

  • 如果父View的MeasureSpec 是EXACTLY,说明父View的大小是确切的,(确切的意思很好理解,如果一个View的MeasureSpec 是EXACTLY,那么它的size 是多大,最后展示到屏幕就一定是那么大)。
    • 如果子View 的layout_xxxx是MATCH_PARENT,父View的大小是确切,子View的大小又MATCH_PARENT(充满整个父View),那么子View的大小肯定是确切的,而且大小值就是父View的size。所以子View的size=父View的size,mode=EXACTLY
    • 如果子View 的layout_xxxx是WRAP_CONTENT,也就是子View的大小是根据自己的content 来决定的,但是子View的毕竟是子View,大小不能超过父View的大小,但是子View的是WRAP_CONTENT,我们还不知道具体子View的大小是多少,要等到child.measure(childWidthMeasureSpec, childHeightMeasureSpec) 调用的时候才去真正测量子View 自己content的大小(比如TextView wrap_content 的时候你要测量TextView content 的大小,也就是字符占用的大小,这个测量就是在child.measure(childWidthMeasureSpec, childHeightMeasureSpec)的时候,才能测出字符的大小,MeasureSpec 的意思就是假设你字符100px,但是MeasureSpec 要求最大的只能50px,这时候就要截掉了)。通过上述描述,子View MeasureSpec mode的应该是AT_MOST,而size 暂定父View的 size,表示的意思就是子View的大小没有不确切的值,子View的大小最大为父View的大小,不能超过父View的大小(这就是AT_MOST 的意思),然后这个MeasureSpec 做为子View measure方法 的参数,做为子View的大小的约束或者说是要求,有了这个MeasureSpec子View再实现自己的测量。
    • 如果如果子View 的layout_xxxx是确定的值(200dp),那么就更简单了,不管你父View的mode和size是什么,我都写死了就是200dp,那么控件最后展示就是就是200dp,不管我的父View有多大,也不管我自己的content 有多大,反正我就是这么大,所以这种情况MeasureSpec 的mode = EXACTLY 大小size=你在layout_xxxx 填的那个值。
  • 如果父View的MeasureSpec 是AT_MOST,说明父View的大小是不确定,最大的大小是MeasureSpec 的size值,不能超过这个值。

    • 如果子View 的layout_xxxx是MATCH_PARENT,父View的大小是不确定(只知道最大只能多大),子View的大小MATCH_PARENT(充满整个父View),那么子View你即使充满父容器,你的大小也是不确定的,父View自己都确定不了自己的大小,你MATCH_PARENT你的大小肯定也不能确定的,所以子View的mode=AT_MOST,size=父View的size,也就是你在布局虽然写的是MATCH_PARENT,但是由于你的父容器自己的大小不确定,导致子View的大小也不确定,只知道最大就是父View的大小。
    • 如果子View 的layout_xxxx是WRAP_CONTENT,父View的大小是不确定(只知道最大只能多大),子View又是WRAP_CONTENT,那么在子View的Content没算出大小之前,子View的大小最大就是父View的大小,所以子View MeasureSpec mode的就是AT_MOST,而size 暂定父View的 size。
    • 如果如果子View 的layout_xxxx是确定的值(200dp),同上,写多少就是多少,改变不了的。
  • 如果父View的MeasureSpec 是UNSPECIFIED(未指定),表示没有任何束缚和约束,不像AT_MOST表示最大只能多大,不也像EXACTLY表示父View确定的大小,子View可以得到任意想要的大小,不受约束

    • 如果子View 的layout_xxxx是MATCH_PARENT,因为父View的MeasureSpec是UNSPECIFIED,父View自己的大小并没有任何约束和要求,那么对于子View来说无论是WRAP_CONTENT还是MATCH_PARENT,子View也是没有任何束缚的,想多大就多大,没有不能超过多少的要求,一旦没有任何要求和约束,size的值就没有任何意义了,所以一般都直接设置成0
    • 同上…
    • 如果如果子View 的layout_xxxx是确定的值(200dp),同上,写多少就是多少,改变不了的(记住,只有设置的确切的值,那么无论怎么测量,大小都是不变的,都是你写的那个值)

3.3 View.onMeasure()

从measure就可以直接进入onMeasure

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
  ......
  onMeasure(widthMeasureSpec,heightMeasureSpec);
  .....
}

再来看看onMeasure的实现

总结一下流程就是:

  1. 获取测量尺寸和模式,定义临时变量存储技术结果
  2. 判断测量模式: 
      • 模式是EXACTL的,就使用测量规格中的尺寸
      • 模式是UNSPECIFIED,使用自身计算的尺寸
      • 模式是AT_MOST的,使用自身计算的尺寸与规定尺寸中较小的一个
  3. 设置测量尺寸

3.4 ViewGroup.onMeasure

以FrameLayout为例

//FrameLayout 的测量
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
....
int maxHeight = 0;
int maxWidth = 0;
int childState = 0;
for (int i = 0; i < count; i++) {    
   final View child = getChildAt(i);    
   if (mMeasureAllChildren || child.getVisibility() != GONE) {   
    // 遍历自己的子View,只要不是GONE的都会参与测量,measureChildWithMargins方法在最上面
    // 的源码已经讲过了,如果忘了回头去看看,基本思想就是父View把自己的MeasureSpec 
    // 传给子View结合子View自己的LayoutParams 算出子View 的MeasureSpec,然后继续往下传,
    // 传递叶子节点,叶子节点没有子View,根据传下来的这个MeasureSpec测量自己就好了。
     measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);       
     final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 
     maxWidth = Math.max(maxWidth, child.getMeasuredWidth() +  lp.leftMargin + lp.rightMargin);        
     maxHeight = Math.max(maxHeight, child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);  
     ....
     ....
   }
}
.....
.....
//所有的孩子测量之后,经过一系类的计算之后通过setMeasuredDimension设置自己的宽高,
//对于FrameLayout 可能用最大的字View的大小,对于LinearLayout,可能是高度的累加,
//具体测量的原理去看看源码。总的来说,父View是等所有的子View测量结束之后,再来测量自己。
setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),        
resolveSizeAndState(maxHeight, heightMeasureSpec, childState << MEASURED_HEIGHT_STATE_SHIFT));
....
}

再来看看ViewGroup的onMeasure一般写法。 
由于ViewGroup的布局卡变万化,根本没有统一的模板,只能根据业务来定,一般大概流程就是:

  • 首先ViewGroup作为容器,有测量所有子view的职责,所以第一步是遍历测量所有的字view。一般我们会选用ViewGroup提供的measureChildWithMargins方法,这个方法已经考虑了子view需要的margin和ViewGroup自身需要的padding。
  • 遍历测量完所有子view,那么子view的宽高基本可以确定了,如果有特殊的需求,可以对子view进行多次测量。
  • 根据自身的布局特性,来确定自身的宽高,但是需要遵守基本的测量规则

4.Layout

接着就是第二个步骤layout了。 
measure只能计算出来的只有view矩阵的大小,具体这个矩阵放在哪里,这就是layout 的工作了。layout的主要作用 :根据子视图的大小以及布局参数将View树放到合适的位置上。 
先来看下ViewGroup的layout

4.1 ViewGroup.layout

public final void layout(int l, int t, int r, int b) {    
   if (!mSuppressLayout && (mTransition == null || !mTransition.isChangingLayout())) {        
    if (mTransition != null) {            
       mTransition.layoutChange(this);        
    }       
    super.layout(l, t, r, b);    
    } else {        
    // record the fact that we noop'd it; request layout when transition finishes        
      mLayoutCalledWhileSuppressed = true;    
   }
}

可以看出来ViewGroup会先处理动画。若没有动画则会先super.layout。否则就会等动画执行完成再来调用super.layout。 
所以ViewGroup.layout的具体实现是在super.layout里面做的

4.2 View.layout

public final void layout(int l, int t, int r, int b) {
       .....
      //设置View位于父视图的坐标轴
       boolean changed = setFrame(l, t, r, b); 
       //判断View的位置是否发生过变化,看有必要进行重新layout吗
       if (changed || (mPrivateFlags & LAYOUT_REQUIRED) == LAYOUT_REQUIRED) {
           if (ViewDebug.TRACE_HIERARCHY) {
               ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_LAYOUT);
           }
           //调用onLayout(changed, l, t, r, b); 函数
           onLayout(changed, l, t, r, b);
           mPrivateFlags &= ~LAYOUT_REQUIRED;
       }
       mPrivateFlags &= ~FORCE_LAYOUT;
       .....
   }

setFrame可以理解为给mLeft 、mTop、mRight、mBottom赋值,然后基本就能确定View自己在父视图的位置了。然后就调用onLayout了。而view的onLayout是一个空实现,只需要重写ViewGroup的onLayout。 
具体怎么onLayout就不分析了

5.Draw

绘制是View树遍历流程的最后一个,前面说的测量和布局只是确定View的大小和位置,如果不对view进行绘制,那么界面上依然不会有任何图形显示出来,draw也是从ViewRoot中的performTraversals发起的。然后会view的draw相关方法,但是并不是每个View都需要执行绘制,在执行绘制的过程中,只会重绘需要绘制的View。 
draw方法的流程为:

  • ViewRoot调用DecorView的draw方法:ViewRoot–>DecorView.draw(canvas)

- DecorView的draw方法调用自己的dispatchDraw(Canvas canvas)方法,然后在此方法中会调用子view的draw(Canvas canvas, ViewGroup parent, long drawtime)方法,此方法会调用单个参数的draw(Canvas canvas)方法。

5.1 draw方法

view有两个重载的draw方法,分别是:


draw(Canvas canvas, ViewGroup parent, long drawtime)
draw(Canvas canvas)

draw(Canvas canvas, ViewGroup parent, long drawtime)方法由父view调用,此方法比较重要的,在这里会判断View的一些内部标识,还会对canvas做一些调整,如绘制区域与绘图坐标系的调整,不一定会调用view的 draw(Canvas canvas)方法,如果不调用则绘制的是view的缓存。具体可以查看相关方法的源码。

 public void draw(Canvas canvas) {
           ......
           //通过内部标识,判断View的行为
            final int privateFlags = mPrivateFlags;
            final boolean dirtyOpaque = (privateFlags & DIRTY_MASK) == DIRTY_OPAQUE &&
                    (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
            mPrivateFlags = (privateFlags & ~DIRTY_MASK) | DRAWN;

            /*
             *draw的步骤
             *
             *      1. 画背景
             *      2. 如果需要, 为显示渐变框做一些准备操作
             *      3. 画内容(onDraw)
             *      4. 画子view
             *      5. 如果需要, 画一些渐变效果
             *      6. 画装饰内容,如滚动条
             */

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

            if (!dirtyOpaque) {
                final Drawable background = mBGDrawable;
                if (background != null) {
                    final int scrollX = mScrollX;
                    final int scrollY = mScrollY;

                    if (mBackgroundSizeChanged) {
                        background.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);
                        mBackgroundSizeChanged = false;
                    }

                    if ((scrollX | scrollY) == 0) {
                        background.draw(canvas);
                    } else {
                        canvas.translate(scrollX, scrollY);
                        background.draw(canvas);
                        canvas.translate(-scrollX, -scrollY);
                    }
                }
            }

            // 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;
    //如果条件不成立,跳过2-5步
            if (!verticalEdges && !horizontalEdges) {
                // Step 3,画内容
                if (!dirtyOpaque) onDraw(canvas);

                // Step 4,画孩子
                dispatchDraw(canvas);

                // Step 6, 画装饰(滚动条)
                onDrawScrollBars(canvas);

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

           ......

            // Step 6, draw decorations (scrollbars)
            onDrawScrollBars(canvas);
dispatchDraw&drawChild
protected void dispatchDraw(Canvas canvas) {
    ......

     for (int i = 0; i < count; i++) {
                    final View child = children[i];
                    if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
                        more |= drawChild(canvas, child, drawingTime);
                    }
                }


    ......
    }


    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
            return child.draw(canvas, this, drawingTime);
        }

可以看到draw的过程分为: 
1. 如果设置了,画背景 
2. 如果需要, 为显示渐变框做一些准备操作 
3. 调用onDraw画内容 
4. 调用dispatchDraw画子view 
5. 如果需要, 画渐变框 
6. 画装饰内容,如前景与滚动条

  • onDraw是每个view需要实现的,否则View默认只能显示背景,而实现onDraw就是为了画出View的内容,而ViewGroup一般不需要实现onDraw,因为它仅仅是作为View的容器没有需要绘制东西,
  • dispatchDraw用来遍历ViewGrop的所有子view,执行draw方法

5.2 onDraw中如何绘制

在系统源码中onDraw是个空实现方法,仅仅提供了一个Canvas画板,到底如何来画View的内容呢?

如果需要熟练的绘制出各种效果的View,我们需要掌握很多知识:

  • Canvas的是使用 绘制-变化-图层操作等等
  • Paint 画笔
  • Path 路径
  • Bitmap Canvas是画布,但是我们需要画纸,Bitmap就是画纸
  • ColorMatrix和Matrix的熟练运用
  • PathMeasure

5.3 View的缓存优化

在Android的显示机制中,View的软件渲染都是基于bitmap图片进行的处理。并且刷新机制中只要是与脏数据区有交集的视图都将重绘,所以在View的设计中就有一个cache的概念存在,这个cache无疑就是一个bitmap对象。也就是说在绘制流程中View不一定会被重新绘制,有可能绘制的只是View的缓存。

参考:http://www.jianshu.com/p/5a71014e7b1b



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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值