自定义View(九)-View的工作原理- View的layout()和draw()

作者:志先生_
地址:https://www.jianshu.com/p/cb8e6c568b50

声明:本文是 志先生_ 原创投稿,转发等请联系原作者授权。

前言

上一节我们将View的测量流程理的差不多了,这篇我们来看下View的剩下的2大流程layout(布局)和draw(绘制)。相对测量来说,布局与绘制就简单了许多,所以我们将这的两大流程放在一起讲解。


performLayout()布局    
由上上篇我们知道,布局是从ViewRootImpl#performLayout()发起的,那我们进入这个方法看一下:

 private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
           int desiredWindowHeight)
{

       ......
       //标记开始当前布局
       mInLayout = true;//2062
       //将全局变量mView(DecorView)赋值给host
       final View host = mView;//2064

       ......

       //DecorView开始布局自己
       host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());//2072

       //标志布局结束
       mInLayout = false;
   }        


首先我们知道host是就DecorView(FrameLayout),那我们进入ViewGroup看看,发现ViewGroup也是调用了它的父类(View)的layout方法,所以这里host.layout()就是调用了View#layout()。layout()方法的四个参数分别是当前View的左(left),上(top),右(right),下(bottom)。由上一篇我们知道,host.getMeasuredWidth(),host.getMeasuredHeight()就是屏幕的大小,所以从参数我们清楚的知道DecorView的四个顶点是从屏幕左上角到屏幕右下角,即整个屏幕。 那我们进入View#layout():

小提示:这里我们需要区分下测量的宽高与最终的宽高:  
我们知道测量宽高和最后的宽高在多数情况下都是相等的,因为从上面我们知道,在layout的时候是调用的getMeasuredWidth()与getMeasuredHeight(),即:测量完成后的宽高作为参数来布局的。不过这是指大多数的情况下,如果你自定义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;
       }

       //保存上一次View的四个点的位置
       int oldL = mLeft;
       int oldT = mTop;
       int oldB = mBottom;
       int oldR = mRight;

       //设置当前View的左顶右低四个位置,并判断布局是否有变换
       boolean changed = isLayoutModeOptical(mParent) ?
               setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
       //在第一次或是位置改变时changed=true 条件成立视图View重新布局
       if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
           onLayout(changed, l, t, r, b);

           if (shouldDrawRoundScrollbar()) {
               if(mRoundScrollbarRenderer == null) {
                   mRoundScrollbarRenderer = new RoundScrollbarRenderer(this);
               }
           } else {
               mRoundScrollbarRenderer = null;
           }

           mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;

           ListenerInfo li = mListenerInfo;
           if (li != null && li.mOnLayoutChangeListeners != null) {
               ArrayList<OnLayoutChangeListener> listenersCopy =
                       (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
               int numListeners = listenersCopy.size();
               for (int i = 0; i < numListeners; ++i) {
                   //布局有变换时的回调 将上面保存的最新的四个位置和上一次的四个位置传给回调监听
                   listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
               }
           }
       }

       mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
       mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
   }

这里会首先通过setFrame方法来设置当前View的四个顶点的位置,即初始化mLeft,mTop,mBottom,mRight这四个值,这四个值一旦确定,那么当前View在父容器中的位置也就确定了。也就是说当setFrame()方法完成后,就基本上完成了当前View的布局。那我们来看下这个方法:

  protected boolean setFrame(int left, int top, int right, int bottom) {
       boolean changed = false;

       if (DBG) {
           Log.d("View", this + " View.setFrame(" + left + "," + top + ","
                   + right + "," + bottom + ")");
       }

       if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
           changed = true;

           // Remember our drawn bit
           int drawn = mPrivateFlags & PFLAG_DRAWN;

           //得到上次的宽和高
           int oldWidth = mRight - mLeft;
           int oldHeight = mBottom - mTop;
           //得到这次的宽和高
           int newWidth = right - left;
           int newHeight = bottom - top;
           //将新旧宽高做比较 判断是否想相等
           boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);

           // Invalidate our old position
           //清除上次布局的位置 重新绘制
           invalidate(sizeChanged);

           //将最新的位置赋值给全局变量
           mLeft = left;
           mTop = top;
           mRight = right;
           mBottom = bottom;
           mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);

           mPrivateFlags |= PFLAG_HAS_BOUNDS;

           判断当前位置是否有变化
           if (sizeChanged) {
               sizeChange(newWidth, newHeight, oldWidth, oldHeight);
           }

           ......

       return changed;
   }


