卷 III · 字· CH 10· 真字体度量 深度 10/28

设计稿上对不上的那几个像素

设计师标了 8dp,你写了 8dp,做出来看着像 10dp。你反复量了三遍代码,没错。这一章告诉你那几个像素藏在哪儿——藏在字体文件的两张表里。

行盒 ≠ 字形includeFontPadding行高是反比

你写的 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,读一下这几个数:

unitsPerEm(字体自己的坐标系)2048
hhea ascender / descender / lineGap1900 / −500 / 0
OS/2 usWinAscent / usWinDescent2146 / 555
紧行盒 (1900 + 500 + 0) / 20481.171875 em = 18.750 px
松行盒 (2146 + 555) / 20481.318848 em = 21.102 px
两者之差上 1.922 px 下 0.430 px 共 2.352 px
这就是那几个像素

你在一段 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.625Compose 的默认答案不是。

多出来5.250 px
Proportional(默认)按 ascent : descent 分上 4.156 下 1.094
Center 对半分上 2.625 下 2.625

差了 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 档 token 来对答案

这条规则是不是编的?把它套到 Material 3 那 15 档官方 typography 上,和 Google 定的行高比一比:

  • 22sp 以上(display / headline / title):平均差 3% 左右,最好的 titleLarge 只差 1.4%。规则站得住。
  • 12sp 以下:差到 15%——bodySmall M3 给 16sp,公式说 18.4sp。

后半段这个偏差不是规则错了,恰恰是规则的后半句在起作用:M3 的 bodySmall / labelSmall 主要用在单行标签上(角标、按钮文字、输入框提示),而单行文本根本没有「回扫」这个动作,所以不需要行距。

结论:这条公式适用于成段的正文,不适用于单行标签。标签的行高按容器高度定,不按可读性定。

把公式跑一遍,得到的就是这本书正文用的那套:

用在哪字号典型行长系数行高
首页主标题42~18 字符1.1046
章首标题33~24 字符1.2240
章节标题27~32 字符1.3035
导语21~50 字符1.4731
正文17~66 字符1.7530
次要文字14~60 字符1.7224
角标(单行)11单行按容器16

注意正文那一行的 1.75 比公式给的 1.5 高——因为中文比拉丁文需要更松的行距:汉字是方块,没有升部降部制造的天然节奏,行距不够就会糊成一片。中文正文的经验值是 1.6–1.8

本章关掉的自由度本章 3 条 · 累计 29 / 78
  • 「设计稿的 8dp 怎么对不上」——关掉 includeFontPadding,并加 LineHeightStyle.Trim.Both。之后你写的 8dp 就是设计师量的 8dp。
  • 「lineHeight 多出来的怎么分」——显式写 Alignment.Center 对半分,不用默认的按比例分。
  • 「行高配多少」——按公式算:字号越大系数越小、行越长系数越大。中文正文 1.6–1.8。单行标签不适用,按容器高度定。