自定义 View 之 onLayout() 和 onDraw() 深入分析

前言:念念不忘,必有回响,永远坚持你所坚持的!

上一篇对 onMeasure() 方法做了深入分析,说实在的,自定义 View 的 onMeasure
看那一篇就足够了。对于想深入学习学习源码的话,后期我也会更新源码分析专栏的。onMeasure 方法是最难理解的一个,理解了 onMeasure() 那么 onLayout() 和 onDraw() 就非常简单了。本篇就一气呵成,对 onLayout() 和 onDraw() 进行深入分析。这两个方法虽然简单,但是不太好讲,也只能贴一点源码在这里讲了,对初学者可能有点不够友好。结尾再给个简单案例帮助理解,希望能加深点印象。

你应该清楚的是,一个 Activity 通过 setContentView 之后,视图的展示可以通过如下图来表示:

image
mDecor 是 Activity 的顶层窗体,他是 FramLayout 的子类对象;
mContentRoot 是根据设置给窗体加载的整个 Activity 可见的视图,这个视图包含标题栏(如果主题设置有标题),用于容纳我们自定义 layout 的 id 为 content 的容器,mContentRoot 被添加到了顶层窗口 mDecor 中;
mContentParent 是 mContentRoot 中 id 为 content 的容器,这个容器就是用来添加我们写的 layout 布局文件的,mContentParent 是嵌套在 mContentRoot 中,mContentRoot 嵌套在 mDecor。所以在上面第⑥步可以直接调用 findViewById() 找到 mContentParent。( 跟踪 findViewById() 方法,发现调用的是 PhoneWindow 中 mDecor 这个顶层窗口的 findViewById() 方法 )
1、onLayout() 分析
你应该知道,View 的布局在 ViewRootImpl.performLayout() 发起的:

ViewRootImpl.performLayout() 请求布局过程
private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
int desiredWindowHeight) {
mLayoutRequested = false;
mScrollMayChange = true;
mInLayout = true;
final View host = mView;

Trace.traceBegin(Trace.TRACE_TAG_VIEW, "layout");
try {
    host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());
    ...
} finally {
    Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
mInLayout = false;

}

ViewRootImpl.performLayout() 方法中会调用 mView( Activity 根窗口 mDecor )的 layout() 方法,为窗口中所有的子控件安排显示的位置,由于不同的容器有不同的布局策略,所以在布局之前首先要确定所有子控件的大小,才能适当的为子控件安排位置,这就是为什么测量过程需要在布局过程之前完成。接着我们看看 DecorView 的layout() 方法( layout 方法继承自 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;

//为控件重新设置新的坐标值,并判断是否需要重新布局
boolean changed = isLayoutModeOptical(mParent) ?
        setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
    //onLayout()方法在View中是一个空实现,各种容器需要重写onLayout()方法,为子控件布局
    onLayout(changed, l, t, r, b);
    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;

}

从上面的代码中,我们注意到关于布局有两个重要的方法,View.layout() 和 View.onLayout(),这两个方法有什么关系?各自的作用是什么呢?他们都是定义在 View 中的,不同的是 layout() 方法中有很长一段实现的代码,而 onLayout() 确实一个空的实现,里面什么事也没做。
  首先我们要明确布局的本质是什么,布局就是为 View 设置四个坐标值,这四个坐标值保存在View的成员变量 mLeft、mTop、mRight、mBottom 中,方便 View 在绘制(onDraw)的时候知道应该在那个区域内绘制控件。而我们看到 layout() 方法中实际上就是为这几个成员变量赋值的,所以到底真正设置坐标的是layout()方法,那onLayout()的作用是什么呢?
  onLayout()都是由ViewGroup的子类实现的,他的作用就是确定容器中每个子控件的位置,由于不同的容器有不容的布局策略,所以每个容器对onLayout()方法的实现都不同,onLayout()方法会遍历容器中所有的子控件,然后计算他们左上右下的坐标值,最后调用child.layout()方法为子控件设置坐标;由于layout()方法中又调用了onLayout()方法,如果子控件child也是一个容器,就会继续为它的子控件计算坐标,如果child不是容器,onLayout()方法将什么也不做,这样下来,只要Activity根窗口mDecor的layout()方法执行完毕,窗口中所有的子容器、子控件都将完成布局操作。

