View
宽高是怎么测量的?相信这是 Android 开发选择性遗忘的问题,也一直记在我的 TODO 小本子上,本文准备挑战一下,给这一栏打个勾。
本文尝试从"难在哪里" ➤ "实测找规律" ➤ "源码懂原理" ➤ "问题验理解" ➤ "有什么用"推演,带给你一张看得懂的 View
测量流程图。
难在哪里
在回答这个问题前,我想了一个喜闻乐见的场景:到饭店吃饭,正常情况每人点个硬菜,最后加个汤和几个小菜就差不多了,全程不会超过 5 分钟。但总会有人会点一道神菜--"随便",最麻烦的是大家都点这道菜,然后就"你点啥我吃啥","你吃啥我点啥"...死锁了。这顿饭吃得反正大家都纠结:"我随便点的不知道是不是你真想要的","你这也太随便了","凑合着吃吧"...
View
高度测量也有类似问题。如果每个 View
都知道自己的宽高,问题就像 5 分钟点菜那么简单,难就难在 View
的宽高与父子 View
宽高有关联,和子 View
一样高(wrap_content
),或者和父 View
一样高(match_parent
),像不像"随便",但玩的却是"你猜",把决策权抛给别人,但自己却又非常在乎结果。
更有甚者,View
指定高度和父/子 View
一样高时,父/子 View
也可能这么想的,甚至会整个 View
树宽高都随便(wrap_content
或 match_parent
),这下宽高到底是多少?不知道,太难了...
听着有点绕,等等,想把逻辑搅浑,但就这种程度想忽悠我放弃,还差点意思。
实测找规律
众所周知,我们耳熟能详的设置 View
宽高的 API
就是 View.setLayoutParams()
,它决定了我们对控件控制力的若干种可能。如果我穷举出所有,是不是也就找到了成功的可能。
先画张表格盘点一下爷孙三代 View
宽高指定为 match_parent
或 wrap_content
混排组合情况,试试水深几何。实测结果如下:
万万没想到,结果竟然出奇一致,就这么简单粗暴又欢乐的吗?我们先让"为什么"飞一会,继续往下走。
如果这些相互关联情况都只有一种结果,好像问题又回归简单了,但实际情况是 match_parent
、wrap_content
和固定值(正
、零
、负
)的混排组合。
爷孙三代 View
,宽/高取值有 5 种(match_parent
、wrap_content
、正
、零
、负
)可能,有 5(爷)× 5(父)× 5(子)
种组合,受不了了。
要整就整二年级的!
通过上面表格,可以进一步简化场景,只需要父子两代 View
就能模拟爷孙三代甚至子子孙孙无穷尽的情况,决定自身宽高就是父 View
宽高和自己宽高(此时子 View
高度可视为内容宽高,即自身宽高的一部分),然后通过递归不断重复就能无穷无尽。
为了便于理解,我们下面将只探讨高度如何测量,因为宽度测量逻辑类似。
终于,父子二代 View
,高取值有 5 种可能,有 5(父)× 5(子)
种组合。
做个简易 Demo 实测验证一下,同时也方便直接 Debug 源码回答问什么?
大家可以尝试自己填写表格结果,我猜错了 5 个(对应③、⑯、⑲、⑳、㉔,上图序号标紫红色)。
总结一下规律:
View
指定高度为固定值时,其中负数等同于0
,实际高度为指定高度(对应①、②、⑥、⑦、⑧、⑪、⑫、⑬)。但有一个特例,就是父View
指定高度为负数,子View
指定高度为非负数,此时父View
实际高度不是为0
,而是为子View
实际高度(对应③)。View
指定高度为wrap_content
,如果其子View
高度为固定值(其中负数等同于0
),则实际高度为子View
实际高度(对应④、⑨、⑭),否则结果同match_parent
,为父View
实际高度(对应⑯、⑰、⑱、⑲、⑳、㉔)。View
指定高度为match_parent
,实际高度为父View
实际高度(对应⑤、⑩、⑮、⑳、㉑、㉒、㉓、㉔、㉕)。
上述总结中,子控件指定高度为 wrap_content
和 match_parent
在各情形下实际高度一样,有点和我的认知不太一样。有必要使用 FrameLayout
、TextView
和 ImageView
这几个常用控件试一试。
一试果然有惊喜,子控件为 FrameLayout
、TextView
和 ImageView
时,指定高度为 wrap_content
时,实际高度等于最小高度,这才是海的味道我知道。
通过上面实测看到的表象,给我们探索源码本质提了三个问题:
- 父
View
指定高-300px
,子View
指定高100px
,父View
实际高度为什么是100px
不是0px
? - 父控件指定高度为
wrap_content
,子控件指定高度为match_parent
如何计算?为什么View
和FrameLayout
等常用控件不一样? - 子控件为
View
,为什么指定高度为match_parent
和wrap_content
时结果都一样?
如果再通过尝试各种可能推断规律,不免把工程师这行搞 Low 了,直接看源码分析逻辑,并解答上述问题。
源码懂原理
重写父子控件的 View.onMeasure()
方法,打上断点,切换指定高度将会断住代码。
整体算法逻辑并不复杂,核心围绕着父控件指定的测量规格 measureSpec
(高 2 位的测量模式 specMode
和低 30 位的测量大小 specSize
的复合值)和自身的内容大小 size
针对几种情况分别给出计算规则。在父控件期望约束和自身内容大小下做权衡。值得注意的是,每个控件基本都重新写 View.onMeasure()
方法,最终直接调用 View.setMeasuredDimension()
而不是调用 super.onMeasure()
设置最终宽高。
View
控件使用的是简单粗暴的 View.getetDefaultSize()
计算高度:
- 测量模式为
UNSPECIFIED
时高度为size
。 - 其他情况高度为
specSize
。
其他常规控件遵循更符合常识的 View.resolveSizeAndState()
计算高度 :
- 测量规格为
AT_MOST
,高度为size
和specSize
的最小值。 - 测量规格为
EXACTLY
,高度为specSize
。 - 测量规格为
UNSPECIFIED
,高度为size
。
这里需要注意的是,如果是容器控件,会先调用 ViewGroup.measureChildWithMargins()
计算子控件高度,这是自身内容高度的一部分。内部核心方法为 ViewGroup.getChildMeasureSpec()
:
- 指定了非负值,则直接为该值。
- 指定为
match_parent
,则为父控件测量规格。 - 指定为
wrap_content
,除EXACTLY
时修正为AT_MOST
,其他同父控件测量规格。 - 其他数对应默认测量规格
(0,UNSPECIFIED)
。
整体只是一堆映射关系,使用表格一览无余,如下图所示。
▲ 测量规格算法表格图
至此,逻辑基本涵盖,停下来总结一下。
先按函数调用画一幅调用关系图:
上图我自己也思索了一阵,发现还是不够清晰,尝试摆脱方法调用关系,从逻辑维度画一幅 View
测量流程图:
问题验理解
回过头来回答上述提到的 3 个问题。
问题一: 父 View
指定高 -300px
,子 View
指定高 100px
,父 View
实际高度为什么是 100px
不是 0px
?
在回答这个问题前,父 View
的父 View
指定的测量规格又是什么呢?无外乎两种方法,要么看看根 View
被指定的测量规格是什么,要么穷举出所有可能。
先看看根 View
被指定的测量规格,通过断点很容易找出根 View
测量规格逻辑在 ViewRootImpl.getRootMeasureSpec()
中,源码如下:
实测根 View
调用为 ViewRootImpl.getRootMeasureSpec(windowSize, -1)
,执行上图第 15 行逻辑,返回测量规格为(windowSize, EXACTLY)
。正常 Activity.setContentView(contentView)
传入的 contentView
被调用 View.measure()
方法时,对应的测量规格也为 (windowSize1, EXACTLY)
,其中 windowSize1
为 windowSize
减去一些边角大小。
那么,父 View
的测量规格又有几种可能呢?
因为父 View
指定高度为 -300px
,通过上面"测量规格算法表格图"可知,无论父 View
的父 View
指定测量规格如何,计算出的测量规格均为(0,UNSPECIFIED)
。
哦豁,传说中的 UNSPECIFIED
竟然出现了,有点小激动。
子 View
指定高度为 100px
,同理,因为指定高度为非负数,无论父 View
指定测量规格如何,计算出的测量规格均为(100,EXACTLY)
。
接着,父 View
按 View.resolveSizeAndState()
计算出最终高度,对应"测量规格为 UNSPECIFIED
,高度为 size
",而 size
就是子控件高度,即证父 View
实际高度为 100px
而不是 0px
。
问题二: 父控件指定高度为 wrap_content
,子控件指定高度为 match_parent
如何计算?为什么 View
和 FrameLayout
等常用控件不一样?
父控件的父控件被指定的测量模式,通常情况下不会是 UNSPECIFIED
,剩下情况通过上面"测量规格算法表格图"可知,父控件被指定的测量规格均为(leftSpecSize,AT_MOST)
。接着递归计算子控件。
子控件计算测量模式入参为 AT_MOST
,自身指定高度 match_parent
,则获得测量规格为(leftSpecSize,AT_MOST)
。
接下来会区分不同控件计算子控件高度。
View
则调用View.getDefaultSize()
获取实际高度为父控件指定的leftSpecSize
,也就会出现和屏幕等高的结果。- 其他常用控件则调用
View.resolveSizeAndState()
,在AT_MOST
模式下返回最小高度。
最终,父控件在 AT_MOST
模式下高度也取 leftSpecSize
和子控件高度最小值。
问题三: 子控件为 View
,为什么指定高度为 match_parent
和 wrap_content
时结果都一样?
View
指定高度为 wrap_content
和 match_parent
,在父控件计算子 View
测量规格时,对应"测量规格算法表格图"第一、二行,结果是(leftSpecSize,EXACTLY)
或(leftSpecSize,AT_MOST)
,但因为 View
通过用 View.getDefaultSize()
获取大小,EXACTLY
和 AT_MOST
规格下都是取 leftSpecSize
,即两种情况下返回值一样。即证。
有什么用
这是一个现实的问题,"为用而学,学以致用"。
- 符合复现
UNSPECIFIED
这种测量模式,设置为小于-2
的数即可做到,能够测试覆盖自定义控件逻辑的鲁棒性。 - 自由设置若干子控件和自适应高度子控件一样高,曾经有个需求就是服务端下发文案和图片 url,要求达到图片作为文案的背景效果。因为
TextView
高度是自适应,需要将ImageView
和TextView
设置成一样高。最佳实践就是重写父控件View.onMeasure()
方法,TextView
计算出高度后直接设置ImageView
高度(文本控件高度,EXACTLY)
即可。 - 继承
View
自定义控件时,记得重写View.onMeasure()
方法处理wrap_content
和match_parent
默认一样效果的反常识情况。 - 方便其他自定义控件实现优雅交互,和技术大牛过几招。