View的工作原理

记得刚开始做安卓的时候,一直很好奇EditText、TextView、ListView、Relativelayout等等控件是如何工作的呢,他们的父控件或者顶层控件究竟是怎么样的呢?

今天我就带着大家一起去探索一下。
那么View的绘制流程是怎么样的呢,通过看源码发现View的绘制流程从ViewRoot的performTraversals方法开始,经过measure、layout和draw三大流程,preformTranversals会依次调用performMeasure、performLayout和preformDraw三个方法,这三个方法分别完成顶级View的measure、layout、和draw这三大流程、performMeasure方法中会调用measure方法,在measure方法中又会调用onMeasure方法,在onMeasure方法中会对所有的子元素进行measure过程,这个时候measure流程就从父容器传递到子元素了,这样就完成了一次measure过程,layout和draw的过程类似,measure过程决定了view的宽高,在几乎所有的情况下这个宽高都等同于view最终的宽高。layout过程决定了view的四个顶点的坐标和view实际的宽高,通过getWidth和getHeight方法可以得到最终的宽高。draw过程决定了view的显示。

下面我们来看看performTraversals这个方法:

private void performTraversals() {
        .....
        int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
        int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
        ......
        mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        ......
        mView.layout(0, 0, mView.getMeasuredWidth(), mView.getMeasuredHeight());
        ......
        mView.draw(canvas);
        ......
    }

接下来我们再看一段Root View的源码:

 /**
     * 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;
        ......
        }
        return measureSpec;
    }

上面传入参数后这个函数走的是MATCH_PARENT,使用MeasureSpec.makeMeasureSpec方法组装一个MeasureSpec,MeasureSpec的specMode等于EXACTLY,specSize等于windowSize,也就是为何根视图总是全屏的原因.

接下来我们从源码看看onMeasure、onLayout、onDraw这三个方法

先来看看是怎么描述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)
     */
     //final方法,子类不可重写
    public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
        ......
        //回调onMeasure()方法
        onMeasure(widthMeasureSpec, heightMeasureSpec);
        ......
    }

我们分析上面的注释知道,measure为整个View树计算实际的大小,然后设置实际的高和宽,每个View控件的实际宽高都是由父视图和自身决定的。实际的测量是在onMeasure方法进行,所以在View的子类需要重写onMeasure方法,这是因为measure方法是final的,不允许重载,所以View子类只能通过重载onMeasure来实现自己的测量逻辑。

onMeasure(widthMeasureSpec, heightMeasureSpec);方法中的这两个参数都是父View传递过来的,也就是代表了父view的规格。总共32位,它由两部分组成,前2位表示MODE,定义在MeasureSpec类(View的内部类)中,有三种类型,MeasureSpec.EXACTLY表示由父控件确定了大小, MeasureSpec.AT_MOST表示父控件允许子控件设置大小,但是设置的大小一定要在父控件的范围之内, MeasureSpec.UNSPECIFIED不确定,随意设,没限制,但是不管你设置多少(值大于父控件了已经),最后展示出来的,都是父控件的大小。后30位表示size,也就是父View的大小。对于系统Window类的DecorVIew对象Mode一般都为MeasureSpec.EXACTLY ,而size分别对应屏幕宽高。对于子View来说大小是由父View和子View共同决定的。
简而言之
widthMeasureSpec 从父控件传递过来的宽度(包括模式和大小),
heightMeasureSpec 从父控件传递过来的高度(包括模式和大小)

下面来看看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 overriden 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)
     */
     //View的onMeasure默认实现方法
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

我们可以看见onMeasure默认的实现仅仅调用了setMeasuredDimension,setMeasuredDimension函数是一个很关键的函数,它对View的成员变量mMeasuredWidth和mMeasuredHeight变量赋值,measure的主要目的就是对View树中的每个View的mMeasuredWidth和mMeasuredHeight进行赋值,所以一旦这两个变量被赋值意味着该View的测量工作结束。既然这样那我们就看看设置的默认尺寸大小吧,可以看见setMeasuredDimension传入的参数都是通过getDefaultSize返回的,所以再来看下getDefaultSize方法源码,如下:

public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        //通过MeasureSpec解析获取mode与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;
    }

从中可以知道,如果specMode等于AT_MOST或EXACTLY就返回specSize,这是系统默认的规格。
其中getDefaultSize参数的widthMeasureSpec和heightMeasureSpec都是由父View传递进来的。getSuggestedMinimumWidth与getSuggestedMinimumHeight都是View的方法,具体如下:

protected int getSuggestedMinimumWidth() {
        return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
    }

    protected int getSuggestedMinimumHeight() {
        return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());

    }

