Android自定义View-2021

自定义View是一个老生常谈的问题,对于一个Android开发者来说是必须掌握的知识点,也是Android开发进阶的必经之路。
要想安卓理解自定义View的流程,首先我们要了解View的绘制流程。

View的绘制流程

ViewRoot

在介绍View的绘制前,首先我们需要知道是谁负责执行View绘制的整个流程。实际上,View的绘制是由ViewRoot来负责的。每个应用程序窗口的decorView都有一个与之关联的ViewRoot对象,这种关联关系是由WindowManager来维护的。
那么decorView与ViewRoot的关联关系是在什么时候建立的呢?答案是Activity启动时,ActivityThread.handleResumeActivity()方法中建立了它们两者的关联关系。这里我们不具体分析它们建立关联的时机与方式,感兴趣的同学可以参考相关源码。下面我们直入主题,分析一下ViewRoot是如何完成View的绘制的。

View绘制的起点

当建立好了decorView与ViewRoot的关联后,ViewRoot类的requestLayout()方法会被调用,以完成应用程序用户界面的初次布局。实际被调用的是ViewRootImpl类的requestLayout()方法,这个方法的源码如下:

@Override
public void requestLayout() {
  if (!mHandlingLayoutInLayoutRequest) {
    // 检查发起布局请求的线程是否为主线程 
    checkThread();
    mLayoutRequested = true;
    scheduleTraversals();
  }
}

上面的方法中调用了scheduleTraversals()方法来调度一次完成的绘制流程,该方法会向主线程发送一个“遍历”消息,最终会导致ViewRootImpl的performTraversals()方法被调用。下面,我们以performTraversals()为起点,来分析View的整个绘制流程。

三个阶段

View的整个绘制流程可以分为以下三个阶段:
measure: 判断是否需要重新计算View的大小,需要的话则计算;
layout: 判断是否需要重新计算View的位置,需要的话则计算;
draw: 判断是否需要重新绘制View,需要的话则重绘制。

measure阶段

此阶段的目的是计算出控件树中的各个控件要显示其内容的话,需要多大尺寸。起点是ViewRootImpl的measureHierarchy()方法,这个方法的源码如下:

private boolean measureHierarchy(final View host, final WindowManager.LayoutParams lp, final Resources res,
    final int desiredWindowWidth, final int desiredWindowHeight) {
  // 传入的desiredWindowXxx为窗口尺寸
  int childWidthMeasureSpec;
  int childHeightMeasureSpec;
  boolean windowSizeMayChange = false;
  . . .
  boolean goodMeasure = false;
 
  if (!goodMeasure) {
    childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
    childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
    performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
 
    if (mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight()) {
      windowSizeMayChange = true;
    }
  }
  return windowSizeMayChange;
}

上面的代码中调用getRootMeasureSpec()方法来获取根MeasureSpec,这个根MeasureSpec代表了对decorView的宽高的约束信息。具体的内部方法您可以直接再AS进行查看,不再赘述。

measure阶段

layout阶段的基本思想也是由根View开始,递归地完成整个控件树的布局(layout)工作。
View.layout()
我们把对decorView的layout()方法的调用作为布局整个控件树的起点,实际上调用的是View类的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);
        . . .
    }
    . . .
}

这个方法会调用setFrame()方法来设置View的mLeft、mTop、mRight和mBottom四个参数,这四个参数描述了View相对其父View的位置(分别赋值为l, t, r, b),在setFrame()方法中会判断View的位置是否发生了改变,若发生了改变,则需要对子View进行重新布局,对子View的局部是通过onLayout()方法实现了。由于普通View( 非ViewGroup)不含子View,所以View类的onLayout()方法为空。因此接下来,您可以通过源码查看ViewGroup类的onLayout()方法的实现,不再赘述。

draw阶段

对于本阶段的分析,我们以decorView.draw()作为分析的起点,也就是View.draw()方法,它的源码如下:

public void draw(Canvas canvas) {
  . . .
  // 绘制背景,只有dirtyOpaque为false时才进行绘制,下同
  int saveCount;
  if (!dirtyOpaque) {
    drawBackground(canvas);
  }
 
  . . .
 
  // 绘制自身内容
  if (!dirtyOpaque) onDraw(canvas);
 
  // 绘制子View
  dispatchDraw(canvas);
 
   . . .
  // 绘制滚动条等
  onDrawForeground(canvas);
 
}

简单起见,在上面的代码中我们省略了实现滑动时渐变边框效果相关的逻辑。实际上,View类的onDraw()方法为空,因为每个View绘制自身的方式都不尽相同,对于decorView来说,由于它是容器View,所以它本身并没有什么要绘制的。dispatchDraw()方法用于绘制子View,显然普通View(非ViewGroup)并不能包含子View,所以View类中这个方法的实现为空。
ViewGroup类的dispatchDraw()方法中会依次调用drawChild()方法来绘制子View,drawChild()方法的源码如下:

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

这个方法调用了View.draw(Canvas, ViewGroup,long)方法来对子View进行绘制。在draw(Canvas, ViewGroup, long)方法中,首先对canvas进行了一系列变换,以变换到将要被绘制的View的坐标系下。完成对canvas的变换后,便会调用View.draw(Canvas)方法进行实际的绘制工作,此时传入的canvas为经过变换的,在将被绘制View的坐标系下的canvas。
进入到View.draw(Canvas)方法后,会向之前介绍的一样,执行以下几步:
绘制背景;
通过onDraw()绘制自身内容;
通过dispatchDraw()绘制子View;
绘制滚动条
至此,整个View的绘制流程我们就分析完了。

自定义View / ViewGroup的步骤大致如下:

1.自定义属性;
2.选择和设置构造方法;
3.重写onMeasure()方法;
4.重写onLayout()方法;
5.重写onDraw()方法;
6.重写其他事件的方法(滑动监听等);

自定义属性

(1) 定义在/values/attr.xml文件中, 使用declare-stylable标签直接定义
(2) 在构造方法中通过TypedArray获取到自定义的属性, 代码如下:

public CustomMenu(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CustomMenu, defStyleAttr, 0);
    int indexCount = a.getIndexCount();
    for (int i = 0; i < indexCount; i++) {
        int attr = a.getIndex(i);
        switch (attr) {
            case R.styleable.CustomMenu_rightPadding:
                mMenuRightPadding = a.getDimensionPixelSize(attr, 0);
                break;
        }
    }
    a.recycle();
}
onMeasure()

onMeasure()方法中主要负责测量,决定控件本身或其子控件所占的宽高。我们可以通过onMeasure()方法提供的参数widthMeasureSpec和heightMeasureSpec来分别获取控件宽度和高度的测量模式测量值(测量 = 测量模式 + 测量值)。
widthMeasureSpec和heightMeasureSpec虽然只是int类型的值,但它们是通过MeasureSpec类进行了编码处理的,其中封装了测量模式和测量值,因此我们可以分别通过MeasureSpec.getMode(xMeasureSpec)和MeasureSpec. getSize(xMeasureSpec)来获取到控件或其子View的测量模式和测量值。
测量模式分为以下三种情况:
(1) EXACTLY:当宽高值设置为具体值时使用,如100DIP、match_parent等,此时取出的size是精确的尺寸;
(2) AT_MOST:当宽高值设置为wrap_content时使用,此时取出的size是控件最大可获得的空间;
(3) UNSPECIFIED:当没有指定宽高值时使用(很少见)。
onMeasure()方法中常用的方法:
(1) getChildCount():获取子View的数量;
(2) getChildAt(i):获取第i个子控件;
(3) subView.getLayoutParams().width/height:设置或获取子控件的宽或高;
(4) measureChild(child, widthMeasureSpec, heightMeasureSpec):测量子View的宽高;
(5) child.getMeasuredHeight/width():执行完measureChild()方法后就可以通过这种方式获取子View的宽高值;
(6) getPaddingLeft/Right/Top/Bottom():获取控件的四周内边距;
(7) setMeasuredDimension(width, height):重新设置控件的宽高。如果写了这句代码,就需要删除“super. onMeasure(widthMeasureSpec, heightMeasureSpec);”这行代码。
注意:onMeasure()方法可能被调用多次,这是因为控件中的内容或子View可能对分配给自己的空间“不>满意”,因此向父空间申请重新分配空间。

