Kotlin Multiplatform 三方库 qrcode-kotlin 的 OpenHarmony 鸿蒙化适配指南

库版本:qrcode-kotlin 4.5.0 鸿蒙 fork|验证环境:HarmonyOS Kotlin 2.2.21-1.0.0|Gradle 8.14.1|JDK 21|DevEco Studio 26.0.0.821|HarmonyOS 7.0.0(API 26)|模拟器 127.0.0.1:5555(x86_64)

我以为把 qrcode-kotlin 链成 libohosqrcode.so,ArkTS 里 import 一下就能出码。llvm-nm -D 一看,QrcodeRender 赫然在动态符号表里。然后我把这个 so 的可枚举导出打了一遍:ChecksumZipGZip。业务函数一个都没有。
在这里插入图片描述

这是这次适配里最贵的一个下午。后面所有决策——软件光栅化、自研 PNG、CMake NAPI——都是被这一下逼出来的。本文按这条真实路径写,不按「理想中的 KMP 接入文档」写。

OpenHarmony 模拟器实拍:squares / circles / rounded / 青绿圆点

一、环境搭建

本章不展开,直接引用官方入口:KMP&CMP 鸿蒙社区HarmonyOS 应用开发导读

本文实际使用:

语言 / 框架Kotlin Multiplatform,HarmonyOS Kotlin 2.2.21-1.0.0
插件仓库https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos
JDKTemurin 21(写进 gradle.propertiesorg.gradle.java.home
Gradle Wrapper8.14.1
DevEco Studio26.0.0.821
HarmonyOS SDK7.0.0(API 26)
真机 ABIohosArm64arm64-v8a
模拟器 ABIohosX64x86_64
HAP 打包 JDKDevEco 自带 JBR,不要用 JDK 8

ohosArm64() / ohosX64() 只存在于这套定制 Kotlin Gradle Plugin。用 Maven Central 上的官方 2.2.21 写这两行,配置期就会 Unresolved reference。这是判断工具链有没有接对的第一根探针。

二、应用背景

二维码在鸿蒙应用里不是「锦上添花」。登录页、设备绑定、会议签到、售后溯源,都要在本地把一段字符串画成图。常见两条路:

  • 调系统分享或三方 App 出码,链路长,样式不可控。
  • 把 ZXing / ML Kit 搬过来,它们的图形栈绑死 Android Bitmap 或 Java AWT。

qrcode-kotlin 不一样。它是纯 Kotlin 实现:编码矩阵在 commonMain,画布是 expect class QRCodeGraphics。JVM 用 AWT,Android 用 Bitmap,JS 用 Canvas。鸿蒙缺的只是一个 actual。不引 Java 图形库,也不跟某个 UI 框架绑死,这正是 KMP 库该有的形状。

上游 4.5.0 的对外入口很干净:

QRCode.ofSquares()
    .withColor(0xFF0F766E.toInt())
    .withBackgroundColor(0xFFCCFBF1.toInt())
    .withSize(16)
    .build("https://atomgit.com/CPF-KMP-CMP")
    .render()
    .getBytes()   // PNG

业务侧真正要用的,也就这几件事:三种模块形状、前景/背景色、单元格大小、把 PNG 交给 Image 控件。Logo 叠加、自定义 ShapeFunction 上游有,这次 Demo 没铺到 UI 上,文末限制里会写。

三、接口分析

按 Demo 实际打到的表面列,不把上游所有扩展 API 都算进「已适配」。

能力上游 API鸿蒙 Demo 怎么调验收
方形模块QRCode.ofSquares()JSON shape=squares模拟器出码
圆点模块QRCode.ofCircles()shape=circles模拟器出码
圆角模块QRCode.ofRoundedSquares()shape=rounded模拟器出码
前景色withColor(Int)JSON dark,ARGB8888五套调色板
背景色withBackgroundColor(Int)JSON light含深色底反色
单元格withSize(Int)JSON cellSize,默认 16Slider 12–20
渲染render().getBytes()KN 内编码 PNG,Base64 回传size=464x464
空串上游允许空数据,业务拒绝ArkTS 先校验提示「请输入要编码的内容」

ArkTS 不直接碰这些 Kotlin API。它只调用一个 NAPI 函数:

import { QrcodeRender } from 'libqrcode_napi.so';

const raw = QrcodeRender(JSON.stringify({
  payload: this.payload,
  shape: this.shape,
  cellSize: this.cellSize,
  dark: PALETTES[this.palette].dark,
  light: PALETTES[this.palette].light,
}));

返回值是 JSON:{ ok, mime, width, height, pngBytes, b64 }。成功时 Imagesrcdata:image/png;base64,...。这是有意设计的:UI 进程不懂 QR 规格,Kotlin/Native 进程不懂 ArkUI。

四、六阶段路线图

六阶段:工具链 → 目标矩阵 → actual → C ABI → NAPI → 出图

  1. 工具链。 JDK 21 + HarmonyOS Kotlin 2.2.21-1.0.0 + Gradle 8.14.1。JDK 8/11 会在配置期直接死。
  2. 目标矩阵。 根项目收成 jvm() + ohosArm64() + ohosX64()。保留 JVM 是为了 jvmTest 当对照;砍掉 iOS/Android/JS 是因为这次要在 Windows 上闭环,不是把上游降级成单平台。
  3. actual。 src/ohosMainQRCodeGraphics 做软件光栅化,再写一个只支持 RGBA8 的 PNG 编码器。
  4. C ABI。 example/nativeAppsharedLib { baseName = "ohosqrcode" } 产出 so,@CName("QrcodeRender") 导出 const char* QrcodeRender(const char*)
  5. NAPI。 CAdapter 不挂业务符号,必须再编 libqrcode_napi.so
  6. 出图。 hvigorw assembleHap 把两个 ABI 的三份 so 打进 HAP,模拟器 hdc installImage 出码。

applyDefaultHierarchyTemplate() 会在两个 ohos 目标之上生成 ohosMain。代码放这里,不要放 nativeMain:以后一旦加 linux/mingw,会把这套软件光栅强加给不该用的平台。

五、三个关键决策

5.1 为什么 actual 不碰 PixelMap

QRCodeGraphics 的 expect 是同步的:构造即分配,fillRect 立刻写像素,getBytes() 立刻返回。它没有 Context,没有 Ability 生命周期,也没有 Promise。

KMP 的 ohosMain 编出来的是 Kotlin/Native,不是 ArkTS。这里不能 import { image } from '@kit.ImageKit'。真要调 ImageKit,就得再挖一层 NAPI/C,适配成本从「一个 actual 文件」变成「再写一套 FFI」。二维码全是色块,不需要抗锯齿,也不需要 GPU。200 行扫描线 + 中点圆,接口就能填满。

这和 JS actual 自己管 Canvas 像素是同一类思路:图形后端跟着平台走,编码器继续呆在 commonMain

5.2 为什么 PNG 自己编,而且故意不压缩

getBytes() 在所有平台上都约定返回 PNG。鸿蒙 actual 如果改成返回裸 ARGB,JVM 对照就断了,ArkTS 也多一次格式分叉。

Kotlin/Native 没有开箱即用的 javax.imageio。我写了 PngEncoder:IHDR + IDAT + IEND,filter 0,zlib stored 块(CMF=0x78, FLG=0x01),带 Adler32。464×464 的码,模拟器状态栏打印的是 png=861781B。这个数字不体面,但解码零风险——HarmonyOS Image 吃标准 PNG 即可。真要瘦身,换成 miniz 只改编码器内部,getBytes 签名不动。

5.3 为什么必须再包一层 CMake NAPI

这是全文最重要的一张图。

左:直接 import KN so 只有 Checksum/Zip/GZip;右:CMake NAPI 才是 ArkTS 入口

HarmonyOS Kotlin 的 CAdapter 会给 so 挂一套 ArkTS 表面。我原以为 @CName 的函数会一起挂上去。实测可枚举导出只有 stdlib 压缩类型:ChecksumZipGZipQrcodeRenderQrcodeBridge、包名 example 全是 undefined

ELF 没问题。llvm-nm -D libohosqrcode.so 两个 ABI 都能看到:

T QrcodeRender
T libohosqrcode_symbols

缺的是 NAPI 表面,不是 C 符号。所以 Demo 按官方 native 模块来:entry/src/main/cpp/napi_init.cpp 编成 libqrcode_napi.sonapi_define_properties 导出同名函数,内部链接 libohosqrcode.so,调完用 libohosqrcode_symbols()->DisposeString 把 KN 分配的 C 字符串释放掉。

ArkTS 只允许 import 这一层:

import { QrcodeRender } from 'libqrcode_napi.so';

libohosqrcode.solibc++_shared.so 放在 entry/libs/<abi>/ 给动态链接器用,不要再给它们写 oh-package 类型去 import

六、桥接怎么接

调用链从上到下是四层,中间少一层就会在 dlopenundefined is not a function 上爆。

ArkTS → libqrcode_napi.so → libohosqrcode.so → QRCodeGraphics / PngEncoder

example/nativeAppBridge.kt 吃一小段 JSON,在 Kotlin 里走完整 builder:

@CName("QrcodeRender")
fun qrcodeRender(request: String): String {
    val json = parse(request)
    val builder = when (json.shape) {
        "circles" -> QRCode.ofCircles()
        "rounded" -> QRCode.ofRoundedSquares()
        else -> QRCode.ofSquares()
    }
    val png = builder
        .withColor(json.dark)
        .withBackgroundColor(json.light)
        .withSize(json.cellSize)
        .build(json.payload)
        .render()
        .getBytes()
    return okJson(png)
}

有三个地方我写错过:

  1. baseNameString,不是 Property<String> 新版 Kotlin Gradle Plugin 写 baseName.set("ohosqrcode") 会在 configuration 阶段报 Unresolved reference 'set'。写成 baseName = "ohosqrcode"
  2. @CName 参数用 String,不要用 CPointer<ByteVar> KN 会生成 const char* ABI。返回的 C 字符串必须 DisposeString,NAPI 包装层已经做了,ArkTS 不用管。
  3. entry/build-profile.json5abiFilters 必须同时有 arm64-v8ax86_64 本机 DevEco 模拟器是 x86_64。只编 arm64,安装会报 9568347,看起来像签名问题,其实是 ABI 缺失。

linkDebugSharedOhosArm64 / OhosX64 已经 finalizedBy 拷贝任务,会把 libohosqrcode.so 和 Kotlin/Native 依赖的 libc++_shared.so 一起放进 entry/libs/<abi>/。漏掉 libc++,运行时是 Error loading shared library libc++_shared.so

CMake 这边还踩过一次 RUNPATH:IMPORTED 库会把本机绝对路径写进 so。真机上这条路径毫无意义。现在强制:

-Wl,-rpath,$ORIGIN
BUILD_WITH_INSTALL_RPATH TRUE
IMPORTED_NO_SONAME TRUE

llvm-readelf -d libqrcode_napi.so 里只剩 $ORIGIN

NAPI 包装本身不到一百行。值得单独说的只有两件事。第一,KN 返回的 const char* 必须在 napi_create_string_utf8 之后立刻 DisposeString,否则每次出码都漏一块堆。第二,nm_modname 必须是 qrcode_napi,和 oh-package.json5file:./src/main/cpp/types/libqrcode_napi 对上。名字写错,ArkTS 会编译过、运行时报模块找不到,看起来像 so 没打进 HAP。

光栅化也不神秘。QRCodeGraphics 内部就是一块 IntArray,行优先,每个像素 ARGB8888。fillRect 两层循环。圆点走椭圆扫描:把像素中心归一化到椭圆方程 nx² + ny² ≤ 1,描边再减掉内椭圆。圆角矩形拆成中间一条横条、上下两条竖条,四个角复用同一套椭圆填充。没有 SIMD,464×464 在模拟器上一次 render() 体感是立刻返回。changed 标志用来避免连续 getBytes() 重复编 PNG。

和 JVM actual 对照时,矩阵必须一致,抗锯齿可以不一致。JVM 走 AWT,圆的边缘会被插值;鸿蒙实际是硬切。所以不要拿两张 PNG 做逐字节对比,拿扫码结果和模块位置对比。jvmTest 覆盖的是编码,不是像素级黄金图。

七、踩坑表

现象根因处理
Unresolved reference: ohosArm64用了官方 Kotlin 2.2.21换成 HarmonyOS Kotlin 2.2.21-1.0.0,仓库放到 pluginManagement 第一位
baseName.set 编译失败新插件里 baseName 是 StringbaseName = "ohosqrcode"
import KN so 只有 Zip 三件套CAdapter 不导出业务符号ArkTS 改 import libqrcode_napi.so
Cannot read property 'render' of undefined同上,还在调 QrcodeBridge@CName("QrcodeRender") 的扁平函数
9568347 安装失败HAP 缺 x86_64abiFilters 加上 x86_64,并链接 ohosX64
Error loading shared library libc++_shared.so只拷了 KN socopy 任务同时带上 Konan 的 libc++_shared.so
PackageHap 报 Could not create JVMJAVA_HOME 指到 JDK 8打包改用 DevEco jbr
so 里出现 C:\Users\... 的 RUNPATHCMake 把 IMPORTED 路径写进去$ORIGIN + IMPORTED_NO_SONAME
PNG 将近 1 MBzlib stored,无压缩可接受;要瘦身只换编码器
空 payload 仍去 native上游允许空串ArkTS 先拦,native 再兜底返回 ok:false

还有一条环境向的:gradlew --offline 在 daemon 缓存被 JDK 切乱之后会假装「插件缺失」。--stop 之后用 JDK 21 在线跑一次 linkDebugShared,再谈离线。

八、Demo 验收:每个接口一张实拍

包名 org.terminator.ohos.qrcode,Ability EntryAbility。本机模拟器 hdc install 后:

hdc shell aa start -a EntryAbility -b org.terminator.ohos.qrcode

下面每张图都是 snapshot_display 从模拟器抠出来的,不是合成。状态栏时间 07:22–07:25,电量 100%。成功态统一打印 生成成功 (Kotlin/Native ohosArm64/ohosX64)png=861781B

8.1 ofSquares() 默认黑白

JSON:shape=squaresdark=0xFF111827light=0xFFFFFFFFcellSize=16。数据区和定位角都是方块模块,定位角保持「外框 + 内心」的标准结构。

squares 默认黑白二维码

8.2 ofCircles() 圆点模块

同一个 payload,只改 shape=circles。数据区变成圆点,三个定位角仍保持「外框 + 内心」的结构,扫码器认的是这个结构,不是模块形状。

circles 圆点二维码

8.3 ofRoundedSquares() 圆角模块

shape=rounded。软件光栅的 fillRoundRect 把圆角拆成矩形加四个椭圆角,没有走 PixelMap 的抗锯齿。近看边缘是像素级台阶,扫码不受影响。

rounded 圆角二维码

8.4 withColor / withBackgroundColor:青绿

调色板第二档:dark=0xFF0F766Elight=0xFFCCFBF1。形状仍是 squares。用来确认颜色通道没有把 ARGB 的 A 和 B 接反——PNG 编码器按 R、G、B、A 写,读错就会整图偏红或透明。

squares 青绿配色

8.5 蓝色工业风

第三档:dark=0xFF1D4ED8light=0xFFDBEAFE

squares 蓝色配色

8.6 反色深色底

第五档:浅字深底,dark=0xFFF9FAFBlight=0xFF111827。很多「深色模式出码」会把前景背景一起压暗,对比度不够。这里是真反色,定位角依然清晰。

squares 深色底反色

8.7 圆点 × 青绿

形状和颜色正交。circles + 青绿是 Demo 里最好看的一档,也是回归「改两个 JSON 字段不会互相踩」的用例。

circles 青绿配色

8.8 圆角 × 酒红

rounded + dark=0xFF9F1239 / light=0xFFFFE4E6。圆角在深色模块上更明显。

rounded 酒红配色

8.9 圆点 × 反色

最后一组把 circles 和深色底叠在一起。如果椭圆填充把 alpha 混错,深色底上会出现一圈灰边。实拍没有灰边。

circles 深色底反色

cellSize 用 Slider 覆盖 12–20。16px 时画布 464×464,对应 QR 版本和静区之后的模块数。改这个值只影响像素尺寸,不改变编码矩阵。空串在点「生成二维码」之前就被 ArkTS 拦住,不会打进 native。

调色板在 Demo 里是五个固定对,不是开放的色盘。这样验收时每个截图都能对上具体的 dark / light,不至于「我调了一下颜色」无法复现。真正接入业务时,把这两个 Int 从设计稿的 HEX 转成 ARGB8888 即可,JSON 字段不用改。

九、分层验收

在这里插入图片描述

不要把「库编过」和「Demo 出图」混成一次验收。四层口子分开过。

L1 jvmTest / L2 链接 / L3 HAP / L3 运行

命令我这边的结果
L1gradlew jvmTest公共 API 与编码矩阵在 JVM actual 上通过
L2gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64两个 ABI 的 so 都导出 QrcodeRender,并拷到 entry/libs/<abi>/
L3 编译hvigorw assembleHap -p product=default -p buildMode=debug --no-daemonHAP 内 arm64-v8a / x86_64 各含 libqrcode_napi.solibohosqrcode.solibc++_shared.so
L3 运行hdc install + aa start模拟器出码,形状和调色板可切换

打包必须用 DevEco JBR。本机默认 JAVA_HOME 若指向 JDK 8,PackageHap 会报 Could not create the Java Virtual Machine,native 其实已经编过了,只是最后把 so 塞进 HAP 的那一步没起来。

命令行打出来的是 unsigned HAP。这个模拟器接受了 hdc install。真机和正式签名仍走 DevEco 的 "signingConfig": "default",不要在 build-profile.json5 里把这段改空。

十、已知限制

  1. CAdapter 现状。 业务符号至今不会出现在 KN so 的 ArkTS 表面上。新库不要再赌「import libxxx.so 就能调 @CName」,直接按 NAPI 包装层做。
  2. PNG 未压缩。 464×464 约 860 KB。列表页如果一次出很多码,应改编码器或改走 nativeImage() 的 ARGB 缓冲。
  3. Logo / 自定义 ShapeFunction。 上游 commonMain 有,Demo UI 没暴露。不是 actual 缺实现,是这次验收范围没铺到。
  4. drawRoundRect 描边。 ohos actual 的描边圆角暂时退化为普通矩形;填充圆角是完整的。Demo 用的是填充,所以截图里看不出来。
  5. Windows 链不出可在本机加载的 ohos so。 ohosArm64/ohosX64 的产物只能进 HAP。对照测试走 jvmTest
  6. 模拟器 ABI。 本地 DevEco 模拟器是 x86_64。只交 arm64 HAP,安装失败码经常被误判成证书问题。
  7. 仓库位置。 适配代码按 KMP 征文要求落到 CPF-KMP-CMP 组织,不往 Flutter 组织塞 KMP 库。

十一、如何提 Issue / PR

上游功能问题优先去 g0dkar/qrcode-kotlin。鸿蒙 actual、NAPI 包装、Demo 安装问题开在适配仓库。

提 Issue 请带 ABI、形状、cellSize、payload、hilog、实拍

请固定带这六项,否则我很难判断是编码器问题还是 so 没装进对的 ABI:

  1. ABI:真机 ohosArm64 还是模拟器 ohosX64
  2. 形状:squares / circles / rounded
  3. cellSize
  4. payload 原文,中文和超长 URL 尤其重要
  5. hilog | grep QrcodeNapi,以及 dlopen 原文
  6. Image 控件实拍,不要只贴 JSON

PR 建议拆开:src/ohosMain 的光栅/PNG 是库本体;example/nativeApp@CName 是 C ABI;example/harmonyApp 的 CMake 是 NAPI。不要把三种改动揉进同一个 commit。示例工程保持 "signingConfig": "default"

十二、小结

这次适配没有发明新的二维码算法。commonMain 里的 QRCodeProcessor 原样工作。鸿蒙侧真正要补的是三块:

  • 一块不依赖 PixelMap 的 QRCodeGraphics actual
  • 一个守住 getBytes() == PNG 契约的小编码器
  • 一层 CAdapter 不肯给的 NAPI 表面

前两块是常规 KMP。第三块是 HarmonyOS Kotlin 目前的现实。谁要是把「so 里有符号」当成「ArkTS 能调用」,会在 Zip/GZip 三个导出上浪费整下午。把这层包装写进工程模板之后,后面再接别的 KN 库,路径就是复制 napi_init.cpp 换函数名。

模拟器已经出码。下一步是把仓库推到 CPF-KMP-CMP,并用 DevEco 默认签名在真机上再走一遍 arm64。

如果只记住一件事:在 HarmonyOS Kotlin 这条链上,Kotlin/Native so 负责算,CMake NAPI so 负责被 ArkTS 看见。两者都叫「so」,职责完全不是一回事。

KMP&CMP 社区地址:https://atomgit.com/CPF-KMP-CMP
GitHub 上游:https://github.com/g0dkar/qrcode-kotlin
Maven Central:https://mvnrepository.com/artifact/io.github.g0dkar/qrcode-kotlin
鸿蒙适配版:https://atomgit.com/oh-tpc/ohos_qrcode-kotlin

欢迎加入 KMP&CMP 鸿蒙社区:https://atomgit.com/CPF-KMP-CMP

Logo

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

更多推荐