Android中的View浅析

简介

View,耳熟能详,无人不识,但是否无人不知呢?这里先来看看官方文档对View的定义。

This class represents the basic building block for user interface components. A View occupies a rectangular area on the screen and is responsible for drawing and event handling. View is the base class for widgets, which are used to create interactive UI components (buttons, text fields, etc.). The ViewGroup subclass is the base class for layouts, which are invisible containers that hold other Views (or other ViewGroups) and define their layout properties.

简单来说,View是用户交互的基础组成部分,占据了屏幕中的一个矩形区域,主要工作涉及绘制以及事件响应。 View是所有控件的基础,用来创建UI交互(按钮,文字区域等)。View的直接子类ViewGroup是布局基础,用来包含其他View或者其他ViewGroup并定义其布局属性。

View的属性

IDs

每个View都对应了一个ID,这个很基础。不过需要注意的是,官方文档指出,View的ID在整个View树里面不需要唯一,甚至在一个布局文件里面都可以有两个同ID的View。但是,平时开发中,要保证你当前操作的父View下的子View都有唯一的ID,否则虽然编译不会报错,但是同ID的View只有一个会生效,其他的会失效。这是因为findViewById(int)会去遍历View树并返回匹配的第一个View。
除了在XML中定义View的ID,还可以通过代码动态的设置ID,调用View的setId(int)方法即可。
在API 17之后,可以通过View.generateViewId()去生成一个动态的唯一的ID,在这之前,可以在values/ids.xml文件中定义一个ID。

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="reservedNamedId" type="id"/>
</resources>

然后在动态创建了View或者ViewGroup之后将这个ID赋给它。

1
myViewGroup.setId(R.id.reservedNamedId);

Position

View是矩形的,通过左上角的坐标和宽高四个数值来唯一定义其位置,这些数值的单位是像素。通过调用getLeft()和getTop()可以得到一个View的位置,但要特别注意的是,这两个方法返回的是相对于其父元素的位置

Size, padding and margins

measured width和measured height定义了一个View想在其父View中占据多大的空间。这两个值可以通过getMeasuredWidth()和getMeasuredHeight()方法获得。
width和height是View在绘制之后的实际宽高,跟measured width和measured height不一定相同。可以通过getWidth()和getHeight()方法获得。
计算View的尺寸时,还要将padding考虑进去。相关方法是setPadding(int, int, int, int)或者setPaddingRelative(int, int, int, int),还有getPaddingLeft(), getPaddingTop(), getPaddingRight(), getPaddingBottom(), getPaddingStart(), getPaddingEnd()等。
View不支持margins,但是ViewGroup提供支持。

Tag

tag常常用来存储跟View相关的数据。

自定义属性

res/values文件下定义一个attrs.xml文件:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>  
<resources>
<declare-styleable name="MyView">
<attr name="textColor" format="color" />
<attr name="textSize" format="dimension" />
</declare-styleable>
</resources>

然后在自定义View的构造方法中获取我们定义的属性值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public MyView(Context context,AttributeSet attrs) { 
super(context,attrs);
mPaint = new Paint();

TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.MyView);

int textColor = a.getColor(R.styleable.MyView_textColor,
0XFFFFFFFF);
float textSize = a.getDimension(R.styleable.MyView_textSize, 36);

mPaint.setTextSize(textSize);
mPaint.setColor(textColor);

a.recycle();
}

将自定义View加到布局中,并使用我们的自定义属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml   
version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:test="http://schemas.android.com/apk/res/com.android.tutor"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent" >
<com.android.tutor.MyView
android:layout_width="fill_parent"
android:layout_height="fill_parent"
test:textSize="20px"
test:textColor="#fff" />
</LinearLayout>

这边要特别注意这一行:

1
xmlns:test="http://schemas.android.com/apk/res/com.android.tutor"

这边xmlns后边的test这个名字可以任意取,属性设置就是使用这个名字开头。而com.android.tutor就是我们的包名。
但是,当想要引用的自定义控件为library时,并且此控件也具有自定义的属性,就是说它在attrs.xml中有自定义属性,此时在新项目中引用时,就不能在xml中引用包名,而是引用

