本文记录 color-picker-dialog 接入 OpenHarmony 的完整过程,覆盖 UI 型 CMP 库的引擎盘点、源码集拆分、真实引擎编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

color-picker-dialog 是基于 Compose Multiplatform 的取色对话框库:给定一个初始 Color,它提供 HSV 面板 / 色相条 / HEX·RGB 双通道输入的完整取色交互,onConfirm 以 HEX 字符串回吐结果,专为持久化(如 SQL)设计;配套 parseColor / colorToHex / getHue / getSaturation / getValue / toArgb / defaultColors 一组纯数学工具。本次适配与社区早期路线(qrose / haze / PreCompose / voyager 的"零依赖内核提取")同源但处境相反:qrose 的编码器本身就是纯 Kotlin,而 color-picker-dialog 五个源文件全部依赖 Compose——连工具层的 ColorExt.kt 都以 Compose Color 值类为签名。因此本适配的关键动作是引擎再表达:把 ColorExt.kt 的全部语义(取整方式、回退策略、HSV 公式、默认色板)逐条平移为 32 位 ARGB Int 上的纯 Kotlin ColorEngine(全仓唯一新增库源码),Compose 对话框源码原样封存进 src/composeMain 不参与 ohos 编译;真实引擎被编译成 OpenHarmony 动态库,ArkUI 页面复刻取色器交互作为验收宿主——页面上出现的每一个颜色值都来自 Native 引擎,ArkTS 不做任何本地颜色数学。

项目地址: AtomGit/oh-tpc/color-picker-dialog.git

上游仓库: isaacjadrey/color-picker-dialog

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

