眯起眼睛:层级是能量出来的
「眯起眼睛看看」是设计师最常给的建议,也是听起来最玄的一条。这一章告诉你它一点都不玄——眯眼就是低通滤波,而滤完之后剩下的东西,可以精确地积分出来。
眯眼到底在干什么
你眯起眼睛的时候,物理上发生了两件事:睫毛遮挡加上焦点失准,让高频信息(笔画、边缘、细节)糊掉,只剩下低频信息(块面、明暗、位置)。
用信号处理的话说:这是一次低通滤波。用图像处理的话说:这是一次高斯模糊。
而「视觉层级」正是低频信息。你第一眼看到什么、第二眼看到什么,不是由「这个标题写得好」决定的,是由哪一块又大又跟背景差得多决定的。
把一屏界面画成灰度图:每个块按它和背景的相对亮度差落墨(第 14 章会讲这个亮度怎么算),然后做一次高斯模糊,最后在每个块所在的区域上积分。
积出来的值,就是这个块的视觉权重。它大致等于:
视觉权重 ≈ 面积 × 与背景的亮度差
注意里面没有「好看」这一项,也没有「重要」这一项。视觉系统不知道你觉得哪个重要,它只会把又大又深的东西推到前面。
算给你看
下面这台引擎做的就是刚才说的三步:栅格化 → 可分离高斯卷积 → 分区积分。拉动 σ 滑杆,就是把眼睛越眯越紧。
看「常见写法」那一版的排序。
你以为的顺序是:标题 → 主按钮 → 插图 → 正文 → 副标题 → 次要链接。
实际算出来的是:主按钮 → 插图 → 标题 → …
标题掉到了第三。原因很朴素:那个标题只有 200 × 26 像素,而按钮是 312 × 48 且是饱和的蓝色,插图是 312 × 150 的大灰块。面积上你就输了两个数量级不到一个数量级,但足够了。
因为你写代码的时候,是按语义顺序写的:先写标题,再写副标题,再写内容,最后写按钮。你在 IDE 里读到的顺序,和用户眼睛接收的顺序,是两回事。
而你每天盯着这个界面,早就知道标题在哪儿了——你已经没法用第一次的眼睛看它。眯眼测试的真正价值,是把你从「知道」的状态强行拉回「只能看见」的状态。
改过之后:只动了量,没动审美
切到「改过之后」那一版。顺序对上了。而改的只有三件事:
没有换字体,没有加阴影,没有「重新设计」。只是把量调对了。
这里有一个很值钱的推论:
视觉权重是相对的。当你觉得标题不够醒目,第一反应是把标题做大——但界面上的空间是有限的,做大标题会挤掉别的东西,而且很容易做过头。
更省的做法是把周围的东西调轻:插图调浅一档,次要文字调淡一档。上面那个例子里,把插图从 #BDBDBD 调到 #E4E4E4,标题一个像素没动就升上去了。
这也是新手界面和成熟界面最大的观感差别:新手界面上所有东西都在用力,成熟界面上只有一两个东西在用力,其余全在让路。
这台引擎是真的
为了让你相信上面那些数字不是编的,说一下它是怎么算的。
可分离卷积。二维高斯核可以拆成横竖两个一维核先后作用,复杂度从 O(r²) 降到 O(2r)。这就是 Photoshop 的高斯模糊快的原因,也是这里 σ 拉到 24 还能实时响应的原因。
// 一维核:3σ 之外的权重小到可以忽略
function gaussKernel(sigma) {
var r = Math.ceil(sigma * 3), k = [], sum = 0;
for (var i = -r; i <= r; i++) {
var v = Math.exp(-(i * i) / (2 * sigma * sigma));
k.push(v); sum += v;
}
return { r: r, k: k.map(function (v) { return v / sum; }) };
}
// 先横后纵,边界夹取
tmp[y][x] = Σ src[y][clamp(x + i)] * k[i]
out[y][x] = Σ tmp[clamp(y + i)][x] * k[i]
亮度差用的是 WCAG 的相对亮度。不是简单的 (R+G+B)/3——那个是错的,因为绿色对人眼的贡献远大于蓝色。正确的做法是先把 sRGB 做逆伽马变换回线性空间,再按 0.2126 / 0.7152 / 0.0722 加权。第 14 章会详细讲这三个系数从哪来。
顺序一致度用逆序对。把「意图顺序」和「实际顺序」当成两个排列,数其中有多少对元素的相对次序反了(Kendall 距离),除以总对数。100% 表示完全一致。
眯眼测试在 Compose 里没有对应的 API,但有两个很实用的近似做法:
// ① 预览里直接糊一层 —— 最快的自查
@Preview
@Composable
fun SquintPreview() {
OrderScreen(
modifier = Modifier.blur(12.dp) // API 31+
)
}
// ② 抽掉颜色,只留明暗 —— 顺带查了「只靠颜色区分」的问题
val grayscale = ColorMatrix().apply { setToSaturation(0f) }
Box(Modifier.graphicsLayer {
renderEffect = null
}.drawWithContent {
drawContent()
}) { OrderScreen() }
更土但更有效的办法:截图,缩到指甲盖大小,再放大看。缩放本身就是一次低通滤波,而且不需要任何 API。
什么时候用它
这一测试的正确用法是做完之后,不是做的过程中。
做的过程中你在处理细节,糊了看什么都看不清。做完之后糊一下,回答一个问题就行:
眯眼之后,最重的那一块,是不是你想让人先看的那一块?
是 —— 过。
不是 —— 要么把想让人先看的做重,要么把抢戏的那个压轻。优先选后者。
- 「层级对不对」——不再靠「感觉还行」。糊一下,看排序,一分钟出结论。
- 「怎么让它更突出」——默认答案是把周围压轻,而不是把它做大。只有压无可压时才动主角。