View绘制流程之Draw

View的绘制流程(三)

每一个视图的绘制过程都必须经历三个最主要的阶段,即onMeasure()、onLayout()和onDraw()

Draw

当measure和layout的过程都结束后,绘制流程就进入到draw最后的过程了。在这里,是对View真正的绘制。performTraversals 方法的代码会继续执行并创建出一个Canvas对象,然后调用mView的draw()方法来执行具体的绘制工作。而此时的draw()方法,就是View.java类中的方法。因为官方建议不要覆写该方法。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
    ...
        background.draw(canvas);
    ...
        // skip step 2 & 5 if possible (common case)
    ...
        // Step 2, save the canvas' layers
    ...
        if (solidColor == 0) {
            final int flags = Canvas.HAS_ALPHA_LAYER_SAVE_FLAG;

            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);
            canvas.drawRect(left, top, right, top + length, p);
        }
    ...
        // Step 6, draw decorations (scrollbars)
        onDrawScrollBars(canvas);
    }
  • 第一步绘制背景
private void drawBackground(Canvas canvas) { 
     Drawable final Drawable background = mBackground; 
      ...... 
     //mRight - mLeft, mBottom - mTop layout确定的四个点来设置背景的绘制区域 
     if (mBackgroundSizeChanged) { 
        background.setBounds(0, 0, mRight - mLeft, mBottom - mTop);   
        mBackgroundSizeChanged = false; rebuildOutline(); 
     } 
     ...... 
     //调用Drawable的draw() 把背景图片画到画布上
     background.draw(canvas); 
     ...... 
}

首先得到一个Drawable对象,也就是该View的成员变量mBackground(每一个视图都至少有一个drawable对象,就是背景)。然后根据layout过程确定的视图位置,来计算视图的大小,从而确定视图背景可绘制的区域(setBound()方法确定可绘制区域矩形)。最后调用Drawable.draw(canvas)方法将背景绘制到画布上。

  • 第三步绘制View的内容

onDraw(canvas) 方法是view用来draw 自己的,具体如何绘制,颜色线条什么样式就需要子View自己去实现,View.java 的onDraw(canvas) 是空实现,ViewGroup 也没有实现,每个View的内容是各不相同的,所以需要由子类去实现具体逻辑。

  • 第四步绘制该View的所有子View

dispatchDraw(canvas) 方法是用来绘制子View的,View.java 的dispatchDraw()方法是一个空方法,因为View没有子View,不需要实现dispatchDraw ()方法,ViewGroup就不一样了,它实现了dispatchDraw ()方法:

@Override
 protected void dispatchDraw(Canvas canvas) {
       ...
        if ((flags & FLAG_USE_CHILD_DRAWING_ORDER) == 0) {
            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);
                }
            }
        } else {
            for (int i = 0; i < count; i++) {
                final View child = children[getChildDrawingOrder(count, i)];
                if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
                    more |= drawChild(canvas, child, drawingTime);
                }
            }
        }
      ......
    }

代码主要作用是遍历了View的所有子View,然后根据一系列的判断决定调用drawChild()方法。drawChild()方法里面主要调用了childView的draw()方法(也就是View.java类中的draw()方法)。ViewGroup类已经为我们实现绘制子View的默认过程,这个实现基本能满足大部分需求,所以ViewGroup类的子类(LinearLayout,FrameLayout)也基本没有去重写dispatchDraw方法,我们在实现自定义控件,除非比较特别,不然一般也不需要去重写它, drawChild()的核心过程就是为子视图分配合适的cavas剪切区,剪切区的大小正是由layout过程决定的,而剪切区的位置取决于滚动值以及子视图当前的动画。设置完剪切区后就会调用子视图的draw()函数进行具体的绘制了。

  • 第六步绘制当前View的滚动条

任何一个视图都是有滚动条的,只是一般情况下我们都没有让它显示出来而已。

