对比度是算出来的,不是看出来的
这是全书最容易自动化的一章。对比度有确切的公式、确切的阈值,你可以把它写成一个单元测试挂进 CI——从此「这个灰是不是太淡了」这个问题永远不用再讨论。
公式
WCAG 2.1 的对比度只有两步:
① 相对亮度 L = 0.2126·R + 0.7152·G + 0.0722·B (R/G/B 先做逆伽马变换回线性空间) ② 对比度 = (L亮 + 0.05) / (L暗 + 0.05)
那三个系数你在第 3 章和第 13 章都见过了——它们是这本书里出现次数最多的三个数,因为「人眼对绿最敏感、对蓝最不敏感」这件事贯穿了亮度、层级、对比度所有话题。
加的那个 0.05 是「环境反射光」——它保证了纯黑对纯白的比值是有限的(21:1),也保证了两个都很暗的颜色不会算出天文数字。
阈值
| 用在哪 | AA(验收线) | AAA |
|---|---|---|
| 正文(< 18.66px 粗体 / < 24px) | 4.5:1 | 7:1 |
| 大字(≥ 18.66px 粗体 或 ≥ 24px) | 3:1 | 4.5:1 |
| 非文本(图标、输入框边框、图表) | 3:1 | — |
| 禁用态 | 不作要求——它就该看不清 | |
#767676 是白底上刚好过 AA 的最浅的灰(4.54:1)。比它再浅一点点就不及格。
这个数很有用,因为你以后想「这个占位符灰会不会太淡」时,只要和它比一下。#9E9E9E?2.68:1,差远了——而这正是 Material 2 时代无数 App 用的那个「次要文字灰」。
下面这台计算器把两套标准都算了。拨颜色、拨字号,看两边怎么变:
第二把尺:APCA
WCAG 的公式有个众所周知的毛病:它不管字有多大多细。一个 11px 的细体和一个 40px 的粗体,只要颜色一样,算出来的对比度就一样——但显然前者难读得多。
APCA(W3C 的草案,面向 WCAG 3)修的就是这一点。它有两个明显不同:
认极性:同一对颜色,深字浅底和浅字深底算出来的值不一样。这符合事实——白字黑底的实际可读性确实比黑字白底差(光晕效应会让细笔画被吃掉)。
可以对答案的锚:纯黑字白底 Lc = 106.04,纯白字黑底 Lc = −107.88。这正是 APCA 文档里印的那两个参考值,上面那台引擎算出来的就是它们。
认字号:门槛跟着字号字重走。16px 正文要 Lc ≥ 75,24px 大字只要 60,36px 粗体只要 45。
它们打架的时候听谁的
两套尺子必然会有分歧。最典型的一处就在 Demo 的默认值上:
谁对?APCA 更接近真实感受,WCAG 是法规引用的那个。
以 WCAG 为验收线(写进测试、写进 CI),拿 APCA 当第二意见。
当两者都过——放心。
当 WCAG 过而 APCA 不过——说明你踩在边缘上了,往深调一档(#757575 → #616161,WCAG 6.19、Lc 80.9,两边都舒服)。
当 WCAG 不过——没得商量,必须改。
无障碍不等于难看
说到这里会有一个很自然的抵触:「守 4.5:1 会让我的界面全是黑白的,很难看。」
《Refactoring UI》对这个抵触给了一个很聪明的回答,值得原样收进来:
你想在品牌色上放白字,但品牌色不够深,对比度只有 3.12——正文不及格。
常规做法是把品牌色调深——但那样它就不是你的品牌色了。
更好的做法是翻过来:不要「浅色底 + 白字」,改成「更浅的同色系底 + 深色的同色系字」。
✗ 底 #7B8FD4(品牌色)+ 白字 对比 3.12 正文不及格 ✓ 底 #E8ECFA(同色相,很浅)+ 字 #2B3A6B 对比 9.27 AAA
后者依然是蓝色的、依然是你的品牌,但完全合规,而且看起来更柔和、更高级——因为它不再靠「强反差」硬撑。
这正是 Material 3 里 primaryContainer / onPrimaryContainer 这一对角色的用途。第 16 章会讲。
- 占位符文字——输入框里的
hint几乎总是太淡。它是内容不是装饰,用户要读它才知道该填什么。 - 图标——大家会记得查文字,忘了查图标。图标属于「非文本」,要过 3:1。一个
#BDBDBD的图标(1.88:1)在白底上基本是隐形的。 - 文字压在图片上——这是唯一一处「对比度会随内容变」的地方。用户上传的图你控制不了,所以必须加一层保底(第 27 章讲)。
写成测试
这一章最值钱的地方:它可以完全自动化。
// 一个纯函数,20 行,没有任何依赖
fun Color.relativeLuminance(): Double {
fun channel(c: Float): Double {
val n = c.toDouble()
return if (n <= 0.04045) n / 12.92 else ((n + 0.055) / 1.055).pow(2.4)
}
return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue)
}
fun contrastRatio(a: Color, b: Color): Double {
val (hi, lo) = listOf(a.relativeLuminance(), b.relativeLuminance()).sortedDescending()
return (hi + 0.05) / (lo + 0.05)
}
// 把整套主题一次性验掉 —— 加进 CI,从此不用再讨论
@Test
fun everyOnColorMeetsAA() {
val s = LightColorScheme
val pairs = listOf(
"onSurface / surface" to (s.onSurface to s.surface),
"onPrimary / primary" to (s.onPrimary to s.primary),
"onSecondary / secondary" to (s.onSecondary to s.secondary),
"onError / error" to (s.onError to s.error),
"onSurfaceVariant / surface" to (s.onSurfaceVariant to s.surface)
)
pairs.forEach { (name, pair) ->
assertWithMessage(name)
.that(contrastRatio(pair.first, pair.second))
.isAtLeast(4.5)
}
}
// 非文本元素单独一条线
@Test
fun outlineMeetsNonTextContrast() {
assertThat(contrastRatio(LightColorScheme.outline, LightColorScheme.surface))
.isAtLeast(3.0)
}
把这两个测试加进去,第 4 章那个「脏」就再也不会以「正文太淡」的形式出现了。这是全书投入产出比最高的二十行代码。
- 「这个颜色够不够深」——算,不看。正文 ≥ 4.5:1,大字和非文本 ≥ 3:1。写成测试挂进 CI。
- 「品牌色上放白字对比度不够怎么办」——翻极性:改成「更浅的同色系底 + 深色同色系字」,而不是把品牌色调深。
- 「WCAG 和 APCA 听谁的」——WCAG 当验收线,APCA 当第二意见。两者分歧时往深调一档。