1
xmlns:test="http://schemas.android.com/apk/res-auto"

View的绘制过程

View的绘制主要分以下三个过程:

Measure

首先,要确定一个概念,View是没有大小的,可以理解为无穷大,也可以理解为没有大小。而layout.xml里面的的layout_width和layout_height属性设置的宽和高不是View的大小,而是父View给这个子View分配的大小。这也就是为什么这两个属性以layout_为前缀,而不是直接用width和height的原因。所以平时我们所说的View大小实际上是父View为子View分配的布局大小,View内部用两个变量measuredWidth和measuredHeight保存其值。
那View内部的mLeft,mRight,mTop,mBottom又是什么?实际上,这四个变量指明了View在父View中占据的区域,mRight-mLeft就等于measuredWidth,同理,mBottom-mTop就等于measuredHeight。
View绘制时,首先会计算一下自己的父View中占据多大的空间,测量过程(measuring pass)是在measure(int, int)中实现的,是从树的顶端由上到下进行的。在这个递归过程中,每一个View会把自己的dimension specifications传递下去。在measure pass的最后,每一个View都存储好了自己的measurements,即测量结果。
这个过程暴露给我们一个方法:

1
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {}

onMeasure()方法是测量view和它的内容,决定measured width和measured height的,这个方法由 measure(int, int)方法唤起,子类可以覆写onMeasure来提供更加准确和有效的测量。
有一个约定:在覆写onMeasure方法的时候,必须调用setMeasuredDimension(int,int)来存储这个View经过测量得到的measured width and height。如果没有这么做,将会由measure(int, int)方法抛出一个IllegalStateException
View类基本的onMeasure实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

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

其中invoke了setMeasuredDimension()方法,设置了measure过程中View的宽高,getSuggestedMinimumWidth()返回View的最小Width,Height也有对应的方法。
MeasureSpec类是View类的一个内部静态类,它定义了三个常量UNSPECIFIED、AT_MOST、EXACTLY,其实我们可以这样理解它,它们分别对应LayoutParams中match_parent、wrap_content、xxxdp。
如果我们不自己重写onMeasure,而用基础的onMeasure方法的话:

  • CustomView设置为match_parent或者wrap_content没有任何区别,其显示大小由父控件决定,它会填充满整个父控件的空间
  • CustomView设置为固定的值,则其显示大小为该设定的值

我们可以重写onMeasure来重新定义View的宽高。

1
2
3
4
5
6
7
8
9
10
public class MyView extends View {  

......

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(200, 200);
}

}

这样的话就把View默认的测量流程覆盖掉了,不管在布局文件中定义MyView这个视图的大小是多少,最终在界面上显示的大小都将会是200*200
再介绍一种比较复杂的重写onMeasure的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class CustomView extends View {
private static final int DEFAULT_VIEW_WIDTH = 100;
private static final int DEFAULT_VIEW_HEIGHT = 100;

...

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int width = measureDimension(DEFAULT_VIEW_WIDTH, widthMeasureSpec);
int height = measureDimension(DEFAULT_VIEW_HEIGHT, heightMeasureSpec);

setMeasuredDimension(width, height);
}

protected int measureDimension( int defaultSize, int measureSpec ) {

int result = defaultSize;

int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);

//1. layout给出了确定的值,比如:100dp
//2. layout使用的是match_parent,但父控件的size已经可以确定了,比如设置的是具体的值或者match_parent
if (specMode == MeasureSpec.EXACTLY) {
result = specSize; //建议:result直接使用确定值
}
//1. layout使用的是wrap_content
//2. layout使用的是match_parent,但父控件使用的是确定的值或者wrap_content
else if (specMode == MeasureSpec.AT_MOST) {
result = Math.min(defaultSize, specSize); //建议:result不能大于specSize
}
//UNSPECIFIED,没有任何限制,所以可以设置任何大小
//多半出现在自定义的父控件的情况下,期望由自控件自行决定大小
else {
result = defaultSize;
}

