设计稿上对不上的那几个像素
设计师标了 8dp,你写了 8dp,做出来看着像 10dp。你反复量了三遍代码,没错。这一章告诉你那几个像素藏在哪儿——藏在字体文件的两张表里。
你写的 padding 加在哪儿
你以为你写的 padding(top = 8.dp) 是从「字的顶部」量起的。不是。
它是从行盒(line box)的顶部量起的。而行盒比字高——上面下面各留了一截,那截宽度写在字体文件里,跟你的代码毫无关系。
每个字体文件里有两组「一行该多高」的声明,而且它们经常不一样:
hhea表的 ascender / descender / lineGap——「紧」的那一组OS/2表的 usWinAscent / usWinDescent——「松」的那一组,为了兜住所有变音符号,通常明显更大
Android 上,这两组正好对应 includeFontPadding 开关的两端。
量出来
下面这台引擎里的数字,不是从博客抄的。scripts/probe-fonts.mjs 直接解析了字体文件的二进制表;Roboto 用的是 Android Studio 里 layoutlib 目录下那一份——也就是 Android 预览真正拿来排版的文件本身。
把它调到 Roboto、16px,读一下这几个数:
你在一段 16sp 的文字上面写 8dp 的 padding,如果 includeFontPadding 开着(旧版 Android 的默认值),字的上边缘离你的 padding 边其实是 8 + 1.922 ≈ 9.92dp。
而设计师在 Figma 里量的是字形的外框,不是行盒。所以他标 8,你写 8,做出来是 10——你俩说的是两个不同的 8。
Compose 里怎么办
好消息:Compose 已经默认关掉了这个坑。但你需要知道它在哪个开关下。
// Compose 的 Text 默认已经不加那截 padding(相当于 includeFontPadding = false)。
// 需要显式控制时,用 PlatformTextStyle / LineHeightStyle:
Text(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(
lineHeight = 26.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(
// Trim.Both:把首行上方、末行下方多出来的行距切掉
// —— 这样文本块的外框就等于「你看到的那块字」
trim = LineHeightStyle.Trim.Both,
alignment = LineHeightStyle.Alignment.Center
)
)
)
// 混排 XML 的项目里,老 TextView 记得也关:
// android:includeFontPadding="false"
Trim.Both 是这里最值钱的一个:它让「一个文本块的高度」等于「你眼睛看到的那块字的高度」。加上它之后,你写的 8dp 就真的是 8dp——你和设计师终于在说同一个 8。
第二个坑:lineHeight 多出来的那部分不是对半分
你给 16px 的文字设了 lineHeight = 24.sp。行盒本来是 18.75px,多出来 5.25px。这 5.25 怎么分给上下?
直觉答案是各 2.625。Compose 的默认答案不是。
差了 1.53px。在一行上看不出来,但在一个多行段落里,第一行的位置会整体偏下 1.5px——而你的 padding 是按对半分算的,于是又对不上了。
这就是上面代码里 alignment = LineHeightStyle.Alignment.Center 的用处:把它改成对半分,行为就和你的直觉一致了。
行高该配多少
最后一个问题:lineHeight 到底该等于多少?
《Refactoring UI》给了一条很好的经验规则,可以直接写成算式:
- 字越大,行距系数越小。42px 的大标题用 1.5 会散架,1.15 才对。
- 行越长,行距系数越大。因为眼睛从行尾扫回行首时,需要更明显的高度差才不会串行。
标定在 16px 正文、66 字符一行 = 1.5,写成:
系数 = 1.5 − 0.15 × log2(字号/16) + 0.10 × (每行字符数 − 66)/20
这条规则是不是编的?把它套到 Material 3 那 15 档官方 typography 上,和 Google 定的行高比一比:
- 22sp 以上(display / headline / title):平均差 3% 左右,最好的
titleLarge只差 1.4%。规则站得住。 - 12sp 以下:差到 15%——
bodySmallM3 给 16sp,公式说 18.4sp。
后半段这个偏差不是规则错了,恰恰是规则的后半句在起作用:M3 的 bodySmall / labelSmall 主要用在单行标签上(角标、按钮文字、输入框提示),而单行文本根本没有「回扫」这个动作,所以不需要行距。
结论:这条公式适用于成段的正文,不适用于单行标签。标签的行高按容器高度定,不按可读性定。
把公式跑一遍,得到的就是这本书正文用的那套:
| 用在哪 | 字号 | 典型行长 | 系数 | 行高 |
|---|---|---|---|---|
| 首页主标题 | 42 | ~18 字符 | 1.10 | 46 |
| 章首标题 | 33 | ~24 字符 | 1.22 | 40 |
| 章节标题 | 27 | ~32 字符 | 1.30 | 35 |
| 导语 | 21 | ~50 字符 | 1.47 | 31 |
| 正文 | 17 | ~66 字符 | 1.75 | 30 |
| 次要文字 | 14 | ~60 字符 | 1.72 | 24 |
| 角标(单行) | 11 | 单行 | 按容器 | 16 |
注意正文那一行的 1.75 比公式给的 1.5 高——因为中文比拉丁文需要更松的行距:汉字是方块,没有升部降部制造的天然节奏,行距不够就会糊成一片。中文正文的经验值是 1.6–1.8。
- 「设计稿的 8dp 怎么对不上」——关掉
includeFontPadding,并加LineHeightStyle.Trim.Both。之后你写的 8dp 就是设计师量的 8dp。 - 「lineHeight 多出来的怎么分」——显式写
Alignment.Center对半分,不用默认的按比例分。 - 「行高配多少」——按公式算:字号越大系数越小、行越长系数越大。中文正文 1.6–1.8。单行标签不适用,按容器高度定。