这个方法完成后,当前View的布局也就基本完成了,并且将最新的4个位置赋值给mLeft,mTop,mBottom,mRight。这里我们在来分析上面的小提示。来区分下测量宽高和最终宽高,其实比较这两个的不同就是比较getWidth()/getHeight与getMeasuredWidth()/getMeasuredHeight()。那我们来看下getWidth()/getHeight()方法:

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


上面这4个值正是我们在layout布局中得到的(具体我们可知是在setFrame()方法中),那我们总结下两者的关系:

  1. 最终宽高的生成需要一般需要测量宽高作为参数。

  2. 测量宽高的生成比最终宽高的生成要早。

  3. 最终宽高是由layout来决定的,也就是View在父布局中显示的位置,通常情况下2着相同 (这里用到通常情况,因为在我重写layout时如果改变layout的参数,那么最终在父布局中显示的位置也会改变)

通过setFrame()方法完成了对自己的布局,那么onLayout()他的作用是什么呢?其实onLayout方法的用途是父容器确定子元素的位置。我们来看下View#onLayout():

   /**
    * Called from layout when this view should
    * assign a size and position to each of its children.
    *
    * Derived classes with children should override
    * this method and call layout on each of
    * their children.
    * @param changed This is a new size or position for this view
    * @param left Left position, relative to parent
    * @param top Top position, relative to parent
    * @param right Right position, relative to parent
    * @param bottom Bottom position, relative to parent
    */

   protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
   }

可以发现这是一个空的方法,既然上面提到了他的作用是父容器确定子元素的位置。那我们就是容器所有的父类,View的直接子类ViewGroup去看一下:

  @Override
   protected abstract void onLayout(boolean changed,
           int l, int t, int r, int b)
;

可以发现他是一个抽象方法,那么就说明所有直接继承ViewGroup的容器都要实现这个方法。其实也容易理解,想想平时我们用到的LinearLayout,RelativeLayout都是直接继承ViewGroup的。很明显在使用的时候,在布局子View的时候位置使不用的。在回到开始处,是由host.layout()发起的布局,并且host就是我们的顶级View(DecorView),同时知道DecorView是继承FrameLayout的那么我进入FrameLayout#onLayout(),如下:

@Override
   protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
       layoutChildren(left, top, right, bottom, false /* no force left gravity */);
   }

这里直接调用了layoutChildren()方法,那么我们继续往下走:

void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
       final int count = getChildCount();

       final int parentLeft = getPaddingLeftWithForeground();
       final int parentRight = right - left - getPaddingRightWithForeground();

       final int parentTop = getPaddingTopWithForeground();
       final int parentBottom = bottom - top - getPaddingBottomWithForeground();

       //遍历所有FrameLayout下的ziView
       for (int i = 0; i < count; i++) {
           final View child = getChildAt(i);
           //判断当前子View的可见度。不过是GONE那么就不进行布局
           if (child.getVisibility() != GONE) {
               //获取子子View的getLayoutParams
               final LayoutParams lp = (getLayoutParams) child.getLayoutParams();
               获取子View的宽高
               final int width = child.getMeasuredWidth();
               final int height = child.getMeasuredHeight();

               int childLeft;
               int childTop;
               //根据子View的LayoutParams来获取gravity的设置
               int gravity = lp.gravity;
               if (gravity == -1) {
                   gravity = DEFAULT_CHILD_GRAVITY;
               }

               final int layoutDirection = getLayoutDirection();
               final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
               final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;
               //下面都是根据gravity属性的设置来决定如何设置子View四个点的值
               switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                   case Gravity.CENTER_HORIZONTAL:
                       childLeft = parentLeft + (parentRight - parentLeft - width) / 2 +
                       lp.leftMargin - lp.rightMargin;
                       break;
                   case Gravity.RIGHT:
                       if (!forceLeftGravity) {
                           childLeft = parentRight - width - lp.rightMargin;
                           break;
                       }
                   case Gravity.LEFT:
                   default:
                       childLeft = parentLeft + lp.leftMargin;
               }

               switch (verticalGravity) {
                   case Gravity.TOP:
                       childTop = parentTop + lp.topMargin;
                       break;
                   case Gravity.CENTER_VERTICAL:
                       childTop = parentTop + (parentBottom - parentTop - height) / 2 +
                       lp.topMargin - lp.bottomMargin;
                       break;
                   case Gravity.BOTTOM:
                       childTop = parentBottom - height - lp.bottomMargin;
                       break;
                   default:
                       childTop = parentTop + lp.topMargin;
               }
               //设置完成子View的四个点的值传入子View的layout方法开始布局
               child.layout(childLeft, childTop, childLeft + width, childTop + height);
           }
       }
   }

