☰
渐变中间那一格怎么算?用 colormath 做个鸿蒙调色器
2026/10/14 11:56:07 网站建设 项目流程

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 属于上游的其他扩展。

新增位置

为什么需要它

harmony/build.gradle.kts

定义 ohosArm64、ohosX64 及共享源集

harmony/src/ColorBridge.kt

调用颜色模型、组织输出与设备检查

harmony/build-native.py

使用官方编译器生成双架构动态库和头文件

example/harmony/entry/src/main/cpp

通过 N-API 传递参数、返回字符串并释放资源

example/harmony/entry/src/main/ets/pages/Index.ets

接收交互、解析结果并绘制色块

环境搭建直接参考 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。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询