其实布局过程的调用方式和测量过程是一样的,ViewGroup的子类都要重写onMeasure()方法遍历子控件调用他们的measure()方法,measure()方法又会调用onMeasure()方法,如果子控件是普通控件就完成了测量,如果是容器将会继续遍历其孙子控件。

继续查看DecorView.onLayout()方法:

DecorView .onLayout()
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
getOutsets(mOutsets);
if (mOutsets.left > 0) {
offsetLeftAndRight(-mOutsets.left);
}
if (mOutsets.top > 0) {
offsetTopAndBottom(-mOutsets.top);
}
}

DecorView是FrameLayout的子类,FrameLayout又是ViewGroup的子类,FrameLayout重写了onLayout()方法,DecorView也重写了onLayout()方法,但是调用的是super.onLayout(),然后做了一些边界判断,下面我们看FrameLayout.onLayout():

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

void layoutChildren(int left, int top, int right, int bottom,
boolean forceLeftGravity) {
//获取子控件数量
final int count = getChildCount();

//获取padding值
final int parentLeft = getPaddingLeftWithForeground();
final int parentRight = right - left - getPaddingRightWithForeground();

final int parentTop = getPaddingTopWithForeground();
final int parentBottom = bottom - top - getPaddingBottomWithForeground();
//遍历子控件,为其计算左上右下坐标,由于不同容器的布局特性,下面的计算过程都是根据容器的布局特性计算的
for (int i = 0; i < count; i++) {
    final View child = getChildAt(i);
    if (child.getVisibility() != GONE) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();

        final int width = child.getMeasuredWidth();
        final int height = child.getMeasuredHeight();

        int childLeft;
        int childTop;

        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;

        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;
        }
        //调用其layout()方法为子控件设置坐标
        child.layout(childLeft, childTop, childLeft + width, childTop + height);
    }
}

}

所有的布局容器的onLayout方法都是一样的流程,都是先遍历子控件,然后计算子控件的坐标,最后调用子控件的layout()方法设置布局坐标,但是不同的布局容器有不同的布局策略,所以区别就在于计算子控件坐标时的差异。比如LinearLayout线性布局,如果是水平布局,第一个子控件的l值是0,r是100,那第二个子控件的l就是101(只是打个比方),而FrameLayout,如果没有设置padding,子控件也没设置margin,第一个子控件的l值就是0,第二个子控件的l还是0,这就是不同容器的计算区别。

FrameLayout.onLayout()方法执行完毕后,整个Activity的根窗口的布局过程也就完成了。接下来进入第三个过程–绘制过程:

onDraw()分析
ViewRootImpl.performDraw()控件绘制过程:
你应该清楚,View的绘制起点在ViewRootImpl.performDraw()开始的。

private void performDraw() {

mIsDrawing = true;
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "draw");
try {
    draw(fullRedrawNeeded);
} finally {
    mIsDrawing = false;
    Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}

...

}
private void draw(boolean fullRedrawNeeded) {
Surface surface = mSurface;

final Rect dirty = mDirty;
...

if (!dirty.isEmpty() || mIsAnimating || accessibilityFocusDirty) {
    if (mAttachInfo.mHardwareRenderer != null && mAttachInfo.mHardwareRenderer.isEnabled()) {
        ...
        //使用硬件渲染
        mAttachInfo.mHardwareRenderer.draw(mView, mAttachInfo, this);
    } else {
        ...
        // 通过软件渲染.
        if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {
            return;
        }
    }
}

...

}
private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff, int yoff,
boolean scalingRequired, Rect dirty) {
final Canvas canvas;

try {
    ...
    canvas = mSurface.lockCanvas(dirty);
    ...
} catch (Surface.OutOfResourcesException e) {
    return false;
} catch (IllegalArgumentException e) {
    return false;
}
...
try {
    ...
    try {
        ...
        mView.draw(canvas);
        ...
    } finally {
            ...
    }
} finally {
   ...
}
return true;

}

