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