渐变中间那一格怎么算?用 colormath 做个鸿蒙调色器
1. 先选两个颜色,再看中间发生什么
给一张卡片做渐变背景,最简单的办法是取两个 HEX,再让框架填上中间的颜色。不过,端点固定下来之后,中间那一格仍然有不同的算法。把红、绿、蓝三个分量分别取平均是一种做法,先换到另一种颜色空间再求中间值,也是一种做法。滑块都停在 50%,结果未必是同一种颜色。
这次接入的是 colormath,一个 Kotlin Multiplatform 颜色转换与处理库。示例叫“颜色中间站”,界面同时放两种插值结果,下面再铺开两条色带。切换预设、拖动滑块,就能对照颜色和数值,不用在两张图之间来回翻。
输入约定先说清楚:紫金、红蓝、黑白三组端点都以默认 sRGB HEX 创建,透明度为 1;滑块把位置传成 0~1。输出统一转回 sRGB HEX,页面直接用这些色值绘制。这个小调色器的用途是比较颜色计算,屏幕校准和印刷色彩管理需要各自的设备与工作流程。
2. 同一个滑块,为什么能得到两种颜色
2.1 先拿黑白做一次对照
不妨先点“黑色 → 白色”,再点 50%。直接在 sRGB 中插值得到 #808080,Oklab 插值后转回 sRGB 则得到 #636363。两个值都来自当前设备执行的库函数。黑白只涉及明暗变化,适合看清楚:50% 描述的是所选空间的插值位置,它在两条路径里对应不同的亮度。
sRGB 的编码分量与光的强度呈非线性关系。把编码后的分量直接取平均,与先转到 Oklab、对它的分量插值,得到的路径自然不同。Oklab 以感知相关的明度和色度分量组织颜色。具体选哪条路径,可以结合主题背景、数据图表或品牌色过渡的用途来比较。
两条路径分别调用原库的 interpolate。色带在 0~1 中等距取 21 个样本,页面把返回的 HEX 排成一行。
private fun rgbMix(a: RGB, b: RGB, t: Float): RGB = a.interpolate(b, t).clamp()
private fun labMix(a: RGB, b: RGB, t: Float): RGB = a.toOklab().interpolate(b.toOklab(), t).toSRGB().clamp()
private fun strip(a: RGB, b: RGB, perceptual: Boolean): String = (0..20).joinToString(",", "[", "]") {
quote((if (perceptual) labMix(a, b, it / 20f) else rgbMix(a, b, it / 20f)).toHex())
}

再换回“紫色 → 金色”,停在 50%,两张色块分别显示 #bc7d89 与 #b38dae。色带能展示整段过渡,色块用于看当前这一格;两者使用同样的端点和采样方法。想比较某个位置,点下面的百分比按钮比反复对齐滑块更方便。
先别凭感觉说“颜色不对劲”,报一下它在哪个空间、停在哪个位置。这个对照做完,才轮到审美上线。
下面是在 MatePad Edge 上切换预设、改变位置和运行检查的过程。

