Compose Multiplatform 三方库 KMPalette 的 OpenHarmony 鸿蒙化适配实战(Kotlin/Native 编译 .so + NAPI 像素级桥接 + ArkUI 图片取色)

库版本:KMPalette 2.1.0(androidx-palette 模块,Google Palette 的 Kotlin 移植)|验证环境:Compose Multiplatform 生态 / Kotlin 2.2.21-1.0.0(鸿蒙定制版)|DevEco Studio 26.0.0|DevEco 模拟器|HarmonyOS 7.0.0(API 26)

在这里插入图片描述

「从一张图片里提取主色调」是动态主题的第一步——Android 12 的 Material You 从壁纸取色,鸿蒙的「一镜到底」同样需要从壁纸提取主题色。KMPalette(296★)是 Kotlin 生态里图片取色的事实标准库:它把 Google 的 androidx.palette 移植成了纯 Kotlin,核心算法只有 8 个文件、只依赖 kotlin.math,零平台依赖。本文记录我把它完整跑上鸿蒙的全过程:与上一篇 MaterialKolor(种子色 → 配色方案)正好组成完整故事——KMPalette 负责图片 → 主色调,MaterialKolor 负责主色调 → 全应用配色,两库串联就是鸿蒙动态主题的完整算法链。这次的新课题是像素级桥接:68 万个像素如何高效穿越 ArkTS → NAPI → Kotlin/Native。

验收效果预览 *先睹为快:DevEco 模拟器实测,KMPalette 官方演示图提取的主色调 + 六大目标色 + 全部色板,取色耗时 6ms* ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/ccf2cf506ee94ce99b16af0e480a7efe.png)

一、适配目标与整体链路

目标:在鸿蒙模拟器里跑一个 ArkTS 应用,切换测试图片时,真实调用 Kotlin/Native 里的 androidx.palette 取色算法,把主色调(Dominant)、六大目标色(Vibrant/Light Vibrant/Dark Vibrant/Muted/Light Muted/Dark Muted)、全部色板(Swatches)渲染出来——以此证明整条链路真正打通,而不是 UI 上摆几个写死的颜色。

整体链路:

ArkTS (Index.ets)
   │  import kpalette_napi from 'libkpalette.so'
   │  PixelMap → readPixelsToBuffer → ArrayBuffer(ARGB_8888 像素)
   ▼  NAPI 调用 generatePalette(buffer, width, height)
libkpalette.so  ← C++ NAPI 薄层
   │
   ▼  extern "C" 调用
libohospalette.so  ← Kotlin/Native (ohosArm64 / ohosX64)
   │
   ▼
palette 模块 → androidx-palette(8 个纯 Kotlin 算法文件)

与 MaterialKolor 适配(纯标量入参)最大的不同:这次的输入是整张图的像素。1090×624 的演示图就是 68 万个像素、2.7MB 数据,跨语言边界的传输方式直接决定成败——这是本文的核心看点。

二、为什么是 KMPalette:与 MaterialKolor 串联的完整故事

在这里插入图片描述

上一篇 MaterialKolor 适配验证了「种子色 → 完整配色方案」,但种子色从哪来?Material You 的答案是壁纸取色。在 Kotlin 生态里,这个「图片 → 主色调」的环节由 KMPalette 承担:

环节库输入 → 输出鸿蒙化状态
图片 → 主色调KMPalette(androidx-palette)像素 → Dominant/六大目标色本文
主色调 → 配色方案MaterialKolor(material-color-utilities)种子色 → 27 角色色 + Tonal 色阶上一篇已完成

两库串联 = 壁纸 → 主色调 → 全应用配色,鸿蒙动态主题的完整算法链就此闭环。

选型时同样先过 skiko 筛查:KMPalette 的核心模块 androidx-palette 是 androidx Palette 的 Kotlin 移植,8 个文件全是色彩量化算法(颜色直方图、VBox 切分、目标色匹配),只依赖 kotlin.math,不碰 skiko、不碰 okio、不碰 coroutines。它的 Compose 包装层(kmpalette-core,rememberDominantColorState 等)在鸿蒙端由 ArkUI 替代即可。算法核心 100% 复用原库源码。

三、工程结构