源码建议的最小宽度和高度都是由View的Background尺寸与通过设置View的miniXXX属性共同决定的。
到这里一次最基本的Measure过程就完了,View实际是嵌套的,而且measure是递归传递的,所以每个View都需要measure。(还有viewGroup的measure,这个稍显复杂,下一篇我们聊)
对于view控件的子控件来说最好不要重载onMeasure的时候调用setMeasuredDimension来设置任意大小的布局,最好使用默认的值。

接下来开始第二步,layout

private void performTraversals() {
    ......
    mView.layout(0, 0, mView.getMeasuredWidth(), mView.getMeasuredHeight());
    ......
}

可以看见layout方法接收四个参数,这四个参数分别代表相对Parent的左、上、右、下坐标。而且还可以看见左上都为0,右下分别为上面刚刚测量的width和height。

至此又回归到View的layout(int l, int t, int r, int b)方法中去实现具体逻辑了,所以接下来我们开始分析View的layout过程(View的layout源码):

 public void layout(int l, int t, int r, int b) {
        ......
        //实质都是调用setFrame方法把参数分别赋值给mLeft、mTop、mRight和mBottom这几个变量
        //判断View的位置是否发生过变化,以确定有没有必要对当前的View进行重新layout
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
        //需要重新layout
        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            //回调onLayout
            onLayout(changed, l, t, r, b);
            ......
        }
        ......
    }

再来看看ViewGroup的layout方法:

@Override
    public final void layout(int l, int t, int r, int b) {
        ......
        super.layout(l, t, r, b);
        ......
    }

对比上面View的layout和ViewGroup的layout方法可以发现,View的layout方法是可以在子类重写的,而ViewGroup的layout是不能在子类重写的,言外之意就是说ViewGroup中只能通过重写onLayout方法.
而在ViewGroup中onLayout的方法是这样的:

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

是一个抽象方法,这就是说,只要是ViewGroup的子类就必须重写onLayout这个方法所以在自定义ViewGroup控件中,onLayout配合onMeasure方法一起使用可以实现自定义View的复杂布局。自定义View首先调用onMeasure进行测量,然后调用onLayout方法动态获取子View和子View的测量大小,然后进行layout布局。重载onLayout的目的就是安排其children在父View的具体位置,重载onLayout通常做法就是写一个for循环调用每一个子视图的layout(l, t, r, b)函数,传入不同的参数l, t, r, b来确定每个子视图在父视图中的显示位置。
整个layout过程比较容易理解,layout是从顶层父View向子View的递归调用view.layout方法的过程,即父View根据上一步measure子View所得到的布局大小和布局参数,将子View放在合适的位置上。具体layout核心主要有以下几点:
View.layout方法可被重载,ViewGroup.layout为final的不可重载,ViewGroup.onLayout为abstract的,子类必须重载实现自己的位置逻辑。

measure操作完成后得到的是对每个View经测量过的measuredWidth和measuredHeight,layout操作完成之后得到的是对每个View进行位置分配后的mLeft、mTop、mRight、mBottom,这些值都是相对于父View来说的。

凡是layout_XXX的布局属性基本都针对的是包含子View的ViewGroup的,当对一个没有父容器的View设置相关layout_XXX属性是没有任何意义的。

使用View的getWidth()和getHeight()方法来获取View测量的宽高,必须保证这两个方法在onLayout流程之后被调用才能返回有效值。

最后就来分析分析draw这个方法了,View的draw源码:

