做了这么多年 Android,如果让我选一个"看起来最简单、实际最容易出问题"的控件,LinearLayout 一定排在前面。几乎每个人写的第一个布局都是它:拖两个按钮,设一个android:orientation="horizontal",跑起来挺好,于是就觉得这东西没什么可讲的。等到接手别人的项目,打开一个列表项的布局文件,三四层 LinearLayout 套在一起,layout_weight撒了一地,滑动的时候帧率掉到 40,你才会重新审视这个控件。
线性布局的坑不在于 API 复杂,恰恰相反,它只有十来个属性,但每个属性的生效条件、作用对象、对测量流程的影响都不一样。gravity和layout_gravity差一个前缀,行为完全不同;layout_weight看着是按比例分,实际分的是剩余空间,稍微改一下layout_width结果就全变;baselineAligned默认开着,平时没感觉,一到多行文字对齐就出鬼。这些问题文档里都写了,但没人会一次性读完,基本都是踩一遍才记住。
这篇内容我按自己在项目里实际遇到问题的顺序来写,从测量流程讲到权重算法,再讲到嵌套性能和三套真实布局的完整写法,最后给一套自查方法。适合刚开始写 UI 的朋友,也适合已经写了一两年但没系统梳理过测量逻辑的人。文中所有参数和结论都来自实际调试,不是照抄文档。
1. LinearLayout 的 onMeasure:两次遍历里藏着多少重复计算
1.1 从一个真实卡顿案例说起
前阵子帮朋友看一个电商 App 的商品列表,问题是滑动到中后段开始明显掉帧,用 GPU 呈现模式看,大部分柱子在 16ms 线附近晃,偶尔冲到 30ms 以上。列表项本身不复杂,一张图、标题、价格、两个标签、一个横向排列的按钮组,业务上没什么重活。
把布局文件拉出来一看,结构是这样的:最外层垂直 LinearLayout,里面第二层垂直 LinearLayout 包着标题和价格,第三层水平 LinearLayout 放标签,第四层水平 LinearLayout 放按钮。四层,而且每一层都有一到两个子 View 使用了layout_weight。
问题就在这儿。LinearLayout 的垂直测量不是一次算完的,它对带权重的子 View 需要走两轮:第一轮先把不带权重的孩子量出来,算出已占用的高度,第二轮再用总高度减去已占用高度,按权重把剩余空间分下去,重新测量带权重的孩子。这四层嵌套意味着,每一次onMeasure都可能在子树上触发连锁的二次测量,层级越深,重复计算的量越大,而且是按层级数量叠加的。
改成一层 ConstraintLayout 之后,同一台设备上柱子基本都压在 16ms 线以下。这不是说 LinearLayout 不能用,而是说你要清楚它的成本在哪儿,别在列表项这种高频复用的地方无意识地堆叠。
1.2 垂直方向的测量顺序与被"跳过"的那一轮
看垂直方向的measureVertical,逻辑大致是这样:遍历所有子 View,对每一个先判断要不要在这一轮测量。
// 简化后的源码逻辑,用于说明测量顺序 final boolean matchHeightLocally = heightMode != MeasureSpec.EXACTLY && lp.height == LayoutParams.MATCH_PARENT && weight > 0; if (lp.height != 0 || lp.weight <= 0 || matchHeightLocally) { // 正常测量这个 child measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec, usedHeight); childHeight = child.getMeasuredHeight(); ... }关键在lp.height != 0 || lp.weight <= 0这个条件。如果一个子 View 的layout_height写的是0dp并且weight > 0,它会被直接跳过第一轮测量,先记一个 0 高度占位;等第一轮把所有正常子 View 量完、算出mTotalLength之后,第二轮再用"剩余高度 + 权重比例"精确测量它。
这个设计本身是合理的,它保证了权重分配基于准确的总高度。但对开发者来说意味着一件事:凡是写了height=0dp+weight的子 View,注定要被测量两次。如果这个子 View 本身又是一棵子树(比如一个嵌套的 LinearLayout),那这棵子树里的所有孩子都跟着走两遍。
理解了这一点,很多"为什么我的布局在 Profiler 里 onMeasure 调了这么多次"的疑问就有答案了。不是框架写得不好,是你在要求它做一件必须两次才能算准的事。
1.3 measureWithLargestChild 和 baselineAligned 这两个默认行为
有两个属性平时几乎不会主动去设,但默认值就在悄悄影响测量次数。
先说measureWithLargestChild,默认是false,这个好处是明确的。一旦把它设成true,LinearLayout 在测量时会先把所有子 View 按UNSPECIFIED规格量一遍,找出最大的那个宽度(垂直布局里是高度),再按这个最大值统一分配空间。这个特性在做等宽按钮时很省事,代价是所有孩子至少多测一轮。列表项里慎用,尤其是子 View 数量不固定的时候。
再说baselineAligned,默认是true,而且只对水平方向的 LinearLayout 生效。开启后,如果子 View 有基线(TextView 这类显示文字的都有),LinearLayout 会尝试让它们按文字基线对齐。这个行为在行内混排不同字号时会很好看,比如"¥"符号小一点、价格数字大一点,基线对齐后视觉上很稳。
但它带来的成本是:带基线的孩子往往需要额外一轮测量来确认基线位置。我在一个聊天列表的输入框区域实测过,把横向 LinearLayout 的baselineAligned关掉,onMeasure的耗时能降下来一截,视觉上也没变化,因为那几个 TextView 字号本来就一样。
提示:横向 LinearLayout 里如果所有文字字号一致,或者你本来就通过
gravity="center_vertical"对齐了,那就直接把android:baselineAligned="false"加上,属于零成本优化。
2. layout_weight 的真实算法:不是"按比例分配"这么简单
2.1 权重分配的是剩余空间,这条搞错会一直算不明白
几乎所有讲权重的文章都会说"按比例分配剩余空间",但真正理解这句话的人不多,因为大部分人第一次用权重是为了做等分,恰好等分场景下"剩余空间"和"总空间"的算法结果一样,就误以为理解对了。
举个会翻车的例子。你要做一行,左右两个 TextView,想让右边占三分之二。写:
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_weight="1" android:text="订单编号:20240012345678" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_weight="2" android:text="已完成" /> </LinearLayout>跑出来你会发现右边那个"已完成"被压得只剩一点宽度,左边反而占了绝大部分。原因就是:第一轮测量时,左边那个wrap_content的 TextView 按它内容的真实宽度(一串长数字,很宽)被量出来了,右边"已完成"三个字也量出来了。假设总宽度 1080px,左边内容宽 700px,右边内容宽 200px,加起来 900px,剩余 180px。这 180px 再按 1:2 分给两边,左边最终 700+60=760,右边 200+120=320。左边依然是压倒性的。
权重的分配公式可以写成这样:
| 项 | 含义 |
|---|---|
| totalWidth | 父容器给出的可用宽度 |
| occupied | 所有不带权重或权重为 0 的子 View 的测量宽度之和 |
| remain | totalWidth - occupied |
| share | remain × (weight / weightSum) |
| finalWidth | 该子 View 的基础宽度 + share |
所以只要基础宽度不为 0,权重就不是在分"全部空间"。想让权重说了算,就必须让基础宽度归零——这就是0dp存在的意义。
2.2 为什么 width=0dp 比 match_parent 更靠谱
把上面例子里左边的wrap_content改成0dp,右边也改成0dp,此时两个孩子的第一轮基础宽度都是 0,occupied 为 0,整个可用宽度都进入remain,权重 1:2 就真正实现了三分之一和三分之二。
很多人习惯写android:layout_width="match_parent"配权重,这里的差异要分情况:
- 如果同一方向上只有一个孩子带权重,用
match_parent或者0dp效果差不多,因为其他孩子都是固定宽度,剩余空间是确定的。 - 如果有两个及以上孩子带权重,且都写
match_parent,行为就变得很反直觉。第一轮测量时每个match_parent的孩子都会按父容器的完整宽度来量,最后occupied会远超totalWidth,remain变成负数,权重分配就变成了"削减",谁权重大谁被削得多。最终呈现出来往往和你想要的相反。
我在实际项目里统一了一条规范:只要用了layout_weight,同方向上的宽(高)一律写0dp。这条规则没有任何例外情况需要纠结,团队新人上手也快。
2.3 weightSum 在什么时候比自动求和更好用
weightSum不设的时候,LinearLayout 会自动把所有孩子的权重加起来当分母。大多数时候这样没问题,但有两种场景手动指定更省心。
第一种是部分空间留白。比如你想让一行里的三个按钮各占四分之一,剩下四分之一空着,那么设weightSum="4",三个按钮各weight="1",多出来的 1 份天然就是空白。如果不设weightSum,三个按钮会各占三分之一。
第二种是动态增删孩子。RecyclerView 或者动态添加 View 的场景下,孩子数量会变,自动求和算出来的比例会跟着变,界面会跳。把weightSum固定成一个值,每个孩子按固定权重参与,即使数量变化,已存在的孩子比例也是稳定的。
<LinearLayout android:layout_width="match_parent" android:layout_height="48dp" android:orientation="horizontal" android:weightSum="4"> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="取消" /> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="稍后" /> <Button android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:text="确认" /> <!-- 剩余 1 份保持空白 --> </LinearLayout>还有一点容易被忽略:View.GONE的孩子不参与测量,它的权重和空间会被其他孩子按比例重新分配;而INVISIBLE的孩子仍然占位。做条件显示的时候,用GONE能自动让同行的其他元素撑开,这是 LinearLayout 相比约束布局更省事的地方,不需要额外写联动逻辑。
3. gravity 与 layout_gravity:两个长得像但作用域完全不同的属性
3.1 作用对象决定了它们能不能生效
这两个属性是新手最容易搞混的,我自己带人的时候发现,讲多久不如一句话:gravity管的是"我里面的东西怎么摆",layout_gravity管的是"我自己在父容器里怎么摆"。
gravity作用在当前 View 的内容或子 View 上。对一个 TextView 来说,gravity="center"是让文字在它自己的背景区域内居中;对一个 LinearLayout 来说,gravity="center"是让它内部所有子 View 整体居中。layout_gravity写在子 View 上,作用对象是这个子 View 相对于父容器的位置,而且只有当父容器支持它时才生效。
这里就出现了最常见的"设置了没反应":在一个 LinearLayout 的子 View 上写layout_gravity="center",什么都不发生。原因在于 LinearLayout 是线性排布的,子 View 在主轴方向上是一个接一个挤着走的,没有"居中"这个概念。layout_gravity在 LinearLayout 里只能作用于交叉轴:
| 父容器方向 | layout_gravity 生效的轴 | 主轴方向的处理 |
|---|---|---|
| horizontal | 垂直方向(top / bottom / center_vertical) | 由 weight 和排列顺序决定,设置无效 |
| vertical | 水平方向(left / right / center_horizontal) | 由 weight 和排列顺序决定,设置无效 |
想让主轴方向也居中,要么用父容器的gravity,要么用weight撑开,要么换成 FrameLayout / ConstraintLayout。
3.2 几个"看起来该生效却没生效"的具体情况
第一种,父容器高度是wrap_content时的垂直居中。假设 LinearLayout 的高度也是包裹内容,那它的高度就等于所有孩子高度之和,没有多余空间,孩子再怎么layout_gravity="center_vertical"也没地方可居中。这个错误非常隐蔽,因为写match_parent的时候是好的,一改成wrap_content就失效,很多人找不到原因。要么把父容器高度改成match_parent或固定值,要么给孩子设layout_height="match_parent"再配gravity="center"。
第二种,gravity和layout_gravity同时设置时的优先级冲突。父容器设了gravity="center",某个子 View 又设了layout_gravity="left",最终这个子 View 会按它自己的layout_gravity走。父容器的gravity是默认值,子 View 的显式设置会覆盖它,这个顺序要记住。
第三种,padding与gravity叠加。父容器有paddingStart="16dp",又设了gravity="center",居中的基准是扣掉 padding 之后的可用区域,所以视觉上不是屏幕正中,而是内容区正中。这个不是 bug,但做设计稿还原时要先算清楚。
3.3 baselineAligned 带来的文字错位
这个问题只在横向布局里出现,但出现的时候很让人费解:一行里放一个图标 ImageView 和一个 TextView,两个都设了gravity="center_vertical",跑起来文字却比图标位置偏低或偏高一点。
原因就是baselineAligned默认开启。ImageView 没有文字基线,TextView 有,LinearLayout 按 TextView 的基线去对齐整行,导致视觉重心偏移。解决办法有两个:一是关掉baselineAligned,二是给 ImageView 设android:layout_gravity="center_vertical"并确保父容器高度足够。我一般选前者,顺手还能省一轮测量。
反过来说,这个特性在做价格展示的时候特别有用:"¥"字号小、数字字号大,开启基线对齐后两者底部自然齐平,不用手动调paddingBottom。
4. 嵌套层级与性能:LinearLayout 什么时候该退场
4.1 层级膨胀的成本是怎么来的
每一次布局测量,父容器要遍历所有子 View;每个子 View 如果又是 ViewGroup,就要递归往下走。层级越深,遍历的节点数越多,而且带权重的嵌套会成倍放大——外层一次二次测量,内层也跟着来一次,最坏情况下测量次数是指数级的。
这里有个实用的估算方法:打开开发者选项里的"显示布局边界",看屏幕上的边框层数。超过三层的区域,基本都值得重构。超过五层,在 RecyclerView 里的滚动一定会受影响,尤其是低端机。
我见过最夸张的一个布局,一个订单卡片套了七层 LinearLayout,其中四层带权重。单次onMeasure在千元机上要 8ms 以上,列表一滑动直接掉到 30 帧。
4.2 换与不换的判断标准
不是所有嵌套都要改成约束布局。我的判断习惯是这样:
| 场景 | 建议 | 理由 |
|---|---|---|
| 两三层的简单线性排列,无权重 | 保持 LinearLayout | 可读性最好,性能损耗可忽略 |
| 同方向三层以上,或带权重的嵌套 | 改 ConstraintLayout | 扁平化后测量次数明显下降 |
| 列表项、高频复用的小卡片 | 优先 ConstraintLayout | 复用次数多,单次成本会被放大 |
| 需要动态增删子 View(如标签流) | 保持 LinearLayout | 约束布局动态增删要手动维护约束,容易出错 |
| 表单页这种一次性布局 | 看复杂度,不强求 | 只测一次,优化收益有限 |
这张表里的"带权重的嵌套"是关键判断点。纯粹的垂直堆叠即使三层,代价也不大;但只要是嵌套里带权重,就值得动刀。
4.3 用 merge 和 include 把层级压下来
重构的时候,<include>和<merge>是两个很实用的工具。
<include>把重复的布局片段抽出来复用,比如每个列表项顶部的标题栏。但要注意,include默认会把被包含布局的根节点也带进来,如果那个根节点又是个 LinearLayout,层级就多了一层。
<merge>解决的就是这个问题。把被复用片段的根节点写成<merge>,在 include 的时候它会直接把自己内部的孩子塞进宿主容器,不产生额外的 ViewGroup 层级。
<!-- layout_title_bar.xml --> <merge xmlns:android="http://schemas.android.com/apk/res/android"> <ImageView android:layout_width="24dp" android:layout_height="24dp" android:src="@drawable/ic_back" /> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="@string/title" /> </merge><!-- 使用处:宿主必须是 LinearLayout,merge 的内容直接成为它的孩子 --> <LinearLayout android:layout_width="match_parent" android:layout_height="48dp" android:orientation="horizontal" android:gravity="center_vertical"> <include layout="@layout/layout_title_bar" /> </LinearLayout>有一点必须注意:<merge>的宿主容器类型要和片段内部的布局方式匹配。上面这个片段用了layout_weight,所以宿主必须是横向 LinearLayout,如果被 include 到 FrameLayout 里,权重会失效,而且编译期不报错,运行时才看到错乱。
提示:
<merge>用在setContentView里也可以,前提是宿主是 ViewGroup。如果直接在 Activity 里setContentView(R.layout.xxx)而 xxx 的根节点是 merge,会直接抛异常。
5. 三个真实场景的完整写法:进度条、表单行、底部操作栏
5.1 带文字标签的水平进度条
业务里常见的形态是:左边一个"下载中"标签,中间一条进度条,右边一个百分比数字。这类布局用 LinearLayout 写最自然。
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false" android:paddingStart="16dp" android:paddingEnd="16dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="下载中" android:textSize="14sp" /> <ProgressBar android:id="@+id/pb_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="0dp" android:layout_height="6dp" android:layout_weight="1" android:layout_marginStart="12dp" android:layout_marginEnd="12dp" android:max="100" android:progress="0" /> <TextView android:id="@+id/tv_percent" android:layout_width="48dp" android:layout_height="wrap_content" android:gravity="end" android:text="0%" android:textSize="14sp" /> </LinearLayout>这里的几个决定都是有理由的。进度条用0dp+weight=1,让它吃掉左右两侧固定宽度之外的全部空间,这样标签和百分比的宽度变化不会挤压进度条。百分比数字给固定48dp并对齐到 end,是为了避免数字从 9% 变成 100% 时进度条宽度跳动——如果写wrap_content,每刷新一次数字,整行的宽度分配就会重算一次,进度条肉眼可见地在抖。这个细节不做一次真机测试很难发现。
Kotlin 侧更新进度就是常规写法:
val progressBar = findViewById<ProgressBar>(R.id.pb_progress) val percentText = findViewById<TextView>(R.id.tv_percent) fun updateProgress(percent: Int) { progressBar.progress = percent percentText.text = "$percent%" }如果进度回调触发很频繁,比如每秒十几次,建议加一个节流:数值没变就不更新,或者用post合并到下一帧,避免频繁触发 requestLayout。
5.2 表单行里 label 和输入框的对齐
表单是 LinearLayout 用得最多的地方,也是最容易写乱的。典型需求:左边固定宽度的标签,右边输入框占满剩余。
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false" android:minHeight="56dp" android:paddingStart="16dp" android:paddingEnd="16dp"> <TextView android:layout_width="80dp" android:layout_height="wrap_content" android:text="收货人" android:textSize="15sp" /> <EditText android:id="@+id/et_name" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:background="@null" android:hint="请输入姓名" android:inputType="textPersonName" android:maxLines="1" android:textSize="15sp" /> </LinearLayout>标签宽度固定成 80dp 是为了多行表单上下对齐。如果图省事写wrap_content,两行标签文字长度不同("收货人"和"详细地址"),右边的输入框起始位置就会错开,视觉上很乱。固定宽度后所有输入框左边缘严格对齐。标签文字本身再用gravity="start|center_vertical"控制它在自己的 80dp 区域里靠左居中。
还有一个容易被忽略的点:EditText 默认带下划线背景,表单里通常要去掉,用android:background="@null"或者设成透明。去掉背景后光标位置不变,但要注意行高会变小,靠minHeight把整行撑到 56dp,符合常见的手指点击热区标准。
5.3 底部操作栏的等分与边距处理
底部两个按钮各占一半,是很经典的写法:
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="16dp" android:weightSum="2"> <Button android:id="@+id/btn_secondary" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1" android:layout_marginEnd="8dp" android:background="@drawable/bg_btn_outline" android:text="取消" /> <Button android:id="@+id/btn_primary" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1" android:layout_marginStart="8dp" android:text="确认" /> </LinearLayout>两个按钮的0dp + weight=1保证等宽,中间的间隔用marginEnd和marginStart各 8dp 拼出来,总共 16dp,且从两个按钮各自的空间里扣除,总宽度不会溢出。如果改用layout_marginStart只加在右边按钮上,效果一样,但左右边距的对称性就不直观了,改动的时候容易漏。
底部栏还要留意系统手势区域。如果这个布局贴在屏幕底部,外层需要包一层处理 inset,或者用fitsSystemWindows让父容器自动加内边距,否则按钮会被手势条压住。这一块的处理和布局结构有关,在wrap_content高度的容器上更明显。
6. 调试手段:Layout Inspector 与布局边界开关
6.1 先用布局边界看层级,比读代码快
判断一个界面有没有过度嵌套,最快的办法不是看代码,而是打开设备上的开发者选项,找到"显示布局边界"(Show layout bounds)。开启后每个 View 的边缘会被描出来,同一块区域叠加的边框越多,说明层级越深。
我的习惯是截一张图,然后从视觉上数边框。一个简单的列表项如果边框叠了三层以上,就会去翻布局文件。这个方法的好处是能立刻定位到是哪一块有问题,而不是在几百行的 XML 里盲找。
顺便说一句,如果用的是较新版本的开发工具,布局边界开关的位置在同一菜单里,名称可能略有差异,找带 "layout bounds" 字样的那个就行。
6.2 Layout Inspector 里该看哪几个数
布局边界只能看出"深不深",看不出"贵不贵"。要量化成本,用 Layout Inspector 更合适。连接到运行中的进程后,能看到完整的 View 树,点开每个节点可以看它的宽高、坐标、可见性。
我更关注这几个点:
- 带
weight的节点是不是嵌套在另一棵带weight的子树里。如果是,这里的测量成本会翻倍。 - 有没有尺寸为 0 但没设
GONE的 View,它们仍然参与测量,白白消耗。 - 有没有
wrap_content的容器包着match_parent的孩子,这种组合常常导致多余的测量轮次。
改完布局后重新 attach 一次,对比前后树深度和节点数量,效果很直观。
6.3 一份可以直接照着过的自查清单
写完一个用 LinearLayout 的界面,我会照着下面几条过一遍:
- 用了
layout_weight的子 View,同方向的尺寸是不是都写成了0dp?有没有漏掉一个写了match_parent? - 横向布局里所有文字字号一致吗?一致就关掉
baselineAligned。 - 嵌套是不是超过三层?超过三层的区域有没有带权重?有就考虑改约束布局。
layout_gravity是不是用在了主轴方向?用错了就换成父容器的gravity。- 条件隐藏用的是
GONE还是INVISIBLE?需要让位就用GONE。 - 行内元素宽度会不会随内容变化?会变的(比如进度百分比、价格)给固定宽度或用
minWidth,避免布局抖动。 - 复用的布局片段有没有用
<merge>去掉一层多余的根节点?
这七条过完之后,基本不会出现"跑起来才知道有问题"的情况。
最后分享一个我用了很多年的小习惯:新写一个复杂布局时,先只放空 View 把结构搭出来,加上背景色,用布局边界跑一遍,确认层级没问题再往里面填内容。等 UI 全部做完再去重构层级,改动面会大得多,也更容易引入回归问题。布局这东西,一开始搭得干净,后面就省心;一开始就随手套几层,后面每次加需求都在还债。