kmpalette-ohos-demo/
├── palette/                      # 库模块:androidx-palette 源码(8 个算法文件)
│   └── src/commonMain/kotlin/com/kmpalette/palette/  # 100% 复用上游
├── example/
│   ├── nativeApp/                # Kotlin/Native 桥接层 → libohospalette.so
│   │   └── src/
│   │       ├── commonMain/kotlin/PaletteBridge.kt    # 算法包装 + JSON 序列化
│   │       └── ohosMain/kotlin/PaletteExport.kt       # @CName 导出 C ABI
│   └── ohosApp/                  # ArkTS 鸿蒙应用
│       └── entry/src/main/
│           ├── cpp/napi_init.cpp               # C++ NAPI 薄层(像素 ArrayBuffer → int*)
│           ├── ets/pages/Index.ets             # ArkUI 取色页面
│           └── libs/{arm64-v8a,x86_64}/        # 双 ABI .so
└── settings.gradle.kts / build.gradle.kts / gradle.properties

四、适配过程:四个关键步骤

壁纸取色 ### 4.1 Gradle 工程配置(鸿蒙定制工具链)

与 MaterialKolor 适配完全同构:pluginManagement 第一位放 eazytec Nexus 仓库,Kotlin 2.2.21-1.0.0 定制版,库模块与桥接模块声明 ohosArm64() + ohosX64() 双 target:

// palette/build.gradle.kts(拷入 8 个算法源文件)
plugins { kotlin("multiplatform") version "2.2.21-1.0.0" }
kotlin {
    ohosArm64()
    ohosX64()
    sourceSets { commonMain.dependencies { /* 零依赖 */ } }
}

// example/nativeApp/build.gradle.kts
kotlin {
    ohosArm64 { binaries { sharedLib { baseName = "ohospalette" } } }
    ohosX64   { binaries { sharedLib { baseName = "ohospalette" } } }
    sourceSets { commonMain.dependencies { api(project(":palette")) } }
}

4.2 源码改造:两类注解的清理

androidx-palette 上游带着 Android 的历史包袱,两类注解在鸿蒙工程里都无法解析:

  • androidx.annotation(@ColorInt、@FloatRange、@IntRange):纯装饰性注解,删 import + 删用法即可。坑在三种形态:独立行(@ColorInt\n val rgb: Int)、行内参数(@ColorInt color: Int,)、属性前缀(@get:ColorInt)——正则要分别处理;
  • @Poko(编译期插件):Palette 和 Swatch 两个类在用。这次运气好——两个类都没被用作 Map key(算法内部用 ArrayList 持有 Swatch),直接删注解与 import,无需手写 equals/hashCode。

判断方法与上一篇相同:全局搜 @Poko,再搜类名是否出现在 Map<、Set< 泛型参数里。这次零命中,算法源码零逻辑改动。

4.3 像素级桥接:这次的核心课题

与 MaterialKolor(4 个标量入参)不同,KMPalette 的输入是整张图的像素。跨语言边界设计:

ArkTS 侧——PixelMap 读出 ARGB_8888 的 ArrayBuffer,连同宽高一起传给 NAPI:

// Index.ets 核心调用
const pixelMap: image.PixelMap = await source.createPixelMap({
  desiredPixelFormat: image.PixelMapFormat.ARGB_8888
})
const info = await pixelMap.getImageInfo()
const byteCount = info.size.width * info.size.height * 4  // 每像素 4 字节
const buffer = new ArrayBuffer(byteCount)
await pixelMap.readPixelsToBuffer(buffer)
const raw: string = kpalette_napi.generatePalette(
  buffer, info.size.width, info.size.height)
const parsed = JSON.parse(raw) as PaletteJson

C++ NAPI 层——napi_get_arraybuffer_info 拿到裸指针,校验 byteLength / 4 == width * height 后直接把 int* 传下去,零拷贝:

// napi_init.cpp
void *data = nullptr;
size_t byteLength = 0;
napi_get_arraybuffer_info(env, args[0], &data, &byteLength);
const int pixelCount = static_cast<int>(byteLength / 4);
if (pixelCount != width * height) { /* range error */ }

const char *raw = OhosPaletteGenerate(
    static_cast<const int *>(data), pixelCount, width, height);
// raw 是 JSON 字符串,转 napi string 后立即 OhosPaletteFree(raw)