return result;
}
}

这样重载了onMeasure函数之后,你会发现,当CustomView使用match_parent的时候,它会占满整个父控件,而当CustomView使用wrap_content的时候,它的大小则是代码中定义的默认大小100x100像素。
需要注意的是,在setMeasuredDimension()方法调用之后,我们才能使用getMeasuredWidth()和getMeasuredHeight()来获取视图测量出的宽高,以此之前调用这两个方法得到的值都会是0。
由此可见,视图大小的控制是由父视图、布局文件、以及视图本身共同完成的,父视图会提供给子视图参考的大小,而开发人员可以在XML文件中指定视图的大小,然后视图本身会对最终的大小进行拍板。
总结来说,measure的过程就是把View布局使用的“相对值”转换为具体值得过程,也就是把WRAP_CONTENT以及MATCH_PARENT转换为具体的值。

Layout

布局过程(layout pass),它发生在 layout(int, int, int, int)中,仍然是从上到下进行(top-down)。在这一遍中,每一个parent都会负责用measure过程中得到的子View大小和布局参数,把自己的所有孩子放在正确的地方。
View给我们暴露了onLayout方法

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

如果View没有子类,这一步我们就不需要做额外的工作,但如果是ViewGroup,就需要在onLayout方法中将所有子View的大小宽高设置好。
ViewGroup类的onLayout()函数是abstract型,继承者必须实现,由于ViewGroup的定位就是一个容器,用来盛放子控件的,所以就必须定义要以什么的方式来盛放,比如LinearLayout就是以横向或者纵向顺序存放,而RelativeLayout则以相对位置来摆放子控件,同样,我们的自定义ViewGroup也必须给出我们期望的布局方式,而这个定义就通过onLayout()函数来实现。
在onLayout()过程结束后,我们就可以调用getWidth()方法和getHeight()方法来获取视图的宽高了。
这里就涉及到一个问题,getWidth()方法和getMeasureWidth()方法到底有什么区别呢?

  • getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到
  • getMeasureWidth()方法中的值是通过setMeasuredDimension()方法来进行设置的,而getWidth()方法中的值则是通过视图右边的坐标减去左边的坐标计算出来的

height_measureHeight

Draw

作为一个View,最重要的就是Draw的过程了,我们需要在Canvas上绘制出我们的View。
View调用draw()开始绘制,draw()方法实现的功能如下:

  1. 绘制该View的背景
  2. 如果有必要的话,为显示渐变框做一些准备操作(大多数情况下,不需要改渐变框),比如保存Canvas的层
  3. 调用onDraw()方法绘制视图本身(每个View都需要重载该方法,ViewGroup不需要实现该方法)
  4. 调用dispatchDraw()方法绘制子视图(如果该View类型不为ViewGroup,即不包含子视图,不需要重载该方法)。dispatchDraw()方法内部会遍历每个子视图,调用drawChild()去重新回调每个子视图的draw()方法(注意,这个地方“需要重绘”的视图才会调用draw()方法)。值得说明的是,ViewGroup类已经为我们重写了dispatchDraw ()的功能实现,应用程序一般不需要重写该方法,但可以重载父类函数实现具体的功能。
  5. 有必要的话,绘制View四周的阴影渐变效果
  6. 绘制滚动条

其中第三步,我们可以看到,View需要我们自己处理绘制步骤。
View暴露出的绘制方法是:

1
2
protected void onDraw(Canvas canvas) {
}

默认View类的onDraw方法没有一行代码,给我们提供了一块空白的画布,任我们自由的绘画。
绘制主要借助了Canvas类。在绘制时,系统内部为每一个窗口创建了一个Canvas对象,并把这个Canvas对象从根View传递给所有子View。View系统将Canvas传递给子View时,都先将该Canvas进行一次裁剪(Clip),从而在子View看来,总是从Canvas的(0, 0)坐标开始绘制。

绘制的其他重要方法

invalidate()