2.2 显示之前还有一步裁剪
Oklab 转回 sRGB 时,某些颜色分量可能超出显示范围。示例调用原库 clamp() 之后再生成 HEX,让 ArkUI 接收到可以直接绘制的色值。分量裁剪会改变超出 sRGB 范围的颜色;这次使用的是这一种简单处理方式,完整色域映射需要另外选择算法。
页面右下角显示的是最终色块的 LAB 和 Oklab 数值,即把转回 sRGB、裁剪之后的颜色再转换得到的结果。LAB 使用库的默认 D65 白点,数值为了阅读保留三位小数,运算仍采用原库的 Float 精度。把这层顺序交代清楚,才不会拿屏幕上的色块去对比一个尚未裁剪的中间值。
3. 颜色选好了,字也得看得清
背景变化之后,固定一套白字并不总是合适。示例在当前 Oklab 色块上放了一张小卡片:同一个背景分别与黑、白计算 WCAG 对比度,选择数值较高的那一个作为文字颜色。拖动滑块时,卡片上的文字颜色和对比度会一起更新。
这一步用的是 colormath 的 wcagContrastRatio,由颜色的相对亮度计算。对比度是两个颜色之间的数值,黑白的极限对照为 21:1,相同颜色为 1:1。它在这里帮助选择一处文字配色。应用整体的可读性还需要结合字号、背景和实际页面逐项检查。
下面是页面实际调用的 Kotlin 入口。两条色带、当前样本、转换数值和文字候选都在这里组织,然后作为 JSON 返回。
fun palette(preset: Int, progress: Double): String {
val (start, end) = pairs[preset.coerceIn(0, pairs.lastIndex)]
val t = if (progress.isFinite()) progress.toFloat().coerceIn(0f, 1f) else 0.5f
val rgb = rgbMix(start, end, t)
val oklab = labMix(start, end, t)
val lab = oklab.toLAB()
val ok = oklab.toOklab()
val black = RGB("#000000")
val white = RGB("#ffffff")
val blackRatio = oklab.wcagContrastRatio(black)
val whiteRatio = oklab.wcagContrastRatio(white)
val useBlack = blackRatio >= whiteRatio
return "{" +
"\"start\":" + quote(start.toHex()) + ",\"end\":" + quote(end.toHex()) +
",\"rgb\":" + quote(rgb.toHex()) + ",\"oklab\":" + quote(oklab.toHex()) +
",\"rgbStrip\":" + strip(start, end, false) + ",\"oklabStrip\":" + strip(start, end, true) +
",\"lab\":" + quote("L ${number(lab.l)} a ${number(lab.a)} b ${number(lab.b)}") +
",\"ok\":" + quote("L ${number(ok.l)} a ${number(ok.a)} b ${number(ok.b)}") +
",\"textColor\":" + quote(if (useBlack) "#000000" else "#ffffff") +
",\"contrast\":" + number(if (useBlack) blackRatio else whiteRatio) +
",\"blackContrast\":" + number(blackRatio) + ",\"whiteContrast\":" + number(whiteRatio) +
",\"percent\":" + (t * 100).roundToInt() + "}"
}
这里特意使用不透明颜色。将来如果加入透明度滑块,需要先计算它与背景合成之后的颜色,再求文字对比度,因为读者看到的是合成后的卡片。当前检查验证了 alpha 在转换中保留;透明色合成可以作为这个调色器的下一项功能。
4. 从共享源码接到页面,再核对每个数值
原库的核心模块共有 48 个 commonMain Kotlin 文件,本次保持原样。鸿蒙部分放在 harmony,可运行的 ArkUI 示例放在原仓库 example/harmony。接入范围为 KMP 核心模块;Android Color、ColorInt 和 Jetpack Compose 属于上游的其他扩展。
| 新增位置 | 为什么需要它 |
|
| 定义 ohosArm64、ohosX64 及共享源集 |
|
| 调用颜色模型、组织输出与设备检查 |
|
| 使用官方编译器生成双架构动态库和头文件 |
|
| 通过 N-API 传递参数、返回字符串并释放资源 |
|
| 接收交互、解析结果并绘制色块 |
环境搭建直接参考 CPF-KMP-CMP 快速开始。这次使用官方 Kotlin/Native 2.2.21-1.1.0 和 API 24 SDK,分别生成 ARM64 与 X64 产物。build-native.py 调用 konanc 编译原生库,DevEco 再完成 N-API、ArkTS 与 HAP 构建。脚本使用参数文件传递公共源集,原来的 Kotlin/JVM 可选注解也能保留在共享源码中。
页面刷新只传预设编号和插值进度。与计算有关的值已经在 Native 侧得到,ArkTS 这一层负责更新界面状态。
private refresh(): void {
this.palette = JSON.parse(native.palette(this.preset, this.amount / 100)) as Palette;
}

