本文记录 kmp-kolor-picker 接入 OpenHarmony 的完整过程,覆盖取色器组件库盘点、Compose UI 隔离、无 Compose 颜色引擎提取、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

kmp-kolor-picker 是基于 Compose Multiplatform 的 HSV 取色器组件库:一行 KolorPicker(initialColor, onColorSelected) 得到完整取色器——左侧亮度/饱和度二维选色区、中间竖直色相滑轨、右侧带棋盘底的透明度滑轨,拇指样式与尺寸由 PickerConfig / TrackConfig 配置。本次适配与 KStore 的"真编译整库"路线(kotlinx ohos 制品线补齐后存储引擎原样编译)恰好构成另一极:上游 7 个源文件 100% 是 Compose UI,连 HSV 数学都以扩展函数挂在 androidx.compose.ui.graphics.Color 上。因此走的是社区早期已过审的"内核提取"路线(qrose / haze / PreCompile 模式)——把颜色引擎移植为 Compose-free 的 Kolor 模型编译进 OpenHarmony KLIB,把 7 个 Composable 原样隔离到 composeMain 不参与 ohos 编译,交互由 ArkTS 重建、每一次颜色决策都过 C ABI。引擎不漂移不靠口头保证:13 项 JVM 参考测试钉住上游公式,其中 200 组随机采样证明上游"两层渐变叠加"与 fromHsv 逐像素等价——这是鸿蒙侧光栅化的数学依据。

项目地址: AtomGit/oh-tpc/kmp-kolor-picker.git

上游仓库: krizzu/kmp-kolor-picker

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

