View的绘制流程

Android View绘制流程

撕书最近正在找工作,面试经常会被问到View的绘制流程,之前根本没有深入到源码当中去看,今天好好看了一下这方面相关的内容。当然本篇文章也是针对面试怎么回答多点,后边会补一篇更加详细的源码解析。

引言

从生活的角度来讲,我们要画素描的时候步骤是什么?肯定是先拿着2B铅笔先进行测量,接着要在画纸上进行布局,最后才开始动手画画。对比到View的绘制就是其三个重要的过程:measure->layout->draw。接下来主要针对这三个步骤回答一下。

View绘制开始

View绘制开始的地方是ViewRootImpl类的performTraversals()方法,我们看下这个方法的具体内容:

private void performTraversals() {
        ......
        int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
        int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
         ......
         // Ask host how big it wants to be
         performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
         ......
         performLayout(lp, desiredWindowWidth, desiredWindowHeight);
         ......
         performDraw();
        ......
     }

其中childWidthMeasureSpec和childHeightMeasureSpec是通过getRootMeasureSpec获取的宽和高。看下getRootMeasureSpec具体干了什么:

/**
     * Figures out the measure spec for the root view in a window based on it's
     * layout params.
     *
     * @param windowSize
     *            The available width or height of the window
     *
     * @param rootDimension
     *            The layout params for one dimension (width or height) of the
     *            window.
     *
     * @return The measure spec to use to measure the root view.
     */
    private static int getRootMeasureSpec(int windowSize, int rootDimension) {
        int measureSpec;
        switch (rootDimension) {

        case ViewGroup.LayoutParams.MATCH_PARENT:
            // Window can't resize. Force root view to be windowSize.
            measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
            break;
        case ViewGroup.LayoutParams.WRAP_CONTENT:
            // Window can resize. Set max size for root view.
            measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
            break;
        default:
            // Window wants to be an exact size. Force root view to be that size.
            measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
            break;
        }
        return measureSpec;
    }

在这里插入图片描述
首先了解一下什么是MeasureSpec(测量规格),MeasureSpec是一个32位整数,由SpecMode和SpecSize两部分组成,其中,高2位为SpecMode,低30位为SpecSize。SpecMode为测量模式,SpecSize为相应测量模式下的测量尺寸。
View(包括普通View和ViewGroup)的SpecMode由本View的LayoutParams结合父View的MeasureSpec生成
SpecMode的三种模式
MeasureSpec.EXACTLY //确定模式,父View希望子View的大小是确定的,由specSize决定;
MeasureSpec.AT_MOST //最多模式,父View希望子View的大小最多是specSize指定的值;
MeasureSpec.UNSPECIFIED //未指定模式,父View对子View的大小不做限制,完全由子View自己决定;

这里传入进来的windowSize参数是window的可用宽高信息,rootDimension宽高参数均为MATCH_PARENT。

接下来继续回到performTraversals()方法中,我们performMeasure,performLayout,perforDraw这几个方法:

private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "measure");
        try {
            mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
    }
private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
            int desiredWindowHeight) {
  
        ...
        final View host = mView;

        host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());
       ...
    }
private void performDraw() {
         ...
         draw(fullRedrawNeeded);
          ...
    }
private void draw(boolean fullRedrawNeeded) {

          ...
          if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {
             return;
          }
          ...
    }
private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff, int yoff,
            boolean scalingRequired, Rect dirty) {
        ...
         mView.draw(canvas);
        ...
        return true;
    }

实际上View的绘制流程可以分为三个阶段:

  • measure: 判断是否需要重新计算View的大小,需要的话则计算;
  • layout: 判断是否需要重新计算View的位置,需要的话则计算;
  • draw: 判断是否需要重新绘制View,需要的话则重绘制。
    在这里插入图片描述

measure

/**
     * <p>
     * This is called to find out how big a view should be. The parent
     * supplies constraint information in the width and height parameters.
     * </p>
     *
     * <p>
     * The actual measurement work of a view is performed in
     * {@link #onMeasure(int, int)}, called by this method. Therefore, only
     * {@link #onMeasure(int, int)} can and must be overridden by subclasses.
     * </p>
     *
     *
     * @param widthMeasureSpec Horizontal space requirements as imposed by the
     *        parent
     * @param heightMeasureSpec Vertical space requirements as imposed by the
     *        parent
     *
     * @see #onMeasure(int, int)
     */
    public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
        ...
        // measure ourselves, this should set the measured dimension flag back
        onMeasure(widthMeasureSpec, heightMeasureSpec);
         ...
    }

