View 测量算法我知道

0 阅读



宽高是怎么测量的?相信这是 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 时,实际高度等于最小高度,这才是海的味道我知道。

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

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

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

源码懂原理

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

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

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

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

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

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

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

  1. 指定了非负值,则直接为该值
  2. 指定为 match_parent,则为父控件测量规格
  3. 指定为 wrap_content,除 EXACTLY 时修正为 AT_MOST,其他同父控件测量规格
  4. 其他数对应默认测量规格(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,即两种情况下返回值一样。即证。

有什么用

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

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

本文遵守 CC-BY-NC-4.0 许可协议。

Creative Commons License

欢迎转载,转载需注明出处,且禁止用于商业目的。

上篇动态生成CSS轮播动画
下篇Android事件分发机制抽象--钓钩模型