人云亦云的「MeasureSpec」
说到Android View的测量流程,绝对都绕不开MeasureSpec
,相信大部分人都能说上一两句:32位整形,高两位表示测量模式SpecMode ,分为EXACTLY
、AT_MOST
、UNSPECIFIED
三种,低30位表示对应的大小 SpecSize ...父View通过自身的测量模式,结合子View的LayoutParams
确定其子View的测量模式,像上图一样,父View测量模式为 EXACTLY 、子View的 LayoutParams 的宽(或高)为具体的 dp/px 时,子View的测量模式为 EXACTLY ,大小为 childSize;如果父View的测量模式为AT_MOST...
其实这块的逻辑并不复杂,MeasureSpec
这个类总共也就一百多行的代码,但很多人对这部分的就是存在困惑:
- 三种测量模式的应用场景是什么?
EXACTLY
和AT_MOST
还好理解,UNISPECIFIED
可真让人一头雾水了。- 不就是测量个 View 大小么,还专门整个 MeasureSpec 类出来,直接传递大小不行么?过度设计,绝对是过度设计!
网上各种各样的文章对MeasureSpec的说法也众说纷纭,但差别不大,对于SpecMode,大家普遍保持以下观点:
模式 | 含义 | 应用场景 |
---|---|---|
EXACTLY | 父View已确定子View 的确切大小,子View的大小为父View测量所得的值 | 具体数值 |
AT_MOST | 父View 指定一个子View可用的最大尺寸值,View大小 不能超过该值。 | wrap_content |
UNSPECIFIED | 父View不对子View 施加任何约束,子View可以是它想要的任何尺寸 | 系统内部使用 |
其实上面的两个表格 并没有什么大问题
,从某种意义上讲也是非常清晰的,一目了然。问题在于如果你之前对MeasureSpec知之甚少,很容易囿于前人定死的条条框框,忽略了这块代码设计的初衷,陷入囫囵吞枣甚至只会死记硬背的境地,一旦实际业务开发需要使用时,仍是两手一摊,大脑空白,大呼:
这也是很多人学习新知识时「能看懂,但过一段时间就忘了」的根本原因。
今天我将带着我的理解,站在产品设计的角度,尝试从「零」开始,设计出一套合理的测量模式,或许能让你对View测量流程有一个全新的理解。
tips:接下来的实现基于Android 源码中 View 体系的测量流程,为方便理解,可能会酌情减少一些代码,请放心食用。
开工造轮子
既然觉得Android源码中 MeasureSpec 那一套流程太复杂,那我们先尝试一下轻装上阵,假定View在构建时就直接由开发者制定对应的宽高,说干就干。
v0.1:view的宽高构造时确定
kotlin
open class MView(width: Int, height: Int) {
}
class MViewGroup(width: Int, height: Int): MView(width, height) {
private val childrenView: ArrayList<MView> = ArrayList()
/**
* 添加子视图
*/
fun addView(view: MView) {
childrenView.add(view)
}
/**
* 获取所有子View
*/
fun getChildren(): List<MView> {
// 伪代码,此处直接返回空,不影响测量流程的实现
return emptyList()
}
}
- MView的构造方法要求传入width、height
- MViewGroup 继承自 MView,比MView多了一个
addView
方法以及一个getChildren
方法,第一个用来添加子View,另一个用来获取所有的子view。
这也很符合直觉,父母在为人父母前也曾经是孩子
,至于为什么不反过来让MView 继承自 MViewGroup ------ 因为MView并不需要处理子View相关逻辑。
通过让 ViewGroup 继承自 View,Android 整个View体系在整个视图层次结构方面保持了高度的一致性,同时这个设计提供了很强的灵活性和代码重用性,使得开发者能够更轻松地构建复杂的用户界面,并实现定制化的布局和交互效果。
v0.2:剥离出测量方法
很快我们就能发现v0.1
版本的缺陷:实际的业务开发过程中,View的宽高往往并不是构造View时就能确定的 ------ View的宽高往往随着View内容的变化而变化。
拿 显示文本内容的TextView
举例,其文本内容往往并不是构造 TextView 时就能固定不变的,所以其宽、高也不是一成不变。再比如 MViewGroup
,每次添加 子View 后,都会影响到其自身的宽高,在这样的需求背景下,宽、高在 View 构建时直接传入显然就无法满足了,所以需要我们对其加以改造:
kotlin
open class MView {
private var width = 0
private var height = 0
open fun setDimension(width: Int, height: Int) {
this.width = width
this.height = height
}
/**
* 获取宽度
*/
public fun getWidth() = width
/**
* 获取高度
*/
public fun getHeight() = height
}
class MViewGroup: MView() {
// 省略不相关代码...
override fun setDimension(width: Int, height: Int) {
super.setDimension(width, height)
}
// 继续省略不相关代码...
}
对比v0.1版本的代码:
- 将View的宽高属性从构造方法中剥离,通过对应的
setter/getter
方法更灵活的设置宽高; - 调用
setDimension()
即意味着View的宽高已确定。
经过改造后的代码,宽高的设置更灵活,开发者只需在 可能影响View宽高的属性发生变化时 ,调用setDimension
更新宽高即可。
这段代码其实还可以再优化下,按照现在的测量流程,开发者需要在可能影响View宽高的属性发生变化后 ,重新计算出变化后的宽高, 再调用setDimension
更新对应View的宽高 ------ 这是一个相当高频的操作,如果不对 "重新计算出变化后的宽高" 这一行为进行收敛,相似代码可能会在项目里满天飞。基于提升代码的内聚性
和关注点分离的设计原则
考虑,我们需要抽象出测量方法内置于MView
类内:
kotlin
open class MView {
private var measuredWidth = 0
private var measuredHeight = 0
/**
* 执行测量流程,根据View自身特性,测量大小,最终调用{@link #setMeasuredDimension(int, int)}保存测量结果
*/
open fun measure() {
// 根据View自身内容大小计算宽高
val width = ...
val height = ...
// 保存测量结果
setMeasuredDimension(width, height)
}
open fun setMeasuredDimension(measuredWidth: Int, measuredHeight: Int) {
this.measuredWidth = measuredWidth
this.measuredHeight = measuredHeight
}
/**
* 获取测量后的宽度
*/
public fun getMeasuredWidth() = measuredWidth
/**
* 获取测量后的高度
*/
public fun getMeasuredHeight() = measuredHeight
}
class MViewGroup: MView() {
// 省略不相关代码...
/**
* 添加子视图,
*/
fun addView(view: MView) {
childrenView.add(view)
// 添加完子View后,自身的宽高可能会受其影响产生变化
// 调用measure()方法重新进行测量流程,确定变化后的宽高
measure()
}
// 继续省略...
}
经过改造后
MView
增加measure()
方法,负责具体的测量流程;- 当View的宽高可能发生变化时,调用measure()方法触发View的测量流程:例如调用ViewGroup的
addView()
方法添加子View后,调用measure方法触发ViewGroup的测量流程。
v0.3:既当爹又当妈的MViewGroup
上面两个版本,我们迭代出了一套针对单个View的测量过程,实际的业务开发中,View(or ViewGroup)大多数都是被添加在其他ViewGroup中,针对这种成体系的View,测量流程要怎么用代码描述? 思考:
- 测量流程的发起方是谁?
一定是从外层的ViewGroup发起的,因为子View被包裹于ViewGroup中------它是没办法与外界直接交互的。
- ViewGroup的大小需受到子View大小的影响
为了保证包裹其内的子View完整、合理的展示,ViewGroup需要先测量其子View,待子View完成测量后,ViewGroup根据子View大小决定自己的大小。
父母之爱子,则为之计深远。
所以我们为MViewGroup
添点料:
kotlin
class MViewGroup: MView() {
/**
* Android ViewGroup源码中,并未对measure()相关方法进行重写
* 因为不同的ViewGroup的布局特性不同,需要具体的布局容器才能根据子View的宽高得到自身合理的宽高
* 此处为了方便理解,使用伪代码大致体现ViewGroup的测量流程
*/
override fun measure() {
// 主动测量子View
measureChildren()
// 拿到子View测量后的宽高
val childWidth = ...
val childHeight = ...
// 结合自身的布局特性以及子view的宽高得到自身的宽高
val measuredWidth = ...
val measuredHeight = ...
setMeasuredDimension(measuredWidth, measuredHeight)
}
// 省略不相关代码...
/**
* 测量子View
*/
fun measureChildren() {
val childrenView = getChildren()
for (child in childrenView) {
child.measure()
}
}
// 继续省略...
}
对比之前的代码:
- 新增
measureChildren()
方法,用来发起对子view的测量工作 - MViewGroup的measure()方法中会先对子view进行测量,然后结合自身的布局特性以及子view的宽高得到MViewGroup自身的宽高。
tips:Android ViewGroup源码中,并未对measure()相关方法进行重写,这也很好理解 ------ 不同的ViewGroup的布局特性不同,ViewGroup根本无法统一这种特性,需要具体的布局容器才能根据子View的宽高得到自身合理的宽高。此处为了方便读者理解,使用伪代码大致体现ViewGroup的测量流程。
整个测量流程大概是这样的:
v0.4:内容大小?控件大小?剪不断理还乱
kotlin
open class MView {
// 省略不相关代码...
/**
* 执行测量流程,根据View自身特性,测量大小,最终调用{@link #setMeasuredDimension(int, int)}保存测量结果
*/
open fun measure() {
// 根据View自身内容大小计算宽高
val width = ...
val height = ...
// 保存测量结果
setMeasuredDimension(width, height)
}
// 省略不相关代码...
}
回过头来看我们对MView的处理,里面有一句描述:根据View自身内容大小计算宽高
,这句话意味着,我们的view大小总是等于自身内容大小。看到这,有人就又有点懵逼了,什么是view大小?什么是内容大小?这俩还有什么不一样的么?view大小难道不应该和内容大小一样么?
请看下图:
TextVIew大小我们称之为view大小(控件大小),即图上灰色区域; "这是文本内容的大小"所占据的空间大小,我们称之为内容大小,即图中紫色区域。
这里TextView的内容大小与TextView控件本身的大小并不相等,实际业务开发中也很常见,许多app的登录按钮大体都是这样的展示逻辑,例如: 这也是UI设计上一种常用的布局方式,用以突出重要操作的按钮。按照我们之前设计的测量流程,显然满足不了这样的布局要求。所以,我们需要一套机制用来表示控件基于布局要求之下的宽、高
。
不仅如此,每一个 ViewGroup 子类都有可能需要不同的布局参数来描述其子视图应该如何布局。 例如:
- 线性布局
LinearLayout
需要会根据知道其子视图的权重(weight)进行子View得测量布局;- 相对布局
RelativeLayout
需要知道子视图相对于彼此的位置来进行测量布局;而这正是我们上面测量流程缺少的。
所以,我们需要一套用来表示基于布局考虑的并且决能定最终view大小的参数,对于MViewGroup来说,其并不包含具体的布局逻辑,所以只有宽、高两个参数:
kotlin
open class MView() {
// 省略不相关代码...
private var layoutParams: MViewGroup.MLayoutParams? = null
fun getLayoutParams(): MViewGroup.MLayoutParams? = layoutParams
fun setLayoutParams(params: MViewGroup.MLayoutParams?) {
this.layoutParams = params
// 设置LayoutParams后,需重新测量View
measure()
}
open fun measure() {
// 根据layoutParams和View自身内容大小计算宽高
// 如果layoutParams 宽/高 为WRAP_CONTENT,View的宽高即为内容宽高
// 如果layoutParams 宽/高 为MATCH_PARENT,View的宽高即为父View的宽/高
// 如果layoutParams 宽/高 为 >= 0 的准确数字,那View得宽高即为这个准确的数字
val width = ...
val height = ...
// 保存测量结果
setMeasuredDimension(width, height)
}
// 继续省略...
}
class MViewGroup(): MView() {
// 省略不相关代码...
fun addView(view: MView) {
childrenView.add(view)
// 确保view有对应的LayoutParams,没有的话给个默认值
val layoutParams = view.getLayoutParams()
if (layoutParams == null) {
view.setLayoutParams(generateDefaultLayoutParams())
}
measure()
}
open fun generateDefaultLayoutParams(): MLayoutParams {
return MLayoutParams(MLayoutParams.WRAP_CONTENT, MLayoutParams.WRAP_CONTENT)
}
class MLayoutParams(width: Int, height: Int) {
companion object {
public const val MATCH_PARENT = -1
public const val WRAP_CONTENT = -2
}
}
// 继续省略...
}
由于是基于布局原因考虑的,所以我们将其命名为MLayoutParams
:
- MLayoutParams包含两个基础属性,width 和 height;
- MLayoutParams中的width和height包含了两个特殊的值:
WRAP_CONTENT
和MATCH_PARENT
,这也是日常业务开发中经常使用的两个值; WRAP_CONTENT
表示View想要足够大,以适应它自己的内容,MATCH_PARENT
表示View想要和父View一样大;- MView新增了
layoutParams
属性,同时新增其getter/setter
方法,调用setLayoutParams()
后会调用measure()
方法触发测量流程; - MViewGroup 在
addView
时会校验 子View 的MLayoutParams属性,如果为空会给默认值。
为什么Android源码中将 LayoutParamas 作为 ViewGroup 的内部类而不是View的内部类?其作为View必不可少的属性,作为View得内部类不是更合理么?
LayoutParams 是用来指定视图(View)在其父视图(ViewGroup)中的布局参数的。每一个 ViewGroup 子类都有可能需要不同的布局参数来描述其子视图应该如何布局。 例如,线性布局
LinearLayout
需要知道其子视图的权重(weight),而相对布局RelativeLayout
需要知道子视图相对于彼此的位置。LayoutParams 作为 ViewGroup 的内部类,就是因为它们是针对父 ViewGroup 的布局行为而设计的。这种设计允许每种类型的 ViewGroup 定义自己的布局参数类,这些类继承自 ViewGroup.LayoutParams。这样,每个 ViewGroup 都可以添加它需要的特定布局信息,而不会影响到其他类型的 ViewGroup。如果 LayoutParams 是 View 的内部类,那么每个 View 都会包含所有不同 ViewGroup 所需的布局参数,这显然违反了封装的原则,本末倒置了。 因此,LayoutParams 作为 ViewGroup 的内部类,是为了确保布局参数与使用它们的具体 ViewGroup 类型紧密相关,而不是与 View 本身相关,这样可以保持类结构的清晰和逻辑性。
至此,我们的测量流程已完成了大半:
- MViewGroup 继承自 MView,保持视图结构层次的统一性,最大限度的复用代码逻辑。
- MView通过
measure()
方法测量自己大小,并调用setMeasuredDimension(measuredWidth, measuredHeight)
方法完成测量,确定自身大小。MViewGroup 则需要先帮助 其子View完成测量后,再进行自身的测量流程。 - 抽像出
MLayoutParams
,包含两种特殊值:WRAP_CONTENT
和MATCH_PARENT
。
再回过头来看我们所设计的MView的测量流程:
kotlin
open class MView() {
// 省略不相关代码...
open fun measure() {
// 根据layoutParams和View自身内容大小计算宽高
// 如果layoutParams 宽/高 为WRAP_CONTENT,View的宽高即为内容宽高
// 如果layoutParams 宽/高 为MATCH_PARENT,View的宽高即为父View的宽/高
// 如果layoutParams 宽/高 为 >= 0 的准确数字,那View得宽高即为这个准确的数字
val width = ...
val height = ...
// 保存测量结果
setMeasuredDimension(width, height)
}
// 继续省略...
}
我们能发现,MView的大小存在三种情况:
- MLayoutParams 宽/高 为 >= 0 的准确数字,那View得宽高即为这个准确的数字
- MLayoutParams 宽/高 为MATCH_PARENT,View的宽高即为父View的宽/高
- MLayoutParams 宽/高 为WRAP_CONTENT,View的宽高即为内容宽高
第一种情况没什么好讲的,我们来仔细思考下其余的两种场景:
MATCH_PARENT
要实现MATCH_PARENT的效果,子View就需要知道父View的宽/高。问题是,在我们实现的测量流程中,子View无法知道父View的宽/高。
WRAP_CONTENT
WRAP_CONTENT 的效果是 View的宽高即为内容宽高,看起来没什么大问题,仔细想想,不对,有问题。 想象一下这样的场景,MViewGroup的宽度是 固定的值,例如100dp,其子View的 MLayoutParams 的宽度设定为 WRAP_CONTENT,内容宽度为 200dp,按照我们现有的实现,子View的最终宽度即为200dp,这显然是有问题的。举个不恰当的例子:王思聪问王健林要一辆劳斯莱斯,他老爸啪唧给他买了;你羡慕坏了,也问你老爸要劳斯莱斯,你老爸啪唧给你一个大逼斗子。
扯远了,没错,按我们目前的设计,子View目前明显缺乏来自其父View的指导和约束。
所以,我们需要让 子view 受到 父view 的约束,同时让 子view 能够获取到父view 的宽高。
v0.5:为MView添加约束
明确我们的期望:
- 子View 能够获取 父View 的宽/高
- 父View 能够基于自身的大小约束 子View
第一点很简单,回想下我们的测量流程,子View 的measure()
方法由父VIew调用,我们只需在 父View 调用 子View 的measure方法时,将自己的宽高传递过去。 第二点需要动动脑筋思考了,子View应该受到父View的哪些约束? 分析一下:
-
父View的大小能够确定
- 子View的宽高设置为 具体的数值 或者 MATCH_PARENT ,此时父View能够确定子View的确切大小,子View必须按照这种
精确模式
进行自己的测量工作; - 子View的宽高设置为WRAP_CONTENT ,此时父View无法确定子View的大小,但是可以将自己的大小传递给子View,子View的大小最大不能超过父View的大小,也就是子View在测量过程中
尽可能的大
;
- 子View的宽高设置为 具体的数值 或者 MATCH_PARENT ,此时父View能够确定子View的确切大小,子View必须按照这种
-
父View的大小暂无法确定
- 子View的宽高设置为 具体的数值,那子View就按照这种精确模式进行测量;
- 子View的宽高设置为MATCH_PARENT ,子View的大小想要跟随父View的大小,但是父View的大小此时不确定,所以将自己从父View处受到的约束的最大的大小继续传递给自己的子View,子View可以在这个范围内
尽可能的大
; - 子View的宽高设置为WRAP_CONTENT ,子View的大小想要包裹住自己的内容,但是父View的大小此时不确定,所以将自己从父View处受到的约束的最大的大小继续传递给自己的子View,子View可以在这个范围内
尽可能的大
;
结合以上分析,我们修改一下measure()
方法,使 MView 的测量流程能够受到自己 父View MViewGroup 的指导和约束。
kotlin
open class MView() {
// 省略不相关代码...
open fun measure(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) {
// 根据widthMeasureSpec和heightMeasureSpec进行测量,计算出合适的宽/高
// 以宽度为例
// 1.获取宽度的测量模式和测量大小
val widthSpecMode = widthMeasureSpec.mode
val widthSpecSize = widthMeasureSpec.size
// 2.根据widthSpecMode、widthSpecSize以及View自身的内容宽度计算最终的宽度,两种情况:
// - specMode 为 AT_MOST, MView 的宽高取 内容宽度 和 最小值,即MView尽可能的宽,但不能超出widthSpecSize
// - specMode 为 EXACTLY,MView 的宽直接取 widthSpecSize
val width = ...
val height = ..
// 保存测量结果
setMeasuredDimension(width, height)
}
/**
* 测量规格,包含测量模式mode和测量大小两个值
*/
class MeasureSpec(val mode: Int, val size: Int) {
companion object {
// 精准测量模式
const val EXACTLY = 1
// 至多测量模式
const val AT_MOST = 2
}
}
}
open class MViewGroup(): MView() {
// 省略不相关代码...
/**
* 父View帮助子View测量的重要方法‼️
* 结合MViewGroup的MeasureSpec与子View的MLayoutParams,计算出适合子View的MeasureSpec
*
* @param spec MViewGroup的测量模式
* @param childDimension 子View期望大小,通过getLayoutParams获取
*/
open fun getChildMeasureSpec(spec: MeasureSpec, childDimension: Int): MeasureSpec {
val specMode = spec.mode
val specSize = spec.size
var resultSpecMode = 0
var resultSpecSize = 0
when {
// 父布局的测量模式是准确的,即父View的大小是准确的
specMode == MeasureSpec.EXACTLY -> {
if (childDimension >= 0) {
// 子View有自己指定的大小,随他去
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = childDimension
} else if (childDimension == MLayoutParams.MATCH_PARENT) {
// 子View想要和父View大小一样,将我们的大小传给他
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = specSize
} else if (childDimension == MLayoutParams.WRAP_CONTENT) {
// 子View想要根据自身内容决定自己的大小,随他去,但是不能比父View的大小还大
resultSpecMode = MeasureSpec.AT_MOST
resultSpecSize = specSize
}
}
// 父布局有尺寸上限
specMode == MeasureSpec.AT_MOST -> {
if (childDimension >= 0) {
// 子View有自己指定的大小,随他去
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = childDimension
} else if (childDimension == MLayoutParams.MATCH_PARENT) {
// 子View想要和父View大小一样,将父View的尺寸上限传递给子View
resultSpecMode = MeasureSpec.AT_MOST
resultSpecSize = specSize
} else if (childDimension == MLayoutParams.WRAP_CONTENT) {
// 子View想要根据自身内容决定自己的大小,随他去,但是不能比父View的大小还大
resultSpecMode = MeasureSpec.AT_MOST
resultSpecSize = specSize
}
}
}
return MeasureSpec(resultSpecMode, resultSpecSize)
}
fun measureChildren(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) {
val childrenView = getChildren()
for (child in childrenView) {
// MViewGroup在测量子View时,会根据自身的MeasureSpec与子View的MLayoutParams,计算出适合子View的MeasureSpec
child.measure(
getChildMeasureSpec(widthMeasureSpec, child.getLayoutParams().width),
getChildMeasureSpec(heightMeasureSpec, child.getLayoutParams().height)
)
}
}
// 继续省略...
}
- MView 新增了
MeasureSpec
内部类,表示测量规格,测量规格包含两个字段:mode和size。MViewGroup 通过 MeasureSpec 来指导和约束MView的测量工作。 - MView 的 测量方法
measure()
新增了两个参数:widthMeasureSpec
和heightMeasureSpec
,由 父View 测量时传入。 - MViewGroup 在协助子View测量时,会结合自身的 MeasureSpec 以及 子View的LayoutParams参数,得到子View合理的MeasureSpec,具体生成逻辑在
getChildMeasureSpec
方法中,这也是测量流程中最重要的一环。
v0.6:继续完善MeasureSpec
基于上面我们所设计的测量流程,MViewGroup通过geChildMeasureSpec()
方法确保其子View的测量流程受到自身的指导和约束。 MeasureSpec
的测量模式mode有两种:AT_MOST
和EXACTLY
。仔细研究一下getChildMeasureSpec()
方法,我们不难总结出一个结论:子View大小总是受到父View大小的限制的,即子View的大小不能超过其父View的大小,除非子View的宽高指定为一个大于父View宽高的固定的值。 那会不会有这么一种情况:子View的宽高不是具体的值,而是包裹内容大小(即WRAP_CONTENT
),但是可以不受父View大小的限制? 会有这样的情况么? 答案当然是肯定的,而且这种情况更加常见:
如图中这种情况,用户的设备空间是有限的,feed流的长度显然超过了用户的设备空间。按照我们设计的测量流程,显然实现不了这种既要包裹住布局内容又不受父View大小限制测量要求。 一般这种布局,我们会将feed流使用可滑动的View来展现,例如ScrollView或者RecyclerView等。按照我们的设计,ScrollView的子View受限于ScrollView大小的限制,是有可能不能完整的展示的。
当然是不合理的,将feed流设计成可滑动的View,本身就是为了用户通过滑动,浏览更多的、完整的信息流,结果却因为测量模式的限制,子View无法完整展示。
那怎么办?简单啊,现有的测量模式满足不了,我们就新造一个嘛:
kotlin
open class MView() {
// 省略不相关代码...
class MeasureSpec(val mode: Int, val size: Int) {
companion object {
// 精准测量模式
const val EXACTLY = 1
// 至多测量模式
const val AT_MOST = 2
// 未指定模式
const val UNSPECIFIED = 3
}
}
// 继续省略...
}
MeasureSpec新增了一种测量模式UNSPECIFIED
,中文直译为未指定模式,即父View不限制子View的测量,既然新增了一种测量模式,那其他涉及到测量模式处理的方法也要同步修改:
ini
open class MView() {
// 省略不相关代码...
/**
* 测量视图及其内容以确定测量的宽度和高度。此方法由measure(int, int)调用,并且应该被子类覆盖,以提供对其内容的准确和有效的测量。
*/
open fun measure(widthMeasureSpec: MeasureSpec, heightMeasureSpec: MeasureSpec) {
// contentWidthSize、contentHeightSize为View本身的内容宽、高
setMeasuredDimension(
getDefaultSize(contentWidthSize, widthMeasureSpec),
getDefaultSize(contentHeightsize, heightMeasureSpec)
)
}
/**
* 根据MeasureSpec获取默认大小
*/
fun getDefaultSize(size: Int, measureSpec: MeasureSpec): Int {
var result = size
val specMode = measureSpec.mode
val specSize = measureSpec.size
when (specMode) {
// 如果测量模式为UNSPECIFIED,View想要多大就给它多大,不限制它
MeasureSpec.UNSPECIFIED -> result = size
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> result = specSize
}
return result
}
// 继续省略...
}
open class MViewGroup() : MView() {
// 省略不相关代码...
/**
* 父View帮助子View测量的重要方法‼️
* 结合MViewGroup的MeasureSpec与子View的MLayoutParams,计算出适合子View的MeasureSpec
*
* @param spec MViewGroup的测量模式
* @param childDimension 子View期望大小,通过getLayoutParams获取
*/
open fun getChildMeasureSpec(spec: MeasureSpec, childDimension: Int): MeasureSpec {
val specMode = spec.mode
val specSize = spec.size
var resultSpecMode = 0
var resultSpecSize = 0
when {
// 父布局的测量模式是准确的,即父View的大小是准确的
specMode == MeasureSpec.EXACTLY -> {
if (childDimension >= 0) {
// 子View有自己指定的大小,随他去
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = childDimension
} else if (childDimension == MLayoutParams.MATCH_PARENT) {
// 子View想要和父View大小一样,将我们的大小传给他
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = specSize
} else if (childDimension == MLayoutParams.WRAP_CONTENT) {
// 子View想要根据自身内容决定自己的大小,随他去,但是不能比父View的大小还大
resultSpecMode = MeasureSpec.AT_MOST
resultSpecSize = specSize
}
}
// 父布局有尺寸上限
specMode == MeasureSpec.AT_MOST -> {
if (childDimension >= 0) {
// 子View有自己指定的大小,随他去
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = childDimension
} else if (childDimension == MLayoutParams.MATCH_PARENT) {
// 子View想要和父View大小一样,将父View的尺寸上限传递给子View
resultSpecMode = MeasureSpec.AT_MOST
resultSpecSize = specSize
} else if (childDimension == MLayoutParams.WRAP_CONTENT) {
// 子View想要根据自身内容决定自己的大小,随他去,但是不能比父View的大小还大
resultSpecMode = MeasureSpec.AT_MOST
resultSpecSize = specSize
}
}
// 父布局有尺寸上限
specMode == MeasureSpec.UNSPECIFIED -> {
if (childDimension >= 0) {
// 子View有自己指定的大小,随他去
resultSpecMode = MeasureSpec.EXACTLY
resultSpecSize = childDimension
} else if (childDimension == MLayoutParams.MATCH_PARENT) {
// 子View想要和父View大小一样,将我们的大小传给他
resultSpecMode = MeasureSpec.UNSPECIFIED
resultSpecSize = specSize
} else if (childDimension == MLayoutParams.WRAP_CONTENT) {
// 子View想要根据自身内容决定自己的大小,随他去
resultSpecMode = MeasureSpec.UNSPECIFIED
resultSpecSize = specSize
}
}
}
return MeasureSpec(resultSpecMode, resultSpecSize)
}
// 继续省略...
}
getChildMeasureSpec()
方法中新添了对 MeasureSpec.UNSPECIFIED 的处理;- MView的measure方法提供了一套获取默认大小的实现,重点就是当MeasureSpec的mode为UNSPECIFIED时,不对View大小作限制。
总结一下三种测量模式对应的场景(size为MeasureSpec中的size):
EXACTLY
:View的大小就应该是size这么大, 不管它内容大小是多大;AT_MOST
:View的大小最多是size,如果内容大小没这么大的话,就取内容大小。UNSPECIFIED
:为了完整展示内容大小,View的大小可以是任意大小,不受size的限制。
Android 6.0 之前和之后的版本 getChildMeasureSpec 方法对UNSPECIFIED模式的处理有区别,6.0之前,UNSPECIFIED 模式下size会指定为0,6.0之后会指定为MViewGroup自身MeasureSpec的size。
v1.0:神功已成
经过上面6个版本的迭代,View的测量流程已设计完毕,它足以应对我们日常开发中的各种各样的场景,我们来继续看下有没有可以优化的点。
MeasureSpec的优化
上面的设计中,我们将MeasureSpec设计成了一个拥有mode和size两个字段的类,MViewGroup 在 measureChildren 时,会调用 getChildMeasureSpec方法生成MeasureSpec,而measure又是一个高频操作,每次都new一个新的MeasureSpec对象显然会增加额外的内存开销,想办法优化下:
kotlin
open class MView {
open fun measure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(
getDefaultSize(contentWidthSize, widthMeasureSpec),
getDefaultSize(contentHeightsize, heightMeasureSpec)
)
}
fun getDefaultSize(size: Int, measureSpec: Int): Int {
var result = size
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
when (specMode) {
MeasureSpec.UNSPECIFIED -> result = size
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> result = specSize
}
return result
}
object MeasureSpec {
private const val MODE_SHIFT = 30
private val MODE_MASK = 0x3 shl MODE_SHIFT
val UNSPECIFIED = 0 shl MODE_SHIFT
val EXACTLY = 1 shl MODE_SHIFT
val AT_MOST = 2 shl MODE_SHIFT
fun makeMeasureSpec(
size: Int,
mode: Int
): Int {
return size and MODE_MASK.inv() or (mode and MODE_MASK)
}
fun getMode(measureSpec: Int): Int {
return measureSpec and MODE_MASK
}
fun getSize(measureSpec: Int): Int {
return measureSpec and MODE_MASK.inv()
}
}
}
open class MViewGroup: MView() {
open fun getChildMeasureSpec(spec: Int, childDimension: Int): Int {
val specMode = MeasureSpec.getMode(spec)
val specSize = MeasureSpec.getSize(spec)
var resultSpecMode = 0
var resultSpecSize = 0
// 省略不相关代码...
return MeasureSpec.makeMeasureSpec(resultSpecSize, resultSpecMode)
}
fun measureChildren(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val childrenView = getChildren()
for (child in childrenView) {
// MViewGroup在测量子View时,会根据自身的MeasureSpec与子View的MLayoutParams,计算出适合子View的MeasureSpec
child.measure(
getChildMeasureSpec(widthMeasureSpec, child.getLayoutParams().width),
getChildMeasureSpec(heightMeasureSpec, child.getLayoutParams().height)
)
}
}
}
我们将MeasureSpec从class改为了object,将之前的mode和size统一用一个32位的int来表示,高两位表示测量模式mode,低30位来表示测量大小size,这样极大程度的减少了measure()过程中的对象分配。
到这儿,我们的测量流程已设计完毕。
整个View体系就是按照这个测量流程,递归的完成每一个View的测量流程,得到测量结果。至于最外层的ViewGroup,它的测量规格MeasureSpec由android.view.ViewRootImpl#getRootMeasureSpec
方法指定。
上面我们迭代出的v1.0版本的测量流程代码,和Android源码里的大体思路和流程如出一辙,只是为了方便理解精简了些许代码,有兴趣的读者可以按照下面的索引自行比照源码加深一下理解:
Android源码中将measure方法拆分成了measure和onMeasure两个方法:
measure 方法:
- measure 是 View 类中的一个 final 方法,开发者不能覆盖它。
- 它负责调用 onMeasure 方法,并且处理与测量相关的其他逻辑,如考虑 padding,以及设置测量的宽度和高度。
- measure 方法还负责保存测量结果(宽度和高度),这样它们就可以在布局阶段被使用。
- 该方法会处理 MeasureSpec,并将它们传递给 onMeasure 方法。
- 它确保 onMeasure 不会被多次不必要地调用,从而优化性能。
onMeasure 方法:
- onMeasure 是一个 protected 方法,不同于measure方法,它可以被重写。
- 开发者应该重写这个方法来定义自定义 View 或 ViewGroup 的大小规则。
- 在这个方法中,开发者需要使用传入的 widthMeasureSpec 和 heightMeasureSpec 来决定当前视图的大小,并且通过 setMeasuredDimension 方法来设置测量的宽度和高度。
将测量过程分成 measure 和 onMeasure 两个方法的设计,反映了面向对象设计原则中的单一职责原则(SRP)。measure 方法处理测量流程的管理,而 onMeasure 方法则专注于具体的测量逻辑。这样的设计使得视图的测量逻辑更加清晰,并且更容易通过重写 onMeasure 方法来自定义视图的大小。
总结
回到文章开头,我们不难发现,表格里的内容,不过就是对getChildMeasureSpec
方法的总结,至于UNSPECIFIED的应用场景,也不是简简单单的系统内部使用而已。