ViewRootImpl的performDraw()方法调用draw(boolean),在这个过程中主要完成一些条件判断,surface的设置准备,以及判断使用硬件渲染还是软件渲染等操作,由于我们主要研究绘制代码流程层面,所以直接看drawSoftware()方法,对于硬件渲染具体是怎样的有兴趣可以跟踪一下。drawSoftware()方法中,通过mSurface.locakCanvas(dirty)拿到画布,然后调用mView.draw(canvas),这里的mView就是Activity的根窗口DecorView类型的对象。

DecorView.draw()
public void draw(Canvas canvas) {
super.draw(canvas);

if (mMenuBackground != null) {
    mMenuBackground.draw(canvas);
}

}

DecorView重写了View的draw()方法,增加了绘制菜单背景的内容,因为Activity根窗口上会有一些菜单按钮(比如屏幕下方的返回键等),draw()方法中调用了super.draw(cancas),所以我们看看View的draw()方法:

View.draw()
public void draw(Canvas canvas) {

/*
* 绘制遍历执行几个绘图步骤,必须以适当的顺序执行:
* 1.绘制背景
* 2.如果有必要,保存画布的图层,以准备失效
* 3.绘制视图的内容
* 4.绘制子控件
* 5.如果必要,绘制衰落边缘和恢复层
* 6.绘制装饰(比如滚动条)
*/

// Step 1, 绘制背景
int saveCount;

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

// 通常情况请跳过2和5步
final int viewFlags = mViewFlags;
boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
if (!verticalEdges && !horizontalEdges) {
    // Step 3, 绘制本控件的内容
    if (!dirtyOpaque) onDraw(canvas);

    // Step 4, 绘制子控件
    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;
}
//下面的代码是从第一步到第六步的完整流程
...

}

可以看到,第一步 // Step 1, 绘制背景,这一步的作用是对视图的背景进行绘制。这里会先得到一个mBGDrawable对象:
image
然后根据layout过程确定的视图位置来设置背景的绘制区域,之后再调用Drawable的draw()方法来完成背景的绘制工作。那么这个mBGDrawable对象是从哪里来的呢?其实就是在XML中通过android:background属性设置的图片或颜色。当然你也可以在代码中通过setBackgroundColor()、setBackgroundResource()等方法进行赋值。

接下来的第三步是在第34行执行的,这一步的作用是对视图的内容进行绘制。可以看到,这里去调用了一下onDraw()方法,那么onDraw()方法里又写了什么代码呢?进去一看你会发现,原来又是个空方法啊。其实也可以理解,因为每个视图的内容部分肯定都是各不相同的,这部分的功能交给子类来去实现也是理所当然的。

第三步完成之后紧接着会执行第四步,这一步的作用是对当前视图的所有子视图进行绘制。但如果当前的视图没有子视图,那么也就不需要进行绘制了。因此你会发现View中的dispatchDraw()方法又是一个空方法,而ViewGroup的dispatchDraw()方法中就会有具体的绘制代码。

以上都执行完后就会进入到第六步,也是最后一步,这一步的作用是对视图的滚动条进行绘制。那么你可能会奇怪,当前的视图又不一定是ListView或者ScrollView,为什么要绘制滚动条呢?其实不管是Button也好,TextView也好,任何一个视图都是有滚动条的,只是一般情况下我们都没有让它显示出来而已。绘制滚动条的代码逻辑也比较复杂,这里就不再贴出来了,因为我们的重点是第三步过程。

通过以上流程分析,相信大家已经知道,View是不会帮我们绘制内容部分的,因此需要每个视图根据想要展示的内容来自行绘制。如果你去观察TextView、ImageView等类的源码,你会发现它们都有重写onDraw()这个方法,并且在里面执行了相当不少的绘制逻辑。绘制的方式主要是借助Canvas这个类,它会作为参数传入到onDraw()方法中,供给每个视图使用。Canvas这个类的用法非常丰富,基本可以把它当成一块画布,在上面绘制任意的东西。

