HSL 骗了你十年
颜色是全书唯一一处「凭感觉」会必然出错的地方——不是因为它难,是因为你手上那把尺子本身是歪的。这一章先把尺子换掉。
一个能当场证伪的例子
你想做一套「亮度一致」的品牌色。你知道 hex 不好调,于是听了那句很常见的建议——改用 HSL——把 L 全部设成 50%,只改 H。
结果是这样的:
| 你写的 | 得到 | 相对亮度 | L*(真感知亮度) | 白底对比度 |
|---|---|---|---|---|
hsl(60 100% 50%) | #FFFF00 黄 | 0.9278 | 97.1 | 1.07:1 |
hsl(120 100% 50%) | #00FF00 绿 | 0.7152 | 87.7 | 1.37:1 |
hsl(0 100% 50%) | #FF0000 红 | 0.2126 | 53.2 | 4.00:1 |
hsl(240 100% 50%) | #0000FF 蓝 | 0.0722 | 32.3 | 8.59:1 |
同一个「50% 亮度」,白底对比度从 1.07 到 8.59——差 8 倍。黄色那一行在白底上几乎是隐形的。
它是 (max(R,G,B) + min(R,G,B)) / 2——一个纯粹的几何量,和人眼怎么感知完全无关。
人眼对绿光的敏感度远高于蓝光:同样的能量,绿色看起来比蓝色亮十倍。所以任何不给三个通道加权的「亮度」,都是假的。
那三个权重是 0.2126 / 0.7152 / 0.0722(R / G / B),来自 CIE 的实验测定,也是第 3 章那台模糊引擎和第 14 章那个对比度公式共用的同一组数。
《Refactoring UI》说对了方向,但那把尺还是歪的
「扔掉 hex,改用 HSL」这条建议来自《Refactoring UI》,它在 2018 年是完全正确的方向——hex 确实没法调,而 HSL 至少让你能独立地动色相、饱和度、明度。
但它停早了一步。HSL 解决了「可调」,没解决「可比」。你能方便地把 L 从 50% 调到 60%,可你没法比较两个不同色相的颜色谁更亮——而「谁更亮」恰恰是对比度、层级、无障碍全部依赖的那个量。
现在有更好的选择了:OKLCH(Björn Ottosson,2020,现已进 CSS Color 4)。它的三个分量是:
切换下面这台引擎的两个模式,看下排那些柱子(每个色相的真实感知亮度):
HSL 模式下,柱子是波浪形的——你明明把 L 全设成了同一个值。OKLCH 模式下,柱子齐平。
色域是个纺锤,不是球
切到 OKLCH 模式还能看到第二件事:色带的鲜艳程度不均匀。黄和青那一段明显发灰。
这不是引擎的 bug,是 sRGB 的事实。看 Demo 下面那张彩度天花板表:
- 蓝(265°)在 L=0.45 最艳,能到 0.309;
- 绿(145°)要到 L=0.87 才最艳,0.271;
- 青(205°)怎么都上不去,顶点只有 0.147——不到蓝的一半。
而且在 L=0.95 这种很亮的地方,红色的彩度上限只剩 0.025——比它顶点时的 0.255 掉了十倍。
《Refactoring UI》里有一节叫「Don't let lightness kill your saturation」,说的是:越把颜色调亮或调暗,越要手动提高饱和度,否则会变得灰扑扑。
这个现象它说对了,但结论要修一下:你加不上去。在很亮或很暗的地方,色域本身就没有那么多彩度可用。你在 HSL 里能写 S: 100%,是因为 HSL 的 S 是个相对量——它相对的是「这个亮度下最大能有多少」,所以 100% 的 S 在 L=95% 时其实是很灰的一点点彩度。HSL 用一个百分号把这个事实藏起来了。
是用一套自动贴着色域边界走的色阶。第 15 章那台 HCT 引擎做的就是这件事:你给色相和目标彩度,它在每个亮度上自动取「目标值和天花板中的较小者」。
你根本不用知道天花板在哪儿——但你需要知道天花板存在,否则你会花一下午试图把一个浅黄色调得更黄。
这本书自己就是例子
这本书的六个卷色不是挑的,是这台引擎生成的:同一个 L = 0.52、同一个目标 C = 0.125,只有 H 在动(265 / 325 / 25 / 85 / 145 / 205)。
| 卷 | 色相 | 实际彩度 | 色值 | 白底对比度 |
|---|---|---|---|---|
| I 看清 | 265° | 0.1250 | #4565B0 | 5.61 |
| II 距离 | 325° | 0.1250 | #8C4D90 | 5.87 |
| III 字 | 25° | 0.1250 | #A54743 | 5.86 |
| IV 色 | 85° | 0.1066 | #856300 | 5.55 |
| V 手 | 145° | 0.1250 | #327B38 | 5.22 |
| VI 交付 | 205° | 0.0888 | #007781 | 5.31 |
注意红色那两格:黄(85°)和青(205°)在 L=0.52 上塞不下 0.125,被夹到了各自的天花板。这就是上一节说的事,在这本书自己身上发生了一次。
结果是六个卷色的白底对比度落在 5.22 到 5.87 这个很窄的区间里——没有哪一卷的标题会比别的卷更抢眼。这是 HSL 做不到的。
灰色不必是灰的
最后一条,也来自《Refactoring UI》,而且实施成本极低、效果极明显:
纯灰(#808080 这种 R=G=B 的)在界面上会显得脏、死,因为现实里几乎不存在纯中性的光。
做法是给整条中性阶一个统一的、很低的彩度:
- 偏冷(h ≈ 250–270):科技感、克制。适合工具、效率类。
- 偏暖(h ≈ 60–80):亲和、纸感。适合内容、生活类。
关键是整条阶用同一个色相——第 4 章那个「脏」,一半来自「有的灰偏蓝有的灰偏黄」。
这本书的中性阶就是 OKLCH h = 262、C = 0.004,L 从 0.955 排到 0.215:
--canvas L 0.955 #EFF0F3 --board-alt L 0.985 #F9FAFD --rule L 0.905 #DEE0E2 --ink-4 L 0.665 #929496 --ink-3 L 0.545 #6F7072 --ink-2 L 0.395 #454649 --ink L 0.215 #18191B
彩度只有 0.004,肉眼几乎看不出偏色——但把它和一条纯灰阶并排放,纯灰那条会立刻显得发死。这就是「说不出哪里不一样,但就是更像样」的一个具体来源。
Android 上没有原生的 OKLCH,但你不需要在运行时算——颜色应该在构建期就定好:
// 这些值是用 OKLCH 生成好之后写死进来的,
// 每一行的注释就是它的出处。
object Neutral {
// OKLCH(L, 0.004, 262) —— 整条阶同一个色相,微冷
val canvas = Color(0xFFEFF0F3) // L 0.955
val surface = Color(0xFFFFFFFF) // L 1.000
val outline = Color(0xFFDEE0E2) // L 0.905
val textDim = Color(0xFF6F7072) // L 0.545
val text = Color(0xFF18191B) // L 0.215
}
// 需要在运行时插值时,用 OKLab 而不是 sRGB ——
// sRGB 里两个颜色的中点在感知上不是中点
val mid = lerp(startColor, endColor, 0.5f) // ✗ 会经过一个发灰的中间态
// ✓ 先转 OKLab 插值再转回来,或者干脆预生成好几档
- 「怎么调出一组亮度一致的颜色」——用 OKLCH 固定 L,不用 HSL。HSL 的 L 是几何量,不是亮度。
- 「灰色用哪个灰」——整条中性阶用同一个色相、同一个极低彩度(C ≈ 0.004),只改 L。不再一个界面一个灰。