从 18 个字号收到 5 个
你界面上 90% 的像素是文字,而你从没量过它。这一卷从最容易收的那一项开始:字号。两个数就能定死一整套——但这件事有个像样的反对意见,我们也一起看。
为什么是「一套」而不是「一个个挑」
第 1 章数出来的 18 个字号,问题不在数量本身,在于它们之间没有关系。
13 和 13.5 挨得太近,制造不出层级;13.5 和 14 更是白给。而真正需要拉开的地方(比如正文和大标题),你又不敢一下子跳太远,于是插了个 18、又插了个 19。
两级字号之间如果差不到 10%,人眼基本分不出来——你付了两个决定的成本,买到零个层级。
反过来,如果差到 60% 以上,两级之间会出现断层:中间那档没了,遇到「比正文重一点但又不是标题」的东西时你就得临时造一个。
所以你要的不是「几个字号」,是一串比例合适的字号。
模块化音阶
做法在排版界很老了:定一个基准,定一个比例,剩下的全是幂。
字号(n) = 基准 × 比例ⁿ
比例的名字来自音乐——大三度 1.25、完全四度 1.333、黄金比 1.618。这不是附会:音程和视觉比例都是在处理「怎样的差异既明显又和谐」。
拖动比例试试。你会看到几个规律:
- 比例越大,能用的级数越少——1.618 往上走三级就顶到 70px,手机屏幕装不下。
- 比例越小,取整越容易撞——把「往下几级」拉满,小比例那头会出现两级取整到同一个整数。
- 1.2 到 1.25 是 App 的甜区:级差看得出来,又能排下六七级。
一个像样的反对意见
现在说一件我认为应该照实写进书里的事。
《Refactoring UI》明确反对纯模块化音阶。它的理由是:算出来的值全是小数(16 × 1.25³ = 31.25),你要么用小数(在某些渲染下会糊),要么取整——而取整之后,那套「和谐的比例」其实已经被你破坏了,那还不如一开始就手挑一套好记的整数。
把比例调到 1.067、往下拉满级数,你会看到真的撞车:12.33 和 11.56 都取整到 12。两个不同的 token,同一个效果——比没分级还糟,因为代码里看着像有层级。
所以「音阶算完就完事」是错的。
但「因此不要用音阶」也过头了。音阶解决的是「相邻两级差多少」这个问题该由谁回答——手挑的话,回答它的是你的直觉,而你的直觉刚好就是第 1 章那 18 个字号的来源。
我的做法(也是这本书自己用的)是把两者接起来:
- 用音阶算一遍——挑一个基准和比例,得到一串带小数的值。这一步保证了级差是均匀的。
- 手工圆整成整数,并检查有没有撞车。撞了就减一级,或者换个大一点的比例。
- 把圆整后的结果写死成一张表,从此不再回头算。这张表就是唯一的真相。
这本书的正文用的是 17 × 1.25ⁿ,圆整后是:
t--2 11 px 图注、角标 t--1 14 px 次要文字、代码 t-0 17 px 正文 t-1 21 px 小标题、导语 t-2 27 px 章节标题 t-3 33 px 章首大标题 t-4 42 px 首页主标题
七级,无碰撞,最小相邻比 14 / 11 = 1.27。你现在正在读的这段就是 t-0——按 M 键可以看到它被量出来。
一屏该用几级
阶梯有七级,不代表一屏要用七级。
典型分配:一个标题、一个正文、一个次要、一个角标。多出来的层级用第 12 章那几个手段(字重、深浅)解决,不要再开新字号。
这是第 4 章那个「花」的直接判据。查起来也简单:截图数一下,或者 grep 一下这个文件里出现了几个不同的 typography.*。
写进 Compose
Material 3 自带 15 档 Typography,覆盖 display / headline / title / body / label 五族各三级。先用它,别急着自己造。要改的话,改的是「哪一档用什么字号」,而不是新增档位:
// 只覆盖你真正需要改的那几档,其余保留 M3 默认
private val Sizes = Typography().let { d ->
Typography(
// 圆整过的音阶:17 × 1.25ⁿ
headlineMedium = d.headlineMedium.copy(fontSize = 33.sp, lineHeight = 40.sp),
headlineSmall = d.headlineSmall.copy(fontSize = 27.sp, lineHeight = 34.sp),
titleLarge = d.titleLarge.copy(fontSize = 21.sp, lineHeight = 28.sp),
bodyLarge = d.bodyLarge.copy(fontSize = 17.sp, lineHeight = 26.sp),
bodyMedium = d.bodyMedium.copy(fontSize = 14.sp, lineHeight = 21.sp),
labelSmall = d.labelSmall.copy(fontSize = 11.sp, lineHeight = 16.sp)
)
}
MaterialTheme(typography = Sizes) { App() }
// 用的时候只准引 style,不准写 fontSize
Text(title, style = MaterialTheme.typography.headlineSmall) // ✓
Text(title, fontSize = 27.sp) // ✗ lint 挡掉
那些 lineHeight 不是随手配的——下一章会告诉你它该等于多少,以及为什么它和字号是反比关系。
sp 和 dp 的区别就在这儿:sp 会跟着系统的「字体大小」设置缩放,最高可以到 2 倍。
所以文字必须用 sp(用了 dp 等于无视用户的无障碍设置),而容器高度不能写死——一个写死 48dp 高的按钮,在 200% 字号下里面的文字会被切掉。用 Modifier.heightIn(min = 48.dp) 而不是 height(48.dp)。
Android 14 之后还有一层:非线性字体缩放。大字号放大得比小字号少,所以你不能假设「所有字号乘同一个系数」。唯一安全的做法是让布局自适应,而不是算出来。
- 「这个字用多大」——从圆整过的七级阶梯里取,一屏不超过 4 级。阶梯用音阶算初值、手工圆整、然后锁死。
- 「文字用 sp 还是 dp、高度写死不写死」——文字一律
sp;含文字的容器一律heightIn(min = …),不用height()。