总结:首先调用mView的draw()方法,方法中调用父类的draw()的方法。第一步绘制背景;第三步调用mView中实现的onDraw()方法,实现内容的绘制;第四步调用dispatchDraw()方法,如果mView是ViewGroup,那么他会调用ViewGroup.java类中的dispatchDraw()方法,遍历mView的子View,并调用他们的draw()方法。如果不是ViewGroup,那么dispatchDraw()方法就是一个空方法;第六步,调用onDrawScrollBars,绘制滚动条。

实践

绘制一个弧形展示图:
java代码:

/**
     * 图形元素初始化
     * @param width
     */
    private void  init(int width, int height) {
        //mSweepAngle = 200;
        mShowText = "Android Skill";


        mCircleX = width / 2;
        mCircleY = height / 2;
        mRadius = (float)(width * 0.5 / 2);


        mArcRectF = new RectF(
                (float)(width * 0.1),
                (float)(height / 2 - width * 0.4),
                (float)(width * 0.9),
                (float)(height / 2 + width * 0.4)
        );



        mCirclePaint = new Paint();
        //mCirclePaint.setColor(getResources().getColor(android.R.color.holo_blue_light));
        mCirclePaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_blue_light));
        mCirclePaint.setStyle(Paint.Style.FILL);


        mArcPaint = new Paint();
        mArcPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_blue_light));
        //mArcPaint.setColor(getResources().getColor(android.R.color.holo_blue_light));
        mArcPaint.setStyle(Paint.Style.STROKE);
        mArcPaint.setStrokeWidth(100);

        mTextPaint = new Paint();
        mTextPaint.setTextSize(textSize);
        mTextPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.black));
        mTextX = width / 2 - MeasureText.getTextWidth(mTextPaint, mShowText) / 2;
        mTextY = height / 2 +
                (int) Math.ceil(MeasureText.measureTextHeight(mTextPaint, mShowText)) / 2;
        //mTextPaint.setColor(getResources().getColor(android.R.color.black));
    }
@Override
    protected void onDraw(Canvas canvas) {
        //init(getWidth(), getHeight());
        canvas.drawCircle(mCircleX, mCircleY, mRadius, mCirclePaint);
        canvas.drawArc(mArcRectF, 270, mSweepAngle, false, mArcPaint);
        canvas.drawText(mShowText, 0, mShowText.length(), mTextX, mTextY, mTextPaint);
    }

XML代码:

<?xml version="1.0" encoding="utf-8"?>
<android.example.com.testmeasure.view.CircleProgressView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:custom="http://schemas.android.com/apk/res-auto"
    android:id="@+id/circle_progress"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    custom:circleTextSize="20sp">
</android.example.com.testmeasure.view.CircleProgressView>

效果图:

CircleProgressView.png

遇到的问题

获取自定义属性

这个小demo中我用到了自定义属性:

<declare-styleable name="CircleProgressView">
        <attr name="circleTextSize" format="dimension"/>
</declare-styleable>

要引用自定义属性,需要创建自己的名字空间。起初,布局文件代码中在CircleProgressView的最外层是LinearLayout。在LinearLayout根标签中我创建了名字空间xmlns:custom="http://schemas.android.com/apk/res-auto"。但是在CircleProgressView标签中无法通过custom引用自定义属性,并且报错:Unexpected namespace prefix "custom" found for tag。Google发现,自定义名字空间在哪里创建,就是调用该View的自定义属性。而此时的LinearLayout并没有自定义属性,所以会报错。于是我们只有在layout目录下单独建立一个CircleProgressView布局文件,然后创建名字空间,引用自定义属性并赋值。接着在LinearLayout标签下,include这个CircleProgressView布局。

创建名字空间的位置只能在跟标签中

文本居中

接下来我想要文本内容在这个View中居中,那么就需要得到文本的高和宽。首先要搞清楚canvas.drawText()方法参数的意义。

  • drawText (String text, float x, float y, Paint paint)
    第一个参数是要绘制的文本;第二个参数是文本的origin的X坐标;第三个参数是文本的baseLine的Y坐标;第四个参数是绘制文本的画笔。

  • drawText (String text, int start, int end, float x, float y, Paint paint)
    第一个参数是要绘制的文本;第二个参数是绘制文本从字符串的第几个开始;第三个参数是绘制文本到字符串的第几个(end - 1);第四,五个参数和上面的第二,三个参数意义相同;第六个参数就是画笔。