onLayout()

onLayout()方法负责布局,大多数情况是在自定义ViewGroup中才会重写,主要用来确定子View在这个布局空间中的摆放位置。
onLayout(boolean changed, int l, int t, int r, int b)方法有5个参数,其中changed表示这个控件是否有了新的尺寸或位置;l、t、r、b分别表示这个View相对于父布局的左/上/右/下方的位置。
以下是onLayout()方法中常用的方法:
(1) getChildCount():获取子View的数量;
(2) getChildAt(i):获取第i个子View
(3) getWidth/Height():获取onMeasure()中返回的宽度和高度的测量值;
(4) child.getLayoutParams():获取到子View的LayoutParams对象;
(5) child.getMeasuredWidth/Height():获取onMeasure()方法中测量的子View的宽度和高度值;
(6) getPaddingLeft/Right/Top/Bottom():获取控件的四周内边距;
(7) child.layout(l, t, r, b):设置子View布局的上下左右边的坐标。

onDraw()

onDraw()方法负责绘制,即如果我们希望得到的效果在Android原生控件中没有现成的支持,那么我们就需要自己绘制我们的自定义控件的显示效果。
要学习onDraw()方法,我们就需要学习在onDraw()方法中使用最多的两个类:Paint和Canvas。
注意:每次触摸了自定义View/ViewGroup时都会触发onDraw()方法。

Paint类