注释已经详细描述,这是用来测绘view大小的方法,measure方法是final修饰的,因此是不能重写的,但是measure方法最终调用的是onMeasure,这个方法是可以被重写的。看下onMeasure方法具体干了什么事:

/**
     * <p>
     * Measure the view and its content to determine the measured width and the
     * measured height. This method is invoked by {@link #measure(int, int)} and
     * should be overridden by subclasses to provide accurate and efficient
     * measurement of their contents.
     * </p>
     *
     * <p>
     * <strong>CONTRACT:</strong> When overriding this method, you
     * <em>must</em> call {@link #setMeasuredDimension(int, int)} to store the
     * measured width and height of this view. Failure to do so will trigger an
     * <code>IllegalStateException</code>, thrown by
     * {@link #measure(int, int)}. Calling the superclass'
     * {@link #onMeasure(int, int)} is a valid use.
     * </p>
     *
     * <p>
     * The base class implementation of measure defaults to the background size,
     * unless a larger size is allowed by the MeasureSpec. Subclasses should
     * override {@link #onMeasure(int, int)} to provide better measurements of
     * their content.
     * </p>
     *
     * <p>
     * If this method is overridden, it is the subclass's responsibility to make
     * sure the measured height and width are at least the view's minimum height
     * and width ({@link #getSuggestedMinimumHeight()} and
     * {@link #getSuggestedMinimumWidth()}).
     * </p>
     *
     * @param widthMeasureSpec horizontal space requirements as imposed by the parent.
     *                         The requirements are encoded with
     *                         {@link android.view.View.MeasureSpec}.
     * @param heightMeasureSpec vertical space requirements as imposed by the parent.
     *                         The requirements are encoded with
     *                         {@link android.view.View.MeasureSpec}.
     *
     * @see #getMeasuredWidth()
     * @see #getMeasuredHeight()
     * @see #setMeasuredDimension(int, int)
     * @see #getSuggestedMinimumHeight()
     * @see #getSuggestedMinimumWidth()
     * @see android.view.View.MeasureSpec#getMode(int)
     * @see android.view.View.MeasureSpec#getSize(int)
     */
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

用来测量view以及自身内容来决定宽高,子类应该重写这个方法提供更精确更高效的测量的内容。当重写这个方法的时候子类必须调用setMeasuredDimension(int, int)来存储已经测量出来的宽高。
我们看到系统默认的onMeasure方法只是直接调用了setMeasuredDimension,setMeasuredDimension函数是一个很关键的函数,它对View的成员变量mMeasuredWidth和mMeasuredHeight变量赋值,measure的主要目的就是对View树中的每个View的mMeasuredWidth和mMeasuredHeight进行赋值,所以一旦这两个变量被赋值意味着该View的测量工作结束

protected int getSuggestedMinimumHeight() {
     return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
 }
protected int getSuggestedMinimumWidth() {
     return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
 }
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;
}

getDefaultSize返回值由上面讲到的getSuggestedMinimumXXX方法获取的Size以及父类传递过来的measureSpec共同决定。

ViewGroup容器类布局大部分情况下是用来嵌套具体子View的,所以需要负责其子View的测量,在ViewGroup中定义了:
measureChildren(int widthMeasureSpec, int heightMeasureSpec)
measureChild(View child, int parentWidthMeasureSpec,intparentHeightMeasureSpec)
measureChildWithMargins(View child,int parentWidthMeasureSpec, int widthUsed,int parentHeightMeasureSpec, int heightUsed)
三个方法来供其子类调用对具体子View进行测量。

protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
}
protected void measureChild(View child, int parentWidthMeasureSpec,
            int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

可以看到measureChildren就是不断的调用measureChild,而measureChild方法中会根据父类提供的测量规格parentXXXMeasureSpec以及子类自己LayoutParams调用getChildMeasureSpec方法生成子类自己具体的测量规格。

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

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

与measureChild相比最主要的区别就是measureChildWithMargins额外将具体子View LayoutParams参数的margin也当作参数来生成测量规格。
measureChild与measureChildWithMargins均调用了getChildMeasureSpec方法来生成具体测量规格,接下来我们重点看下这个方法:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);//获取父View的mode 3         int specSize = MeasureSpec.getSize(spec);//获取父View的size
     //父View的size减去padding与0比较取其大,specSize - padding得到的值是父View可以用来盛放子View的空间大小
        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://父View希望子View是明确大小
            if (childDimension >= 0) {//子View设置了明确的大小:如 10dp,20dp
                resultSize = childDimension;//设置子View测量规格大小为其本身设置的大小
                resultMode = MeasureSpec.EXACTLY;//mode设置为EXACTLY
            } else if (childDimension == LayoutParams.MATCH_PARENT) {//子VIEW的宽或者高设置为MATCH_PARENT,表明子View想和父View一样大小
                // Child wants to be our size. So be it.
                resultSize = size;//设置子View测量规格大小为父View可用空间的大小
                resultMode = MeasureSpec.EXACTLY;//mode设置为EXACTLY
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {//子VIEW的宽或者高设置为WRAP_CONTENT,表明子View大小是动态的
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;//设置子View测量规格大小为父View可用空间的大小
                resultMode = MeasureSpec.AT_MOST;//mode设置为AT_MOST,表明子View宽高最大值不能超过resultSize 
            }
            break;27      //其余情况请自行分析
        ......
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

上面的方法展现了根据父View的MeasureSpec和子View的LayoutParams生成子View的MeasureSpec的过程, 子View的LayoutParams表示了子View的期待大小。这个产生的MeasureSpec用于指导子View自身的测量。

在我们自定义View的时候一般会重写onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法,其中的widthMeasureSpec与heightMeasureSpec参数就是父类通过getChildMeasureSpec方法生成的。一个好的自定义View会根据父类传递过来的测量规格动态设置大小,而不是直接写死其大小。

  1. View的measure方法是final的,不允许重载,View子类只能重载onMeasure来完成自己的测量逻辑。
  2. 最顶层DecorView测量时的MeasureSpec是由ViewRootImpl中getRootMeasureSpec方法确定的。
  3. ViewGroup类提供了measureChild,measureChild和measureChildWithMargins方法,以供容器类布局测量自身子View使用
  4. 使用View的getMeasuredWidth()和getMeasuredHeight()方法来获取View测量的宽高,必须保证这两个方法在onMeasure流程之后被调用才能返回有效值,只有onMeasure流程完后mMeasuredWidth与mMeasuredHeight才会被赋值
  5. View的布局大小是由父View和子View共同决定的。我们平时设置的宽高可以理解为希望的大小,具体大小还要结合父类大小来确定。
    在这里插入图片描述

layout

performMeasure执行完,接着就会执行performLayout:

private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
            int desiredWindowHeight) {
  
        ...
        final View host = mView;

        host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());
       ...
    }

mView为根View,即DecorView,DecorView是FrameLayout的子类,最终会调用ViewGroup中layout方法。所以接下来我们看下ViewGroup中layout方法源码:

@Override
    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;
        }
    }

上面显示调用父类的layout方法

public void layout(int l, int t, int r, int b) {
    // l为本View左边缘与父View左边缘的距离
      // t为本View上边缘与父View上边缘的距离
     // r为本View右边缘与父View左边缘的距离
     // b为本View下边缘与父View上边缘的距离
    ...
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);
     ...
    }

主要判断View的位置是否发生变化,发生变化则changed 会为true,并且setOpticalFrame也是调用的setFrame方法

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;
    
            ...
            mLeft = left;
            mTop = top;
            mRight = right;
            mBottom = bottom;
     ...
        }
        return changed;
    }

第6行代码分别比较之前的记录的mLeft,mRight,mTop,mBottom 与新传入的参数如果有一个不同则进入判断,将changed变量置为true,并且将新传入的参数分别重新赋值给mLeft,mRight,mTop,mBottom,最后返回changed。

这里还有一点要说,getWidth()、getHeight()和getMeasuredWidth()、getMeasuredHeight()这两对方法之间的区别,先看一下源码:

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