kmp-kolor-picker 为 Compose Multiplatform 应用提供"选颜色"这一件事的完整答案:亮度/饱和度二维选色区把 HSV 的 S/V 两轴摊平成拖拽平面,色相轨给出 0°–360° 全光谱,透明度轨叠加在棋盘底上直观察看 alpha,选中的 Color 实时回调。上游把交互映射做成了可描述的模型——选色区"x = 饱和度、1 − y = 明度"、色相轨"顶部 0° 底部 360°"、透明度轨"顶部 alpha=1 底部 alpha=0"——这套映射不依赖 Compose 本身。KMP 项目把这些放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要取色能力(主题色设置、画笔调色、品牌色微调),但上游构建(Kotlin 2.2.20 / Gradle 8.13 / AGP 8.12.3 / Compose 1.9.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示滑块,却无法证明颜色引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行,更无法证明两端选出的颜色逐位一致。适配需要同时验证颜色引擎、光栅化、Native 动态库、C ABI、N-API、ArkUI 交互页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游根构建带 AGP / Compose Compiler / spotless / vanniktech publish / dokka 插件,与鸿蒙工具链无法共存于一个 Gradle 构建,配置期即失败。
实现全站在 Compose 上7 个源文件全是 @Composable + Canvas + pointerInput,compose.foundation / compose.ui 没有 ohos 制品;HSV 数学挂在 Color 扩展上,Color 背后是整个 colorspaces 体系。
交互语义跨不了桥Composable 状态、手势回调、Brush 渐变是 Compose 侧概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:颜色引擎的提取与 ohos 编译、根构建替换(cmp_qrose 模式)、C ABI/N-API 桥接层和 ArkTS 交互重建。颜色语义完全由上游公式负责(逐行移植而非重写),ArkTS 只负责手势、布局和展示。

1.2 库提供的能力

Demo 提供一个真实的取色会话,对应桥接的全部入口(6 种 kind):

  • 选色(pick):二维选色区归一化坐标 (x, y) + 当前色相 → 颜色全字段。x = 饱和度,明度 = 1 − y,与上游 BrightnessPicker.positionToColor 同一映射;
  • 颜色信息(info):#RRGGBB / #AARRGGBB 或 h/s/v 输入 → hex / argb / r / g / b / a / h / s / v 全字段;
  • 色相色阶(hueStops):N 档全光谱颜色(上游 360 档 colorList 的采样密度),供 ArkTS 渲染色相轨渐变;
  • 透明度色阶(alphaStops):基于任意颜色生成 alpha 从 1 到 0 的 N 档颜色,供透明度轨叠加在棋盘底上;
  • 光栅化(square):KN 侧按上游模型逐像素生成选色区 PNG(Base64),展示"上游模型直接出图"的能力;
  • 随机色(random):四通道全随机,复刻上游 sample-app 的 Randomize 按钮。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文(引擎 require 文案原样回传)。
hex / argb6 位大写 RGB 与 8 位 ARGB(argb 量化与 Compose toArgb() 同款,跨端逐位一致)。
r / g / b / a0–255 通道分量。
h / s / vHSV 分量(hue 0–360)。
stops渐变色阶数组(hueStops / alphaStops)。
pngBase64 / width / height / bytes光栅化 PNG 产物(square)。

引擎侧完整能力(HSV ↔ RGB、色相角、360 档色相表、hex 格式化与解析)全部在 commonMain 里随编译进入 .so,JVM 侧保留 13 项参考测试交叉验证,证明 ohos .so 里执行的颜色逻辑没有漂移。

1.3 实现适配

维度要求
代码复用颜色数学从上游 ColorExt.kt 逐行移植进 Kolor.kt(连 require 报错文案与 when (M) 的 float 分支语义都保持一致);7 个 Compose 文件原样隔离不改动。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);sample-app(Android/iOS/wasm Compose 示例)与 iosApp 留盘不进构建。
桥接稳定使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律引擎零第三方依赖(上游的 Compose 依赖随 UI 一起隔离),工具链直接落在社区钉法 2.2.21-1.0.0 线。
UI 完整三 Tab 覆盖全部桥接入口:选色器(交互)、转换(全字段)、光栅化(PNG + 渐变条)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现,也不假装是。取色器是交互组件,不能像条码那样整张吐 PNG——静态背景(选色方形)走 KN 光栅化,高频交互(拖动解算)走同步 JSON,这是交互类 CMP 库过桥的参考形状。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认上游 7 文件全 Compose、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:桥协议建模     ── 交互模型拆解为 6 个原子 kind、JSON 契约、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、光栅化与 PNG、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 三 Tab 页面、手势归一化、PixelMap 生命周期、状态行
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
kolor-picker/                          上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
kolor-picker/src/commonMain/           新增:Kolor.kt(无 Compose 颜色引擎,全仓唯一新增库源文件)
kolor-picker/src/commonTest/           新增:13 项参考测试(上游无测试)
kolor-picker/src/composeMain/          上游 7 个 Compose 文件原样隔离(不进任何源集)
example/nativeApp/                     Kotlin/Native 动态库(Bridge + Rasterizer + PngEncoder + MiniJson)
example/harmonyApp/                    DevEco Stage、CMake N-API 和 ArkTS 三 Tab 页面
README.OpenHarmony.md / _CN.md         鸿蒙适配说明(中英双份)
README.upstream.md                     上游原文保存
sample-app/ iosApp/                    上游 Compose 示例,留盘不进 settings

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :kolor-picker 与 :example:nativeApp,rootProject.name = "ohos_kmp-kolor-picker"。上游的 spotless / dokka / vanniktech 发布 / AGP classpath / Compose Hot Reload 插件全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Eclipse Temurin,org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 8.13;2.2.21 线要求 8.14.1,与 qrose / Highlight 同款)
依赖引擎零第三方依赖(无 kotlinx、无 Compose——UI 已隔离)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.kolorpicker
export JAVA_HOME="/path/to/jdk-21"    # Windows 上已由 gradle.properties 钉死
java -version
1.3 源码依赖度盘点
上游文件内容依赖形态处置
KolorPicker.kt组件组合@Composable + Row/Spacer隔离 composeMain
components/BrightnessPicker.kt二维选色区 + 映射Canvas + pointerInput + Brush隔离 composeMain(映射提取进桥)
components/HueTrack.kt色相轨Canvas + verticalGradient隔离 composeMain(渐变提取为 hueStops)
components/AlphaTrack.kt透明度轨Canvas + 棋盘 + 渐变隔离 composeMain(渐变提取为 alphaStops)
components/ThumbCanvas.kt拇指绘制Canvas + drawRoundRect隔离 composeMain
config/Configs.ktPickerConfig / TrackConfigDp / DpSize / PaddingValues隔离 composeMain(纯 UI 参数不跨桥)
ext/ColorExt.ktHSV 数学Color 扩展 + toArgb()逐行移植进 Kolor.kt

与 KStore"全库只有 1 个 expect、补 8 行 actual"相对照,本库的适配面是一个引擎文件:上游把数学写在 Color 的扩展上,扩展接收者是 Compose 类型,只能连着公式一起搬家、换宿主。Kolor 与 Color 同构——同样四个 sRGB float(0f…1f),argb 用 Compose toArgb() 同款的 (v * 255f + 0.5f).toInt() 打包——表示层不换语义,跨端 hex 才能逐位一致。

