Android 自定义View之Draw过程(上)

作者: 小鱼人爱编程 | 来源:发表于2020-08-31 00:53 被阅读0次

    前言

    Android 展示之三部曲:

    Measure(测量)---->Layout(摆放)---->Draw(绘制)

    前边我们已经分析了:

    这俩最主要的任务是:确定View/ViewGroup可绘制的矩形区域。
    接下来将会分析,如何在这给定的区域内绘制想要的图形。
    通过本篇文章,你将了解到:

    1、为什么要自定义View
    2、一个简单的Demo
    3、View Draw过程
    4、ViewGroup Draw过程
    5、View/ViewGroup 常用方法分析

    为什么要自定义View

    Android 提供了关于View最基础的两个类:

    ViewGroup与View

    然而ViewGroup 并没有约定其内部的子View是如何布局的,是叠加在一起呢?还是横向摆放、纵向摆放等。同样的View 也没有约定其展示的内容是啥样,是矩形、圆形、三角形、一张图片、一段文字抑或是不规则的形状?这些都要我们自己去实现吗?
    不尽然,值得高兴的是Android已经考虑到上述需求了,为了开发方便已经预制了一些常用的ViewGroup、View。
    如:
    继承自ViewGroup的子类

    FrameLayout --> 里面的子View是层叠摆放的
    LinearLayout -->里边的子View是可以纵向/横向排列的
    RelativeLayout -->里边的子View可以相对布局
    RecyclerView -->里边的子View以列表形式展示
    等等...

    继承自View的子类

    TextView --> 用于绘制一段文本
    ImageView --> 用于绘制一张图片
    EditText -->用于绘制输入框
    Button --> 用户绘制按钮
    等等...

    虽然以上衍生的View/ViewGroup子类已经大大为我们提供了便利,但也仅仅是通用场景下的通用控件,我们想实现一些较为复杂的效果,比如波浪形状进度条、会发光的球体等,这些系统控件就无能为力了,也没必要去预制千奇百怪的控件。想要达到此效果,我们需要自定义View/ViewGroup。
    通常来说自定义View/ViewGroup有以下几种:

    1、如果你觉得系统提供的ViewGroup子类基本符合你需求,但你想将一些功能封装到一个组件里,那么就直接继承FrameLayout、LinearLayout等。这样一来,继承了他们的特性,也将自己的逻辑封装了。
    2、如果你觉得系统提供的View子类基本符合你的需求,但你想将一些功能封装到一个控件里,比如显示Emoji,那么直接继承自TextView(AppCompatTextView兼容)。
    3、如果你看不起系统预制的ViewGroup子类,直接继承自ViewGroup,那么你需要重写onMeasure(xx)、onLayout(xx)等方法。
    4、如果不想用系统预制的View子类,直接继承自View,那么你需要自己绘制内容,重写onDraw(xx)方法。

    3 一般不怎么用,除非布局比较特殊。1、2、4 是我们常用的手段,对于我们常说的"自定义View" 一般指的是 4。
    接下来我们来看看 4是怎么实现的。

    一个简单的Demo

    public class MyView extends View {
    
        private Paint paint;
    
        public MyView(Context context) {
            super(context);
            init();
        }
    
        //从xml加载MyView时调用该方法
        public MyView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        private void init() {
            paint = new Paint();
            paint.setAntiAlias(true);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            //涂红色
            canvas.drawColor(Color.RED);
    
            //画笔设置为黄色
            paint.setColor(Color.YELLOW);
            //画实心圆
            canvas.drawCircle(getWidth()/2, getHeight()/2, 30, paint);
        }
    

    在xml里引用MyView

        <com.fish.myapplication.MyView
            android:id="@+id/myview"
            android:layout_width="100px"
            android:layout_height="100px">
        </com.fish.myapplication.MyView>
    

    效果如下:


    image.png

    黑色部分为其父布局背景。
    红色矩形+黄色圆形即是MyView绘制的内容。
    以上是最简单的自定义View的实现,我们提取重点归纳如下:

    1、继承自View
    2、重写onDraw(xx)方法(通常onMeasure(xx)也需要重写,此处为突出重点忽略)

    View Draw过程

    View onDraw(xx)

    由上述Demo可知,我们只需要在重写的onDraw(xx)方法里绘制想要的图形即可。
    来看看View 默认的onDraw(xx)方法:

    #View.java
        protected void onDraw(Canvas canvas) {
        }
    

    发现是个空实现,因此继承自View的类必须重写onDraw(xx)方法才能实现绘制。该方法传入参数为:Canvas类型。
    Canvas翻译过来一般叫做画布,在重写的onDraw(xx)里拿到Canvas对象后,有了画布我们还需要一支笔,这只笔即为Paint,翻译过来一般称作画笔。两者结合,就可以愉快的作画(绘制)了。
    你可能发现了,在Demo里调用

    canvas.drawColor(Color.RED);
    

    并没有传入Paint啊,是不是Paint不是必须的?实际上调用该方法后,底层会自动生成Paint对象。

    #SkCanvas.cpp
    void SkCanvas::drawColor(SkColor c, SkBlendMode mode) {
        SkPaint paint;
        paint.setColor(c);
        paint.setBlendMode(mode);
        this->drawPaint(paint);
    }
    

    可以看到,底层初始化了Paint,并且给其设置的颜色为在Java层设置的颜色。

    View Draw(xx)

    onDraw(xx)比较简单,开局一个Canvas,效果全靠画。
    试想,这个Canvas怎么来的呢,换句话说是谁调用了onDraw(xx)。发挥一下联想功能,在Measure、Layout 过程有提到过两者套路很像:

    measure(xx)、layout(xx) 一般不需要重写
    onMeasure(xx)、onLayout(xx)[View 不需要] 需要重写
    measure(xx)里调用了onMeasure(xx)
    layout(xx)里调用了onLayout(xx)

    那么Draw过程是否也是如此套路呢?看见了onDraw(xx),那么draw(xx)还远吗?
    没错,还真有draw(xx)方法:

    #View.java
        public void draw(Canvas canvas) {
            final int privateFlags = mPrivateFlags;
            //打上已绘制标记
            mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;
            int saveCount;
            //第一步 绘制背景
            drawBackground(canvas);
    
            final int viewFlags = mViewFlags;
            //检查横向、纵向是否设置了边缘渐变效果
            boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
            boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
            
            //条件分支A
            if (!verticalEdges && !horizontalEdges) {
                //第三步 调用onDraw(xx),绘制View 内容
                onDraw(canvas);(1)
    
                //第四步 分发Draw,绘制子布局
                dispatchDraw(canvas); (2)
                //绘制自动填充的高亮(默认不会绘制)
                drawAutofilledHighlight(canvas);
    
                //mOverlay 绘制在内容之上,在前景色之下 (3)
                if (mOverlay != null && !mOverlay.isEmpty()) {
                    mOverlay.getOverlayView().dispatchDraw(canvas);
                }
    
                //第六步,绘制装饰,如前景、滚动条等 (4)
                onDrawForeground(canvas);
    
                //第七步,绘制默认高亮,在touch mode模式基本不生效
                drawDefaultFocusHighlight(canvas);
                //调试用的,可以忽略
                if (debugDraw()) {
                    debugDrawFocus(canvas);
                }
    
                //绘制完成,直接返回
                return;
            }
    
            //条件分支B
            //下面还有一大堆源码,主要就是做了一件事:绘制边缘渐变
            //大部分情况下都不会走到这
            //绘制步骤大体分为 七 个步骤,而上面只列出了1、3、4、6、7,剩下的步骤在此完成
            //如果设置了边缘渐变,那么绘制步骤就会比不设置时多两个步骤,多出来的步骤是:2、5
            //用文字简单概述一下
            //1--->绘制背景
            //2--->canvas.getSaveCount(); 记录canvas状态,为绘制边缘渐变做准备(canvas坐标要改变,因此先保存)
            //3--->绘制内容
            //4--->分发Draw,绘制子布局
            //5--->绘制边缘渐变
            //6--->绘制装饰
            //7--->绘制默认高亮
        }
    

    可以看出,draw(xx)主要分为两个部分:

    • 条件分支A-->大部分情况下都会走该分支
    • 条件分支B--->极小部分情况会走该分支
    • B分支比A分支多了个2个步骤,目的是为了绘制边缘渐变效果

    不管是A分支还是B分支,都进行了好几步的绘制。
    通常来说,单一一个View的层次分为:

    背景-->内容-->前景

    后面绘制的可能会遮挡前边绘制的。
    对于一个ViewGroup来说,层次分为:

    背景-->内容-->子布局的层次-->前景

    来看看A分支标注的4个点:
    (1)
    onDraw(canvas)
    前面分析过,对于单一的View,onDraw(xx)是空实现,需要由我们自定义绘制。
    而对于ViewGroup,也并没有具体实现,如果在自定义ViewGroup里重写onDraw(xx),它会执行吗?默认是不会执行的,相关分析请移步:
    Android ViewGroup onDraw为什么没调用

    (2)
    dispatchDraw(canvas),来看看在View.java里的实现:

        protected void dispatchDraw(Canvas canvas) {
    
        }
    

    发现是个空实现,再看看ViewGroup.java里的实现:

        protected void dispatchDraw(Canvas canvas) {
            ...
            //遍历子布局,发起Draw 过程
            ...
        }
    

    也即是说,对于单一View,因为没有子布局,因此没必要再分发Draw,而对于ViewGroup来说,需要触发其子布局发起Draw过程(此过程后续分析),可以类比事件分发过程View、ViewGroup的处理。感兴趣的请移步:
    Android 输入事件一撸到底之View接盘侠(3)

    (3)
    OverLay,顾名思义就是"盖在某个东西上面",此处是在绘制内容之后,绘制前景之前。怎么用呢?

            View viewGroup = findViewById(R.id.myviewgroup);
            //给overLay 指定一个Drawable
            Drawable drawable = ContextCompat.getDrawable(this, R.drawable.shapeme);
            //设置Drawable 的尺寸
            drawable.setBounds(0, 0, 400, 58);
            //为overLay添加Drawable
            viewGroup.getOverlay().add(drawable);
    

    以上是给一个ViewGroup设置overLay,效果如下:

    image.png
    黑色部分为ViewGroup背景
    红色矩形+黄色圆圈 为子布局
    黄色矩形即为为ViewGroup添加的overLay,可以看出overLay绘制在内容之上。
    (4)
    onDrawForeground(xx)
    绘制前景,使用方法如下:
            View viewGroup = findViewById(R.id.myviewgroup);
            Drawable drawable = ContextCompat.getDrawable(this, R.drawable.shapeme);
            drawable.setBounds(0, 0, 400, 58);
            viewGroup.setForeground(drawable);
    

    你可能发现了,这和设置overLay差不多的嘛,实际还是有差别的。在onDrawForeground(xx)里会重新调整Drawable的尺寸,该尺寸与View大小一致,之前给Drawable设置的尺寸会失效。运行效果如下:


    image.png

    可以看出,ViewGroup都被前景盖住了。
    再来看看B分支的重点:边缘渐变效果
    先来看看TextView 边缘渐变效果:

    my (1).gif
    这是个TextView,以跑马灯的形式展示。
    给它水平方向加上边缘渐变效果,如上所示,两边是渐变的。
    怎么实现的呢?
        //水平还是垂直方向
        android:requiresFadingEdge="horizontal"
        //渐变的长度
        android:fadingEdgeLength="100dp"
    

    加上这俩参数。
    实际上系统自带的一些控件也使用了该效果,如NumberPicker、YearPickerView


    you.gif

    以上是NumberPicker 的效果,可以看出是垂直方向渐变的。

    ViewGroup Draw过程

    对于View.java 里的onDraw(xx)、draw(xx),ViewGroup.java里并没有重写。
    而对于dispatchDraw(xx),在View.java里是空实现。在ViewGroup.java里发起对子布局的绘制。

    ViewGroup dispatchDraw(xx)

    #ViewGroup.java
        @Override
        protected void dispatchDraw(Canvas canvas) {
            boolean usingRenderNodeProperties = canvas.isRecordingFor(mRenderNode);
            final int childrenCount = mChildrenCount;
            final View[] children = mChildren;
            int flags = mGroupFlags;
            //动画相关
            ...
            int clipSaveCount = 0;
            //设置了padding后,绘制的子布局不能超过padding (1)
            final boolean clipToPadding = (flags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK;
            if (clipToPadding) {
                //因此需要对canvas坐标进行变换,先保存其状态
                clipSaveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
                canvas.clipRect(mScrollX + mPaddingLeft, mScrollY + mPaddingTop,
                        mScrollX + mRight - mLeft - mPaddingRight,
                        mScrollY + mBottom - mTop - mPaddingBottom);
            }
    
            //重置相关标记
            mPrivateFlags &= ~PFLAG_DRAW_ANIMATION;
            mGroupFlags &= ~FLAG_INVALIDATE_REQUIRED;
            ...
            for (int i = 0; i < childrenCount; i++) {
                ...
                //遍历子布局,发起子布局绘制
                final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
                final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
                if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {
                    more |= drawChild(canvas, child, drawingTime); (2)
                }
            }
            ...
        }
    

    来看看标记的2点:
    (1)
    设置padding的目的是为了让子布局留出一定的空隙出来,因此当设置了padding后,子布局的canvas需要根据padding进行裁减。判断标记为:

    (flags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK
    
    protected static final int CLIP_TO_PADDING_MASK = FLAG_CLIP_TO_PADDING | FLAG_PADDING_NOT_NULL;
    

    FLAG_CLIP_TO_PADDING 默认设置为true
    FLAG_PADDING_NOT_NULL 只要有padding不为0,该标记就会打上。
    也就是说:只要设置了padding 不为0,子布局显示区域需要裁减。
    能不能不让子布局裁减显示区域呢?
    答案是可以的。
    考虑到一种场景:使用RecyclerView的时候,我们需要设置paddingTop = 20px,效果是:RecyclerView Item展示时离顶部有20px,但是滚动的时候永远滚不到顶部,看起来不是那么友好。这就是上述的裁减起作用了,需要将此动作禁止。通过设置:

    setClipToPadding(false)
    

    当然也可以在xml里设置:

    android:clipToPadding="false"
    

    (2)
    drawChild(xx)

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

    从方法名上看是调用子布局进行绘制。
    child.draw(x1,x2,x3)里分两种情况:

    一是 硬件加速绘制
    二是 软件绘制

    这两者具体作用与区别会在下篇文章分析,不管是硬件加速绘制还是软件加速绘制,最终都会调用View.draw(xx)方法,该方法上面已经分析过。
    注意,draw(x1,x2,x3)与draw(xx)并不一样,不要搞混了。

    View/ViewGroup 常用方法分析

    用图表示:


    image.png

    View/ViewGroup Draw过程的联系:


    image.png

    一般来说,我们通常会自定义View,并且重写其onDraw(xx)方法,有没有绘制内容的ViewGroup需求呢?
    是有的,举个例子,大家可以去看看RecyclerView ItemDecoration 的绘制,其中运用到了ViewGroup draw(xx)、ViewGroup onDraw(xx) 、View onDraw(xx)绘制的先后顺序来实现分割线,分组头部悬停等功能的。

    接下来的一篇还是分析Draw 过程,重点在软件绘制与硬件加速绘制的流程分析,敬请关注。

    本篇文章基于 Android 10.0

    相关文章

      网友评论

        本文标题:Android 自定义View之Draw过程(上)

        本文链接:https://www.haomeiwen.com/subject/vyeqsktx.html