请求重绘View树,即draw()过程,假如视图发生大小没有变化就不会调用layout()过程,并且只绘制那些需要重绘的视图,即谁(View的话,只绘制该View ;ViewGroup,则绘制整个ViewGroup)请求invalidate()方法,就绘制该视图。
一般引起invalidate()操作的函数如下:

  1. 直接调用invalidate()方法,请求重新draw(),但只会绘制调用者本身
  2. setSelection()方法,请求重新draw(),但只会绘制调用者本身
  3. setVisibility()方法,当View可视状态在INVISIBLE转换VISIBLE时,会间接调用invalidate()方法,继而绘制该View
  4. setEnabled()方法,请求重新draw(),但不会重新绘制任何视图包括该调用者本身

requestLayout()

会导致调用measure()过程和layout()过程,只是对View树重新布局layout过程,包括measure()和layout()过程,但不会调用draw()过程,不会重新绘制任何视图包括该调用者本身。
一般引起invalidate()操作的函数如下:

  1. setVisibility()方法,当View的可视状态在INVISIBLE/ VISIBLE转换为GONE状态时,会间接调用requestLayout() 和invalidate方法。同时,由于整个个View树大小发生了变化,会请求measure()过程以及draw()过程,同样地,只绘制需要“重新绘制”的视图

requestFocus()

请求View树的draw()过程,但只绘制“需要重绘”的视图。

onSizeChange(),onMeasure(),onLayout()调用时机

  1. 首先执行最底层child的onMeasure()方法,逐层向上调用,最后调用root的onMeasure()方法。上面提到过,onMeasure()方法的作用就是告诉父View自己占用多大的位置,所以,会自下往上递归调用。
    root的onMeasure()是由DecorView获取的。
  2. 统计完大小后,开始调用onSizeChange(),首次显示的原因,调用顺序是从root开始,逐级往下调用。
    调用完每个child的onSizeChange()后,每个Child执行onLayout()方法。
  3. 从顺序来看,onLayout()和onMeasure()都是从下往上调用。
    只有大小发生了变化,才会调用onSizeChange()。如果没有onSizeChange(),就会从下往上执行onMeasure(),再从下往上执行onLayout()。
  4. onSizeChange()不一定会调用,只有View的大小发生变化的时候才调用。而且不一定是从root开始调用。onMeasure()在界面上增减View的时候会调用onSizeChange(),比如addView()或removeView()。另外,child设置为gone会触发onMeasure(),但是设置为invisible不会。一旦执行onMeasure(),往往就会重新执行onLayout()布局。
  5. 如果root有两个child在不同分支,一个child变化时,会影响其所在的分支,但是不影响另一条分支。
    又比如,在root下加一个child,如果加在前两个child后面,就不会影响前两个child。如果加在两个child中间,就会对第二个child产生影响。

自定义View可能用到的方法

官方文档里面列出了自定义一个View可能要继承到的方法,如下:

Category Methods Description
Creation Constructors There is a form of the constructor that are called when the view is created from code and a form that is called when the view is inflated from a layout file. The second form should parse and apply any attributes defined in the layout file.
onFinishInflate() Called after a view and all of its children has been inflated from XML.
Layout onMeasure(int, int) Called to determine the size requirements for this view and all of its children.
onLayout(boolean, int, int, int, int) Called when this view should assign a size and position to all of its children.
onSizeChanged(int, int, int, int) Called when the size of this view has changed.
Drawing onDraw(android.graphics.Canvas) Called when the view should render its content.
Event processing onKeyDown(int, KeyEvent) Called when a new hardware key event occurs.
onKeyUp(int, KeyEvent) Called when a hardware key up event occurs.
onTrackballEvent(MotionEvent) Called when a trackball motion event occurs.
onTouchEvent(MotionEvent) Called when a touch screen motion event occurs.
Focus onFocusChanged(boolean, int, android.graphics.Rect) Called when the view gains or loses focus.
onWindowFocusChanged(boolean) Called when the window containing the view gains or loses focus.
Attaching onAttachedToWindow() Called when the view is attached to a window.
onDetachedFromWindow() Called when the view is detached from its window.
onWindowVisibilityChanged(int) Called when the visibility of the window containing the view has changed.