Kotlin 侧——@CName 导出 C ABI,把 CPointer<IntVar> 拷贝为 IntArray 后立即交给算法:

// PaletteExport.kt
@CName("OhosPaletteGenerate")
fun ohosPaletteGenerate(
    pixelsPtr: CPointer<IntVar>?, pixelCount: Int, width: Int, height: Int,
): CPointer<ByteVar> {
    val json = try {
        requireNotNull(pixelsPtr) { "pixelsPtr is null" }
        require(pixelCount == width * height) { "size mismatch" }
        val pixels = IntArray(pixelCount) { i -> pixelsPtr[i] }  // 一次性拷贝
        PaletteBridge.generatePaletteJson(pixels, width, height)
    } catch (t: Throwable) {
        "{\"error\":\"${t.message ?: "unknown"}\"}"
    }
    return jsonToCString(json)  // nativeHeap 分配,null 结尾
}

@CName("OhosPaletteFree")
fun ohosPaletteFree(ptr: CPointer<ByteVar>?) {
    ptr?.let { nativeHeap.free(it.rawValue) }  // 谁分配谁释放
}

像素内存的所有权链:ArkTS 的 ArrayBuffer 由 NAPI 直接读裸指针(零拷贝)→ Kotlin 侧 IntArray(pixelCount) { i -> pixelsPtr[i] } 一次性拷入 Kotlin 堆(之后 C 侧 buffer 可任意释放)→ 返回的 JSON 字符串在 nativeHeap 分配,C++ 侧转成 napi string 后立即 OhosPaletteFree。每个环节谁分配谁释放,无泄漏无悬垂。

PaletteBridge 把 Palette.from(pixels, width, height).generate() 的结果序列化为 JSON:全部 Swatch(rgb + population + hsl)、六大目标色(无匹配为 null)、主色调。

4.4 编译与部署

