查看原文
其他

这样学习View的测量算法,真的很有趣哦

盛书强 郭霖 2022-12-14


/   今日科技快讯   /

近日,谷歌宣布将关闭旗下Stadia游戏开发工作室。整个Google Stadia云游戏业务将调整发展方向,不再涉足游戏开发,而是向第三方游戏开发商提供平台,同时为用户提供Stadia Pro订阅服务。

/   作者简介   /

本篇文章来自盛书强的投稿,分享了他对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() 计算高度:

  • 测量模式为 UNSPECIFIED 时高度为 size
  • 其他情况高度为 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. 方便其他自定义控件实现优雅交互,和技术大牛过几招。

推荐阅读:
kotlin-android-extensions插件也被废弃了?
一文教你轻松搞定ANR异常捕获
用烂OkIO,隔壁产品看不懂了

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存