color-picker-dialog 为 Compose 应用提供"选一个颜色、拿到一个 HEX 字符串"这一件事的完整答案:ColorPickerDialog(initialColor, onDismiss, onConfirm) 一行弹窗,内部 SaturationValuePanel 二维取饱和度/明度、HueSlider 取色相、ColorInputBox 支持手输 HEX(输入满 7 位实时反解 HSV,非法输入按上游语义静默回退默认首色)、defaultColors 提供五个预置色板,onConfirm(hex) 直接吐出可落 SQL 的字符串。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套取色与持久化能力,但上游构建(Kotlin 2.3.20 / Compose 1.10.3 / AGP 9.1.0 / Gradle 9.3.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示渐变和按钮,却无法证明上游的颜色引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证颜色引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.3.20 / Gradle 9.3.1 / AGP 9.1.0(com.android.kotlin.multiplatform.library)与鸿蒙工具链无法共存于一个 Gradle 构建。
全库 UI 化五个源文件全部依赖 androidx.compose;连纯数学的 ColorExt.kt 签名也是 Compose Color,不能像 qrose 那样"编码器直接进 commonMain"。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:颜色引擎的 ARGB 再表达与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。取色语义完全由上游原版实现负责,ArkTS 只负责交互、展示与日志。

1.2 库提供的能力

Demo 提供一个真实的取色会话,对应桥暴露的全部入口:

  • 版本握手(version):colorPickerVersion() 返回 fork 版本号,来自真实 Native 调用而非静态文字;
  • 默认色板(defaults):五个预置色由引擎 defaultColors 计算返回,ArkTS 不硬编码;
  • HSV 取色(hsv):面板/色相条每次拖动都以 h/s/v 调引擎,返回全量颜色信息;
  • HEX 解析(parse):手输 HEX 满 7 位触发解析,非法输入回退默认首色(上游 parseColor 语义),响应带 fallback 标记;
  • 确认持久化(Apply Color):onConfirm(hex) 的语义在 Demo 中落为日志记录——确认后的 HEX 字符串即业务侧要持久化的值。

响应(info 对象)包含:

字段作用
ok操作是否成功,失败时 error 带原文。
fallbackparse 输入非法、引擎按上游语义回退时为 true。
hex / argb#RRGGBB 大写字符串 / 32 位 ARGB 整数。
a/r/g/b四通道 0-255 整数。
hue/saturation/valueHSV 三分量(hue 为 0-360 度)。
rgb上游对话框只读 RGB 展示串(如 "30, 111, 206",截断取整与上游一致)。

引擎侧完整能力(HSV↔RGB 扇区插值、hex 解析与格式化、默认色板、取整差异)全部在 commonMain 里随编译进入 .so,JVM 侧保留 jvm() 目标做交叉验证,证明 ohos .so 里执行的颜色逻辑没有漂移。

1.3 实现适配

维度要求
代码复用上游五个源文件零改动:四个 UI 文件整体封存 src/composeMain(非源集、不编译),ColorExt.kt 的语义由 commonMain 新增的 ColorEngine.kt(全仓唯一新增库源码)逐条镜像。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);sample(Android 示例)、apiCheck、Maven 发布链路全部移出构建。
桥接稳定使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI;parse 永不失败(回退语义显式化)。
依赖纪律引擎零外部依赖(连 kotlinx 都不需要),桥内置 MiniJson 扁平解析,so 体积只有 ~3.4MB。
UI 完整单页覆盖上游对话框全部交互面:SV 面板、色相条、HEX/RGB 输入、默认色板、Selected Color、Cancel/Apply、操作日志和版本握手。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。ColorPickerDialog 是 androidx.compose.ui.window.Dialog 上的 Composable,社区 CMP 1.9.2 工具链接不进上游 Compose 1.10;ArkTS Demo 复刻的是交互语义,颜色数值语义全部由 Kotlin 引擎保证。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认"全库 UI 化"处境、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1 降级
第 3 阶段:桥协议建模     ── 5 个 op 的 JSON 契约、info 全量响应、fallback 显式化、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 引擎权威状态机、SV 面板/色相条手势、7 位 HEX 触发、确认链路
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
color-picker-dialog/                    上游取色库(进 ohos 构建,jvm() + ohosArm64 + ohosX64)
color-picker-dialog/src/commonMain/     新增:ColorEngine 纯 Kotlin 引擎(唯一新增库源码)
color-picker-dialog/src/composeMain/    上游五个源文件封存(git mv 保留历史,不编译)
example/nativeApp/                      Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                     DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md          鸿蒙适配说明(中英双份)
sample/                                 上游 Android 示例,留盘不进 settings

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :color-picker-dialog 与 :example:nativeApp,rootProject.name = "ohos_color_picker_dialog"。上游的 vanniktech 发布插件、binary-compatibility-validator、AGP classpath、sample 模块全部随根构建替换剥离——本 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(上游 wrapper 9.3.1 降级;2.2.21 旧线要求)
外部依赖无(引擎零依赖,桥零依赖)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.colorpicker
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
文件依赖形态处置
ColorPickerDialog.ktDialog/material3/pointerInput/icons-extended封存 composeMain
components/SaturationValuePanel.ktCompose Canvas + 手势封存 composeMain
components/HueSlider.ktCompose Canvas + 手势封存 composeMain
components/ColorInputBox.ktmaterial3 BasicTextField封存 composeMain
util/ColorExt.kt唯一 Compose 依赖是 Color 值类(数学本身纯 Kotlin)语义镜像进 commonMain

expect/actual 声明全库为 0——上游本来就是纯 commonMain,这为引擎再表达扫清了障碍。ColorExt.kt 值得单独说明:它的取色数学(max/min 求 HSV、roundToInt 格式化 HEX、toInt 截断的 RGB 展示、非法回退默认首色)一行都不碰 Compose,唯一的耦合是 Color 参数与返回值。把 Color 换成与 Color.toArgb() 逐位同布局的 32 位 ARGB Int,全部语义可以原样平移——这就是整个 fork 在库侧唯一的新增源文件 ColorEngine.kt:

// color-picker-dialog/src/commonMain/kotlin/io/github/isaacjadrey/util/ColorEngine.kt
// 与上游 ColorExt.kt 逐条对照:
// defaultColors → defaultColorsArgb / defaultColorsHex()
// getHue/getSaturation/getValue(Color) → 同名 (argb: Int)
// colorToHex(Color) → argbToHex(argb)(roundToInt + 大写 + padStart)
// parseColor(String) → parseColorArgb(String)(非法回退默认首色)
// Color.hsv(h,s,v) → hsvToArgb(h,s,v)(标准扇区插值,同 Android HSVToColor)
1.4 页面边界
标题区          COLOR PICKER · OPENHARMONY / 版本握手行(KMP 取色对话框引擎 · ohosArm64/ohosX64)
Tab 切换        Pick Color / About
SV 面板         二维渐变面板(底色=引擎纯色相,白→透明横叠,透明→黑纵叠)+ 白色游标拖拽
色相区          HUE 标签 + 当前度数 + 七段彩虹渐变条 + 游标拖拽(0-360)
输入区          HEX CODE 输入框(满 7 位触发解析)/ RGB CODE 只读展示
色板区          五个默认色圆点(来自 op=defaults,选中描白边)
确认区          48vp 色块 + Selected Color + 确认状态 / Cancel / Apply Color 按钮
日志区          带时间戳的操作日志(最新在前,上限 100 条)
关于区          版本握手、桥接协议、分层结构、仓库与社区链接、已知边界

页面启动时先调 colorPickerVersion() 做握手,再取 op=defaults 拿默认色板,最后以初始色 #1E6FCE(上游默认首色)调 op=parse 初始化全部状态。

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

2.1 依赖边界验证(本适配的成败手)

KStore 适配时要先验证社区 Maven 上的 kotlinx ohos 制品线;本库连这一步都省了——commonMain 的引擎和 ohosMain 的桥都是零外部依赖的纯 Kotlin,唯一要解析的"外部制品"就是 HarmonyOS Kotlin 插件本身:

org.jetbrains.kotlin.multiplatform:2.2.21-1.0.0   (eazytec Maven,提供 ohosArm64()/ohosX64())

libs.versions.toml 收敛为两行:kotlin = "2.2.21-1.0.0" + kotlin-multiplatform 插件别名。compose、AGP、vanniktech、validator、material3 全部删除——它们只服务于被剥离的 UI 线与发布线。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

社区批准的 CMP 库钉法有两条线:qrose 线是 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1,KStore 线是 2.4.20-0.1.0-07 + Gradle 9.6.1。本库没有 kotlinx 依赖,选线不受制品元数据约束,按最小工具链风险选 qrose 线:

  • 引擎源码是从 Compose 1.10 库里平移出来的初等数学,不使用任何 2.3/2.4 语法特性,2.2.21 编译零成本;
  • 上游 Gradle 9.3.1 在 2.2.21 旧线上不可用,wrapper 降级 8.14.1(cmp_qrose 实测同款组合);
  • 上游 gradle-daemon-jvm.properties(JETBRAINS daemon criteria)与 8.14 冲突,删除,统一走 gradle.properties 的 org.gradle.java.home 钉 Temurin 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_color_picker_dialog"

include(":color-picker-dialog")
include(":example:nativeApp")
2.3 编译真实引擎

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

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

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

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

    sourceSets {
        commonMain.dependencies {}
    }
}

上游脚本里的 explicitApi()、android target(com.android.kotlin.multiplatform.library + withHostTestBuilder/withDeviceTestBuilder)、iOS framework 三件套、vanniktech mavenPublishing 发布块全部随重写剥离。实测:

./gradlew :color-picker-dialog:compileKotlinJvm                          # BUILD SUCCESSFUL
./gradlew :color-picker-dialog:compileKotlinOhosArm64                    # BUILD SUCCESSFUL
./gradlew :color-picker-dialog:compileKotlinOhosX64                      # BUILD SUCCESSFUL

引擎语义一行没动,只有 ColorEngine.kt 是新的;composeMain 里的五个上游文件一个字节没改。

2.4 上游遗留的处置:sample、apiCheck、发布链路

上游工程有三块"长在旧构建上"的遗留:sample(Android Compose 示例,引用已删除的插件别名)、color-picker-dialog/api/(binary-compatibility-validator 的 dump 基线)、publish.yml + release.sh(Maven Central 发布流)。处置与 cmp_qrose 一致——留盘、不进构建:settings.gradle.kts 移除 :sample,validator 插件随根构建替换消失(api 目录变成纯文档),发布流保留为上游遗迹(只在 release 事件触发,任务已不存在)。CI 收敛为单 job 编译 JVM 目标;ohos 构建本地执行。