通过代码和注释,我们了解到FrameLayout调用了这里直接调用了layoutChildren()方法,在这个方法中完成每个子View的布局。这个方法中通过对对齐方式和Margin的计算,来获得子View四个点的位置,最后调用child.layout()方法,如果是View就会走上面View的布局如果是ViewGrouop那么就和上面FrameLayout的布局逻辑一致(这里说的逻辑一致是因为直接继承ViewGroup的容器都会根据自己的特点重写onLayout()方法。比如LinearLayout和FrameLayout的布局方式是不同。但是最后都是会调用child.layout()方法,也就是逻辑都是一样的)

总结:

上面我们就完成了整个布局的绘制流程。下面我就对我们将到的知识点进行一下总结
通过整个layout(布局)我们可以总结如下:

  1. 直接继承ViewGroup的容器要重写onLayout方法,根据自己的特点,完成对子View的布局。

  2. 直接继承ViewGroup的容器要自己处理子View的Margin属性,否则会到时失效。

  3. 通过上面我们知道,在View设置可见度为GONE是不会布局。这个是为什么设置View.GONE不会占用布局的原因。

  4. 必须要在布局完成后才能获取到调用getHeight()和getWidth()方法获取到的View的宽高否则为0。

关于其他容器是如何重写onLayout()的大家可以自己看下。相信在理解上面的内容,妈妈就再也不用担心我不敢看源码啦~~!我们将我们的流程用流程图来表示,如下:

View树layout绘制流程.png

到此View的绘制也就完成了。下面我们来看下draw(绘制)。


performDraw()绘制    
现在我们来看看View三大流程最后一个流程-->draw(绘制)。它的作用就是讲View绘制在屏幕上。我们还是来看下绘制发起的方法ViewRootImpl#performDraw():

private void performDraw() {

       ......

       try {
           draw(fullRedrawNeeded);//2337
       } finally {
           mIsDrawing = false;
           Trace.traceEnd(Trace.TRACE_TAG_VIEW);
       }

      ......
   }

调用了draw()方法,我们继续走:

    private void draw(boolean fullRedrawNeeded) {

     ......

     if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {//2519
                   return;
               }
     ......    
   }

在draw()方法的最后调用了drawSoftware(),这个方法比较中重要。我们进入这个方法来看下:

/**
    * @return true if drawing was successful, false if an error occurred
    */

 private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff, int yoff,
           boolean scalingRequired, Rect dirty)
{

       // Draw with software renderer.
       final Canvas canvas;
       try {
           //从surface对象中获得canvas变量
           canvas = mSurface.lockCanvas(dirty);

           // If this bitmap's format includes an alpha channel, we
           // need to clear it before drawing so that the child will
           // properly re-composite its drawing on a transparent
           // background. This automatically respects the clip/dirty region
           // or
           // If we are applying an offset, we need to clear the area
           // where the offset doesn't appear to avoid having garbage
           // left in the blank areas.
           if (!canvas.isOpaque() || yoff != 0 || xoff != 0) {
               canvas.drawColor(0, PorterDuff.Mode.CLEAR);
           }

          ......

           try {
               //调整画布的位置
               canvas.translate(-xoff, -yoff);
               if (mTranslator != null) {
                   mTranslator.translateCanvas(canvas);
               }
               canvas.setScreenDensity(scalingRequired ? mNoncompatDensity : 0);
               attachInfo.mSetIgnoreDirtyState = false;
               //调用View类中的成员方法draw开始绘制View视图
               mView.draw(canvas);
           }

       ......

       return true;
   }

从这个方法名字我们可以看出绘制成功返回true,失败返回false,说明绘制是在这里进行的。在这个方法中我们获得了画布canvas并将这个参数传递给 mView.draw(canvas);方法,我们在点进去看看:

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

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

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

       /*
        * Here we do the full fledged routine...
        * (this is an uncommon case where speed matters less,
        * this is why we repeat some of the tests that have been
        * done above)
        */


       /*第二步开始 */

       boolean drawTop = false;
       boolean drawBottom = false;
       boolean drawLeft = false;
       boolean drawRight = false;

       float topFadeStrength = 0.0f;
       float bottomFadeStrength = 0.0f;
       float leftFadeStrength = 0.0f;
       float rightFadeStrength = 0.0f;

       // Step 2, save the canvas' layers
       int paddingLeft = mPaddingLeft;

       final boolean offsetRequired = isPaddingOffsetRequired();
       if (offsetRequired) {
           paddingLeft += getLeftPaddingOffset();
       }

       int left = mScrollX + paddingLeft;
       int right = left + mRight - mLeft - mPaddingRight - paddingLeft;
       int top = mScrollY + getFadeTop(offsetRequired);
       int bottom = top + getFadeHeight(offsetRequired);

       if (offsetRequired) {
           right += getRightPaddingOffset();
           bottom += getBottomPaddingOffset();
       }

       final ScrollabilityCache scrollabilityCache = mScrollCache;
       final float fadeHeight = scrollabilityCache.fadingEdgeLength;
       int length = (int) fadeHeight;

       // clip the fade length if top and bottom fades overlap 如果顶部和底部淡入淡出,则剪切淡入淡出长度
       // overlapping fades produce odd-looking artifacts
       if (verticalEdges && (top + length > bottom - length)) {
           length = (bottom - top) / 2;
       }

       // also clip horizontal fades if necessary
       if (horizontalEdges && (left + length > right - length)) {
           length = (right - left) / 2;
       }

       if (verticalEdges) {
           topFadeStrength = Math.max(0.0f, Math.min(1.0f, getTopFadingEdgeStrength()));
           drawTop = topFadeStrength * fadeHeight > 1.0f;
           bottomFadeStrength = Math.max(0.0f, Math.min(1.0f, getBottomFadingEdgeStrength()));
           drawBottom = bottomFadeStrength * fadeHeight > 1.0f;
       }

       if (horizontalEdges) {
           leftFadeStrength = Math.max(0.0f, Math.min(1.0f, getLeftFadingEdgeStrength()));
           drawLeft = leftFadeStrength * fadeHeight > 1.0f;
           rightFadeStrength = Math.max(0.0f, Math.min(1.0f, getRightFadingEdgeStrength()));
           drawRight = rightFadeStrength * fadeHeight > 1.0f;
       }

       saveCount = canvas.getSaveCount();

       int solidColor = getSolidColor();
       if (solidColor == 0) {
           final int flags = Canvas.HAS_ALPHA_LAYER_SAVE_FLAG;

           if (drawTop) {
               canvas.saveLayer(left, top, right, top + length, null, flags);
           }

           if (drawBottom) {
               canvas.saveLayer(left, bottom - length, right, bottom, null, flags);
           }

           if (drawLeft) {
               canvas.saveLayer(left, top, left + length, bottom, null, flags);
           }

           if (drawRight) {
               canvas.saveLayer(right - length, top, right, bottom, null, flags);
           }
       } else {
           scrollabilityCache.setFadeColor(solidColor);
       }

       /*第二步结束 */

       // Step 3, draw the content
       if (!dirtyOpaque) onDraw(canvas);

       // Step 4, draw the children
       dispatchDraw(canvas);

       // Step 5, draw the fade effect and restore layers  绘制淡入淡出效果
       final Paint p = scrollabilityCache.paint;
       final Matrix matrix = scrollabilityCache.matrix;
       final Shader fade = scrollabilityCache.shader;

       if (drawTop) {
           matrix.setScale(1, fadeHeight * topFadeStrength);
           matrix.postTranslate(left, top);
           fade.setLocalMatrix(matrix);
           p.setShader(fade);
           canvas.drawRect(left, top, right, top + length, p);
       }

       if (drawBottom) {
           matrix.setScale(1, fadeHeight * bottomFadeStrength);
           matrix.postRotate(180);
           matrix.postTranslate(left, bottom);
           fade.setLocalMatrix(matrix);
           p.setShader(fade);
           canvas.drawRect(left, bottom - length, right, bottom, p);
       }

       if (drawLeft) {
           matrix.setScale(1, fadeHeight * leftFadeStrength);
           matrix.postRotate(-90);
           matrix.postTranslate(left, top);
           fade.setLocalMatrix(matrix);
           p.setShader(fade);
           canvas.drawRect(left, top, left + length, bottom, p);
       }

       if (drawRight) {
           matrix.setScale(1, fadeHeight * rightFadeStrength);
           matrix.postRotate(90);
           matrix.postTranslate(right, top);
           fade.setLocalMatrix(matrix);
           p.setShader(fade);
           canvas.drawRect(right - length, top, right, bottom, p);
       }
       //恢复图层
       canvas.restoreToCount(saveCount);

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

这个方法有点长,但是很好理解。已进入方法就提示了绘制的过程遵循以下6个步骤:

  1. 绘制当前视图的背景。

  2. 保存当前画布的堆栈状态,并且在在当前画布上创建额外的图层,以便接下来可以用来绘制当前视图在滑动时的边框渐变效果。

  3. 绘制当前视图的内容。

  4. 绘制当前视图的子视图的内容。

  5. 绘制当前视图在滑动时的边框渐变效果。

  6. 绘制当前视图的滚动条。

在一般情况下2和5我们在自定义View时是不会去修改的。但是为了记录,还是简单讲解下。

由于篇幅字数限制,后面的分析部分请点击文末阅读原文查看或者打开文章开始的地址在网页查看。

总结 :

到此View的draw绘制流程6步我们已经完全理清了。其实不是所有的我们都需要掌握其实在我们自定义View的时候只需要注意1,3,4,6即可而在这4个步骤中往往我们只是需要重写第三步或第四步而已。下面按照管理我们上流程图讲我们的流程串联起来,加深理解。如下:

View的绘制流程.png


View绘制6步分析.png

我们在来总结几个关于View绘制相关的知识点:

  • 父类View绘制主要是绘制背景,边框渐变效果,进度条,View具体的内容绘制调用了onDraw方法,通过该方法把View内容的绘制逻辑留给子类去实现。因此,我们在自定义View的时候都一般都需要重写父类的onDraw方法来实现View内容绘制。

  • onDraw,dispatchDraw区别

  • View还是ViewGroup对它们俩的调用顺序都是onDraw()->dispatchDraw()

  • 在ViewGroup中,当它有背景的时候就会调用onDraw()方法,否则就会跳过onDraw()直接调用dispatchDraw();所以如果要在ViewGroup中绘图时,往往是重写dispatchDraw()方法

  • 在View中,onDraw()和dispatchDraw()都会被调用的,所以我们无论把绘图代码放在onDraw()或者dispatchDraw()中都是可以得到效果的,但是由于dispatchDraw()的含义是绘制子控件,所以原则来上讲,在绘制View控件时,我们是重新onDraw()函数

  • 最后总结:在绘制View控件时,需要重写onDraw()函数,在绘制ViewGroup时,需要重写dispatchDraw()函数。

  • .不管任何情况,每一个View视图都会绘制 scrollBars滚动条,且绘制滚动条的逻辑是在父类View中实现,子类无需自己实现滚动条的绘制。其实TextView也是有滚动条的,可以通过代码让其显示滚动条和内容滚动效果。你只需在TextView布局设置android:scrollbars=”vertical”属性,同时在代码中进行如下设置:

textView.setMovementMethod(ScrollingMovementMethod.getInstance()); 
  • ViewGroup绘制的过程会对每个子视图View设置布局容器动画效果,如果你在ViewGroup容器布局里面设置了如下属性的话

android:animateLayoutChanges="true"

结语

至此View的三大流程就结束了。当然里面的只是不管我写的这些,但是我觉的这也应该是比较全了。不过自定义View是个熟能生巧的一个技术,光理解原理是不够的,但是不理解原理写起来出现问题就不好处理。希望大家在对完后自己去多看下源码,一遍不行就多看几遍把他变成自己的东西。对于菜鸟的写这些文章也不容易,只是希望能对入门android的小伙伴有些帮助。如果有问题留言,我知道的一定会回复你。最后希望大家都能成为大神。加油!!!


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值