[TOC]
前言
View
是我们开发中再熟悉不过的一个组件了,几乎任何需求的开发实现都离不开它。那么今天今天就好好梳理一下View的基础内容,常用自定义方法,事件分发等知识。
看了不少Android的源码,总结出一点心得: 所谓的代码简洁不是代码量少,而是逻辑简单明了。 常常看到源码中会有不少重复判断的地方,或者说可以合并起来的判断语句,但是Google工程师都是分开处理的,这就不得不让我疑惑了。随着每一次的思考,今天突然发现,原来我一直在享受这样的写的便利,就是每一段代码判断都只做一件事,逻辑清晰明了~
从LayoutInflater讲起
为什么讲View要先讲LayoutInflater
呢?因为View的加载都是通过LayoutInflater
的inflate(...)
方法进行的。可是我们在Activity中使用setContentView(int layoutID)
也调用这个方法了吗?答案是肯定的。
setContentView()
的源码是这样的:
1 | @Override |
注意:如果你继承的是AppCompatActivity的话会追踪到一个
AppCompatDelegate
类,这个不用管 直接看他的实现类的对应方法就行了
LayoutInflater.inflate()方法的作用也很清晰,就是将XML文件中的一个布局加载转化成一个View类对象。究竟这个过程是如何完成的,我们一步一步的看一下。
LayoutInflater的基本用法
首先看一下它的基本用法:
获得LayoutInflater实例有两种方法,
第一种:
1 | LayoutInflater inflater = LayoutInflater.from(context); |
第二种:
1 | LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); |
其实第一种是第二种方法的一种简单封装,内部还是通过getSystemService去获取的。另外,LayoutInflater是一个单例。
使用LayoutInflater加载布局一般用这两个方法:
1 | public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) |
LayoutInflater的源码分析
第一个参数是需要加载的布局的ID,第二个参数是加载该布局时候的外部布局,而第三个参数是指是否要将该布局添加到外部的布局上面。当然了,这个外部布局可以使空的。这里我们还是看一下具体的代码。不管你是使用的哪个inflate()方法的重载,最终都会辗转调用到LayoutInflater的如下代码中:
1 | public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) { |
从这这里我们可以看出,LayoutInflater其实就是使用Android提供的pull解析方式来解析文件的。其中我们发现调用了createViewFromTag()
这个方法,接收一个rootView参数和一个属性参数,通过名字可以看出,这个方法是用来创建View对象的。跟踪这个方法,里面又调用了createView()方法,然后反射创建View实例并且返回。
创建完成这个根布局后,又调用rInflate()
方法来循环遍历这个跟布局下面的所有子元素:
1 | private void rInflate(XmlPullParser parser, View parent, final AttributeSet attrs) |
我们发现其实也是调用了createViewFromTag()
方法来创建View实例,然后又递归调用rInflate()
方法查找View的子元素,每次递归玩成后将这个View添加到父布局中。这样,把xml中的所有View都解析完成,然后将跟布局返回,这样inflate()方法就到此结束了,而我们也得到了View对象实例。
1 | inflate(int resource, ViewGroup root, boolean attachToRoot) |
我们可以通过代码解释一下这个root参数和attachroot参数的意思和用处。
在加载View的时候,如果root存在并且attachToRoot为false的话,View在调用createViewFromTag()
创建的时候会把root的ViewGroup.LayoutParams当参数传递进去。那么有什么用吗?当然有用。我们知道,在View的onMeasure()方法中需要根据父布局去确定当前View的大小,除非我们指定某一确定的值为View的大小,否则在inflater一个View的时候传递空的root进去,可能得到的布局大小并不是你所想要的。这个时候你就要考虑一下这个因素的了。那么attachToRoot什么意思呢?我们看到,在它为true的时候直接调用了root.addView()将temp view添加了进去,也就是说我们加载的View会被自动添加绘制到root下面,说道这里,这俩参数什么意思应该很明确了。
View的绘制流程
在创建得到一个View之后,那么显示在屏幕上的时候,显示大小,显示位置是怎么确定的呢?这就涉及到了View的绘制过程了。接下来就看一下View的绘制流程。
View要想显示在屏幕上面,要经过测算绘制才能显示在屏幕上面。每一个View的绘制过程都必须经过三个过程:测算,布局和绘制,即onMeasure()
,onLayout()
,onDraw()
。
onMeasure()
通过名字我们就可以知道这个方法是用来测算View大小的。
1 | public final void measure(int widthMeasureSpec, int heightMeasureSpec) |
View的measure()方法接收两个整形参数,这两个值分贝用于确定视图的宽度和高度的规则和大小。但是他们并不是直接表示宽度和高度的,而是带有不同规格的。什么意思呢?
简单讲是这样的,我们知道Java中的int是32位的,widthMeasureSpec
和heightMeasureSpec
中的两位代表了测试模式,也就是specMode
,剩下的才是测量的大小,即specSize
。也就是说,MeasureSpecMode
和MeasureSpecSize
组成。
而specMode有三种:
- EXACTLY:精确模式,父布局提供一个精确的尺寸给这个View。无论View想要多大的尺寸,父布局给的布局边界已经确定。
- AT_MOST:该View可以获得的最大的尺寸
- UNSPECIFIED:父布局对该View的大小没有任何局限,View想要多大都可以。
那么,widthMeasureSpec
和heightMeasureSpec
这两个值是从哪里来的呢?
我们发现,在View中还有一个measure()
方法:
1 | public final void measure(int widthMeasureSpec, int heightMeasureSpec) { |
可以看到,measure()
方法调用了onMeasure()
方法去计算View大小。这个方法是一个final类型的,所以子类是无法继承重写这个方法的,说明Google并不希望开发者改变measure的流程,我们只能去改变这个计算的数值。而onMeasure()
的源码也很简单:
1 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
getDefaultSize()
:
1 | public static int getDefaultSize(int size, int measureSpec) { |
getSuggestedMinimumWidth()
:
1 | protected int getSuggestedMinimumHeight() { |
而setMeasuredDimension()
方法最终会调用setMeasuredDimensionRaw()
方法:
1 | private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) { |
最终我们在onMeasure()
方法中传入的specWidth和specHeight会赋值给mMeasureWIdth和mMeasureHeight。这样View的大小就确定了。
那么,measure方法里面的specWidth和specHeight是从哪里来得呢?measure方法又是怎么被调用的呢?这就涉及到ViewGroup了,我们知道,任何View都是要添加到ViewGroup里面才能显示出来的,而测算View的大小也是在ViewGroup里面完成的。ViewGroup中定义了一个measureChildren()方法来去测量子视图的大小,如下所示:
1 | protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) { |
在measureChildren()方法里面遍历了所有的View,并且调用measureChild()方法:
1 | protected void measureChild(View child, int parentWidthMeasureSpec, |
可以看到,在第4行和第6行分别调用了getChildMeasureSpec()方法来去计算子视图的MeasureSpec,计算的依据就是布局文件中定义的MATCH_PARENT、WRAP_CONTENT等值,这个方法的内部细节就不再贴出。然后在第8行调用子视图的measure()方法,并把计算出的MeasureSpec传递进去,之后的流程就和前面所介绍的一样了。
这样,View绘制流程的第一步就完成了。
onLayout()
measure过程结束后,View的大小就已经测量好了,接下来就是layout的过程了。这个方法是用于给View进行布局的,也就是确定View的位置。View的onMeasure和onLayout的调用都是在ViewRootImpl的performTraversals()方法中,在onMeasure()方法执行完成后还会接着调用View的layout()。
1 | // 这段代码存在于performLayout()方法中 |
layout()方法接收四个参数,分别代表左上右下的坐标,需要注意的是这里的坐标值都是相对于当前的父布局来说的,是相对坐标。host代表的就是当前的View。所以这里的host.getMeasuredWidth()
和host.getMeasuredHeight()
就是上一步中测算出来的值。
1 | public void layout(int l, int t, int r, int b) { |
在这个方法中,会先调用setFrame()
方法来判断View的大小是否已经发生了变化。同时还会在这里把传递过来的四个参数分别赋值给mLeft、mTop、mRight和mBottom这几个变量。接下来就会调用onLayout()
,这个方法在View中是一个空方法:
1 | protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
因为一个View的布局是由父布局完成的,所以这个方法已经在ViewGroup
中来实现。我们知道ViewGroup
是所有布局的父类,而onLayout()
也是一个抽象方法,所以这个方法会在LinearLayout
等子类来实现的。所以一般来说,如果我们要自定义一个View是不需要重写这个方法的,当我们需要自定义一个布局的时候才会用到它。比如我们需要一个侧滑布局-DrawerLayout
。
在五个布局中,FrameLayout
是最简单的一个布局,我们可以看一下这个代码:
1 | @Override |
1 | void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) { |
这段代码不是很复杂,就是首先获取FrameLayout
的Padding
值计算出FrameLayout
内容可以使用的空间大小,然后通过遍历每一个View,根据它的Gravity
,margin
属性计算出View的位置。在方法最后将值回调给child.layout()
,View可以根据自己需要再次更改
到此为止,我们把视图绘制流程的第二阶段也分析完了。
onDraw()
measure和layout的过程都结束后,接下来就进入到draw的过程了。同样,根据名字你就能够判断出,在这里才真正地开始对视图进行绘制。ViewRootImpl中的代码会继续执行并创建出一个Canvas对象,然后调用View的draw()方法来执行具体的绘制工作。draw()方法内部的绘制过程总共可以分为六步,其中第二步和第五步在一般情况下很少用到,因此这里我们只分析简化后的绘制过程。代码如下所示:
1 | public void draw(Canvas canvas) { |
可以看到,第一步是从第9行代码开始的,这一步的作用是对视图的背景进行绘制。这里会先得到一个mBGDrawable对象,然后根据layout过程确定的视图位置来设置背景的绘制区域,之后再调用Drawable的draw()方法来完成背景的绘制工作。那么这个mBGDrawable对象是从哪里来的呢?其实就是在XML中通过android:background属性设置的图片或颜色。当然你也可以在代码中通过setBackgroundColor()、setBackgroundResource()等方法进行赋值。
接下来的第三步是在第34行执行的,这一步的作用是对视图的内容进行绘制。可以看到,这里去调用了一下onDraw()方法,那么onDraw()方法里又写了什么代码呢?进去一看你会发现,原来又是个空方法啊。其实也可以理解,因为每个视图的内容部分肯定都是各不相同的,这部分的功能交给子类来去实现也是理所当然的。
第三步完成之后紧接着会执行第四步,这一步的作用是对当前视图的所有子视图进行绘制。但如果当前的视图没有子视图,那么也就不需要进行绘制了。因此你会发现View中的dispatchDraw()方法又是一个空方法,而ViewGroup的dispatchDraw()方法中就会有具体的绘制代码。
最后还会绘制一个滚动条,所以,我们可以知道不只是srcollview
或者recyclerView
这类的View,任何一个View都有一个滚动条,只是它默认没有开启罢了。
我们可以在canvas
上面绘制我们想要的任何试图。具体canvas
是有许多方法的,可以参考官网文档去学习。
实战应用-实现两端对齐的TextView
记得产品之前提了一个需求,要我们实现类似于微信聊天窗口那样的文本对齐方式-两端对齐。这个在android原生的API里面并没有提供,先看下效果:
我们发现,微信通过调整文字之间的间距来保证每一行文字的左右端都是对齐的状态的,这样文字看起来就会整齐很多。我们要实现这种效果就要自定义一个TextView,在onDraw()方法中处理每一个文字之间的间隔。思路大概这样:
- 将一篇文章按段落分成若干段
- 将每一段的文字拆分成各个单词,然后根据控件长度确定每一行最多可以填入的单词数,并且算出排满该行还需要填入多大的间距
- 将间距平均填充到文字之间
主要涉及onDraw()
方法和calc()
方法
1 | @Override |
1 | /** |
看一下实现的效果,上面是系统自带的效果,下面使我们实现的效果:
总结
View是我们日常开发中用到的频率很高的一部分,要掌握好了绘制的基本流程以及一些canvas的基本方法,对于三大流程onMeasure()
,onLayout()
以及onDraw()
,我们要很熟悉他的调用流程以及每个方法的作用,这样很多效果实现起来就比较简单了。