由此看来决定文本的位置由文本origin的X坐标和baseLine的Y坐标决定的。
文本示意图如下:

文本参数.png

一开始我的想法是将文本的中心点与View的中心重叠,而这个中心点是根据文本的宽,高来计算(这里我认为baseLine就是文本的bottom坐标)。所以首先要计算出文本的宽度和高度。google发现有三种计算宽度方法,有一种通过文本所在的矩形计算高度的方法。

    /**
     * 粗略的计算文本的宽度
     * @param paint
     * @param str
     * @return
     */
    public static float measureText(Paint paint, String str) {
        return paint.measureText(str);
    }

    /**
     * 计算文本所在的矩形,得到宽度
     * @param paint
     * @param str
     * @return
     */
    public static float measureTextWidth(Paint paint, String str) {
        Rect bound = new Rect();
        //获取文本所在的矩形
        paint.getTextBounds(str, 0, str.length(), bound);//返回的rect上下左右坐标是以baseLine为基准的
        return bound.width();
    }

    /**
     * 计算文本所在的矩形,得到高度
     * @param paint
     * @param str
     * @return
     */
    public static float measureTextHeight(Paint paint, String str) {
        Rect bound = new Rect();
        paint.getTextBounds(str, 0, str.length(), bound);
        return bound.height();
    }

    /**
     * 精确计算文本宽度
     * 指明两个字符其实位置之间的间隔,包括字符之间的间隔
     * @param paint
     * @param str
     * @return
     */
    public static int getTextWidth(Paint paint, String str) {
        int width = 0;
        if(!TextUtils.isEmpty(str)) {
            int length = str.length();
            float widths[] = new float[length];
            paint.getTextWidths(str, widths);
            for(int i = 0; i < length; i++) {
                width += (int) Math.ceil(widths[i]);//Math.ceil方法是截断小数点后面的数字,
                // 并且把整数部分扩大.
                //例如99.1截取后变成100.0;-99.1截取后变成99.0.与之相反的是floor,截取后整数部分变小
            }
        }
        return width;
    }
    ```
    
最后将文本的中心点与View的中心点重叠。

       mTextX = width / 2 - MeasureText.getTextWidth(mTextPaint, mShowText) / 2;
       mTextY = height / 2 +
                (int) Math.ceil(MeasureText.measureTextHeight(mTextPaint, mShowText)) / 2;
                
                
实际的效果如上面实践中的那样,实现了“居中”。但其实通过getTextBounds()方法获取的高度是不可靠的。所以我们要实现精确的居中,必须了解文本的参数。Canvas绘制文本时,使用FontMetrics对象,计算位置的坐标。FontMetrics里是字体图样的信息,有float型和int型的版本,都可以从Paint中获取。它的每个成员数值都是以baseline为基准计算的,所以负值表示在baseline之上。为了方便,我们用FontMetricsInt也就是Int版本来绘制字体各个参数的具体位置,如下图所示。```从上至下,灰色代表top;蓝色代表ascent;橙色方框是文本前两个字符所在的矩形;紫色方框代表所有字符所在的矩形;淡蓝色代表baseLine;红色代表descent;绿色代表bottom。```

效果图:

![字体参数位置绘制.png](http://upload-images.jianshu.io/upload_images/1311456-b939e6ff31056d0c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

由此我们知道,根据文本所在的矩形来确定文本的中心坐标是不可靠的。而通过字体的top,bottom,以及文本所在View的中心坐标y来确定字体的baseLine,实现居中。计算代码如下:


//1.计算出精确的居中坐标,绘制文本,背景是白色
        //计算出文本所在View的中心Y坐标
        int boundCenterY = mFontHeight / 2;
        //计算出baseLine相对于View的Y轴坐标
        int baseLine = boundCenterY - (bottom - top) / 2 - top;
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.black));
        canvas.drawText(mFontText, 0, baseLine, mPaint);

        //2.绘制字体各个参数位置
        //绘制baseLine的Y轴坐标线,蓝色
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_blue_light));
        canvas.drawLine(0, baseLine, mFontWidth, baseLine, mPaint);

        //绘制字体bottom的Y轴坐标线,绿色
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_green_light));
        mPaint.setStrokeWidth(5);
        int bottomY = baseLine + mFontMetricsInt.bottom;
        canvas.drawLine(0, bottomY, mFontWidth, bottomY, mPaint);

        //绘制字体descent的Y轴坐标线,红色
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_red_light));
        mPaint.setStrokeWidth(3);
        int descentY = baseLine + mFontMetricsInt.descent;
        canvas.drawLine(0, descentY, mFontWidth, descentY, mPaint);

        //绘制字体top的Y轴坐标线,灰色
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.darker_gray));
        int topY = baseLine + mFontMetricsInt.top;
        canvas.drawLine(0, topY, mFontWidth, topY, mPaint);

        //绘制字体ascent的Y轴坐标线,蓝色
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_blue_dark));
        int ascentY = baseLine + mFontMetricsInt.ascent;
        canvas.drawLine(0, ascentY, mFontWidth, ascentY, mPaint);

        //3.bound的不可靠性
        //前两个字符的bound,橙色.
        // 这里得到的bound的坐标是基于baseLine的,
        // 所以需要移动画布才可以在View上绘制制定的bound
        Rect boundMin = new Rect();
        mPaint.getTextBounds(mFontText, 0, 2, boundMin);
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_orange_light));
        mPaint.setStrokeWidth(10);
        canvas.save();
        canvas.translate(0, baseLine);
        canvas.drawRect(boundMin, mPaint);
        canvas.restore();

        //后面带J字母的bound,和上面的区别在于:现在所在的矩形区域包含baseLine,紫色
        Rect boundMax = new Rect();
        mPaint.getTextBounds(mFontText, 0, mFontText.length(), boundMax);
        mPaint.setColor(ContextCompat.getColor(getContext(), android.R.color.holo_purple));
        mPaint.setStrokeWidth(3);
        canvas.save();
        canvas.translate(0, baseLine);
        canvas.drawRect(boundMax, mPaint);
        canvas.restore();
       
####getDimension()方法
在上面的小demo中,我自定了一个属性,文本的大小的属性。通过TypedArray来获取到它,并调用getDimension()方法将XML文件中指定的dp值,转化为px值。
>单位的转换是基于当前资源显示分比率的。也就是说字体的大小会根据不同屏幕的分辨率大小而改变字体的大小。

那么自定义字体的大小转换值,和Android自带的TextView属性确定的字体大小的值相同吗?生命在于折腾,我们来测试下。效果图如下图(我个人感觉看不出有差距,不知道是不是眼睛不灵光--):

![getDimension.png](http://upload-images.jianshu.io/upload_images/1311456-1389e3ca14256515.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

##参考
[郭神的博客](http://blog.csdn.net/guolin_blog/article/details/16330267)、[kelin大神的简书](http://www.jianshu.com/p/5a71014e7b1b#)、[文献3](http://blog.csdn.net/yangzl2008/article/details/7879019)、[文献4](http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2013/0409/1143.html)、[文献5](http://blog.csdn.net/chuekup/article/details/7518239)、[文献6](http://blog.csdn.net/hursing/article/details/18703599)、[文献7](http://blog.csdn.net/lovexieyuan520/article/details/43153275)、[文献8](http://www.eoeandroid.com/thread-322627-1-1.html)。

##目标
1. 如何在自定义View中获取Android系统定义的属性。例如我想在自定义View中获取XML制定的padding值,以便在measure过程中计算view的大小。
2. 自定义属性。好好阅读[博客](http://blog.csdn.net/xmxkf/article/details/51468648)。
3. TextView实战,好好阅读[博客](http://blog.csdn.net/sdkfjksf/article/details/51317204)。
4. sp->px等单位之间的转换

推荐阅读更多精彩内容