1.4 页面边界
标题区          KOLORPICKER · OPENHARMONY / 副标题(CMP HSV 取色器 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换        选色器 / 转换 / 光栅化
选色器 Tab      随机颜色按钮、hex 显示、alpha 显示、显隐开关、预览块
                SV 方形(240×240,KN 光栅化 PNG 背景 + 白圈拇指拖动)
                色相轨(36px 竖直渐变 + 拇指)、透明度轨(棋盘底 + 渐变 + 拇指)
                状态行(最近一次 kind 与结果)
转换 Tab        hex 输入框、h/s/v 三滑杆 → info 全字段展示、随机按钮、预览块
光栅化 Tab      色相滑杆 + 128/256/512 尺寸档 → square PNG(宽高/字节/耗时)
                hueStops 水平色相条、alphaStops 棋盘透明度条

页面启动即处于可用状态:初始 h=340 / x=0.86 / y=0.09,对应上游 sample-app 默认色 #E92063(Color(233, 32, 99))。

第 2 阶段:目标与依赖打通

2.1 工具链钉版(为什么是 2.2.21-1.0.0)

KStore 那次必须升到 Kotlin 2.4.20-0.1.0-07,是被 coroutines 制品线的 stdlib 元数据顶上去的。本库恰好相反:提取出的 Kolor 引擎零第三方依赖(上游唯一的依赖 Compose 已随 UI 一起隔离出去),没有任何制品的元数据牵着版本走——直接落社区已过审的钉法 2.2.21-1.0.0(qrose / haze / Highlight 同款),Gradle 相应钉 8.14.1(上游 8.13 同代微调)。上游源码本来就是 Kotlin 2.2.20 编的,2.2.21 与之同代,源码级兼容成本为零。

settings.gradle.kts 把 eazytec 仓库写在最前:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_kmp-kolor-picker"

include(":kolor-picker")
include(":example:nativeApp")
2.2 编译提取的引擎

kolor-picker/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

gradle/libs.versions.toml 只剩一行 kotlin 版本;根构建只剩版本号(2.1.0-ohos.1)与"全局禁用 Ohos*Test* 任务"两条纪律。实测:

./gradlew.bat :kolor-picker:compileKotlinOhosArm64 :kolor-picker:compileKotlinOhosX64   # BUILD SUCCESSFUL

引擎文件一个没动公式,7 个 Compose 文件一个字节不改地躺在 composeMain。

2.3 Kolor 引擎与 Compose Color 的同构

移植的三条纪律:

  1. 表示同构:data class Kolor(red, green, blue, alpha) 四个 sRGB float,与 Compose Color 内部表示一致;
  2. 量化同款:argb 属性用 (v * 255f + 0.5f).toInt() 位移打包,与 Compose Color.toArgb() 逐位一致——toHex() 的输出才能和 Android 端完全相同;
  3. 公式逐行:toHsv / fromHsv / toHueDegree / hueDegreeToKolor / colorList 从上游 ColorExt.kt 逐行搬过来,连 require 的报错文案、when (M) 的 float 相等分支、((hue + 360f) % 360f).coerceIn(0f, 360f) 的闭合写法都保持原样。

新增的只有桥需要的 I/O 面:fromHex(#RRGGBB / #AARRGGBB 解析,非法返回 null)、toArgbHex(8 位大写)、fromChannels(0–255 整数通道)。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.kborowy.colorpicker.Kolor(提取引擎,commonMain 编译)
        ↓ toHsv / fromHsv / hueDegreeToKolor / colorList / toHex
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API kolorPickerRender()
        ↓
ArkUI Index.ets(三 Tab)

请求与响应:

请求 : {"kind":"pick","h":340,"x":0.86,"y":0.09}
       {"kind":"info","color":"#E92063"}        // 或 {"kind":"info","h":..,"s":..,"v":..}
       {"kind":"hueStops","stops":13}
       {"kind":"alphaStops","color":"#E92063","stops":9}
       {"kind":"square","h":210,"size":256}
       {"kind":"random"}
响应 : {"ok":true,"kind":"pick","hex":"#E31459","argb":"#FFE31459",
       "r":227,"g":20,"b":89,"a":255,"h":338.4,"s":0.91,"v":0.89}
       {"ok":true,"kind":"square","hue":210,"width":256,"height":256,
       "bytes":262488,"pngBase64":"..."}
       {"ok":true,"kind":"hueStops","stops":["#FF0000",...,"#FF0000"]}
       {"ok":false,"error":"..."}

两个关键设计:其一,交互模型拆成 6 个原子操作而不是一个大杂烩——选色是高频微秒级调用(拖动每帧触发),光栅化是低频毫秒级调用(色相变化时),拆开才能分别对待(后者需要节流);其二,输入解析有明确优先级——color 字段优先,其次 h/s/v 三字段齐全时按 HSV 解析,否则落到 sample-app 默认色 #E92063。

3.2 上游 Compose API 与桥的对照
上游(Compose)鸿蒙桥说明
Color.toHsv()info 返回 h/s/v同一公式,JVM 测试对照
Color.Companion.fromHsv(h, s, v)info 输入 h/s/v;pick 内部调用require 越界转 JSON error
Color.toHueDegree()info 返回 h色相角 0–360
hueDegreeToColor(deg)hueStops 的每一档上游 colorList 同源
Color.Companion.colorList(360 档)hueStops 默认 13 档,可到 64档数是采样密度,不是重新算色
Color.toHex()hex 字段6 位大写(桥补 # 前缀)
BrightnessPicker.positionToColorpick + h,x,yx=饱和度,1−y=明度,桥内钳 0…1
BrightnessPicker 两层渐变背景square PNGKN 逐像素 fromHsv,等价性有测试
HueTrack 竖直渐变hueStops顶部 0° 底部 360°,方向与上游一致
AlphaTrack 棋盘 + 渐变alphaStops + ArkTS 棋盘alpha 1→0,棋盘是 UI 惯例留在 ArkTS
sample 的 Color.random()randomRGBA 全随机
TrackConfig / PickerConfigArkTS 尺寸纯 UI 参数,不跨桥

这些映射必须来自上游自己的实现,不要在 ArkTS 里手写"另一套 HSV"——手写会和 Android/iOS 端选出的颜色对不齐,CMP 适配就失去意义。

3.3 错误通道

导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("KolorPickerRender")
fun kolorPickerRender(request: String): String {
    return try {
        /* kind 分发 */
    } catch (t: Throwable) {
        """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
    }
}

非法 hex(fromHex 返回 null)、HSV 越界(引擎 require)、参数缺失,全部走同一个 ok:false 通道,ArkTS 侧统一渲染,HAP 不崩。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libkolorpicker_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoskolorpicker.so)
  │ KolorPickerRender
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制颜色语义,所以选择 C ABI + JSON——与 qrose / Highlight / KStore 同一条纪律。

4.2 Kotlin/Native 导出函数
@CName("KolorPickerRender")
fun kolorPickerRender(request: String): String   // 6 种 kind 一口进
C ABI 符号ArkTS 方法覆盖内核
KolorPickerRenderkolorPickerRender(req)fromHsv / toHsv / toHueDegree / hueDegreeToKolor / colorList / toHex / fromHex 全部入口
4.3 光栅化:先证等价,再写像素(本适配的数学难点)

上游 BrightnessPicker 的背景是两层 Compose Brush 叠加:横向 White→纯色相、纵向 Transparent→Black。要在 KN 里"复刻"它,正确姿势不是实现两层渐变混合,而是先证明:

lerp(lerp(white, hue, x), black, y)  ==  fromHsv(hue, s = x, v = 1 - y)   (对纯色相恒等)

横向层是 sRGB 线性插值 1 + x*(hue−1),纵向层乘 (1−y);fromHsv 的通道公式是 v*(s*f + 1 − s),其中 f 是该通道在当前扇区的三角波——对纯色相 f 恰好等于该通道归一化值,两式恒等。KolorTest.brightnessSquareGradientEquivalence 用 200 组随机 (hue, x, y) 采样、1e-4 容差把这个等价性钉死在测试里,然后 Rasterizer.brightnessSquare 才敢直接逐像素调 fromHsv:

fun brightnessSquare(hue: Float, size: Int): RasterImage {
    val n = size.coerceIn(32, 1024)
    val pixels = IntArray(n * n)
    for (y in 0 until n) {
        val value = 1f - (y.toFloat() / (n - 1).coerceAtLeast(1))
        for (x in 0 until n) {
            val saturation = x.toFloat() / (n - 1).coerceAtLeast(1)
            pixels[y * n + x] = Kolor.fromHsv(hue, saturation, value).argb or 0xFF000000.toInt()
        }
    }
    return RasterImage(n, n, PngEncoder.encodeArgb(pixels, n, n))
}

PNG 编码走 zlib stored 块(零依赖、不压缩),256×256 RGBA 约 256 KB,Base64Helper.decodeSync + image.createImageSource 一次出 PixelMap。

4.4 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"kolorPickerRender", nullptr, NapiKolorPickerRender, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoskolorpicker_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoskolorpicker_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。API 26 的 NAPI 头把 napi_get_value_string_utf8 缓冲区标成非 const,先量长度、开 std::vector<char>、拷完再构造 std::string。

4.5 CMake 和类型声明
add_library(ohoskolorpicker SHARED IMPORTED)
set_target_properties(ohoskolorpicker PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(kolorpicker_napi SHARED napi_init.cpp)
target_link_libraries(kolorpicker_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoskolorpicker)

OHOS_ARCH 为空或 libohoskolorpicker.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const kolorPickerRender: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 统一桥调用与手势解算
private callBridge(kind: string, extra: Record<string, Object>): BridgeResult {
  const req: Record<string, Object> = { 'kind': kind };
  // 合并 extra → kolorPickerRender(JSON.stringify(req)) → JSON.parse
}

onTouch 方形/滑轨 → 归一化坐标 (px, py) →
  this.callBridge('pick', { 'h': this.hue, 'x': this.px, 'y': this.py })

拖动的每一帧都过一次 C ABI——单次颜色解算微秒级,直接同步调用即可跟手;只有 square 的 PNG 重生成(色相变化触发,毫秒级)需要节流兜底。拇指组件 HitTestBehavior.None,保证触摸坐标落在轨道上而不是被拇指吃掉。

5.2 页面交互与生命周期

选色器 Tab 复刻上游 sample-app 的全部行为:hex 与 alpha 实时显示(alpha 两位小数格式,与上游 alphaFormatted 一致)、预览块、「随机颜色」(random)、「显示/隐藏」开关(收起整个选色区只留信息区,对应上游 visible)。转换 Tab 的 hex 输入与 h/s/v 三滑杆双入口都走 info。光栅化 Tab 展示 128/256/512 三档尺寸的 bytes 与耗时。

PixelMap 纪律:色相每变一次就重生成一张方形背景,旧的 pixelMap.release() 再换新,不释放会涨内存。

5.3 对比度和错误边界

暗色背景 #202230 仿上游 sample;ok:false 时错误原文进状态行。Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行,页面不崩。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoskolorpicker)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       ├── Bridge.kt                         @CName + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
│       ├── Rasterizer.kt                     亮度方形逐像素 fromHsv
│       └── PngEncoder.kt                     zlib stored PNG
└── harmonyApp/
    ├── build-profile.json5                   signingConfig "default"(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.kolorpicker、versionName 2.1.0-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohoskolorpicker_api.h
        ├── src/main/cpp/types/libkolorpicker_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
./gradlew.bat :kolor-picker:jvmTest                                    # 13 项交叉验证
./gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.kolorpicker -m entry

四、完整代码对照

4.1 整体架构

com.kborowy.colorpicker.Kolor(提取引擎,上游公式逐行移植)
    │ toHsv / fromHsv / toHueDegree / hueDegreeToKolor / colorList / toHex
Bridge.kt + MiniJson
    │ KolorPickerRender(6 种 kind)
Rasterizer + PngEncoder
    │ fromHsv 逐像素 → PNG → Base64
libkolorpicker_napi.so
    │ kolorPickerRender
Index.ets
    │ JSON parse + @State 更新 + PixelMap
    ▼
ArkUI 真机页面(三 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_kmp-kolor-picker、sample-app 移出
gradle/libs.versions.toml只剩 Kotlin 2.2.21-1.0.0 一行
build.gradle.kts(根)版本 2.1.0-ohos.1、全局禁用 Ohos*Test* 任务
kolor-picker/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
kolor-picker/src/commonMain/.../Kolor.kt无 Compose 颜色引擎(唯一新增库源文件)
kolor-picker/src/commonTest/.../KolorTest.kt13 项参考测试(含等价性证明)
kolor-picker/src/composeMain/**上游 7 文件原样隔离(不编译)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、6 种 kind、MiniJson、错误通道
nativeApp/.../Rasterizer.kt / PngEncoder.kt亮度方形光栅化 / zlib stored PNG
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(23 节)

4.3 关键 API 对照

层次API作用
引擎Kolor.toHsv() / fromHsv(h, s, v)HSV ↔ RGB(上游公式逐行移植)
引擎Kolor.toHueDegree() / hueDegreeToKolor(deg)色相角 ↔ 纯色相
引擎Kolor.colorList360 档全光谱(上游同源)
引擎Kolor.toHex() / toArgbHex() / fromHex(str)hex 三件套(量化与 Compose 对齐)
NativeKolorPickerRenderC ABI JSON 导出(6 种 kind)
光栅化Rasterizer.brightnessSquare(hue, size)上游选色区模型逐像素 PNG
N-APIkolorPickerRender(req)向 ArkTS 暴露能力
ArkTScallBridge(kind, extra)触发解算并刷新 UI

4.4 ArkTS 与 Kotlin 的边界

// ArkTS:拖动 SV 方形,每帧解算一次颜色
const res = JSON.parse(kolorPickerRender(JSON.stringify({
  kind: 'pick', h: this.hue, x: this.px, y: this.py
}))) as BridgeResult;
// res.hex === '#E31459',res.h/s/v 为 HSV 分量
// Kotlin:桥内即上游公式的直接调用
val saturation = json.float("x").coerceIn(0f, 1f)
val value = (1f - json.float("y")).coerceIn(0f, 1f)
return Kolor.fromHsv(json.float("h"), saturation, value)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:内核提取而不是真编译整库

KStore 真编译整库的前提是 kotlinx ohos 制品线补齐;本库挡路的不是依赖制品,而是语言级 UI 框架——7 个文件全是 @Composable,compose.foundation / compose.ui 在鸿蒙工具链里不存在。路线选择必须基于障碍的性质:依赖缺失走换线,UI 框架缺失走"模型提取 + ArkTS 重建"。提取的边界划在"公式"上——凡是纯数学(HSV、色相、hex)全部带走,凡是 UI(Canvas、手势、Dp 配置)全部留下。留下的 7 个文件原样躺在 composeMain,社区 CMP 工具链对齐后可以整体接回。

决策 2:Kolor 与 Compose Color 同构,而不是换一套表示

提取引擎时最容易犯的错是"顺手优化"——用 Int 打包代替四 float、用 0–255 通道代替 0–1 归一化。任何一处表示变更都会让 toHex() 的量化口径漂移,跨端颜色就不一致。Kolor 保持四 sRGB float + Compose toArgb() 同款量化,JVM 测试里 #E92063 → h=340 / s=(233−32)/233 / v=233/255 这类断言就是两端一致的契约。

决策 3:交互模型 JSON 化,6 个原子 kind

取色器是连续交互组件,不能整图吐 PNG(不跟手),也不能让 ArkTS 自算颜色(模型双源必然漂)。拆成 6 个原子操作后按频率分治:pick 微秒级每帧调用,square 毫秒级节流调用,hueStops / alphaStops 只在颜色变化时刷新。PickerConfig / TrackConfig 是纯 UI 参数,不跨桥——要透出时加 JSON 字段即可,C 签名不变。

决策 4:光栅化走"等价性证明"路线

复刻上游两层渐变叠加会引入两倍浮点误差且未必对齐;正确姿势是先在测试里证明"两层渐变 ≡ fromHsv"(200 组随机采样、1e-4 容差),再让光栅化直接逐像素调 fromHsv。等价性写进测试,以后任何人改 Rasterizer 或 Kolor,回归立刻现形。

决策 5:新增参考测试而不是"相信公式"

上游仓库没有任何测试。本次在 commonTest 新增 13 项参考测试:六纯色锚点、中间扇区手工核算、500 组 HSV 随机往返、360 档色相回环、hex 大小写与解析、ARGB 往返、渐变等价性。JVM 与 ohos 编同一份 commonMain,JVM 全绿即"提取没漂"的闸门——对没有上游测试的库,参考测试就是唯一可信的交叉验证。

六、测试与验证

6.1 测试环境

本次验证使用 Windows 11(x86_64)、JDK 21(Eclipse Temurin)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew.bat :kolor-picker:jvmTest

13 项用例全部通过(新增参考测试):

测试钉住什么
fromHsvPrimaryAndSecondary / fromHsvMidSector / fromHsvRejectsOutOfBounds六纯色 + 中间扇区手工核算值 + require 越界文案
toHsvPrimaryAndSecondary / toHsvSampleColor六纯色 + sample 默认色 #E92063(h=340、s=(233−32)/233、v=233/255)
hsvRoundTrip500 组随机 HSV 量化往返(h<0.5°、s<0.02、v<0.01)
hueDegreeConversions / hueDegreeToKolorMatchesToHueDegree / colorListCovers360Hues色相角锚点、360 档全回环、灰度=0
hexFormatting / hexParsing / argbRoundTrip6/8 位大写、解析容错、200 组 ARGB 往返
brightnessSquareGradientEquivalence两层渐变叠加 ≡ fromHsv(200 组随机、1e-4 容差)——光栅化的数学依据

6.3 HAP 验证

:kolor-picker:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(copyKnSo* 回填三件套)
llvm-nm -D: KolorPickerRender / libohoskolorpicker_symbols 均为全局 T 符号
KN so: arm64-v8a 约 3.80MB、x86_64 约 3.50MB(debugShared 含 Kotlin/Native 运行时)
libc++_shared.so: 约 1.85MB / 1.73MB(随 copyKnSo* 与 KN so 同 triple 拷入)
BuildNativeWithCmake: finished(双 ABI 编出 libkolorpicker_napi.so)
CompileArkTS / PackageHap: finished
install bundle successfully / start ability successfully

6.4 功能用例

  1. 启动即处于默认状态:SV 方形为 KN 光栅化 PNG(h=340),拇指位于 x=0.86 / y=0.09,对应上游 sample 默认色 #E92063;
  2. 拖动 SV 方形拇指:hex 与预览实时刷新;拖到左上角变 #FFFFFF(s=0, v=1)、左下角变 #000000(v=0)、右上角为当前色相纯色;
  3. 拖动色相轨:方形背景整张重生成、全局色相偏移,拇指的 (x, y) 位置保持;
  4. 拖动透明度轨:argb 的 AA 段与预览透明度变化,hex 不变(RGB 六位与上游 toHex 一致);
  5. 真机实测选中 #E31459,状态行显示 生成成功 kind=pick #E31459——每次拖动都是真实的 Native 解算(见下节效果图);
  6. 「随机颜色」四通道全随机;「显示/隐藏」收起整个选色区只留信息区(对应上游 visible);
  7. 转换 Tab:输入 #E92063 显示 r=233 / g=32 / b=99 / h=340 / s≈0.863 / v≈0.914(与 JVM 断言同值);输入非法串显示 ok:false 的 error,页面不崩;
  8. h/s/v 三滑杆联动:h=120 / s=1 / v=1 时 hex 为 #00FF00;
  9. 光栅化 Tab:128 / 256 / 512 三档尺寸的 bytes 分别约 64KB / 256KB / 1MB(未压缩 RGBA 的确定值),状态行显示毫秒级耗时;色相条首尾都是 #FF0000(0° 与 360° 闭合),透明度条在棋盘底上从实到透。

6.5 验证结论

颜色引擎、光栅化、Native 动态库、C ABI、N-API、ArkUI 交互页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,选色器三轨联动、hex 与 Android 端逐位一致、随机与显隐按钮、非法输入不崩溃全部通过(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/kmp-kolor-picker.git
cd kmp-kolor-picker
./gradlew.bat :kolor-picker:jvmTest                                     # 13 项交叉验证
./gradlew.bat :kolor-picker:compileKotlinOhosArm64 :kolor-picker:compileKotlinOhosX64   # klib
./gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.kolorpicker -m entry
hdc shell hilog | grep KolorPicker

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让 :kolor-picker 的颜色引擎真正编译成 ohosArm64 / ohosX64 动态库,且公式逐位对齐上游;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游根构建的 AGP / Compose Compiler / spotless / publish 插件必须整体剥离,只留 kotlin-multiplatform apply false——留着任何一个都在配置期失败;
  4. 移植公式时漏掉上游 toHex() 末尾的 .uppercase(),JVM 测试第一轮就抓到(expected #FF0000 but was ff0000)——这正是参考测试存在的意义:人眼会漏,断言不会;
  5. 手工核算期望值同样会错:中间扇区测试最初把 fromHsv 的 m(value − chroma)忘在 blue 通道外,实测 128 才想起上游公式是 Color(r + m, g + m, b + m)——m 加在所有通道上。测试期望值必须按公式代数推,不能凭直觉;
  6. CAdapter 链接期 api.cpp:448 的 non-void 警告是 HarmonyOS Kotlin 生成代码,可忽略,.so 正常链出;
  7. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  8. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  9. ArkTS 侧不要对 libohoskolorpicker.so 写 oh-package 依赖——CAdapter 不会把 @CName 业务符号挂到 ArkTS,类型包只给 libkolorpicker_napi.so;
  10. API 26 的 NAPI 头把 napi_get_value_string_utf8 缓冲区标成非 const,std::string::data() 直接传入编不过,先量长度再开 std::vector<char>;
  11. 拇指组件要 HitTestBehavior.None,否则触摸坐标被拇指吃掉,拖动位置错位;
  12. 生成 PNG 的 PixelMap 必须先 release() 再换新,连续拖色相不释放会持续涨内存。

8.2 已知问题

  • composeMain 里的 7 个上游 Compose 文件未接入鸿蒙构建(等待社区 compose.ui ohos 制品),当前选色器是 ArkTS 重建交互 + 引擎过桥,不是 Compose UI;
  • PickerConfig / TrackConfig(Dp 尺寸、圆角、拇指边框)未跨桥,ArkTS Demo 用自己的尺寸常量;需要时加 JSON 字段即可,C 签名不变;
  • PNG 未压缩(zlib stored),256×256 约 256KB 可接受,1024 档约 4MB 仅在光栅化 Tab 提供;
  • 色相拖动触发整张方形 PNG 重生成(256 档约 6.5 万次 fromHsv,毫秒级),连续拖动依赖 ArkTS 侧节流(约 80ms 一档);
  • Kolor 不含 color space 体系,所有路径均为 sRGB(与上游默认行为一致);
  • android / ios / wasmJs / desktop 目标剥离(源文件留在树里),spotless、publish、dokka 一并剥离,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.kolorpicker 匹配的签名。

8.3 未来优化方向

  • 若社区 compose.ui ohos 制品出现,把 composeMain 接回 commonMain,让 KolorPicker 在 ComposeArkUIViewController 里直接跑,ArkTS 选色器降级为 fallback;
  • 把 square 的 PNG 生成挪到 worker 线程或改为增量重绘,消除节流依赖;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本(uitest + snapshot_display)。

九、总结

9.1 核心难点回顾

上游 kmp-kolor-picker(7 文件全 Compose,数学挂在 Color 上)
    → 路线判定:UI 框架缺失 → 内核提取(qrose 模式),不是换线(KStore 模式)
    → 工具链对齐 Kotlin 2.2.21-1.0.0(引擎零依赖,直接落社区钉法)/ Gradle 8.14.1
    → 根构建替换 + Kolor 引擎(与 Compose Color 同构,公式逐行移植)
    → ohosArm64 / ohosX64 KLIB + 13 项 JVM 参考测试(含渐变等价性证明)
    → 交互模型 JSON 化(6 原子 kind)+ 光栅化(fromHsv 逐像素)
    → C ABI(@CName × 1)+ C++ N-API(DisposeString 纪律)
    → ArkTS 手势归一化 + PixelMap 生命周期
    → ArkUI 真机三 Tab 验收(选色 / 转换 / 光栅化)

9.2 封装层次

  • 引擎层:Kolor 四 sRGB float 表示、HSV / 色相 / hex 数学(上游公式逐行移植,量化与 Compose 对齐);
  • 光栅化层:上游选色区模型逐像素 fromHsv,zlib stored PNG;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、手势归一化、PixelMap 生命周期、状态行与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 路线选择基于障碍性质,不是历史结论:依赖制品缺失走"换线不换版"(KStore),UI 框架缺失走"模型提取 + ArkTS 重建"(qrose / 本库)。同样是 CMP 库,存储引擎的引擎/边界在依赖上,取色器的边界在语言级 UI 上——先盘点障碍再定路线;
  2. 表示同构是"没漂"的前提:提取引擎时保持与原表示(四 float、(v*255+0.5).toInt() 量化、大写 hex)逐位同构,任何"顺手优化"都会在跨端对齐时现形;等价性主张(两层渐变 ≡ fromHsv)必须写进测试,而不是写在注释里;
  3. 没有上游测试就自己造契约:13 项参考测试是本适配唯一可信的交叉验证——其中两项(uppercase 漏加、m 通道错算)正是开发过程中被测试当场抓回来的。交互类库的验收重点落在"同一位置两端同一 hex"这条对齐线上,而不是"页面能拖"。

9.4 适配成果

当前适配已完成(提交 6553d65 鸿蒙适配,52 个文件):

  • kmp-kolor-picker 2.1.0 颜色引擎的双 ABI Kotlin/Native 编译(Kolor.kt 公式逐行移植,上游 7 个 Compose 文件零改动隔离进 composeMain);
  • 1 个 C ABI 符号 + 6 种 JSON kind 和 C++ N-API 包装层(llvm-nm 验证全局 T 符号;KN so 约 3.80MB / 3.50MB 双 ABI);
  • KN 光栅化管线:上游选色区模型逐像素出 PNG,等价性由 200 组随机采样测试钉死;
  • DevEco Stage ArkUI 三 Tab 验收页面(选色器交互、全字段转换、光栅化展示);
  • 13 项 JVM 参考测试全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
  • AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