View 宽高是怎么测量的?相信这是 Android 开发选择性遗忘的问题,也一直记在我的 TODO 小本子上,本文准备挑战一下,给这一栏打个勾。

本文尝试从“难在哪里” ➤ “实测找规律” ➤ “源码懂原理” ➤ “问题验理解” ➤ “有什么用”推演,带给你一张看得懂的 View 测量流程图。

难在哪里

在回答这个问题前,我想了一个喜闻乐见的场景:到饭店吃饭,正常情况每人点个硬菜,最后加个汤和几个小菜就差不多了,全程不会超过 5 分钟。但总会有人会点一道神菜–“随便”,最麻烦的是大家都点这道菜,然后就“你点啥我吃啥”,“你吃啥我点啥”…死锁了。这顿饭吃得反正大家都纠结:“我随便点的不知道是不是你真想要的”,“你这也太随便了”,“凑合着吃吧”…

View 测量算法我知道

View 高度测量也有类似问题。如果每个 View 都知道自己的宽高,问题就像 5 分钟点菜那么简单,难就难在 View 的宽高与父子 View 宽高有关联,和子 View 一样高(wrap_content),或者和父 View 一样高(match_parent),像不像“随便”,但玩的却是“你猜”,把决策权抛给别人,但自己却又非常在乎结果。

更有甚者,View 指定高度和父/子View 一样高时,父/子View 也可能这么想的,甚至会整个 View 树宽高都随便(wrap_contentmatch_parent),这下宽高到底是多少?不知道,太难了…

View 测量算法我知道

听着有点绕,等等,想把逻辑搅浑,但就这种程度想忽悠我放弃,还差点意思。

实测找规律

众所周知,我们耳熟能详的设置 View 宽高的 API 就是View.setLayoutParams(),它决定了我们对控件控制力的若干种可能。如果我穷举出所有,是不是也就找到了成功的可能。

先画张表格盘点一下爷孙三代 View 宽高指定为 match_parentwrap_content 混排组合情况,试试水深几何。实测结果如下:

View 测量算法我知道

万万没想到,结果竟然出奇一致,就这么简单粗暴又欢乐的吗?我们先让“为什么”飞一会,继续往下走。

View 测量算法我知道

如果这些相互关联情况都只有一种结果,好像问题又回归简单了,但实际情况是 match_parentwrap_content 和固定值()的混排组合。

爷孙三代 View,宽/高取值有 5 种(match_parentwrap_content)可能,有 5(爷)5(父)5(子) 种组合,受不了了。

要整就整二年级的!

View 测量算法我知道

通过上面表格,可以进一步简化场景,只需要父子两代 View 就能模拟爷孙三代甚至子子孙孙无穷尽的情况,决定自身宽高就是父 View 宽高和自己宽高(此时子 View 高度可视为内容宽高,即自身宽高的一部分),然后通过递归不断重复就能无穷无尽。

View 测量算法我知道

为了便于理解,我们下面将只探讨高度如何测量,因为宽度测量逻辑类似。

终于,父子二代 View,高取值有 5 种可能,有 5(父)5(子) 种组合。

做个简易 Demo 实测验证一下,同时也方便直接 Debug 源码回答问什么?

View 测量算法我知道

View 测量算法我知道

大家可以尝试自己填写表格结果,我猜错了 5 个(对应③、⑯、⑲、⑳、㉔,上图序号标紫红色)。

总结一下规律:

  • View 指定高度为固定值时,其中负数等同于 0,实际高度为指定高度(对应①、②、⑥、⑦、⑧、⑪、⑫、⑬)。但有一个特例,就是父 View 指定高度为负数,子 View 指定高度为非负数,此时父 View 实际高度不是为 0,而是为子 View 实际高度(对应③)。
  • View 指定高度为 wrap_content,如果其子 View 高度为固定值(其中负数等同于 0),则实际高度为子 View 实际高度(对应④、⑨、⑭),否则结果同 match_parent,为父 View 实际高度(对应⑯、⑰、⑱、⑲、⑳、㉔)。
  • View 指定高度为 match_parent,实际高度为父 View 实际高度(对应⑤、⑩、⑮、⑳、㉑、㉒、㉓、㉔、㉕)。

上述总结中,子控件指定高度为wrap_contentmatch_parent 在各情形下实际高度一样,有点和我的认知不太一样。有必要使用 FrameLayoutTextViewImageView 这几个常用控件试一试。

一试果然有惊喜,子控件为FrameLayoutTextViewImageView时,指定高度为wrap_content时,实际高度等于最小高度,这才是海的味道我知道。

View 测量算法我知道

通过上面实测看到的表象,给我们探索源码本质提了三个问题:

  1. View 指定高 -300px,子 View 指定高 100px,父 View 实际高度为什么是 100px 不是 0px
  2. 父控件指定高度为 wrap_content,子控件指定高度为 match_parent 如何计算?为什么 ViewFrameLayout 等常用控件不一样?
  3. 子控件为 View,为什么指定高度为 match_parentwrap_content 时结果都一样?

如果再通过尝试各种可能推断规律,不免把工程师这行搞 Low 了,直接看源码分析逻辑,并解答上述问题。

View 测量算法我知道

源码懂原理

重写父子控件的View.onMeasure()方法,打上断点,切换指定高度将会断住代码。

整体算法逻辑并不复杂,核心围绕着父控件指定的测量规格 measureSpec(高 2 位的测量模式 specMode 和低 30 位的测量大小 specSize 的复合值)和自身的内容大小 size针对几种情况分别给出计算规则。在父控件期望约束和自身内容大小下做权衡。值得注意的是,每个控件基本都重新写View.onMeasure()方法,最终直接调用View.setMeasuredDimension()而不是调用super.onMeasure()设置最终宽高。

