为什么是 8
上一章讲了间距的比值。这一章讲间距的取值——为什么全世界的设计系统都选了 8,而不是 5 或者 10。答案不在审美里,在密度桶里。
先把 dp 说清楚
你每天都在写 16.dp,但可能没仔细想过它是什么。
换算公式就一行:
物理像素 = dp × (屏幕 dpi / 160)
Android 把这个倍率分成几档,叫密度桶:
| 密度桶 | 倍率 | 1 dp = | 8 dp = | 5 dp = |
|---|---|---|---|---|
| mdpi | ×1 | 1 px | 8 px | 5 px |
| hdpi | ×1.5 | 1.5 px | 12 px | 7.5 px |
| xhdpi | ×2 | 2 px | 16 px | 10 px |
| xxhdpi | ×3 | 3 px | 24 px | 15 px |
| xxxhdpi | ×4 | 4 px | 32 px | 20 px |
看红色那一格:5dp 在 hdpi 上是 7.5 个物理像素。
半个像素是不存在的。系统只能取整——四舍五入成 7 或者 8。于是同一个「5dp 的间距」,在某些设备上是 7px,在另一些上是 8px。你的边线会时粗时细,你的间距会时紧时松。
而 8 呢?8 × 1.5 = 12,8 × 3 = 24——全是整数。
8 是能被所有常见密度倍率(1、1.5、2、3、4)整除得到整数像素的最小实用值。
4 也行(4 × 1.5 = 6),所以 4 被留给「小间距」那一档。2 也行,但 2dp 的间距在视觉上几乎等于没有。
10 不行(10 × 1.5 = 15 可以,但 5 × 1.5 = 7.5 不行,而你迟早会用到一半)。这就是为什么「整十」这个直觉在移动端是错的。
落不落格,能查
规则清楚了,剩下的是执行。下面这台检查器把一屏的每条边(左、上、右、下)都拿去除以 8,看余数:
点「吸到网格上」。注意两件事:
- 画面几乎没变。最大的一次挪动也只有几个像素,肉眼很难指出哪里动了。
- 但落格率从一堆红变成了 100%。
这正是第 1 章那个论点的又一次出现:收窄的代价接近零,收益是从此有了一把尺。
因为网格的价值不在单个界面上,在界面之间。
一个界面里 15dp 还是 16dp,确实看不出来。但当你有 40 个界面,其中一半用 15、一半用 16 时,用户在页面之间跳转会感到一种说不出的晃动——内容左边缘在微微移动。
网格解决的是跨页面的一致性,而这恰恰是单看一张设计稿时最容易忽略的东西。
哪些东西该落格,哪些不该
这里有个常见的误解:「8pt 网格」不是说所有尺寸都必须是 8 的倍数。
| 东西 | 要不要落格 | 为什么 |
|---|---|---|
| 间距、内外边距 | 必须 | 这是网格的主要作用对象 |
| 组件高度(按钮、输入框、列表行) | 必须 | 它们会上下堆叠,不落格会累积误差 |
| 图标尺寸 | 要 | 16 / 24 / 32 / 40 / 48,正好都是 8 的倍数 |
| 字号 | 不要 | 字号走自己的音阶(第 9 章),13 / 16 / 20 / 25 这种 |
| 行高 | 最好落 4 | 行高会决定文本块的总高,落 4 能让文本块也落格 |
| 圆角 | 不要 | 圆角是形状语言,走自己的一小套(0/4/8/16/全圆) |
| 边框宽度 | 不要 | 1dp 就是 1dp,别为了落格搞成 8dp 的边 |
为了让某个东西落格,去改它本来正确的尺寸。
比如把 24px 的图标改成 32px「因为 24 不够 8 的倍数」——24 是 8 的倍数。或者把行高从 18.75 强行改成 16「为了落格」——那会毁掉可读性。
网格是给你自由选择的量用的。字体度量、图片原始比例、系统组件的最小高度,这些不是你选的,别去掰它们。
写进代码
间距阶梯就是一个对象,没有任何魔法。关键是它必须是唯一的来源:
// 一个文件,全 App 的间距都从这儿来
object Spacing {
val none = 0.dp
val hair = 2.dp // 只给边框、分隔线这类
val tiny = 4.dp // 图标和它的文字之间
val tight = 8.dp // 组内
val snug = 12.dp
val base = 16.dp // 屏幕边距
val group = 24.dp // 组之间
val block = 32.dp // 大区块之间
val wide = 48.dp
val huge = 64.dp
}
// 用的时候:名字表达意图,数字只是实现
Column(
verticalArrangement = Arrangement.spacedBy(Spacing.tight),
modifier = Modifier.padding(horizontal = Spacing.base)
)
为什么用语义名(tight / group / block)而不是 s1 / s2 / s3?因为语义名会在 code review 里替你说话:Arrangement.spacedBy(Spacing.group) 一眼能看出「这是组之间」,而 Spacing.s6 只能看出「这是第六档」。
但也别过度语义化。语义名太具体(cardInnerPaddingTop)就会退化成第 1 章那 24 个间距——只是换了个写法。
让「随手写」变难
光有 Spacing 对象没用,你还是会顺手写 13.dp。要让「随手写」比「查阶梯」更麻烦:
// detekt / ktlint 自定义规则的思路: // UI 层(*.ui.*、*Screen.kt、*Composable)里, // 禁止出现裸的 Int.dp / Int.sp 字面量, // 只允许 Spacing.*、MaterialTheme.*、以及 0.dp。 // // 例外白名单要短,而且每个例外要写注释说明为什么。 // ✗ 编译过,lint 挂 Modifier.padding(13.dp) // ✓ Modifier.padding(Spacing.snug) // ✓ 有出处的例外 Modifier.padding(top = statusBarHeight) // 来自系统,不是我选的
设计师维持网格靠的是 Figma 里的对齐辅助线和自己的注意力。你可以让不落格的代码过不了 CI。
这个差别在项目第一周不明显,在第五十个界面、第三个新人加入之后,差别是决定性的。
一个例外:光学间距
最后说一个网格管不着的地方,免得你以后撞上时以为自己做错了。
当两个元素的形状差别很大时,等距未必等于看起来等距。最典型的是「一个圆形头像 + 一段文字」:如果按外框算,圆的左右会显得比方块空——因为圆的边缘是弯的,它离你的间距的「有效距离」更远。
这时候需要往回收一点点。这属于光学调整,也就是下一章的主题——那一章会告诉你这个「一点点」是可以精确算出来的,不是靠眼睛蒙的。
- 「间距用几」——只能从 0 / 2 / 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 里取,写成一个
Spacing对象。 - 「组件高度和图标尺寸用几」——8 的倍数。图标固定 16 / 24 / 32 / 40 / 48 五档。
- 「怎么保证以后不破例」——一条 lint 规则,禁止 UI 层出现裸的
.dp字面量。不靠自觉。