# 1. 编译双 ABI release .so
.\gradlew.bat :example:nativeApp:linkReleaseSharedOhosArm64 `
              :example:nativeApp:linkReleaseSharedOhosX64

# 2. 部署到 entry/libs(含 Kotlin/Native 运行时 libc++_shared.so)
entry/libs/arm64-v8a/libohospalette.so
entry/libs/x86_64/libohospalette.so

# 3. 构建 HAP(命令行:JAVA_HOME 指向 DevEco JBR + DEVECO_SDK_HOME 指向 sdk 根目录)
$env:JAVA_HOME = "C:/Program Files/Huawei/DevEco Studio/jbr"
$env:DEVECO_SDK_HOME = "C:/Program Files/Huawei/DevEco Studio/sdk"
hvigorw.bat assembleHap --mode module -p product=default

# 4. 安装到模拟器(注意:hdc 的路径参数必须用反斜杠)
hdc -t 127.0.0.1:5555 install -r entry-default-unsigned.hap

五、踩坑记录(4 个)

坑现象解法
注解三种形态@ColorInt 删不干净:独立行/行内参数/@get: 前缀正则分别处理 + CRLF 行尾 \r?
cinterop 下标pixelsPtr[i] unresolved referenceimport kotlinx.cinterop.get(运算符扩展函数)
DEVECO_SDK_HOME命令行 hvigor 报 00303217/00303312显式指向 DevEco Studio/sdk(根目录,含 default/hms/openharmony)
hdc 路径解析install 报 no such file:正斜杠路径被拼接错乱本地路径一律用反斜杠 C:\...

另一个值得记录的坑:ArkTS 严格模式的 Record 索引。palette.targets[name] 在 ForEach 渲染里既报「Object is possibly null」又不好收窄,最终方案是在数据层把 Record 展开为类型化数组(TargetItem[]),渲染层只消费数组——ArkTS 的 UI 语法里连 const 局部声明都不允许,视图模型化是正解。

六、运行效果(DevEco 模拟器实测)

Demo 做成深色 UI,三张测试图片(KMPalette 官方演示图 / 品牌 Logo / 壁纸截图)一键切换,每次切换都真实走一遍 像素读取 → NAPI → 取色算法 → JSON 回传 → ArkUI 渲染。

官方演示图(1090×624,68 万像素)——主色调 #E0E8F0、六大目标色、全部色板一次到位,取色耗时 6ms:

演示图取色 *官方演示图:主色调 #E0E8F0(pop 1339)+ 六大目标色(#F09050 鲜活 / #B0D0E0 浅鲜活 / #2030A0 深鲜活 / #707080 柔和…)+ 全部色板,取色 6ms*

品牌 Logo(932×368)——紫色系图片,色板整体偏移:

Logo 取色 *品牌 Logo:算法对紫色系图片输出完全不同的色板组合*

壁纸截图(1320×2232,294 万像素)——深色系壁纸:

壁纸取色 *壁纸截图:294 万像素 4ms 完成取色,深色系色板*

真实性验证(hilog)——每次切换都有日志铁证:

KpaletteNapi: generatePalette w=1090 h=624 pixels=680160
KpaletteNapi: generatePalette result len=1447 head={"swatches":[{"rgb":-5189408,"population":650,...
KpaletteNapi: generatePalette w=1320 h=2232 pixels=2946240
KpaletteNapi: generatePalette result len=1368 head={"swatches":[{"rgb":-16740144,...
KpaletteNapi: generatePalette w=932 h=368 pixels=342976
KpaletteNapi: generatePalette result len=1448 head={"swatches":[{"rgb":-9947008,...

三张图三个不同的 len 与首 Swatch——算法对不同图片真实产出不同色板,非 mock。294 万像素的壁纸取色仅 4ms(色彩量化的 VBox 切分对像素数不敏感),68 万像素 6ms——纯内存计算,无任何 IO。

七、FAQ

Q1:68 万像素跨 NAPI 会不会很慢?不会。ArrayBuffer 走 napi_get_arraybuffer_info 拿裸指针零拷贝,Kotlin 侧一次 IntArray 拷贝(~2.7MB memcpy),实测全流程 4–6ms。

Q2:为什么不用 PixelMap 直接传?NAPI 跨语言边界传裸数据(ArrayBuffer/int*)最稳,传对象(PixelMap 句柄)需要跨运行时引用管理,复杂度陡增。极简切片原则:边界上只传数据和 JSON 字符串。

Q3:ARGB_8888 的字节序对得上吗?ArkTS 侧 readPixelsToBuffer 出来的是大端 ARGB 打包的 32 位序列,按 int32 读取后与 Kotlin Int 位表示一致,算法内部位运算(rgb >>> 16 & 0xFF)不受符号影响。

Q4:@Poko 这次为什么能直接删?判断标准是类是否被用作 Map/Set key——Palette/Swatch 都只在 ArrayList 里,没有哈希需求。上一篇的 Hct 命中过,这次零命中。

Q5:命令行构建 HAP 报 DEVECO_SDK_HOME 错误?DEVECO_SDK_HOME 要指向 DevEco Studio/sdk(根目录,下面有 default/hms/openharmony),指到 sdk/default 会报「找不到对应 SDK 版本」(00303312)。

Q6:hdc 传文件报 no such file?hdc 对正斜杠本地路径的拼接有 bug,install/file recv 的本地路径一律用反斜杠。

八、总结与参考

KMPalette 适配完成,与 MaterialKolor 串联后,鸿蒙动态主题的算法链完整闭环:壁纸像素 →(KMPalette)→ 主色调 →(MaterialKolor)→ 27 角色色 + Tonal 色阶 → ArkUI 渲染。本篇验证的方法论增量是像素级桥接:ArrayBuffer 零拷贝读指针 + Kotlin 侧一次性 IntArray 拷贝 + JSON 字符串回传,全流程 4–6ms——证明 Kotlin/Native .so 不只能接标量,也能高效接大数据。改造量依旧趋近于零(删注解 + 零逻辑改动),再次印证「选型先筛 skiko,纯算法库优先」的选型逻辑。

OpenHarmony 三方库社区地址:https://atomgit.com/oh-tpc
OpenHarmony 三方库适配地址:https://atomgit.com/oh-tpc/KMPalette
github 三方库地址:https://github.com/jordond/KMPalette
官方文档地址:https://github.com/jordond/KMPalette/blob/main/README.md
鸿蒙定制仓库地址:https://maven.eazytec-cloud.com/nexus/repository/maven-public/
鸿蒙适配版:https://atomgit.com/oh-tpc/KMPalette

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