切换预设的检查还暴露了一个页面问题:两张当前色块已经更新,下面的色带却仍显示原来的紫金组合。最初把色带封装成按值传数组的 @Builder,内部列表保留了首次传入的数组。只把 key 从位置改成位置与色值的组合,色带仍然没刷新。
最后把两条色带放回页面,直接读取 this.palette.rgbStrip 和 this.palette.oklabStrip,同时让 key 包含位置与色值。下面保留完整的 build():输入事件更新 palette,列表从当前状态取色,颜色变化时对应格子也重新建立。只核对上面的两个数值,会漏掉这条显示链路的问题。
build() {
Scroll() {
Column({ space: 14 }) {
Row() {
Column({ space: 6 }) {
Text('颜色中间站').fontSize(32).fontWeight(FontWeight.Bold).fontColor('#212737')
Text('colormath · 让两条渐变自己说话').fontSize(15).fontColor('#626A7D')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('sRGB / Oklab').fontSize(13).fontColor('#5D36AE')
.padding(12).backgroundColor('#EEE7FC').borderRadius(20)
}.width('100%')
Row({ space: 10 }) {
ForEach(this.presets, (title: string, index: number) => {
Button(title).id(`preset-${index}`).fontSize(14).layoutWeight(1)
.backgroundColor(this.preset === index ? '#593BA4' : '#E8EAF1')
.fontColor(this.preset === index ? '#FFFFFF' : '#404759')
.onClick(() => this.choose(index))
})
}.width('100%')
Column({ space: 8 }) {
Row() {
Text(this.palette.start).fontSize(14).fontColor('#454D60').layoutWeight(1)
Text(`插值位置 ${this.palette.percent}%`).id('progress-text').fontSize(14).fontColor('#593BA4')
Text(this.palette.end).fontSize(14).fontColor('#454D60').layoutWeight(1).textAlign(TextAlign.End)
}.width('100%')
Slider({ value: this.amount, min: 0, max: 100, step: 1 }).id('blend-slider')
.selectedColor('#7954C9').onChange((value: number) => { this.amount = value; this.refresh(); })
Row({ space: 8 }) {
ForEach(this.amounts, (value: number) => {
Button(`${value}%`).id(`amount-${value}`).fontSize(12).height(28).layoutWeight(1)
.backgroundColor('#F0EDF8').fontColor('#593BA4')
.onClick(() => { this.amount = value; this.refresh(); })
})
}.width('100%')
}.padding(16).backgroundColor('#FFFFFF').borderRadius(16).width('100%')
Row({ space: 14 }) {
Column({ space: 8 }) {
Text('直接在 sRGB 中插值').fontSize(14).fontColor('#4B5263')
Blank().width('100%').height(72).backgroundColor(this.palette.rgb).borderRadius(12)
Text(this.palette.rgb).id('rgb-result').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#252C3D')
}.layoutWeight(1).padding(16).backgroundColor('#FFFFFF').borderRadius(16)
Column({ space: 8 }) {
Text('转为 Oklab 后插值').fontSize(14).fontColor('#4B5263')
Blank().width('100%').height(72).backgroundColor(this.palette.oklab).borderRadius(12)
Text(this.palette.oklab).id('oklab-result').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#252C3D')
}.layoutWeight(1).padding(16).backgroundColor('#FFFFFF').borderRadius(16)
}.width('100%')
Column({ space: 9 }) {
Text('sRGB:21 个原库插值样本').fontSize(13).fontColor('#555E71').width('100%')
Row() {
ForEach(this.palette.rgbStrip, (color: string, index: number) => {
Blank().id(`rgb-sample-${index}`).backgroundColor(color).height(34).layoutWeight(1)
}, (color: string, index: number) => `${index}:${color}`)
}.id('rgbStrip').width('100%').borderRadius(8).clip(true)
Text('Oklab:相同端点、相同采样位置').fontSize(13).fontColor('#555E71').width('100%')
Row() {
ForEach(this.palette.oklabStrip, (color: string, index: number) => {
Blank().id(`oklab-sample-${index}`).backgroundColor(color).height(34).layoutWeight(1)
}, (color: string, index: number) => `${index}:${color}`)
}.id('oklabStrip').width('100%').borderRadius(8).clip(true)
}.padding(16).backgroundColor('#FFFFFF').borderRadius(16).width('100%')
Row({ space: 14 }) {
Column({ space: 8 }) {
Text('当前 Oklab 样本的数值').fontSize(14).fontColor('#444D60').width('100%')
Text(`LAB ${this.palette.lab}`).fontSize(13).fontColor('#3B455A').width('100%')
Text(`Oklab ${this.palette.ok}`).fontSize(13).fontColor('#3B455A').width('100%')
}.layoutWeight(1)
Column({ space: 7 }) {
Text('自动选择黑 / 白字').fontSize(13).fontColor(this.palette.textColor)
Text(`对比度 ${this.palette.contrast.toFixed(2)}:1`).id('contrast-result')
.fontSize(18).fontWeight(FontWeight.Bold).fontColor(this.palette.textColor)
}.padding(20).backgroundColor(this.palette.oklab).borderRadius(12)
}.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(16)
Row() {
Text(this.checks).id('checks-result').fontSize(14).fontColor('#414B61').layoutWeight(1)
Button('验证颜色计算').id('run-checks').backgroundColor('#593BA4').fontSize(14)
.fontColor('#FFFFFF').onClick(() => {
const result = JSON.parse(native.runChecks()) as Checks;
this.checks = `原库检查 ${result.passed}/${result.total} 通过`;
this.details = result.detail;
})
}.width('100%')
if (this.details.length > 0) {
Text(this.details).fontSize(13).fontColor('#334158').fontFamily('monospace').lineHeight(21)
.padding(16).backgroundColor('#E7ECF5').borderRadius(12).width('100%')
}
Text('显示前转为 sRGB 并裁剪超出色域的分量;本例使用不透明颜色。')
.fontSize(12).fontColor('#6D7483').width('100%')
}.padding(22).width('100%').constraintSize({ maxWidth: 1040 })
}.width('100%').height('100%').backgroundColor('#F4F4F8')
}
返回字符串也有所有权。N-API 先创建 ArkTS 字符串,再调用生成头文件中的 DisposeString 释放 Kotlin 返回值,避免每次拖动滑块都遗留一份 Native 字符串。
static napi_value ReturnString(napi_env env, const char* text) {
napi_value value;
napi_create_string_utf8(env, text ? text : "", NAPI_AUTO_LENGTH, &value);
if (text) colormath_symbols()->DisposeString(text);
return value;
}
数值检查也跟着这些操作走:HEX 解析逐字符比较;LAB、Oklab 往返转换使用每个分量不超过 0.002 的误差范围;插值核对起点、终点和红蓝中点;对比度核对黑白 21:1 与相同颜色 1:1。另外检查无效 HEX、进度越界、透明度保留和 21 项色带输出。
在 API 24 的 MatePad Edge ARM64 真机和 x86_64 模拟器上,使用同一份最终签名 HAP 安装、启动并完成这些操作,两端原生检查均返回 14/14。切换三组预设时,除了核对 HEX 文本,还从实际截屏中采样两条色带的首尾格,确认显示端点随预设一起变化;截屏压缩带来的轻微色差使用 12 个色阶的容差。紫金 0% 与 100%、黑白 50%、红蓝 25% 的数值也分别与预期结果对照。
下面是模拟器上的同一组操作。可以结合色值观察变化,再点“验证颜色计算”查看每一项结果。


5. 把这套调用带进自己的页面
这个示例可以直接抽出两部分:Kotlin 负责颜色模型与运算,ArkTS 负责组件状态和显示。接到主题编辑器时,可以把三组预设换成用户输入;接到图表时,可以根据数据采样渐变。具体使用前,先定下输入空间、插值空间和屏幕输出空间,随后再决定怎样生成最终的 HEX。
我会保留两条色带的比较和设备端数值检查。前者方便挑选配色,后者便于发现数据传递或平台接入的问题。这个版本覆盖了转换、插值和黑白文字比较;要进一步用于 HDR/P3 设备或跨屏显示,还需要在相应设备上补充验证。
源码收录在 鸿蒙跨平台三方库适配合集,本例对应 libraries/colormath。转换和插值的更多用法可以继续查 colormath 官方文档,鸿蒙平台资料来自 CPF-KMP-CMP。
更多推荐


所有评论(0)