
做了这么多年 Android如果让我选一个看起来最简单、实际最容易出问题的控件LinearLayout 一定排在前面。几乎每个人写的第一个布局都是它拖两个按钮设一个android:orientationhorizontal跑起来挺好于是就觉得这东西没什么可讲的。等到接手别人的项目打开一个列表项的布局文件三四层 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之后第二轮再用剩余高度 权重比例精确测量它。这个设计本身是合理的它保证了权重分配基于准确的总高度。但对开发者来说意味着一件事凡是写了height0dpweight的子 View注定要被测量两次。如果这个子 View 本身又是一棵子树比如一个嵌套的 LinearLayout那这棵子树里的所有孩子都跟着走两遍。理解了这一点很多为什么我的布局在 Profiler 里 onMeasure 调了这么多次的疑问就有答案了。不是框架写得不好是你在要求它做一件必须两次才能算准的事。1.3 measureWithLargestChild 和 baselineAligned 这两个默认行为有两个属性平时几乎不会主动去设但默认值就在悄悄影响测量次数。先说measureWithLargestChild默认是false这个好处是明确的。一旦把它设成trueLinearLayout 在测量时会先把所有子 View 按UNSPECIFIED规格量一遍找出最大的那个宽度垂直布局里是高度再按这个最大值统一分配空间。这个特性在做等宽按钮时很省事代价是所有孩子至少多测一轮。列表项里慎用尤其是子 View 数量不固定的时候。再说baselineAligned默认是true而且只对水平方向的 LinearLayout 生效。开启后如果子 View 有基线TextView 这类显示文字的都有LinearLayout 会尝试让它们按文字基线对齐。这个行为在行内混排不同字号时会很好看比如¥符号小一点、价格数字大一点基线对齐后视觉上很稳。但它带来的成本是带基线的孩子往往需要额外一轮测量来确认基线位置。我在一个聊天列表的输入框区域实测过把横向 LinearLayout 的baselineAligned关掉onMeasure的耗时能降下来一截视觉上也没变化因为那几个 TextView 字号本来就一样。提示横向 LinearLayout 里如果所有文字字号一致或者你本来就通过gravitycenter_vertical对齐了那就直接把android:baselineAlignedfalse加上属于零成本优化。2. layout_weight 的真实算法不是按比例分配这么简单2.1 权重分配的是剩余空间这条搞错会一直算不明白几乎所有讲权重的文章都会说按比例分配剩余空间但真正理解这句话的人不多因为大部分人第一次用权重是为了做等分恰好等分场景下剩余空间和总空间的算法结果一样就误以为理解对了。举个会翻车的例子。你要做一行左右两个 TextView想让右边占三分之二。写LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal TextView android:layout_widthwrap_content android:layout_heightwrap_content android:layout_weight1 android:text订单编号20240012345678 / TextView android:layout_widthwrap_content android:layout_heightwrap_content android:layout_weight2 android:text已完成 / /LinearLayout跑出来你会发现右边那个已完成被压得只剩一点宽度左边反而占了绝大部分。原因就是第一轮测量时左边那个wrap_content的 TextView 按它内容的真实宽度一串长数字很宽被量出来了右边已完成三个字也量出来了。假设总宽度 1080px左边内容宽 700px右边内容宽 200px加起来 900px剩余 180px。这 180px 再按 1:2 分给两边左边最终 70060760右边 200120320。左边依然是压倒性的。权重的分配公式可以写成这样项含义totalWidth父容器给出的可用宽度occupied所有不带权重或权重为 0 的子 View 的测量宽度之和remaintotalWidth - occupiedshareremain × (weight / weightSum)finalWidth该子 View 的基础宽度 share所以只要基础宽度不为 0权重就不是在分全部空间。想让权重说了算就必须让基础宽度归零——这就是0dp存在的意义。2.2 为什么 width0dp 比 match_parent 更靠谱把上面例子里左边的wrap_content改成0dp右边也改成0dp此时两个孩子的第一轮基础宽度都是 0occupied 为 0整个可用宽度都进入remain权重 1:2 就真正实现了三分之一和三分之二。很多人习惯写android:layout_widthmatch_parent配权重这里的差异要分情况如果同一方向上只有一个孩子带权重用match_parent或者0dp效果差不多因为其他孩子都是固定宽度剩余空间是确定的。如果有两个及以上孩子带权重且都写match_parent行为就变得很反直觉。第一轮测量时每个match_parent的孩子都会按父容器的完整宽度来量最后occupied会远超totalWidthremain变成负数权重分配就变成了削减谁权重大谁被削得多。最终呈现出来往往和你想要的相反。我在实际项目里统一了一条规范只要用了layout_weight同方向上的宽高一律写0dp。这条规则没有任何例外情况需要纠结团队新人上手也快。2.3 weightSum 在什么时候比自动求和更好用weightSum不设的时候LinearLayout 会自动把所有孩子的权重加起来当分母。大多数时候这样没问题但有两种场景手动指定更省心。第一种是部分空间留白。比如你想让一行里的三个按钮各占四分之一剩下四分之一空着那么设weightSum4三个按钮各weight1多出来的 1 份天然就是空白。如果不设weightSum三个按钮会各占三分之一。第二种是动态增删孩子。RecyclerView 或者动态添加 View 的场景下孩子数量会变自动求和算出来的比例会跟着变界面会跳。把weightSum固定成一个值每个孩子按固定权重参与即使数量变化已存在的孩子比例也是稳定的。LinearLayout android:layout_widthmatch_parent android:layout_height48dp android:orientationhorizontal android:weightSum4 Button android:layout_width0dp android:layout_heightmatch_parent android:layout_weight1 android:text取消 / Button android:layout_width0dp android:layout_heightmatch_parent android:layout_weight1 android:text稍后 / Button android:layout_width0dp android:layout_heightmatch_parent android:layout_weight1 android:text确认 / !-- 剩余 1 份保持空白 -- /LinearLayout还有一点容易被忽略View.GONE的孩子不参与测量它的权重和空间会被其他孩子按比例重新分配而INVISIBLE的孩子仍然占位。做条件显示的时候用GONE能自动让同行的其他元素撑开这是 LinearLayout 相比约束布局更省事的地方不需要额外写联动逻辑。3. gravity 与 layout_gravity两个长得像但作用域完全不同的属性3.1 作用对象决定了它们能不能生效这两个属性是新手最容易搞混的我自己带人的时候发现讲多久不如一句话gravity管的是我里面的东西怎么摆layout_gravity管的是我自己在父容器里怎么摆。gravity作用在当前 View 的内容或子 View 上。对一个 TextView 来说gravitycenter是让文字在它自己的背景区域内居中对一个 LinearLayout 来说gravitycenter是让它内部所有子 View 整体居中。layout_gravity写在子 View 上作用对象是这个子 View 相对于父容器的位置而且只有当父容器支持它时才生效。这里就出现了最常见的设置了没反应在一个 LinearLayout 的子 View 上写layout_gravitycenter什么都不发生。原因在于 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_gravitycenter_vertical也没地方可居中。这个错误非常隐蔽因为写match_parent的时候是好的一改成wrap_content就失效很多人找不到原因。要么把父容器高度改成match_parent或固定值要么给孩子设layout_heightmatch_parent再配gravitycenter。第二种gravity和layout_gravity同时设置时的优先级冲突。父容器设了gravitycenter某个子 View 又设了layout_gravityleft最终这个子 View 会按它自己的layout_gravity走。父容器的gravity是默认值子 View 的显式设置会覆盖它这个顺序要记住。第三种padding与gravity叠加。父容器有paddingStart16dp又设了gravitycenter居中的基准是扣掉 padding 之后的可用区域所以视觉上不是屏幕正中而是内容区正中。这个不是 bug但做设计稿还原时要先算清楚。3.3 baselineAligned 带来的文字错位这个问题只在横向布局里出现但出现的时候很让人费解一行里放一个图标 ImageView 和一个 TextView两个都设了gravitycenter_vertical跑起来文字却比图标位置偏低或偏高一点。原因就是baselineAligned默认开启。ImageView 没有文字基线TextView 有LinearLayout 按 TextView 的基线去对齐整行导致视觉重心偏移。解决办法有两个一是关掉baselineAligned二是给 ImageView 设android:layout_gravitycenter_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:androidhttp://schemas.android.com/apk/res/android ImageView android:layout_width24dp android:layout_height24dp android:srcdrawable/ic_back / TextView android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:gravitycenter android:textstring/title / /merge!-- 使用处宿主必须是 LinearLayoutmerge 的内容直接成为它的孩子 -- LinearLayout android:layout_widthmatch_parent android:layout_height48dp android:orientationhorizontal android:gravitycenter_vertical include layoutlayout/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_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:gravitycenter_vertical android:baselineAlignedfalse android:paddingStart16dp android:paddingEnd16dp TextView android:layout_widthwrap_content android:layout_heightwrap_content android:text下载中 android:textSize14sp / ProgressBar android:idid/pb_progress style?android:attr/progressBarStyleHorizontal android:layout_width0dp android:layout_height6dp android:layout_weight1 android:layout_marginStart12dp android:layout_marginEnd12dp android:max100 android:progress0 / TextView android:idid/tv_percent android:layout_width48dp android:layout_heightwrap_content android:gravityend android:text0% android:textSize14sp / /LinearLayout这里的几个决定都是有理由的。进度条用0dpweight1让它吃掉左右两侧固定宽度之外的全部空间这样标签和百分比的宽度变化不会挤压进度条。百分比数字给固定48dp并对齐到 end是为了避免数字从 9% 变成 100% 时进度条宽度跳动——如果写wrap_content每刷新一次数字整行的宽度分配就会重算一次进度条肉眼可见地在抖。这个细节不做一次真机测试很难发现。Kotlin 侧更新进度就是常规写法val progressBar findViewByIdProgressBar(R.id.pb_progress) val percentText findViewByIdTextView(R.id.tv_percent) fun updateProgress(percent: Int) { progressBar.progress percent percentText.text $percent% }如果进度回调触发很频繁比如每秒十几次建议加一个节流数值没变就不更新或者用post合并到下一帧避免频繁触发 requestLayout。5.2 表单行里 label 和输入框的对齐表单是 LinearLayout 用得最多的地方也是最容易写乱的。典型需求左边固定宽度的标签右边输入框占满剩余。LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:gravitycenter_vertical android:baselineAlignedfalse android:minHeight56dp android:paddingStart16dp android:paddingEnd16dp TextView android:layout_width80dp android:layout_heightwrap_content android:text收货人 android:textSize15sp / EditText android:idid/et_name android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:backgroundnull android:hint请输入姓名 android:inputTypetextPersonName android:maxLines1 android:textSize15sp / /LinearLayout标签宽度固定成 80dp 是为了多行表单上下对齐。如果图省事写wrap_content两行标签文字长度不同收货人和详细地址右边的输入框起始位置就会错开视觉上很乱。固定宽度后所有输入框左边缘严格对齐。标签文字本身再用gravitystart|center_vertical控制它在自己的 80dp 区域里靠左居中。还有一个容易被忽略的点EditText 默认带下划线背景表单里通常要去掉用android:backgroundnull或者设成透明。去掉背景后光标位置不变但要注意行高会变小靠minHeight把整行撑到 56dp符合常见的手指点击热区标准。5.3 底部操作栏的等分与边距处理底部两个按钮各占一半是很经典的写法LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:padding16dp android:weightSum2 Button android:idid/btn_secondary android:layout_width0dp android:layout_height48dp android:layout_weight1 android:layout_marginEnd8dp android:backgrounddrawable/bg_btn_outline android:text取消 / Button android:idid/btn_primary android:layout_width0dp android:layout_height48dp android:layout_weight1 android:layout_marginStart8dp android:text确认 / /LinearLayout两个按钮的0dp weight1保证等宽中间的间隔用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 全部做完再去重构层级改动面会大得多也更容易引入回归问题。布局这东西一开始搭得干净后面就省心一开始就随手套几层后面每次加需求都在还债。