第 3 阶段:桥协议建模

3.1 JSON 契约
io.github.isaacjadrey.util/**(引擎,ARGB/HSV/HEX 纯数学)
        ↓ parseColorArgb / hsvToArgb / argbToHex / rgbCode / defaultColorsHex
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API colorPickerDispatch() / colorPickerVersion()
        ↓
ArkUI Index.ets(单页两 Tab)

请求与响应:

请求 : {"op":"version"}
       {"op":"defaults"}
       {"op":"parse","hex":"#1E6FCE"}
       {"op":"hsv","h":210,"s":0.85,"v":0.8}
       {"op":"hex","argb":-14782514}
响应 : {"ok":true,"fallback":false,"hex":"#1E6FCE","argb":-14782514,
        "a":255,"r":30,"g":111,"b":206,
        "hue":212.3876,"saturation":0.8544,"value":0.8078,
        "rgb":"30, 111, 206"}
       {"ok":false,"error":"..."}
3.2 为什么一次调用返回全量 info

三个不对称设计:其一,hsv 的响应是全量颜色信息(hex/argb/三通道/HSV/RGB 串),ArkTS 拿到一次响应就能刷新整页——页面上不存在引擎没算过的颜色值,这是"真适配"与"ArkTS 重写"的分水岭;其二,parse 永不失败,非法输入返回回退色的 info 并标 fallback:true,把上游 parseColor 的静默回退语义显式化,Demo 据此提示"非法输入 → 引擎回退 #1E6FCE"而不是把上游行为藏起来;其三,浮点输出由桥内 num() 手写定点化(4 位小数去尾零),避免 Kotlin/Native Float.toString 的科学计数法混进 JSON。

3.3 错误通道

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

@CName("ColorPickerDispatch")
fun colorPickerDispatch(request: String): String = try {
    dispatch(request)
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、缺 op / hex / h / s / v、不支持的 op,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进日志区。

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

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

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;HarmonyOS CAdapter 也不会把 @CName 函数挂到 ArkTS 模块表(cmp_qrose 已验证),所以必须 CMake 再包一层 NAPI so。

4.2 Kotlin/Native 导出函数
@CName("ColorPickerDispatch")
fun colorPickerDispatch(request: String): String   // 5 个 op 一口进

@CName("ColorPickerVersion")
fun colorPickerVersion(): String = "1.0.0-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖内核
ColorPickerDispatchcolorPickerDispatch(req)parseColorArgb / hsvToArgb / argbToHex / rgbCode / defaultColorsHex / isValidHexColor
ColorPickerVersioncolorPickerVersion()fork 版本握手(真机版本行的数据来源)

MiniJson 只解析扁平 JSON(按引号状态机切顶层字段、剥转义、认数字),不引 kotlinx.serialization——引擎零依赖,桥也零依赖。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"colorPickerDispatch", nullptr, NapiColorPickerDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"colorPickerVersion", nullptr, NapiColorPickerVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscolorpicker_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoscolorpicker_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。

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

add_library(colorpicker_napi SHARED napi_init.cpp)
target_link_libraries(colorpicker_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoscolorpicker)

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

export const colorPickerDispatch: (request: string) => string;
export const colorPickerVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 引擎权威的页面状态机

页面把上游对话框的状态机逐条搬过来,唯一区别是每个转换都过一次 Native:

aboutToAppear(): void {
  this.version = colorPickerVersion();               // 版本握手:真实 Native 调用
  const defs = JSON.parse(colorPickerDispatch('{"op":"defaults"}'));
  this.defaults = defs.colors;                       // 默认色板来自引擎
  this.applyHex(this.initialHex, true);              // 初始色 #1E6FCE → HSV
}

private applyHsv(h: number, s: number, v: number): void {
  const res = JSON.parse(colorPickerDispatch(JSON.stringify({ op: 'hsv', h: h, s: s, v: v })));
  this.hue = h; this.sat = s; this.val = v;
  this.info = res; this.rgbText = res.rgb;
  this.pureHueHex = this.pureHueOf(h);               // 面板底色 = 引擎算的纯色相
  if (this.hexInput.toUpperCase() !== res.hex.toUpperCase()) {
    this.hexInput = res.hex;                         // 上游 LaunchedEffect 的同步规则
  }
}

三处与上游逐字对齐的细节:hex 输入框只在 hexInput.uppercase() != currentHex.uppercase() 时被覆写(用户输入小写不会被强转大写);输入满 7 位(含 #)才触发解析反推 HSV,不足 7 位不动;非法 7 位输入走引擎回退,页面日志记 invalid hex ... → engine fallback。

5.2 页面交互

Pick Color Tab 自上而下:SV 面板(底色 = op=hsv h,1,1 的引擎返回值,上叠白→透明横向饱和度层与透明→黑纵向明度层,白色游标随拖动移动,每次 move 都是一次 op=hsv)、HUE 彩虹条(右侧实时度数)、HEX CODE 输入框 + RGB CODE 只读展示(截断取整与上游一致)、五个默认色圆点(选中描白边)、Selected Color 区(48vp 色块 + 确认状态)、Cancel / Apply Color 双按钮(配色沿用上游硬编码色板:卡片 #111827、确认键 #1E6FCE)、带时间戳的操作日志(最新在前、上限 100 条)。About Tab 展示版本握手行、桥接协议表、ArkTS → NAPI → C ABI → Kotlin 引擎 分层说明与仓库 / 社区链接。

5.3 对比度和错误边界

确认键深蓝 #1E6FCE 白字,Cancel 透明底灰描边,语义与上游按钮层级一致;ok:false 时错误原文进日志区;fallback:true 时状态行提示回退——Native 调用本身抛错(理论不该发生)同样被 try/catch 收进日志。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoscolorpicker)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/io/github/isaacjadrey/colorpicker/
│       └── Bridge.kt                         @CName × 2 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.colorpicker、versionName 1.0.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/libohoscolorpicker_api.h
        ├── src/main/cpp/types/libcolorpicker_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :color-picker-dialog:compileKotlinJvm                       # 引擎 JVM 交叉验证
./gradlew :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 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

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.colorpicker -m entry

四、完整代码对照

4.1 整体架构

io.github.isaacjadrey.util/**(ColorEngine,语义镜像上游 ColorExt.kt)
    │ parseColorArgb / hsvToArgb / argbToHex / rgbCode / defaultColorsHex
Bridge.kt + MiniJson
    │ ColorPickerDispatch / ColorPickerVersion
libcolorpicker_napi.so
    │ colorPickerDispatch / colorPickerVersion
Index.ets
    │ JSON parse + @State 更新
    ▼
ArkUI 真机页面(单页两 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_color_picker_dialog、sample 移出
gradle/libs.versions.toml只留 Kotlin 2.2.21-1.0.0 + KMP 插件
gradle.properties(新增并提交)Temurin 21 路径、version=1.0.0-ohos.1、KN 开关
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
color-picker-dialog/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,零依赖
color-picker-dialog/src/commonMain/.../ColorEngine.kt唯一新增库源码(ARGB/HSV/HEX 纯数学)
color-picker-dialog/src/composeMain/**上游五个源文件封存(git mv,零改动)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、MiniJson、fallback 显式化、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(19 节)

4.3 关键 API 对照

层次API作用
引擎parseColorArgb(hex)上游 parseColor:可选 #、恰 6 位、非法回退默认首色
引擎hsvToArgb(h, s, v)上游 Color.hsv:标准扇区插值,入参先 clamp
引擎argbToHex(argb)上游 colorToHex:roundToInt、大写、#RRGGBB
引擎rgbCode(argb)上游对话框只读 RGB 串:toInt 截断(与 HEX 的取整刻意不同)
引擎getHue / getSaturation / getValueRGB→HSV 三分量(灰色 hue=0 与上游一致)
引擎defaultColorsArgb / defaultColorsHex上游 defaultColors 五色
NativeColorPickerDispatch / ColorPickerVersionC ABI JSON 导出
N-APIcolorPickerDispatch(req) / colorPickerVersion()向 ArkTS 暴露能力
ArkTSapplyHsv / applyHex引擎权威状态机(同步规则与上游逐字对齐)

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(colorPickerDispatch(JSON.stringify({
  op: 'parse', hex: '#1E6FCE'
})));
// res.hex === '#1E6FCE', res.hue === 212.3876, res.rgb === '30, 111, 206'
val argb = parseColorArgb(hex)          // 非法输入 → defaultColorsArgb.first()
info(argb, fallback = !isValidHexColor(hex))

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

五、关键决策说明

决策 1:引擎再表达而不是整库编译

qrose 的编码器本来就在 commonMain、零 UI 依赖,加 target 即可;KStore 依赖的 kotlinx ohos 制品线已补齐,真编译整库即可。color-picker-dialog 处境相反:五个文件全部依赖 Compose,"整库编译"根本不存在——但库的价值恰恰不在 Dialog 而在 ColorExt 的语义细节(round vs truncate、回退、大小写、灰色 hue=0)。把这份语义逐条平移到 ARGB Int 上,UI 封存 composeMain,是"全库 UI 化"处境下的唯一正确答案;语义漂移由 JVM 交叉验证兜底(见 6.2)。

决策 2:工具链选 qrose 线(Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 降级)

引擎零依赖,选线不受制品元数据约束,按最小风险选社区批准最早的 qrose 组合。Gradle 从上游 9.3.1 降到 8.14.1 是 2.2.21 旧线的硬要求;gradle-daemon-jvm.properties 与 8.14 冲突随之删除,JDK 统一由 org.gradle.java.home 钉死。KStore 的"2.4.20 线可留 Gradle 9.x"经验不适用于本库——没有依赖拉着工具链走,就选最保守的。

决策 3:根构建替换(cmp_qrose 模式),sample / apiCheck / 发布链路留盘

上游根构建(vanniktech 发布、binary-compatibility-validator、AGP classpath、foojay)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample 是 Android Compose 示例、api/ 是 validator 基线、publish.yml/release.sh 是 Maven 发布流——全部留盘不进 settings.gradle.kts,与 qrose 处置 androidapp/desktopApp 完全同款。

决策 4:parse 永不失败 + fallback 显式化

上游 parseColor 对非法输入返回默认首色而不是抛异常。桥把这个行为显式化为 fallback:true 而不是报错——Demo 能展示"非法输入 → 回退 #1E6FCE"这条上游真实语义,验收时它反而是必测用例。把上游的静默行为变成可观测的协议字段,是"语义镜像"优于"重写"的直接证据。

决策 5:零依赖引擎 + 手写 MiniJson

引擎不需要 kotlinx(颜色数学是初等算术),桥不为 5 个键引 kotlinx.serialization。收益直接反映在产物上:含 Kotlin/Native 运行时的 libohoscolorpicker.so 只有 ~3.4MB,是同期适配库里最轻的一档。浮点输出由 num() 手写定点化,绕开 KN Float.toString 的科学计数法。

六、测试与验证

6.1 测试环境

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

6.2 JVM 交叉验证

引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;jvm() 目标保留就是为了这条闸门:

val argb = parseColorArgb("#1E6FCE")
println(argbToHex(argb))                            // #1E6FCE
println(getHue(argb))                               // 212.38757
println(getSaturation(argb))                        // 0.8543689
println(getValue(argb))                             // 0.80784315
println(rgbCode(argb))                              // 30, 111, 206
println(argbToHex(hsvToArgb(210f, 0.85f, 0.8f)))    // #1F75CC
println(argbToHex(parseColorArgb("#GGGGGG")))       // #1E6FCE(回退)

钉住四组语义:HEX 用 roundToInt 而 RGB 展示用 toInt 截断(上游刻意不同);非法 7 位输入回退默认首色;hsv(210,0.85,0.8) 逐位等于标准扇区插值;灰色 hue=0 而非 NaN。真机页面显示的 hue=212.3876 / rgb=30, 111, 206(桥 4 位定点化)与 JVM 打印一致,证明换端没漂。

6.3 HAP 验证

:color-picker-dialog:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(copyKnSo* 自动回填三件套)
llvm-nm -D: ColorPickerDispatch / ColorPickerVersion / libohoscolorpicker_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libcolorpicker_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

实测产物:libohoscolorpicker.so arm64 约 3.4MB / x86_64 约 3.2MB(含 Kotlin/Native 运行时与引擎),libc++_shared.so 约 1.8MB / 1.7MB;CAdapter 链接期 clang api.cpp 警告与 cmp_qrose 同款(生成胶水,不影响产物)。

6.4 功能用例

  1. 启动后版本握手行显示 1.0.0-ohos.1(来自真实 Native 调用,非静态文字),日志区记录 version handshake;
  2. 默认色板五个圆点来自 op=defaults,与上游 defaultColors 逐项一致(#1E6FCE / #E91E63 / #10B981 / #F59E0B / #6366F1);
  3. 拖动 SV 面板白色游标,HEX、RGB、色块实时跟随,每次 move 都是一次 op=hsv Native 调用;游标拖到左上角 HEX 收敛为 #FFFFFF、右下角为 #000000;
  4. 拖动 HUE 条,SV 面板底色(引擎 h,1,1)与 HEX 同步变化,右侧度数实时刷新;
  5. HEX 输入合法 7 位(如 #10B981),两个游标瞬间跳到对应 HSV 位置,RGB 只读框同步;
  6. HEX 输入非法 7 位(如 #GGGGGG),引擎回退 #1E6FCE,日志出现 invalid hex ... → engine fallback,页面不崩溃;
  7. 点默认色板圆点,状态整体切换,当前色描白边;
  8. 点 Apply Color,日志记录 confirm → #XXXXXX (hex string ready to persist, e.g. SQL)——onConfirm(hex) 的持久化语义;点 Cancel 复位初始色;
  9. 切到 About Tab,版本握手值、桥接协议表、分层结构与 AtomGit 链接完整可见;
  10. 每步操作日志按时间戳滚动留痕(最新在前,上限 100 条)。

6.5 验证结论

颜色引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的取色会话:版本握手、默认色板、HSV 拖拽、HEX 双向输入、非法回退与确认持久化语义闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/color-picker-dialog.git
cd color-picker-dialog
export JAVA_HOME="/path/to/jdk-21"
./gradlew :color-picker-dialog:compileKotlinJvm                                # 引擎 JVM 交叉验证
./gradlew :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.colorpicker -m entry
hdc shell hilog | grep -i colorpicker

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :color-picker-dialog 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. Kotlin 2.2.21 旧线必须配 Gradle 8.14.1——上游 9.3.1 wrapper 原样沿用会在插件解析期失败,"KStore 线可留 9.x"的经验不能跨线套用;
  4. 上游 gradle.properties 被 .gitignore 忽略(“防止私钥入库”),本 fork 的 JDK 路径与版本号恰恰要写进它——忽略行必须删除并提交,否则换机构建直接失败;
  5. gradle-daemon-jvm.properties 的 JETBRAINS daemon criteria 在 Gradle 8.14 上不兼容,删除后统一由 org.gradle.java.home 管 JVM;
  6. 全库 UI 化的 CMP 库不能照抄 qrose 的"加 target 就完":ColorExt.kt 的 Color 签名必须先再表达为 ARGB,否则 ohos 编译器在 commonMain 就找不到 androidx.compose;
  7. 上游语义里的取整差异是坑不是 bug:HEX 用 roundToInt、RGB 展示用 toInt 截断,镜像时"修正"它反而制造漂移;
  8. Kotlin/Native 的 Float.toString 会输出科学计数法(如 1.0E-4),直接拼 JSON 会毁掉 ArkTS 侧解析,桥内手写 4 位定点化;
  9. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝(00303038 must have required property 'material')——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. DevEco 打开工程会生成 .idea/、.hvigor/、entry/build/、entry/libs/,root .gitignore 的 build/.idea 虽跨目录生效,entry/libs(含 so 三件套)与 oh_modules 必须显式追加,否则二进制混进提交。

8.2 已知问题

  • HarmonyOS 上没有 Compose Dialog:上游 ColorPickerDialog 等 UI 封存于 composeMain,等社区 CMP 工具链与上游 Compose 1.10 对齐后可整体接回,届时 ArkTS Demo 降级为对照样例;
  • HUE 条 / SV 面板的渐变由 ArkUI 绘制,引擎保证的是数值语义(游标位置 ↔ s/v、底色 = 引擎纯色相、HEX/RGB = 引擎字符串)——与 Vico(图表交 ArkTS Canvas)同口径;
  • DialogProperties(如 usePlatformDefaultWidth)在鸿蒙侧无对应物,本 fork 不透出;
  • sample(Android)、api/ 基线、publish.yml/release.sh 为上游遗留,不可在鸿蒙环境使用;
  • android / iOS target 剥离(源文件留在树里),发布、apiCheck 一并剥离,本 fork 不发 Maven Central。

8.3 未来优化方向

  • 把 6.2 节的 JVM 语义对账落成常驻 jvmTest(含 HSV 0-360 网格逐位比对上游 Color.hsv);
  • 桥增加 op=palette(从任意初始色生成建议色板)与透明度通道(ARGB 的 A 目前恒为 255,与上游一致);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补模拟器(x86_64)验收矩阵与 uitest 自动化截图脚本(qrose 已有先例)。

九、总结

9.1 核心难点回顾

上游 color-picker-dialog(五个源文件全部依赖 Compose)
    → 引擎再表达:ColorExt 语义逐条平移为 ARGB 纯 Kotlin(唯一新增库源码)
    → UI 封存:五个上游文件 git mv 进 composeMain,零改动
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1 降级)
    → 根构建替换(cmp_qrose 模式,sample/发布链路留盘)
    → ohosArm64 / ohosX64 KLIB + 零依赖 so(~3.4MB)
    → C ABI(@CName × 2)+ MiniJson + fallback 显式化
    → C++ N-API(DisposeString 纪律)
    → ArkTS 引擎权威状态机(同步规则与上游逐字对齐)
    → ArkUI 真机单页验收(拖拽 / 输入 / 回退 / 确认持久化)

9.2 封装层次

  • 引擎层:ColorEngine——上游 ColorExt 的 ARGB 再表达(取整差异、回退策略、HSV 公式、默认色板原样);
  • Native 层:双 ABI 动态库,2 个 C ABI 输出全量 JSON;
  • N-API 层:参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:引擎权威状态机、交互手势、日志与错误/回退提示;
  • DevEco 层:CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 型 CMP 库的适配面在"语义"不在"控件": color-picker-dialog 的持久化卖点全部长在 ColorExt 的细节里(round vs truncate、静默回退、大小写、灰色 hue=0)。把这些语义原样平移过桥,比把 Dialog 控件搬过来更接近"适配这个库";控件会随工具链换代,语义才是跨端资产。
  2. 工具链选线按依赖约束走,没有依赖就选最保守线: qrose 线(2.2.21 + 8.14.1)与 KStore 线(2.4.20 + 9.6.1)的差异由各自依赖矩阵决定;本库零依赖,直接选社区批准最久的组合,把变量减到最少。
  3. 把上游的静默语义变成协议字段是验收的捷径: parse 永不失败 + fallback:true 让"非法输入回退默认色"从一条需要翻源码确认的行为,变成 Demo 上一个可点击、可日志留痕的用例——语义镜像做对了,验收自然就顺了。

9.4 适配成果

当前适配已完成:

  • color-picker-dialog 1.0.0 颜色引擎的双 ABI Kotlin/Native 编译(上游五个源文件零改动,唯一新增库源码 ColorEngine.kt);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号,双 ABI);
  • DevEco Stage ArkUI 两 Tab 验收页面(SV 面板、色相条、HEX/RGB、默认色板、Cancel/Apply、操作日志、版本握手);
  • JVM 交叉验证(语义逐组对账)+ HAP 构建(双 ABI 三件套 so,arm64 ~3.4MB / x86_64 ~3.2MB)、签名、设备安装和真机验证;
  • 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

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

更多推荐