View 控件使用的是简单粗暴的View.getetDefaultSize()计算高度:

  1. 测量模式为 UNSPECIFIED 时高度为 size
  2. 其他情况高度为 specSize

View 测量算法我知道

其他常规控件遵循更符合常识的View.resolveSizeAndState()计算高度:

  1. 测量规格为 AT_MOST,高度为sizespecSize的最小值。
  2. 测量规格为 EXACTLY,高度为specSize
  3. 测量规格为 UNSPECIFIED,高度为size

View 测量算法我知道

这里需要注意的是,如果是容器控件,会先调用ViewGroup.measureChildWithMargins()计算子控件高度,这是自身内容高度的一部分。内部核心方法为ViewGroup.getChildMeasureSpec():

  1. 指定了非负值,则直接为该值。
  2. 指定为 match_parent,则为父控件测量规格。
  3. 指定为 wrap_content,除 EXACTLY 时修正为 AT_MOST,其他同父控件测量规格。
  4. 其他数对应默认测量规格(0,UNSPECIFIED)

View 测量算法我知道

整体只是一堆映射关系,使用表格一览无余,如下图所示。

View 测量算法我知道

▲ 测量规格算法表格图

至此,逻辑基本涵盖,停下来总结一下。

先按函数调用画一幅调用关系图:

View 测量算法我知道

上图我自己也思索了一阵,发现还是不够清晰,尝试摆脱方法调用关系,从逻辑维度画一幅 View 测量流程图:

View 测量算法我知道

问题验理解

回过头来回答上述提到的 3 个问题。

问题一:View 指定高 -300px,子 View 指定高 100px,父 View 实际高度为什么是 100px 不是 0px

在回答这个问题前,父 View 的父 View 指定的测量规格又是什么呢?无外乎两种方法,要么看看根 View 被指定的测量规格是什么,要么穷举出所有可能。

先看看根 View被指定的测量规格,通过断点很容易找出根 View 测量规格逻辑在ViewRootImpl.getRootMeasureSpec()中,源码如下:

View 测量算法我知道

实测根 View 调用为ViewRootImpl.getRootMeasureSpec(windowSize, -1),执行上图第 15 行逻辑,返回测量规格为(windowSize, EXACTLY)。正常Activity.setContentView(contentView)传入的 contentView 被调用View.measure()方法时,对应的测量规格也为 (windowSize1, EXACTLY),其中 windowSize1windowSize 减去一些边角大小。

那么,父 View 的测量规格又有几种可能呢?

因为父 View 指定高度为 -300px,通过上面“测量规格算法表格图”可知,无论父 View 的父 View 指定测量规格如何,计算出的测量规格均为(0,UNSPECIFIED)

哦豁,传说中的 UNSPECIFIED 竟然出现了,有点小激动。

View 测量算法我知道

View 指定高度为 100px,同理,因为指定高度为非负数,无论父 View 指定测量规格如何,计算出的测量规格均为(100,EXACTLY)

接着,父 ViewView.resolveSizeAndState()计算出最终高度,对应“测量规格为 UNSPECIFIED,高度为size”,而 size 就是子控件高度,即证父 View 实际高度为 100px 而不是 0px

View 测量算法我知道

问题二: 父控件指定高度为 wrap_content ,子控件指定高度为 match_parent 如何计算?为什么 ViewFrameLayout 等常用控件不一样?

父控件的父控件被指定的测量模式,通常情况下不会是UNSPECIFIED,剩下情况通过上面“测量规格算法表格图”可知,父控件被指定的测量规格均为(leftSpecSize,AT_MOST)。接着递归计算子控件。

子控件计算测量模式入参为AT_MOST,自身指定高度match_parent,则获得测量规格为(leftSpecSize,AT_MOST)

接下来会区分不同控件计算子控件高度。

  • View 则调用View.getDefaultSize()获取实际高度为父控件指定的leftSpecSize,也就会出现和屏幕等高的结果。
  • 其他常用控件则调用View.resolveSizeAndState(),在AT_MOST模式下返回最小高度。

最终,父控件在AT_MOST模式下高度也取leftSpecSize和子控件高度最小值。

View 测量算法我知道

问题三: 子控件为 View,为什么指定高度为 match_parentwrap_content 时结果都一样?

View 指定高度为wrap_contentmatch_parent,在父控件计算子 View 测量规格时,对应“测量规格算法表格图”第一、二行,结果是(leftSpecSize,EXACTLY)(leftSpecSize,AT_MOST),但因为 View通过用View.getDefaultSize()获取大小,EXACTLYAT_MOST 规格下都是取 leftSpecSize,即两种情况下返回值一样。即证。

有什么用

这是一个现实的问题,“为用而学,学以致用”。

  1. 符合复现 UNSPECIFIED 这种测量模式,设置为小于 -2 的数即可做到,能够测试覆盖自定义控件逻辑的鲁棒性。
  2. 自由设置若干子控件和自适应高度子控件一样高,曾经有个需求就是服务端下发文案和图片 url,要求达到图片作为文案的背景效果。因为 TextView 高度是自适应,需要将 ImageViewTextView 设置成一样高。最佳实践就是重写父控件View.onMeasure()方法,TextView 计算出高度后直接设置 ImageView 高度 (文本控件高度,EXACTLY)即可。
  3. 继承 View 自定义控件时,记得重写View.onMeasure()方法处理 wrap_contentmatch_parent 默认一样效果的反常识情况。
  4. 方便其他自定义控件实现优雅交互,和技术大牛过几招。