卷 IV · 色· CH 15· 真 HCT 调色板 深度 15/28

一个种子色长出一整套

第 1 章数出来 25 个颜色。这一章把它收成 1 个——不是「少用颜色」,是只挑一个,剩下的全部算出来。这也是全书技术含量最高的一台引擎。

你需要的颜色比你以为的多HCT = CAM16 + L*与官方库逐位相同

你需要的颜色比你以为的多

先纠正一个方向。你可能以为「收窄颜色」意味着少用颜色——正好相反

《Refactoring UI》里有一节的标题就是「You need more colors than you think」。一个真实的界面需要的不是「一个蓝色」,而是:

  • 主按钮的填充色(要够深,白字能站住)
  • 次要按钮的边框色(浅一点)
  • 选中态的浅底色(很浅,深色字要能站住)
  • 那个浅底上的文字色(很深)
  • 暗色模式下以上全部的对应版本

光一个「蓝」就要五到十档。三个主色 + 中性色,你需要的是六七十个色值

所以真正的问题不是「用几个颜色」

「这六七十个色值,是你一个个挑的,还是算出来的」

一个个挑 = 六七十个随手定的数(第 1 章数出来的 25 个只是冰山一角,因为大部分人根本没做暗色模式)。

算出来 = 一个种子色,加一套规则。

Material 3 的做法:HCT

Material 3 用的取色空间叫 HCT,三个分量:

H 色相来自 CAM16 色貌模型
C 彩度也来自 CAM16
T 色调就是 CIE 的 L*

第三个是关键。T 直接就是 L*,而 L* 和第 14 章那个对比度公式是同源的。于是:

色调差决定对比度,而色调是你定的

「tone 40 的字配 tone 100 的底」——这一对的对比度是有下限保证的,不管你的种子色是什么颜色。

这就是 Material 3 那套角色(primary 用 tone 40、onPrimary 用 tone 100)为什么天生合规:它们不是挑出来正好过 4.5:1 的,是按色调差排出来的,必然过。

一条色阶的生成规则很朴素:色相锁死、彩度按角色定档、只让色调在动

换几个种子色试试。注意五条色阶的彩度是固定的:

primary种子色相,彩度至少 48
secondary同色相,彩度 16(明显收敛)
tertiary色相 +60°,彩度 24
neutral同色相,彩度 4(这就是第 13 章说的「灰色带一点色相」)
neutralVariant同色相,彩度 8

看最后两行——Material 的中性色也不是纯灰,它带着种子色的色相,只是彩度压到了 4 和 8。这和这本书自己那条 C = 0.004 的中性阶是同一个思路。

这台引擎是真的,而且对过答案

HCT 不好实现——它需要完整的 CAM16 色貌模型(含视觉环境参数)、L* 与 Y 的往返、以及一个色域边界求解器(当你要的彩度在那个亮度上塞不下时,得沿 sRGB 立方体的表面找最远点)。

所以这台引擎必须验。验的方式是拿 Google 官方的 material-color-utilities 当裁判:

逐位相同
正向 sRGB → HCT,4000 个随机色Δ < 5×10⁻¹²(浮点噪声)
反向 HCT → sRGB,6000 组随机 (H, C, T)6000 / 6000 逐位相同
8 个种子 × 5 条色阶 × 24 个色调960 / 960 逐位相同

而且引擎里那两个关键矩阵不是抄 Google 源码里的硬编码常数,是从 M16 × sRGB→XYZ 现算的——算出来和它对到小数点后 15 位(0.001200833568784504)。

顺带纠正一个流传很广的误解

Material 官方文档里印的那张「基线调色板」(种子 #6750A4)和 material-color-utilities 现在算出来的不完全一样,好几格差 1/255。

比如 primary tone 10:文档写 #21005D,库算出来是 #22005D

差异来自文档那张表比库旧。以库为准——因为你的 App 运行时用的是库。

种子色从哪来

那唯一要挑的那个颜色呢?三种来路,按可靠性排:

  1. 品牌已经有了——直接用,不要「调整一下更适合 UI」。品牌色就是种子,剩下的 HCT 会替你摆平。
  2. 从用户壁纸提取——这就是 Material You。material-color-utilities 里的 QuantizerCelebi + Score 做的就是这件事:量化出主色,再按「够不够艳、够不够特别」打分挑一个。
  3. 自己定——那就挑一个你喜欢的、彩度足够高的颜色。彩度低的种子会长出一整套发灰的色阶,因为规则里的「至少 48」救不了一个本身就没有彩度的色相。
// Android 12+ 直接用系统的动态取色 —— 种子来自用户壁纸
val colorScheme = when {
    Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
        val ctx = LocalContext.current
        if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
    }
    darkTheme -> DarkColorScheme
    else      -> LightColorScheme
}

// 自己的品牌色:用 Material Theme Builder 生成一次,
// 把结果写死进代码。不要在运行时算 —— 没必要,而且 HCT 求解不便宜。
private val LightColorScheme = lightColorScheme(
    primary            = Color(0xFF6750A4),   // primary40  ← 种子
    onPrimary          = Color(0xFFFFFFFF),   // primary100
    primaryContainer   = Color(0xFFE9DDFF),   // primary90
    onPrimaryContainer = Color(0xFF22005D),   // primary10
    // …剩下的 20 多个角色同理,全部来自那一个种子
)

MaterialTheme(colorScheme = colorScheme) { App() }

注意每行后面的注释:那才是这些值的出处。没有注释的话,三个月后有人想改主色,会以为要改 25 个地方——实际上只要改一个种子重新生成。

语义色怎么办

成功绿、警告黄、危险红——这些不能从种子长出来,因为它们的色相是约定俗成的,不能跟着品牌走。

做法是:它们各自当一个独立的种子,跑同一套规则。

error   种子 #B3261E  →  error40 / onError100 / errorContainer90 / onErrorContainer10
success 种子 #386A20  →  同上四档
warning 种子 #7D5700  →  同上四档

这样它们和主色虽然色相不同,但明暗结构完全一致——放在一起不会有一个特别跳。Material 3 官方只给了 error 一族,success 和 warning 需要你自己按同样的规则扩,这是很常见的一步。

本章关掉的自由度本章 3 条 · 累计 42 / 78
  • 「这里用哪个颜色」——一个种子色 + 一套规则,六七十个色值全部算出来。你只挑种子。
  • 「浅一点的那个蓝用什么」——同一条色阶上换一个 tone,不是重新调一个颜色。
  • 「语义色怎么和品牌色协调」——各自当种子跑同一套规则,色相不同但明暗结构一致。