对于有孩子控件的ViewGroup,需要层级绘制:

protected void dispatchDraw(Canvas canvas) {

final int childrenCount = mChildrenCount;
final View[] children = mChildren;

if ((flags & FLAG_RUN_ANIMATION) != 0 && canAnimate()) {
    ...
    for (int i = 0; i < childrenCount; i++) {
        while (transientIndex >= 0 && mTransientIndices.get(transientIndex) == i) {
            final View transientChild = mTransientViews.get(transientIndex);
            if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE ||
                    transientChild.getAnimation() != null) {
                more |= drawChild(canvas, transientChild, drawingTime);
            }
            transientIndex++;
            if (transientIndex >= transientCount) {
                transientIndex = -1;
            }
        }
        int childIndex = customOrder ? getChildDrawingOrder(childrenCount, i) : i;
        final View child = (preorderedList == null)
                ? children[childIndex] : preorderedList.get(childIndex);
        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);
}

dispatchDraw()方法中首先获取子控件的数量childrenCount,然后遍历所有子控件,调用drawChild()方法,drawChild()方法中只是简单的调用child.draw(),这样就完成了子控件的绘制。细心的你可以发现child的draw()方法又会执行之前DecorView.draw()的六步(draw()是在View里面实现的),所以说,所有的控件在绘制的时候都会调用draw()方法,draw()方法中会先调用onDraw()方法绘制自己,然后调用dispatchDraw()绘制它的子控件(如果有孩子的话),如果此控件不是ViewGroup的子类,也就是说是叶子控件,dispatchDraw()`什么也不做。

那么我们在自定义自己的View 的时候我们应该怎么做呢?下面一张图能更好的帮助你理解:

image
讲了这么多的理论,接下来就做一点小小的实战吧,来帮助学习上面的理论。分别对应onLayout和onMeasure:

onLayout简单案例:
自定义的这个布局目标很简单,只要能够包含一个子视图,并且让子视图正常显示出来就可以了。那么就给这个布局起名叫做SimpleLayout吧,代码如下所示:

public class SimpleLayout extends ViewGroup {

public SimpleLayout(Context context, AttributeSet attrs) {  
    super(context, attrs);  
}  

@Override  
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);  
    if (getChildCount() > 0) {  
        View childView = getChildAt(0);  
        measureChild(childView, widthMeasureSpec, heightMeasureSpec);  
    }  
}  

@Override  
protected void onLayout(boolean changed, int l, int t, int r, int b) {  
    if (getChildCount() > 0) {  
        View childView = getChildAt(0);  
        childView.layout(0, 0, childView.getMeasuredWidth(), childView.getMeasuredHeight());  
    }  
}  

}

代码非常的简单,我们来看下具体的逻辑吧。你已经知道,onMeasure()方法会在onLayout()方法之前调用,因此这里在onMeasure()方法中判断SimpleLayout中是否有包含一个子视图,如果有的话就调用measureChild()方法来测量出子视图的大小。

接着在onLayout()方法中同样判断SimpleLayout是否有包含一个子视图,然后调用这个子视图的layout()方法来确定它在SimpleLayout布局中的位置,这里传入的四个参数依次是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),分别代表着子视图在SimpleLayout中左上右下四个点的坐标。其中,调用childView.getMeasuredWidth()和childView.getMeasuredHeight()方法得到的值就是在onMeasure()方法中测量出的宽和高。

这样就已经把SimpleLayout这个布局定义好了,下面就是在XML文件中使用它了,如下所示:

<com.example.viewtest.SimpleLayout xmlns:android=“http://schemas.android.com/apk/res/android”
android:layout_width=“match_parent”
android:layout_height=“match_parent” >

<ImageView   
    android:layout_width="wrap_content"  
    android:layout_height="wrap_content"  
    android:src="@drawable/ic_launcher"  
    />  

</com.example.viewtest.SimpleLayout>

可以看到,我们能够像使用普通的布局文件一样使用SimpleLayout,只是注意它只能包含一个子视图,多余的子视图会被舍弃掉。这里SimpleLayout中包含了一个ImageView,并且ImageView的宽高都是wrap_content。现在运行一下程序,结果如下图所示:

image
OK!ImageView成功已经显示出来了,并且显示的位置也正是我们所期望的。如果你想改变ImageView显示的位置,只需要改变childView.layout()方法的四个参数就行了。

在onLayout()过程结束后,我们就可以调用getWidth()方法和getHeight()方法来获取视图的宽高了。说到这里,我相信很多朋友长久以来都会有一个疑问,getWidth()方法和getMeasureWidth()方法到底有什么区别呢?它们的值好像永远都是相同的。其实它们的值之所以会相同基本都是因为布局设计者的编码习惯非常好,实际上它们之间的差别还是挺大的。

首先getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。另外,getMeasureWidth()方法中的值是通过setMeasuredDimension()方法来进行设置的,而getWidth()方法中的值则是通过视图右边的坐标减去左边的坐标计算出来的。

public final int getWidth(){
return mRight - mLeft;//视图右边的坐标减去左边的坐标
}

观察SimpleLayout中onLayout()方法的代码,这里给子视图的layout()方法传入的四个参数分别是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),因此getWidth()方法得到的值就是childView.getMeasuredWidth() - 0 = childView.getMeasuredWidth() ,所以此时getWidth()方法和getMeasuredWidth() 得到的值就是相同的,但如果你将onLayout()方法中的代码进行如下修改:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (getChildCount() > 0) {
View childView = getChildAt(0);
childView.layout(0, 0, 200, 200);
}
}

这样getWidth()方法得到的值就是200 - 0 = 200,不会再和getMeasuredWidth()的值相同了。当然这种做法充分不尊重measure()过程计算出的结果,通常情况下是不推荐这么写的。getHeight()与getMeasureHeight()方法之间的关系同上,就不再重复分析了。

onDraw简单案例:
这里简单起见,我只是创建一个非常简单的视图,并且用Canvas随便绘制了一点东西,代码如下所示:

public class MyView extends View {

private Paint mPaint;  

public MyView(Context context, AttributeSet attrs) {  
    super(context, attrs);  
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);  
}  

@Override  
protected void onDraw(Canvas canvas) {  
    mPaint.setColor(Color.YELLOW);  
    canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);  
    mPaint.setColor(Color.BLUE);  
    mPaint.setTextSize(20);  
    String text = "Hello View";  
    canvas.drawText(text, 0, getHeight() / 2, mPaint);  
}  

}

可以看到,我们创建了一个自定义的MyView继承自View,并在MyView的构造函数中创建了一个Paint对象。Paint就像是一个画笔一样,配合着Canvas就可以进行绘制了。这里我们的绘制逻辑比较简单,在onDraw()方法中先是把画笔设置成黄色,然后调用Canvas的drawRect()方法绘制一个矩形。然后在把画笔设置成蓝色,并调整了一下文字的大小,然后调用drawText()方法绘制了一段文字。

就这么简单,一个自定义的视图就已经写好了,现在可以在XML中加入这个视图,如下所示:

<com.example.viewtest.MyView   
    android:layout_width="200dp"  
    android:layout_height="100dp"  
    />  

将MyView的宽度设置成200dp,高度设置成100dp,然后运行一下程序,结果如下图所示:

image
图中显示的内容也正是MyView这个视图的内容部分了。由于我们没给MyView设置背景,因此这里看不出来View自动绘制的背景效果。

到此为止,我们把视图绘制流程的第三阶段也分析完了。整个视图的绘制过程就全部结束了,你现在是不是对View的理解更加深刻了呢?作者:阿瑞921链接:https://www.jianshu.com/p/0b444c4c5cf6来源:简书

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值