View 测量算法我知道
宽高是怎么测量的?相信这是 Android 开发选择性遗忘的问题。
本文尝试从“难在哪里” ➤ “实测找规律” ➤ “源码懂原理” ➤ “问题验理解” ➤ “有什么用”推演,带给你一张看得懂的 View 测量流程图。
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 指定高 -300 px,子 View 指定高 100 px,父 View 实际高度为什么是 100 px 不是 0 px?
- 父控件指定高度为 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 指定高 -300 px,子 View 指定高 100 px,父 View 实际高度为什么是 100 px 不是 0 px?
在回答这个问题前,父 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 指定高度为 -300 px,通过上面“测量规格算法表格图”可知,无论父 View 的父 View 指定测量规格如何,计算出的测量规格均为(0,UNSPECIFIED)。
哦豁,传说中的 UNSPECIFIED 竟然出现了,有点小激动。
子 View 指定高度为 100 px,同理,因为指定高度为非负数,无论父 View 指定测量规格如何,计算出的测量规格均为(100,EXACTLY)。
接着,父 View 按 View.resolveSizeAndState()
计算出最终高度,对应“测量规格为 UNSPECIFIED,高度为 size”,而 size 就是子控件高度,即证父 View 实际高度为 100 px 而不是 0 px。
问题二:父控件指定高度为 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 默认一样效果的反常识情况。 - 方便其他自定义控件实现优雅交互,和技术大牛过几招。