public void draw(Canvas canvas) {
        ......
        /*
         * 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
        ......
        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

        // skip step 2 & 5 if possible (common case)
        ......

        // Step 2, save the canvas' layers
        ......
            if (drawTop) {
                canvas.saveLayer(left, top, right, top + length, null, flags);
            }
        ......

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

        // Step 6, draw decorations (scrollbars)
        onDrawScrollBars(canvas);
        ......
    }

从源码中看出,总共有6步绘制,我们来简单看看这几步,
1.对View的背景进行绘制

private void drawBackground(Canvas canvas) {
        //获取xml中通过android:background属性或者代码中setBackgroundColor()、setBackgroundResource()等方法进行赋值的背景Drawable
        final Drawable background = mBackground;
        ......
        //根据layout过程确定的View位置来设置背景的绘制区域
        if (mBackgroundSizeChanged) {
            background.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);
            mBackgroundSizeChanged = false;
            rebuildOutline();
        }
        ......
            //调用Drawable的draw()方法来完成背景的绘制工作
            background.draw(canvas);
        ......
    }

3.对View的内容进行绘制

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

4.对当前View的所有子View进行绘制,如果当前的View没有子View就不需要进行绘制

  /**
     * Called by draw to draw the child views. This may be overridden
     * by derived classes to gain control just before its children are drawn
     * (but after its own view has been drawn).
     * @param canvas the canvas on which to draw the view
     */
    protected void dispatchDraw(Canvas canvas) {

    }

6.对View的滚动条进行绘制

/**
     * <p>Request the drawing of the horizontal and the vertical scrollbar. The
     * scrollbars are painted only if they have been awakened first.</p>
     *
     * @param canvas the canvas on which to draw the scrollbars
     *
     * @see #awakenScrollBars(int)
     */
    protected final void onDrawScrollBars(Canvas canvas) {
 
    }

基本上就是这样的,在绘制控件的过程中常会用到控件的刷新,invalidate这个方法就是重绘,重新走onDraw这个方法。当然这个方法只能在Ui线程中执行,在子线程中只能执行postInvalidate方法了,起到同样的效果。

上面invalidate以及postInvalidate这两个暂且称控件的刷新的方法是在控件大小不变位置不变的前提下,如果大小和位置改变还得调用另一个方法requestLayout(),这个方法的作用就是调用measure过程和layout过程。

到此View绘制流程大致聊完了,下面我们来看一个小小的案例在代码中熟悉一下:

我们来制作一个壁钟(我小的时候几乎每家每户都有一个,挂在墙上,整点都会报时,估计现在都换成电子时钟了):

下面我们来分析一下该怎么结合上面View的流程画出这个钟:
1.表是圆的,是不是要先画一个圆出来呢。

2.表是有刻度的,那么怎么给这个圆上面画出刻度来呢,其实也很简单,我们先给12点画一个刻度,出来,在设置或测量出控件的大小来后,通过getWidth()可以获取空间的宽度,这样就能得到12点的X坐标getWidth()/2,12点在整个控件的顶部,他的Y坐标显而易见是0,这样通过canvas.drawLine(),方法轻易就能画出刻度来。

3.接下来我们再来分析,按小时总共有12小时,那就是,每两个时间中间的角度是360/12=30°,这就好办了,每画完一次刻度,就让画布旋转30就好了。这样就可以画出刻度来(分秒的刻度道理一样的);

4.在接下来就是如何绘制,时分秒的指针了这个更简单,起始点都是这个圆表盘的圆心,画出不同的直线来。

5.最后就是让时分秒跟着时间动态走了,其实也很简单,就是不断重回时分秒之间就可以了,invalidate();调用这个方法;

public class ClockView extends View {
    private static final String TAG = ClockView.class.getSimpleName();

    /**创建画圆的画笔*/
    private Paint circlePaint;

    /**创建画圆的画笔颜色*/
    private int circleColor = Color.parseColor("#dddd89");

    /**创建画刻度的画笔*/
    private Paint scalePaint;

    /**创建画刻度的画笔颜色*/
    private int scaleColor = Color.parseColor("#000000");

    /**创建画时、分、秒的画笔*/
    private Paint mPaint;

    /**距离圆内部边缘的距离*/
    private int padding = 5;

    public ClockView(Context context) {
        super(context);
        initCirclePaint();
    }

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

    public ClockView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initCirclePaint();
    }

    private void initCirclePaint() {
        circlePaint = new Paint();
        circlePaint.setColor(circleColor);
        circlePaint.setStyle(Paint.Style.STROKE); // 设置画笔的样式,STROKE为空心,FILL为实心
        circlePaint.setStrokeWidth(5);  // 设置空心的边框宽度
        circlePaint.setAntiAlias(true); // 设置画笔无锯齿

        scalePaint = new Paint();
        scalePaint.setStyle(Paint.Style.FILL);
        scalePaint.setColor(scaleColor);
        scalePaint.setStrokeWidth(5);
        scalePaint.setAntiAlias(true);

        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);
    }

    //--------------------------------- 接下来开始最重要的三个方法啦 ----------------------------------------------------

    /**
     * @param widthMeasureSpec    从父控件传递过来的宽度(包括模式和大小)
     * @param heightMeasureSpec   从父控件传递过来的高度(包括模式和大小)
     *
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(measureWidth(widthMeasureSpec),measureHeight(heightMeasureSpec));
    }

    /**
     *
     * @param measureSpec
     * @return  
     */
    private int measureWidth(int measureSpec){
        int result = 0;
        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);
        switch (mode){
            case MeasureSpec.EXACTLY:  
                result = size;
                break;
            case MeasureSpec.AT_MOST: 
                result = getWidth()/2;
                break;
            case MeasureSpec.UNSPECIFIED:
                result = 200;
                break;
        }
        return result;
    }

    /**
     *
     * @param measureSpec 
     * @return  
     */
    private int measureHeight(int measureSpec){
        int result = 0;
        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);
        switch (mode){
            case MeasureSpec.EXACTLY: 
                result = size;
                Log.e(TAG,"************EXACTLY*************");
                break;
            case MeasureSpec.AT_MOST:  
                result = getHeight()/4;
                Log.e(TAG,"************AT_MOST*************");
                break;
            case MeasureSpec.UNSPECIFIED: 
                result = 200;
                Log.e(TAG,"************UNSPECIFIED*************");
                break;
        }
        return result;
    }
    //---------------------------------接下来是重头戏OnDraw()方法的实现-------------------------------------------------

    /**
     * 通过这个方法可以画出我们所需要的控件
     * @param canvas 
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawCircle(canvas);
        drawScale(canvas);
        drawPointer(canvas);
    }

    /**画出圆*/
    private void drawCircle(Canvas canvas){
        // 分别是X轴坐标,Y轴坐标,半径,画笔
        canvas.drawCircle(getWidth()/2,getHeight()/2,getHeight()/2 - padding,circlePaint);
    }

    /**画出刻度*/
    private void drawScale(Canvas canvas){
        /**
         * startX:起始端点的X坐标。
         * startY:起始端点的Y坐标。
         * stopX:终止端点的X坐标。
         * stopY:终止端点的Y坐标。
         * paint:绘制直线所使用的画笔。
         *
         * 至于为什么要这么设置坐标:
         * 控件的宽度getWidth()除以2就可以获取到12点的X坐标,
         * 因为限定(到这一步知道)了控件的高度,12点的Y坐标本身应该为0,但是上面画圆的时候画笔的宽度为5,和padding是一样的
         * 并且画的时候把画笔的宽度减去了,所以这里设置是padding,是让从远的外边距算起,有点绕,需要理解
         */
//        canvas.drawLine(getWidth()/2,padding,getWidth()/2,padding + 14,scalePaint); // 这一步只是为了演示划出的刻度

        /**
         * 既然可以画出刻度,那么接下来我们又要分析, 表的刻度(这里只是计算是真的刻度,分针秒针原理一样的)
         * 时针的刻度在一个表盘里有12个,并且3、6、9、12点的时候刻度会略长
         * 并且每画出一个刻度的时候画布应该旋转360/12的角度,继续画下一个刻度
         * 好了分析清楚后,我们就开始画了
         */

        for (int i = 0; i < 12; i++) {
            if (i%3 == 0) { // 可以获取到3的整数倍的点(3、6、9、12)
                canvas.drawLine(getWidth()/2,padding,getWidth()/2,padding + 20,scalePaint);
            }else{
                canvas.drawLine(getWidth()/2,padding,getWidth()/2,padding + 14,scalePaint);
            }

            /**
             * degrees 旋转的角度
             * px 以某个点来旋转的点的x坐标
             * py 以某个点来旋转的点的y坐标
             */
            canvas.rotate(30,getWidth()/2,getHeight()/2);
        }
    }

    /**
     * 接下来开始绘制时分秒的指针
     * @param canvas 画布
     * 分析:
     *     时分秒的指针都是直线canvas.drawLine();
     *     时的指针旋转角度为 360/12
     *     分的指针旋转的角度 360/120
     *     秒的指针旋转的角度 360/1200
     */
    private void drawPointer(Canvas canvas){

        Time t=new Time(); 
        t.setToNow(); 
        int hour = 1; 
        if (t.hour > 12) {
            hour = t.hour - 12;
        }else{
            hour = t.hour;
        }
        int minute = t.minute;
        int second = t.second;

        // 旋转的角度
        float degrees = hour*30;
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(5);
        canvas.save();
        canvas.rotate(degrees,getWidth()/2,getHeight()/2);
        canvas.drawLine(getWidth()/2,getHeight()/2,getWidth()/2,getHeight()/2 - 90,mPaint);
        canvas.restore();

        // 分
        degrees = minute*3;
        mPaint.setColor(Color.BLUE);
        mPaint.setStrokeWidth(4);
        canvas.save();
        canvas.rotate(degrees,getWidth()/2,getHeight()/2);
        canvas.drawLine(getWidth()/2,getHeight()/2,getWidth()/2,getHeight()/2 - 70,mPaint);
        canvas.restore();

        // 秒
        degrees = (float) (minute*0.3);
        mPaint.setColor(Color.BLUE);
        mPaint.setStrokeWidth(3);
        canvas.save();
        canvas.rotate(degrees,getWidth()/2,getHeight()/2);
        canvas.drawLine(getWidth()/2,getHeight()/2,getWidth()/2,getHeight()/2 - 40,mPaint);
        canvas.restore();

        invalidate();// view的刷新操作,重绘
    }
}

到此结束。

    原文作者:几行代码
    原文地址: https://www.jianshu.com/p/7c736d164a02
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