Compose Multiplatform 三方库 KMPalette 的 OpenHarmony 鸿蒙化适配实战(Kotlin/Native 编译 .so + NAPI 像素级桥接 + ArkUI
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* 
一、适配目标与整体链路
目标:在鸿蒙模拟器里跑一个 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 reference | import 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:算法对紫色系图片输出完全不同的色板组合*
壁纸截图(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
更多推荐

所有评论(0)