在讨论View的measure过程时提到过mMeasuredWidth与mMeasuredHeight只有测量过程完成才会被赋值,所以只有测量过程完成调用getMeasuredWidth()、getMeasuredHeight()才会获取正确的值。
同样getWidth()、getHeight()只有在layout过程完成时mLeft,mRight,mTop,mBottom才会被赋值,调用才会获取正确返回值,所以二者调用时机是不同的。
继续看View中layout源码,如果changed为true,也就是说View的位置发生了变化,或者标记为PFLAG_LAYOUT_REQUIRED则进入判断执行onLayout方法。

我们继续看View中onLayout方法源码:

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

对比View的layout和ViewGroup的layout方法发现,View的layout方法是可以在子类重写的,而ViewGroup的layout是不能在子类重写的,那么容器类View是怎么对其子View进行摆放的呢?别急,在ViewGroup中同样也有onLayout方法,源码如下:

/**
     * {@inheritDoc}
     */
    @Override
    protected abstract void onLayout(boolean changed,
            int l, int t, int r, int b);

是个抽象方法,因为具体ViewGroup摆放规则不同,所以其具体子类需要重写这个方法来实现对其子View的摆放逻辑。
既然这样我们就只能分析一个继承自ViewGroup的具体子类了,我们选取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();

        ......

        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() != GONE) {
               .....

                child.layout(childLeft, childTop, childLeft + width, childTop + height);
            }
        }
    }

在这里插入图片描述
我们总结一下主要部分:

  1. View.layout方法可被重载,ViewGroup.layout为final的不可重载,ViewGroup.onLayout为abstract的,具体ViewGroup子类必须重载来按照自己规则对子View进行摆放。
  2. measure操作完成后得到的是对每个View经测量过的measuredWidth和measuredHeight,layout操作完成之后得到的是对每个View进行位置分配后的mLeft、mTop、mRight、mBottom,这些值都是相对于父View来说的
  3. 使用View的getWidth()和getHeight()方法来获取View测量的宽高,必须保证这两个方法在onLayout流程之后被调用才能返回有效值。

Draw

performLayout过程执行完,接下来就执行performDraw()逻辑了,ViewGroup没有重写View的draw方法,最终调用的是View中的draw方法,源码如下:

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

        /*
         * 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;
        }
        ...
        // Step 2, save the canvas' layers
        ....
        // 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
        ....
        // Step 6, draw decorations (foreground, scrollbars)
        onDrawForeground(canvas);
    }

draw过程分为6步,大部分情况下跳过第2,5步。所以我们着重分析其余4步:
Draw the background

private void drawBackground(Canvas canvas) {
        final Drawable background = mBackground;
        if (background == null) {
            return;
        }
     ....
        setBackgroundBounds();
        ....
        background.draw(canvas);
       
    }


    void setBackgroundBounds() {
        if (mBackgroundSizeChanged && mBackground != null) {
            mBackground.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);
            mBackgroundSizeChanged = false;
            rebuildOutline();
        }
    }

逻辑就是获取我们在xml文件或者代码中设置的背景,然后根据layout过程摆放的位置绘制出来。
Draw view’s content

/**
     * Implement this to do your drawing.
     *
     * @param canvas the canvas on which the background will be drawn
     */
    protected void onDraw(Canvas canvas) {
    }

看到了吧,也是空方法,这个方法被用来绘制子View的,如果有子View则需要调用这个方法去绘制,我们知道一般只有容器类View才可以盛放子View,所以我们看下ViewGroup中有没有相关逻辑,在ViewGroup中果然实现了这个方法,源码如下:

@Override
    protected void dispatchDraw(Canvas canvas) {
        boolean usingRenderNodeProperties = canvas.isRecordingFor(mRenderNode);
        final int childrenCount = mChildrenCount;
        final View[] children = mChildren;
        .......
        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);
                }
                .......
            }
          ......
        }
        ......   
    }

在dispatchDraw方法中遍历每个子View并且调用drawChild方法,接下来我们看下drawChild源码:

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

最终调用每个子View的draw方法来完成自身的绘制。
接下来我们总结一下draw流程的重点

  1. 容器类布局需要递归绘制其所包含的所有子View。
  2. View中onDraw默认是空方法,需要子类自己实现来完成自身容内的绘制。
    在这里插入图片描述
    参考链接
    https://www.cnblogs.com/leipDao/p/7573803.html
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值