卷 II · 距离· CH 06· 网格对齐检查器 深度 6/28

为什么是 8

上一章讲了间距的比值。这一章讲间距的取值——为什么全世界的设计系统都选了 8,而不是 5 或者 10。答案不在审美里,在密度桶里。

dp 到底是什么8 的整除性落格率

先把 dp 说清楚

你每天都在写 16.dp,但可能没仔细想过它是什么。

dp 是一个约定:160dpi 下 1dp = 1 物理像素

换算公式就一行:

物理像素 = dp × (屏幕 dpi / 160)

Android 把这个倍率分成几档,叫密度桶

密度桶倍率1 dp =8 dp =5 dp =
mdpi×11 px8 px5 px
hdpi×1.51.5 px12 px7.5 px
xhdpi×22 px16 px10 px
xxhdpi×33 px24 px15 px
xxxhdpi×44 px32 px20 px

看红色那一格:5dp 在 hdpi 上是 7.5 个物理像素。

半个像素是不存在的。系统只能取整——四舍五入成 7 或者 8。于是同一个「5dp 的间距」,在某些设备上是 7px,在另一些上是 8px。你的边线会时粗时细,你的间距会时紧时松。

而 8 呢?8 × 1.5 = 128 × 3 = 24——全是整数

8 的来历,一句话

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,看余数:

点「吸到网格上」。注意两件事:

  1. 画面几乎没变。最大的一次挪动也只有几个像素,肉眼很难指出哪里动了。
  2. 但落格率从一堆红变成了 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)   // 来自系统,不是我选的
这是第 1 章说的「主场优势」第一次真正落地

设计师维持网格靠的是 Figma 里的对齐辅助线和自己的注意力。你可以让不落格的代码过不了 CI

这个差别在项目第一周不明显,在第五十个界面、第三个新人加入之后,差别是决定性的。

一个例外:光学间距

最后说一个网格管不着的地方,免得你以后撞上时以为自己做错了。

当两个元素的形状差别很大时,等距未必等于看起来等距。最典型的是「一个圆形头像 + 一段文字」:如果按外框算,圆的左右会显得比方块空——因为圆的边缘是弯的,它离你的间距的「有效距离」更远。

这时候需要往回收一点点。这属于光学调整,也就是下一章的主题——那一章会告诉你这个「一点点」是可以精确算出来的,不是靠眼睛蒙的。

本章关掉的自由度本章 3 条 · 累计 18 / 78
  • 「间距用几」——只能从 0 / 2 / 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 里取,写成一个 Spacing 对象。
  • 「组件高度和图标尺寸用几」——8 的倍数。图标固定 16 / 24 / 32 / 40 / 48 五档。
  • 「怎么保证以后不破例」——一条 lint 规则,禁止 UI 层出现裸的 .dp 字面量。不靠自觉。