Paint画笔对象,这个类中包含了如何绘制几何图形、文字和位图的样式和颜色信息,指定了如何绘制文本和图形。画笔对象右很多设置方法,大体上可以分为两类:一类与图形绘制有关,一类与文本绘制有关。
Paint类中有如下方法:
1、图形绘制:
(1) setArgb(int a, int r, int g, int b):设置绘制的颜色,a表示透明度,r、g、b表示颜色值;
(2) setAlpha(int a):设置绘制的图形的透明度;
(3) setColor(int color):设置绘制的颜色;
(4) setAntiAlias(boolean a):设置是否使用抗锯齿功能,抗锯齿功能会消耗较大资源,绘制图形的速度会减慢;
(5) setDither(boolean b):设置是否使用图像抖动处理,会使图像颜色更加平滑饱满,更加清晰;
(6) setFileterBitmap(Boolean b):设置是否在动画中滤掉Bitmap的优化,可以加快显示速度;
(7) setMaskFilter(MaskFilter mf):设置MaskFilter来实现滤镜的效果;
(8) setColorFilter(ColorFilter cf):设置颜色过滤器,可以在绘制颜色时实现不同颜色的变换效果;
(9) setPathEffect(PathEffect pe):设置绘制的路径的效果;
(10) setShader(Shader s):设置Shader绘制各种渐变效果;
(11) setShadowLayer(float r, int x, int y, int c):在图形下面设置阴影层,r为阴影角度,x和y为阴影在x轴和y轴上的距离,c为阴影的颜色;
(12) setStyle(Paint.Style s):设置画笔的样式:FILL实心;STROKE空心;FILL_OR_STROKE同时实心与空心;
(13) setStrokeCap(Paint.Cap c):当设置画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式;
(14) setStrokeJoin(Paint.Join j):设置绘制时各图形的结合方式;
(15) setStrokeWidth(float w):当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的粗细度;
(16) setXfermode(Xfermode m):设置图形重叠时的处理方式;
2、文本绘制:
(1) setTextAlign(Path.Align a):设置绘制的文本的对齐方式;
(2) setTextScaleX(float s):设置文本在X轴的缩放比例,可以实现文字的拉伸效果;
(3) setTextSize(float s):设置字号;
(4) setTextSkewX(float s):设置斜体文字,s是文字倾斜度;
(5) setTypeFace(TypeFace tf):设置字体风格,包括粗体、斜体等;
(6) setUnderlineText(boolean b):设置绘制的文本是否带有下划线效果;
(7) setStrikeThruText(boolean b):设置绘制的文本是否带有删除线效果;
(8) setFakeBoldText(boolean b):模拟实现粗体文字,如果设置在小字体上效果会非常差;
(9) setSubpixelText(boolean b):如果设置为true则有助于文本在LCD屏幕上显示效果;
3、其他方法:
(1) getTextBounds(String t, int s, int e, Rect b):将页面中t文本从s下标开始到e下标结束的所有字符所占的区域宽高封装到b这个矩形中;
(2) clearShadowLayer():清除阴影层;
(3) measureText(String t, int s, int e):返回t文本中从s下标开始到e下标结束的所有字符所占的宽度;
(4) reset():重置画笔为默认值。
这里需要就几个方法解释一下:
1、setPathEffect(PathEffect pe):设置绘制的路径的效果:
常见的有以下几种可选方案:
(1) CornerPathEffect:可以用圆角来代替尖锐的角;
(2) DathPathEffect:虚线,由短线和点组成;
(3) DiscretePathEffect:荆棘状的线条;
(4) PathDashPathEffect:定义一种新的形状并将其作为原始路径的轮廓标记;
(5) SumPathEffect:在一条路径中顺序添加参数中的效果;
(6) ComposePathEffect:将两种效果组合起来,先使用第一种效果,在此基础上应用第二种效果。
2、setXfermode(Xfermode m):设置图形重叠时的处理方式:
关于Xfermode的多种效果,我们可以参考下面一张图:

img

在使用的时候,我们需要通过paint. setXfermode(new PorterDuffXfermode(PorterDuff.Mode.XXX))来设置,XXX是上图中的某种模式对应的常量参数,如DST_OUT。
这16中情况的具体解释如下:
1.PorterDuff.Mode.CLEAR:所绘制不会提交到画布上。
2.PorterDuff.Mode.SRC:显示上层绘制图片
3.PorterDuff.Mode.DST:显示下层绘制图片
4.PorterDuff.Mode.SRC_OVER:正常绘制显示,上下层绘制叠盖。
5.PorterDuff.Mode.DST_OVER:上下层都显示。下层居上显示。
6.PorterDuff.Mode.SRC_IN:取两层绘制交集。显示上层。
7.PorterDuff.Mode.DST_IN:取两层绘制交集。显示下层。
8.PorterDuff.Mode.SRC_OUT:上层绘制非交集部分。
9.PorterDuff.Mode.DST_OUT:取下层绘制非交集部分。
10.PorterDuff.Mode.SRC_ATOP:取下层非交集部分与上层交集部分
11.PorterDuff.Mode.DST_ATOP:取上层非交集部分与下层交集部分
12.PorterDuff.Mode.XOR:异或:去除两图层交集部分
13.PorterDuff.Mode.DARKEN:取两图层全部区域,交集部分颜色加深
14.PorterDuff.Mode.LIGHTEN:取两图层全部,点亮交集部分颜色
15.PorterDuff.Mode.MULTIPLY:取两图层交集部分叠加后颜色
16.PorterDuff.Mode.SCREEN:取两图层全部区域,交集部分变为透明色

Canvas类

