卷 III · 字· CH 09· 模块化音阶 深度 9/28

从 18 个字号收到 5

你界面上 90% 的像素是文字,而你从没量过它。这一卷从最容易收的那一项开始:字号。两个数就能定死一整套——但这件事有个像样的反对意见,我们也一起看。

base × ratio^n取整碰撞音阶 vs 手挑

为什么是「一套」而不是「一个个挑」

第 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),你要么用小数(在某些渲染下会糊),要么取整——而取整之后,那套「和谐的比例」其实已经被你破坏了,那还不如一开始就手挑一套好记的整数。

它说得对一半,而证据就在上面那个 Demo 里

把比例调到 1.067、往下拉满级数,你会看到真的撞车12.3311.56 都取整到 12。两个不同的 token,同一个效果——比没分级还糟,因为代码里看着像有层级。

所以「音阶算完就完事」是错的。

但「因此不要用音阶」也过头了。音阶解决的是「相邻两级差多少」这个问题该由谁回答——手挑的话,回答它的是你的直觉,而你的直觉刚好就是第 1 章那 18 个字号的来源。

我的做法(也是这本书自己用的)是把两者接起来:

音阶定初值,手工圆整,然后锁死
  1. 用音阶算一遍——挑一个基准和比例,得到一串带小数的值。这一步保证了级差是均匀的。
  2. 手工圆整成整数,并检查有没有撞车。撞了就减一级,或者换个大一点的比例。
  3. 把圆整后的结果写死成一张表,从此不再回头算。这张表就是唯一的真相。

这本书的正文用的是 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 键可以看到它被量出来。

一屏该用几级

阶梯有七级,不代表一屏要用七级。

一屏 ≤ 4 个字号

典型分配:一个标题、一个正文、一个次要、一个角标。多出来的层级用第 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 不是随手配的——下一章会告诉你它该等于多少,以及为什么它和字号是反比关系。

别忘了字号会被用户放大

spdp 的区别就在这儿:sp 会跟着系统的「字体大小」设置缩放,最高可以到 2 倍。

所以文字必须用 sp(用了 dp 等于无视用户的无障碍设置),而容器高度不能写死——一个写死 48dp 高的按钮,在 200% 字号下里面的文字会被切掉。用 Modifier.heightIn(min = 48.dp) 而不是 height(48.dp)

Android 14 之后还有一层:非线性字体缩放。大字号放大得比小字号少,所以你不能假设「所有字号乘同一个系数」。唯一安全的做法是让布局自适应,而不是算出来。

本章关掉的自由度本章 2 条 · 累计 26 / 78
  • 「这个字用多大」——从圆整过的七级阶梯里取,一屏不超过 4 级。阶梯用音阶算初值、手工圆整、然后锁死。
  • 「文字用 sp 还是 dp、高度写死不写死」——文字一律 sp;含文字的容器一律 heightIn(min = …),不用 height()