Android 高手核心知识点笔记(不断更新中🔥)点击查看 PS:各位童鞋不要忘记给我 star 一波哦~~
我的 Android 知识体系,欢迎 Star https://github.com/daishengda2018/AndroidKnowledgeSystem
文章目录
自定义View —— onMeasure、 onLayout
布局过程的作用
- 确定每个View的尺寸和位置
- 作用:为绘制和触摸范围做支持
- 绘制:知道往哪里了画
- 触摸返回:知道用户点的是哪里
布局的流程
从整体看
- 测量流程:从根 View 递归调用每一级子 View 的 measure 方法,对它们进行测量。
- 布局流程:从根 View 递归调用每一级子 View 的 layout 方法,把测量过程得出的子 View 的位置和尺寸传给子 View,子 View 保存。
从个体看
对于每一个 View:
-
运行前,开发者会根据自己的需求在 xml 文件中写下对于 View 大小的期望值
-
在运行的时候,父 View 会在
onMeaure()
中,根据开发者在 xml 中写的对子 View 的要求, 和自身的实际可用空间,得出对于子 View 的具体尺寸要求 -
子 View 在自己的
onMeasure
中,根据 xml 中指定的期望值和自身特点(指 View 的定义者在onMeasrue
中的声明)算出自己的**期望*如果是 ViewGroup 还会在
onMeasure
中,调用每个子 View 的 measure () 进行测量. -
父 View 在子 View 计算出期望尺寸后,得出⼦ View 的实际尺⼨和位置
-
⼦ View 在自己的 layout() ⽅法中将父 View 传进来的实际尺寸和位置保存
如果是 ViewGroup,还会在 onLayout() ⾥调用每个字 View 的 layout() 把它们的尺寸 置传给它们
为啥需要两个过程呢?
原因一
measure 的测量过程可能不止一次,比如有三个子 View 在一个 ViewGroup 里面,ViewGroup 的宽度是 warp_content,A 的宽度是 match_parent, B 和 C 是 warp_content, 此时 ViewGroup 的宽度是不固定的,怎么确定 A 的 match_parent 到底有多大呢?此时是如何测量的呢?
以 LinearLayout 为例:第一次测量 LinearLayout 的大小也是没有确定的,所以无法确定 A 的 match_parent 到底有多大,这时候的 LinearLayout 会对 A 直接测量为 0 ,然后测量 B、C 的宽度,因为 B、C 的大小是包裹内容的,在测量后就可以确定 LinearLayout 的宽度了:即为最长的 B 的宽度。
这时候再对 A 进行第二次测量,直接设置为与 LinearLayout 相同的宽度,至此达到了 match_parent 的效果。
如果将 measure 和 layout 的过程糅合在一起,会导致两次测量的时候进行无用的 layout,消耗了更多的资源,所以为了性能,将其二者分开。
原因二
也是二者的职责相互独立,分为两个过程,可以使流程、代码更加清晰。
拓展
上面例子中的情况仅仅存在于 LinearLayout中,每种布局的测量机制是不同的。那么如果 A B C 三个 View 都是 match_parent LinearLayout 是如何做的呢?
- 第一轮测量:LinearLayout 无法确定自己的大小,所以遇到子 View match_parent 都会测量为 0
-
第二轮测量:都没有大小,LinearLayout 会让所有子 View 自由测量(父 View 不限制宽度)。每个测量之后都会变为和最宽的一样的宽度。
注意:
-
onMeasure 与 measure() 、onDraw 与 draw 的区别
onXX 方法是调度过程,而 measure、draw 才是真正做事情的。可以从源码中看到 measure 中调用了 onMeasure 方法。
public final void measure(int widthMeasureSpec, int heightMeasureSpec) { // …………… if (cacheIndex < 0 || sIgnoreMeasureCache) { // measure ourselves, this should set the measured dimension flag back onMeasure(widthMeasureSpec, heightMeasureSpec); // ……………… } }
-
为什么不把对于尺寸的要求直接交个子 View 而是要交给父 View 呢?
因为有些场景子 View 的大小需要父 View 进行规划,例如上面的例子中 LinearLayout 的子 View 设置了 weight。
- layout() 很少被使用到,因为他的改变没有通知父 View,这可能会导致布局重叠等问题 。在下面的「综合演练 —— 简单改写已有 View 的尺寸」中会有一个证明。
##onMeasure 方法
要明确的一个问题是: 什么时候需要我们自己实现 onMeasure 方法呢?
答:具体开发的时候有以下三种场景:
- 当我们继承一个已有 View 的时候,简单改写他们的尺寸,比如自定义一个正方形的 ImageView,取宽高中较大的值为边长。
- 完全进行自定义尺寸的计算。比如实现一个绘制圆形的 View 我们需要在尺寸为 warp_content 时指定一个大小例如下文中的「综合演练 —— 完全自定义 View 的尺寸」。
- 自定义 Layout,这时候内部所有的子 View 的尺寸和位置都需要我们自己控制,需要重写
onMeasure()
和onLayout()
方法。例如下文中的「综合演练 —— 自定义 Layout」
onLayout 方法
onLayout 方法是 ViewGroup 中用于控制子 View 位置的方法。放置子 View 位置的过程很简单,只需重写 onLayout 方法,然后获取子 View 的实例,调用子 View 的 layout 方法实现布局。在实际开发中,一般要配合 onMeasure 测量方法一起使用。在下文「综合演练 —— 自定义 Layout」中会详细演示。
综合演练
简单改写已有 View 的尺寸实现方形 ImageView
- 首先来证明一下改写 layout 方法会存在的问题
/**
* 自定义正方形 ImageView
*
* Created by im_dsd on 2019-08-24
*/
public class SquareImageView extends android.support.v7.widget.AppCompatImageView {
public SquareImageView(Context context) {
super(context);
}
public SquareImageView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SquareImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void layout(int l, int t, int r, int b) {
// 使用宽高的最大值设置边长
int width = r - l;
int height = b - t;
int size = Math.max(width, height);
super.layout(l, t, l + size, t + size);
}
}
代码很简单,获取宽与高的最大值用于设置正方形 View 的边长。再看一下布局文件的设置
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".MainActivity">
<com.example.dsd.demo.ui.custom.measure.SquareImageView
android:background="@color/colorAccent"
android:layout_width="200dp"
android:layout_height="300dp"/>
<View
android:background="@android:color/holo_blue_bright"
android:layout_width="200dp"
android:layout_height="200dp"/>
</LinearLayout>
通过布局文件的描述如果是普通的 View 显示的状态应该是这样的
而我们期待的状态应该是这样的:SquareImageView 的宽高均为 300dp。
但是最终的结果却是下图,虽然我们使用了 LinearLayout 但是我们通过layout()
方法改变了 SquareImageView 的大小,对于这个变化LinearLayout 并不知道,所以会发生布局重叠的问题。可见一般情况下不要使用 layout()
方法。
- 通过
onMeasure
方法更改尺寸。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// super.onMeasure 中已经完成了 View 的测量
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 获取测量的结果比较后得出最大值
int height = getMeasuredHeight();
int width = getMeasuredWidth();
int size = Math.max(width, height);
// 将结果设置回去
setMeasuredDimension(size, size);
}
总结
简单来说,更改已有 View 的尺寸主要分为以下步骤
- 重写
onMeasure()
- 用
getMeasureWidth
和getMeasureHeight()
获取测量尺寸 - 计算最终要的尺寸
- 用
setMeasuredDimension(width, height)
把结果保存
完全自定义 View 的尺寸
此处用绘制圆形的 CircleView 做一个例子。对于这个 View 的期望是:View 的大小有内部的圆决定。
首先画一个圆形看看
/**
* 自定义 View 简单测量
* Created by im_dsd on 2019-08-15
*/
public class CircleView extends View {
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
/**
* 为了方便简单,固定尺寸
*/
private static final float PADDING = DisplayUtils.dp2px(20);
private static final float RADIUS = DisplayUtils.dp2px(80);
public CircleView(Context context) {
super(context);
}
public CircleView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPaint.setColor(Color.RED);
canvas.drawCircle(PADDING + RADIUS, PADDING + RADIUS, RADIUS, mPaint);
}
}
<com.example.dsd.demo.ui.custom.layout.CircleView
android:background="@android:color/background_dark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
此时将大小设置为 wrap_content 包裹布局,结果会是怎么样的呢?
竟然填充了屏幕!根本就没有包裹内容,此时就需要我们大展身手了
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 没有必要再让 view 自己测量一遍了,浪费资源
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 计算期望的 size
int size = (int) ((PADDING + RADIUS) * 2);
// 获取父 View 传递来的可用大小
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
// 开始计算
int result = 0;
switch (widthMode) {
// 不超过
case MeasureSpec.AT_MOST:
// 在 AT_MOST 模式下,取二者的最小值
if (widthSize < size) {
result = widthSize;
} else {
result = size;
}
break;
// 精准的
case MeasureSpec.EXACTLY:
// 父 View 给多少用多少
result = widthSize;
break;
// 无限大,没有指定大小
case MeasureSpec.UNSPECIFIED:
// 使用计算出的大小
result = size;
break;
default:
result = 0;
break;
}
// 设置大小
setMeasuredDimension(result, result);
}
上面的代码就是 onMeasure(int,int)
的模板代码了,要注意一点的是需要注释 super.onMeasure
方法,此处面试的时候普遍会问。
// 没有必要再让 view 自己测量一遍了,浪费资源
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
这段模版代码其实 Android SDK 里面早就有了很好的封装 : resolveSize(int size, int measureSpec)
和 resolveSizeAndState(int size, int measureSpec, int childMeasuredState)
,两行代码直接搞定。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 没有必要再让 view 自己测量一遍了,浪费资源
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 计算期望的 size
int size = (int) ((PADDING + RADIUS) * 2);
// 指定期望的 size
int width = resolveSize(size, widthMeasureSpec);
int height = resolveSize(size, heightMeasureSpec);
// 设置大小
setMeasuredDimension(width, height);
}
使用的时候完全可以这样做,但是非常建议大家都自己手写几遍理解其中的含义,因为面试会问到其中的细节。
还有一点很遗憾,就是 resolveSizeAndState(int, int, int)
不好用。不好用的原因不是方法有问题,而是很多自定义 View 包括原生的 View 都没有使用 resolveSizeAndState(int, int, int)
方法,或者没用指定 sate (state 传递父 View 对于子 View 的期望,相比resolveSize(int, in)
方法对于子 View 的控制更好)所以就算设置了,也不会起作用。
总结
完全自定义 View 的尺寸主要分为以下步骤:
- 重写
onMeasure()
- 计算自己期望的尺寸
- 用
resolveSize()
或者resolveSizeAndState()
修正结果 - 用
setMeasuredDimension(width, height)
保存结果
自定义 Layout
以 TagLayout 为例一步一步实现一个自定义 Layout。具体期望的效果如下图:
重写 onLayout()
在继承 ViewGroup 的时候 onLayout()
是必须要实现的,这意味着子 View 的位置摆放的规则,全部交由开发者定义。
/**
* 自定义 Layout Demo
*
* Created by im_dsd on 2019-08-11
*/
public class TagLayout extends ViewGroup {
public TagLayout(Context context) {
super(context);
}
public TagLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public TagLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
// 此时所有的子 View 都和 TagLayout 一样大
child.layout(l, t, r, b);
}
}
}
实验一下是否和期望的效果一样呢
<?xml version="1.0" encoding="utf-8"?>
<com.example.dsd.demo.ui.custom.layout.TagLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:padding="5dp"
android:background="#ffee00"
android:textSize="16sp"
android:textStyle="bold"
android:text="音乐" />
</com.example.dsd.demo.ui.custom.layout.TagLayout>
的确和期望一致。如果想要 TextView 显示为 TagLayout 的四分之一呢?
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
// 子 View 显示为 TagLayout 的 1/4
child.layout(l, t, r / 4, b / 4);
}
}
效果达成!!!很明显onLayout
可以非常灵活的控制 View 的位置
再尝试让两个 View 呈对角线布局呢?
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (i == 0 ){
child.layout(0, 0, (r - l) / 2, (b - t) / 2);
} else {
child.layout((r - l) / 2, (b - t) / 2, (r - l), (b - t));
}
}
}
onLayout
的方法还是很简单的,但是在真正布局中怎么获取 View 的位置才是难点!如何获取呢,这时候就需要 onMeasure
的帮助了!
计算
在写具体的代码之前,先来搭建大体的框架。主要的思路就是在 onMeasure()
方法中计算好子 View 的尺寸和位置信息包括 TagLayout 的具体尺寸,然后在onLayout()
方法中摆放子 View。
在计算过程中涉及到三个难点,具体请看注释
private List<Rect> mChildRectList = new ArrayList<>();
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 没有必要让 View 自己算了,浪费资源。
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
// 难点1: 计算出对于每个子 View 的尺寸
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
// 难点2:计算出每一个子 View 的位置并保存。
Rect rect = new Rect(?, ?, ?, ?);
mChildRectList.add(rect);
}
// 难点3:根据所有子 View 的尺寸计算出 TagLayout 的尺寸
int measureWidth = ?;
int measureHeight = ?;
setMeasuredDimension(measureWidth, measureHeight);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (mChildRectList.size() == 0) {
return;
}
for (int i = 0; i < getChildCount(); i++) {
if (mChildRectList.size() <= i) {
return;
}
View child = getChildAt(i);
// 通过保存好的位置,设置子 View
Rect rect = mChildRectList.get(i);
child.layout(rect.left, rect.top, rect.right, rect.bottom);
}
}
难点1 :如何计算子 View 的尺寸。
主要涉及两点:开发者对于子 View 的尺寸设置和父 View 的具体可用空间。获取开发者对于子 View 尺寸的设置就比较简单了:
// 获取开发者对于子 View 尺寸的设置
LayoutParams layoutParams = child.getLayoutParams();
int width = layoutParams.width;
int height = layoutParams.height;
获取父 View (TagLayout) 的可用空间要结合两点:
- TagLayout 的父 View 对于他的尺寸限制
- TagLayout 的剩余空间。我们用 width 为例用伪代码简单分析一下如何计算子 View 的尺寸
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
// TagLayout 已经使用过的空间,此处的计算是个难点,此处不是本例子重点,一会儿讨论
int widthUseSize = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
// 获取开发者对于子 View 尺寸的设置
LayoutParams layoutParams = child.getLayoutParams();
int childWidthMode;
int childWidthSize;
// 获取父 View 具体的可用空间
switch (layoutParams.width) {
// 如果说子 View 被开发者设置为 match_parent
case LayoutParams.MATCH_PARENT:
switch (widthMode) {
case MeasureSpec.EXACTLY:
// TagLayout 为 EXACTLY 模式下,子 View 可以填充的部位就是 TagLayout 的可用空间
case MeasureSpec.AT_MOST:
// TagLayout 为 AT_MOST 模式下有一个最大可用空间,子 View 要是想 match_parent 其实是和
// EXACTLY 模式一样的
childWidthMode = MeasureSpec.EXACTLY;
childWidthSize = widthSize - widthUseSize;
break;
case MeasureSpec.UNSPECIFIED:
// 当 TagLayout 为 UNSPECIFIED 不限制尺寸的时候,意味着可用空间无限大!空间无限大还想
// match_parent 二者完全是悖论,所以我们也要将子 View 的 mode 指定为 UNSPECIFIED
childWidthMode = MeasureSpec.UNSPECIFIED;
// 此时 size 已经没有作用了,写 0 就可以了
childWidthSize = 0;
break;
}
case LayoutParams.WRAP_CONTENT:
break;
default:
// 具体设置的尺寸
break;
}
// 获取 measureSpec
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidthSize, childWidthMode);
补充一下什么时候会是 UNSPECIFIED 模式呢?比如说横向或纵向滑动的 ScrollView,他的宽度或者高度的模式就是 UNSPECIFIED
伪代码仅仅模拟了开发者将子 View 的 size 设置为 match_parent 的情况,其他的情况读者要是感兴趣可以自己分析一下。笔者就不做过多的分析了!因为 Android SDK 早就为我们提供好了可用的 API: measureChildWithMargins(int, int, int, int)
一句话就完成了对于子 View 的测量。
难点2:计算出每一个子 View 的位置并保存。
难点3:根据所有子 View 的尺寸计算出 TagLayout 的尺寸
有了 measureChildWithMargins
方法,对于子 View 的测量就很简单啦。 一口气解决难点 2 3。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int lineHeightUsed = 0;
int lineWidthUsed = 0;
int widthUsed = 0;
int heightUsed = 0;
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
// 测量子 View 尺寸。TagLayout 的子 view 是可以换行的,所以设置 widthUsed 参数为 0
// 让子 View 的尺寸不会受到挤压。
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
if (widthMode != MeasureSpec.UNSPECIFIED && lineWidthUsed + child.getMeasuredWidth() > widthSize) {
// 需要换行了
lineWidthUsed = 0;
heightUsed += lineHeightUsed;
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
}
Rect childBound;
if (mChildRectList.size() >= i) {
// 不存在则创建
childBound = new Rect();
mChildRectList.add(childBound);
} else {
childBound = mChildRectList.get(i);
}
// 存储 child 位置信息
childBound.set(lineWidthUsed, heightUsed, lineWidthUsed + child.getMeasuredWidth(),
heightUsed + child.getMeasuredHeight());
// 更新位置信息
lineWidthUsed += child.getMeasuredWidth();
// 获取一行中最大的尺寸
lineHeightUsed = Math.max(lineHeightUsed, child.getMeasuredHeight());
widthUsed = Math.max(lineWidthUsed, widthUsed);
}
// 使用的宽度和高度就是 TagLayout 的宽高啦
heightUsed += lineHeightUsed;
setMeasuredDimension(widthUsed, heightUsed);
}
终于写完代码啦,运行起来瞧瞧看。
竟然奔溃了!通过日志可以定位到是
// 对于子 View 的测量
measureChildWithMargins(child, widthMeasureSpec, widthUsed,
heightMeasureSpec, heightUsed);
这一句出了问题,通过源码得知measureChildWithMargins
方法会有一个类型转换导致了崩溃
protected void measureChildWithMargins(int, int ,int, int) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
………………
}
解决办法就是在 TagLayout 中重写方法 generateLayoutParams(AttributeSet)
返回一个 MarginLayoutParams 就可以解决问题了。
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
再次运行达到最终目标!
总结
自定义 Layout 的主要步骤分为以下几点:
- 重写
onMeasure()
- 遍历每一个子 View,用
measureChildWidthMargins()
测量 View- MarginLayoutParams 和 generateLayoutParams()
- 有些子 View 可能需要多次测量
- 测量完成后,得出子 View 的实际尺寸和位置,并暂时保存
- 测量出所有子 View 的位置和尺寸后,计算出自己的尺寸,并用
setMeasuredDimension(width, height)
保存
- 遍历每一个子 View,用
- 重写
onLayout()
- 遍历每个子 View,调用它们的 layout() 方法来将位置和尺寸传递给它们。
getMeasureWidth 与 getWidth 的区别
getMeasureXX 代表的是 onMeasure 方法结束后(准确的说应该是测量结束后)测量的值,而 getXX 代表的是 layout 阶段 right - left、bottom - top 的真实显示值,所以第一个不同点就是赋值的阶段不同,可见 getXXX 在 layout() 之前一直为 0, 而 getMeasureXX 可能不是最终值( onMeasure 可能会被调用多次),但是最终的时候二者的数值都会是相同的。使用那个还需要看具体的场景。
总结: getMeasureXX 获取的是临时的值,而 getXX 获取的时候最终定稿的值,一般在绘制阶段、触摸反馈阶段使用 getXXX,在 onMeasure 阶段被迫使用 getMeasureXX 。
我的 Android 知识体系,欢迎 Star https://github.com/daishengda2018/AndroidKnowledgeSystem