View(视图)的内容很多,这里只总结了一些我认为比较重要的点。
1.1View基础
1.1.1视图分类
视图View主要分为两类:
- 单一视图:即一个View、不包含子View,如TextView
- 视图组,即多个View组成的ViewGroup、包含子View,如LinearLayout
Android中的UI组件都由View、ViewGroup共同组成。
1.1.2视图结构
- 对于包含子View的视图组(ViewGroup),结构是树形结构
-
ViewGroup下可能有多个ViewGroup或View,如下图:
这里需要特别注意的是:在View的绘制过程中,永远都是从View树结构的根节点开始(即从树的顶端开始),一层一层、一个个分支地自上而下遍历进行(即树形递归),最终计算整个View树中各个View,从而最终确定整个View树的相关属性。
1.1.3Android坐标系
Android的坐标系定义为:
- 屏幕的左上角为坐标原点
- 向右为x轴增大方向
- 向下为y轴增大方向
具体如下图:
视图的位置由四个顶点决定,如图所示的A、B、C、D。
视图的位置是相对于父控件而言的,四个顶点的位置描述分别由四个与父控件相关的值决定:
- 顶部(Top):视图上边界到父控件上边界的距离;
- 左边(Left):视图左边界到父控件左边界的距离;
- 右边(Right):视图右边界到父控件左边界的距离;
- 底部(Bottom):视图下边界到父控件上边界的距离。
具体如图所示。
可根据视图位置的左上顶点、右下顶点进行记忆:
- 顶部(Top):视图左上顶点到父控件上边界的距离;
- 左边(Left):视图左上顶点到父控件左边界的距离;
- 右边(Right):视图右下顶点到父控件左边界的距离;
- 底部(Bottom):视图右下顶点到父控件上边界的距离。
1.1.4位置获取方式
视图的位置获取是通过View.getXXX()
方法进行获取。
获取顶部距离(Top):getTop()
获取左边距离(Left):getLeft()
获取右边距离(Right):getRight()
获取底部距离(Bottom):getBottom()
- 与MotionEvent中
get()
和getRaw()
的区别
//get() :触摸点相对于其所在组件坐标系的坐标
event.getX();
event.getY();
//getRaw() :触摸点相对于屏幕默认坐标系的坐标
event.getRawX();
event.getRawY();
具体如下图:
1.2自定义View工作流程
1.2.1Window、Activity、DecorView 与 ViewRoot的关系
1.2.2绘制流程概述
View
的绘制流程开始于:ViewRootImpl
对象的performTraversals()
,源码如下:
/**
* 源码分析:ViewRootImpl.performTraversals()
*/
private void performTraversals() {
// 1. 执行measure流程
// 内部会调用performMeasure()
measureHierarchy(host, lp, res,desiredWindowWidth, desiredWindowHeight);
// 2. 执行layout流程
performLayout(lp, mWidth, mHeight);
// 3. 执行draw流程
performDraw();
}
- 从上面的
performTraversals()
可知:View
的绘制流程从顶级View(DecorView)
的ViewGroup
开始,一层一层从ViewGroup
至子View
遍历测绘
即:自上而下遍历、由父视图到子视图、每一个
ViewGroup
负责测绘它所有的子视图,而最底层的 View 会负责测绘自身
-
绘制的流程 =
measure
过程、layout
过程、draw
过程,具体如下:
1.2.3Measure 过程
- 作用
测量View
的宽 / 高
- 在某些情况下,需要多次测量
(measure)
才能确定View
最终的宽/高;- 该情况下,
measure
过程后得到的宽 / 高可能不准确;- 此处建议:在
layout
过程中onLayout()
去获取最终的宽 / 高
- 具体流程
单一view的getDefaultSize():
单一View的measure过程对onMeasure()有统一的实现(如下代码),但为什么ViewGroup的measure过程没有呢?
原因是:onMeasure()方法的作用是测量View的宽/高值,而不同的ViewGroup(如LinearLayout、RelativeLayout、自定义ViewGroup子类等)具备不同的布局特性,这导致它们的子View测量方法各有不同,所以onMeasure()的实现也会有所不同。
因此,ViewGroup无法对onMeasure()作统一实现。这个也是单一View的measure过程与ViewGroup的measure过程最大的不同。
复写onMeasure()
针对Measure流程,自定义ViewGroup的关键在于:根据需求复写onMeasure(),从而实现子View的测量逻辑。复写onMeasure()的步骤主要分为三步:
- 遍历所有子View及测量:measureChildren()
- 合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值:需自定义实现
- 存储测量后View宽/高的值:setMeasuredDimension()
具体如下所示。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//仅展示关键代码
...
// 步骤1:遍历所有子View & 测量 -> 分析1
measureChildren(widthMeasureSpec, heightMeasureSpec);
// 步骤2:合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值
void measureCarson{
... // 需自定义实现
}
// 步骤3:存储测量后View宽/高的值
setMeasuredDimension(widthMeasure, heightMeasure);
// 类似单一View的过程,此处不作过多描述
}
LinearLayout的onMeasure()实现如下:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 根据不同的布局属性进行不同的计算
// 此处只选垂直方向的测量过程,即measureVertical() ->分析1
if (mOrientation == VERTICAL) {
measureVertical(widthMeasureSpec, heightMeasureSpec);
} else {
measureHorizontal(widthMeasureSpec, heightMeasureSpec);
}
}
/**
* 分析1:measureVertical()
* 作用:测量LinearLayout垂直方向的测量尺寸
*/
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
// 获取垂直方向上的子View个数
final int count = getVirtualChildCount();
// 遍历子View获取其高度,并记录下子View中最高的高度数值
for (int i = 0; i < count; ++i) {
final View child = getVirtualChildAt(i);
// 子View不可见,直接跳过该View的measure过程,getChildrenSkipCount()返回值恒为0
// 注:若view的可见属性设置为VIEW.INVISIBLE,还是会计算该view大小
if (child.getVisibility() == View.GONE) {
i += getChildrenSkipCount(child, i);
continue;
}
// 记录子View是否有weight属性设置,用于后面判断是否需要二次measure
totalWeight += lp.weight;
if (heightMode == MeasureSpec.EXACTLY && lp.height == 0 && lp.weight > 0) {
// 如果LinearLayout的specMode为EXACTLY且子View设置了weight属性,在这里会跳过子View的measure过程
// 同时标记skippedMeasure属性为true,后面会根据该属性决定是否进行第二次measure
// 若LinearLayout的子View设置了weight,会进行两次measure计算,比较耗时
// 这就是为什么LinearLayout的子View需要使用weight属性时候,最好替换成RelativeLayout布局
final int totalLength = mTotalLength;
mTotalLength = Math.max(totalLength, totalLength + lp.topMargin + lp.bottomMargin);
skippedMeasure = true;
} else {
int oldHeight = Integer.MIN_VALUE;
// 步骤1:该方法内部最终会调用measureChildren(),从而 遍历所有子View & 测量
measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec,totalWeight == 0 ? mTotalLength : 0);
...
}
// 步骤2:合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值(需自定义实现)
final int childHeight = child.getMeasuredHeight();
// 1. mTotalLength用于存储LinearLayout在竖直方向的高度
final int totalLength = mTotalLength;
// 2. 每测量一个子View的高度, mTotalLength就会增加
mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin +
lp.bottomMargin + getNextLocationOffset(child));
// 3. 记录LinearLayout占用的总高度
// 即除了子View的高度,还有本身的padding属性值
mTotalLength += mPaddingTop + mPaddingBottom;
int heightSize = mTotalLength;
// 步骤3:存储测量后View宽/高的值
setMeasureDimension(resolveSizeAndState(maxWidth,width))
...
}
1.2.3Layout过程
- 作用
计算视图(View)
的位置
即计算
View
的四个顶点位置:Left
、Top
、Right
和Bottom
- 具体流程
单一view不需要重写onLayout,ViewGroup必须重写onLayout,接下来看一下LinearLayout的onLayout实现:
/**
* 源码分析:LinearLayout复写的onLayout()
* 注:复写的逻辑 和 LinearLayout measure过程的 onMeasure()类似
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 根据自身方向属性,而选择不同的处理方式
if (mOrientation == VERTICAL) {
layoutVertical(l, t, r, b);
} else {
layoutHorizontal(l, t, r, b);
}
}
// 由于垂直/水平方向类似,所以此处仅分析垂直方向(Vertical)的处理过程 ->分析1
/**
* 分析1:layoutVertical(l, t, r, b)
*/
void layoutVertical(int left, int top, int right, int bottom) {
// 子View的数量
final int count = getVirtualChildCount();
// 1. 遍历子View
for (int i = 0; i < count; i++) {
final View child = getVirtualChildAt(i);
if (child == null) {
childTop += measureNullChild(i);
} else if (child.getVisibility() != GONE) {
// 2. 计算子View的测量宽 / 高值
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
// 3. 确定自身子View的位置
// 即:递归调用子View的setChildFrame(),实际上是调用了子View的layout() ->分析2
setChildFrame(child, childLeft, childTop + getLocationOffset(child),
childWidth, childHeight);
// childTop逐渐增大,即后面的子元素会被放置在靠下的位置
// 这符合垂直方向的LinearLayout的特性
childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child);
i += getChildrenSkipCount(child, i);
}
}
}
/**
* 分析2:setChildFrame()
*/
private void setChildFrame( View child, int left, int top, int width, int height){
child.layout(left, top, left ++ width, top + height);
// setChildFrame()仅仅只是调用了子View的layout()而已
// 在子View的layout()又通过调用setFrame()确定View的四个顶点
// 即确定了子View的位置
// 如此不断循环确定所有子View的位置,最终确定ViewGroup的位置
}
细节问题:getWidth() 与 getMeasuredWidth() 获取的宽 (高)有什么区别?
getWidth()
/getHeight()
:获得View
最终的宽 / 高
getMeasuredWidth()
/getMeasuredHeight()
:获得View
测量的宽 / 高// 获得View测量的宽 / 高 public final int getMeasuredWidth() { return mMeasuredWidth & MEASURED_SIZE_MASK; // measure过程中返回的mMeasuredWidth } public final int getMeasuredHeight() { return mMeasuredHeight & MEASURED_SIZE_MASK; // measure过程中返回的mMeasuredHeight } // 获得View最终的宽 / 高 public final int getWidth() { return mRight - mLeft; // View最终的宽 = 子View的右边界 - 子view的左边界。 } public final int getHeight() { return mBottom - mTop; // View最终的高 = 子View的下边界 - 子view的上边界。 }
上面标红:一般情况下,二者获取的宽 / 高是相等的。那么,“非一般”情况是什么?
答:人为设置:通过重写
View
的layout()
强行设置@Override public void layout( int l , int t, int r , int b){ // 改变传入的顶点位置参数 super.layout(l,t,r+100,b+100); // 如此一来,在任何情况下,getWidth() / getHeight()获得的宽/高 总比 getMeasuredWidth() / getMeasuredHeight()获取的宽/高大100px // 即:View的最终宽/高 总比 测量宽/高 大100px }
虽然这样的人为设置无实际意义,但证明了
View
的最终宽 / 高 与 测量宽 / 高是可以不一样
1.2.4Draw过程
- 作用
绘制View
视图 - 具体流程
1.3自定义View的步骤
步骤1:实现Measure、Layout、Draw流程
- 从View的工作流程(
measure
过程、layout
过程、draw
过程)来看,若要实现自定义View
,根据自定义View的种类不同(单一View
/ViewGroup
),需自定义实现不同的方法 - 主要是:
onMeasure()
、onLayout()
、onDraw()
,具体如下
步骤2:自定义属性
- 在values目录下创建自定义属性的xml文件
- 在自定义View的构造方法中加载自定义XML文件 & 解析属性值
- 在布局文件中使用自定义属性
使用注意点
支持特殊属性
- 支持wrap_content
如果不在onMeasure()
中对wrap_content
作特殊处理,那么wrap_content
属性将失效
具体原因请看文章:为什么你的自定义View wrap_content不起作用?
- 支持padding & margin
如果不支持,那么padding
和margin
(ViewGroup情况)的属性将失效
- 对于继承View的控件,padding是在draw()中处理
- 对于继承ViewGroup的控件,padding和margin会直接影响measure和layout过程
多线程应直接使用post方式
View的内部本身提供了post系列的方法,完全可以替代Handler的作用,使用起来更加方便、直接。
避免内存泄露
主要针对View中含有线程或动画的情况:当View退出或不可见时,记得及时停止该View包含的线程和动画,否则会造成内存泄露问题。
启动或停止线程/ 动画的方式:
- 启动线程/ 动画:使用
view.onAttachedToWindow()
,因为该方法调用的时机是当包含View的Activity启动的时刻- 停止线程/ 动画:使用
view.onDetachedFromWindow()
,因为该方法调用的时机是当包含View的Activity退出或当前View被remove的时刻
处理好滑动冲突
当View带有滑动嵌套情况时,必须要处理好滑动冲突,否则会严重影响View的显示效果。