Canvas即画布,其上可以使用Paint画笔对象绘制很多东西。
Canvas****对象中可以绘制:
(1) drawArc():绘制圆弧;
(2) drawBitmap():绘制Bitmap图像;
(3) drawCircle():绘制圆圈;
(4) drawLine():绘制线条;
(5) drawOval():绘制椭圆;
(6) drawPath():绘制Path路径;
(7) drawPicture():绘制Picture图片;
(8) drawRect():绘制矩形;
(9) drawRoundRect():绘制圆角矩形;
(10) drawText():绘制文本;
(11) drawVertices():绘制顶点。
Canvas****对象的其他方法:
(1) canvas.save():把当前绘制的图像保存起来,让后续的操作相当于是在一个新图层上绘制;
(2) canvas.restore():把当前画布调整到上一个save()之前的状态;
(3) canvas.translate(dx, dy):把当前画布的原点移到(dx, dy)点,后续操作都以(dx, dy)点作为参照;
(4) canvas.scale(x, y):将当前画布在水平方向上缩放x倍,竖直方向上缩放y倍;
(5) canvas.rotate(angle):将当前画布顺时针旋转angle度。

其他方法
generateLayoutParams()

generateLayoutParams()方法用在自定义ViewGroup中,用来指明子控件之间的关系,即与当前的ViewGroup对应的LayoutParams。我们只需要在方法中返回一个我们想要使用的LayoutParams类型的对象即可。
在generateLayoutParams()方法中需要传入一个AttributeSet对象作为参数,这个对象是这个ViewGroup的属性集,系统根据这个ViewGroup的属性集来定义子View的布局规则,供子View使用。
例如,在自定义流式布局中,我们只需要关心子控件之间的间隔关系,因此我们需要在generateLayoutParams()方法中返回一个new MarginLayoutParams()即可。

onTouchEvent()

onTouchEvent()方法用来监测用户手指操作。我们通过方法中MotionEvent参数对象的getAction()方法来实时获取用户的手势,有UP、DOWN和MOVE三个枚举值,分别表示用于手指抬起、按下和滑动的动作。每当用户有操作时,就会回掉onTouchEvent()方法。

onScrollChanged()

如果我们的自定义View / ViewGroup是继承自ScrollView / HorizontalScrollView等可以滚动的控件,就可以通过重写onScrollChanged()方法来监听控件的滚动事件。
这个方法中有四个参数:l和t分别表示当前滑动到的点在水平和竖直方向上的坐标;oldl和oldt分别表示上次滑动到的点在水平和竖直方向上的坐标。我们可以通过这四个值对滑动进行处理,如添加属性动画等。

invalidate()

invalidate()方法的作用是请求View树进行重绘,即draw()方法,如果视图的大小发生了变化,还会调用layout()方法。
一般会引起invalidate()****操作的函数如下:
(1) 直接调用invalidate()方法,请求重新draw(),但只会绘制调用者本身;
(2) 调用setSelection()方法,请求重新draw(),但只会绘制调用者本身;
(3) 调用setVisibility()方法,会间接调用invalidate()方法,继而绘制该View;
(4) 调用setEnabled()方法,请求重新draw(),但不会重新绘制任何视图,包括调用者本身。

postInvalidate()

功能与invalidate()方法相同,只是postInvalidate()方法是异步请求重绘视图。

requestLayout()

requestLayout()方法只是对View树进行重新布局layout过程(包括measure()过程和layout()过程),不会调用draw()过程,即不会重新绘制任何视图,包括该调用者本身。

requestFocus()

请求View树的draw()过程,但只会绘制需要重绘的视图,即哪个View或ViewGroup调用了这个方法,就重绘哪个视图。

总结

最后,让我们来总览一下自定义View / ViewGroup时调用的各种函数的顺序,如下图所示:


在这些方法中:

  • onMeasure()会在初始化之后调用一到多次来测量控件或其中的子控件的宽高;
  • onLayout()会在onMeasure()方法之后被调用一次,将控件或其子控件进行布局;
  • onDraw()会在onLayout()方法之后调用一次,也会在用户手指触摸屏幕时被调用多次,来绘制控件。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容