Kotlin Multiplatform 三方库 qrcode-kotlin 的 OpenHarmony 鸿蒙化适配指南
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 的可枚举导出打了一遍:Checksum、Zip、GZip。业务函数一个都没有。

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

一、环境搭建
本章不展开,直接引用官方入口:KMP&CMP 鸿蒙社区、HarmonyOS 应用开发导读。
本文实际使用:
| 项 | 值 |
|---|---|
| 语言 / 框架 | Kotlin Multiplatform,HarmonyOS Kotlin 2.2.21-1.0.0 |
| 插件仓库 | https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos |
| JDK | Temurin 21(写进 gradle.properties 的 org.gradle.java.home) |
| Gradle Wrapper | 8.14.1 |
| DevEco Studio | 26.0.0.821 |
| HarmonyOS SDK | 7.0.0(API 26) |
| 真机 ABI | ohosArm64 → arm64-v8a |
| 模拟器 ABI | ohosX64 → x86_64 |
| HAP 打包 JDK | DevEco 自带 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,默认 16 | Slider 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 }。成功时 Image 的 src 用 data:image/png;base64,...。这是有意设计的:UI 进程不懂 QR 规格,Kotlin/Native 进程不懂 ArkUI。
四、六阶段路线图

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

HarmonyOS Kotlin 的 CAdapter 会给 so 挂一套 ArkTS 表面。我原以为 @CName 的函数会一起挂上去。实测可枚举导出只有 stdlib 压缩类型:Checksum、Zip、GZip。QrcodeRender、QrcodeBridge、包名 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.so,napi_define_properties 导出同名函数,内部链接 libohosqrcode.so,调完用 libohosqrcode_symbols()->DisposeString 把 KN 分配的 C 字符串释放掉。
ArkTS 只允许 import 这一层:
import { QrcodeRender } from 'libqrcode_napi.so';
libohosqrcode.so 和 libc++_shared.so 放在 entry/libs/<abi>/ 给动态链接器用,不要再给它们写 oh-package 类型去 import。
六、桥接怎么接
调用链从上到下是四层,中间少一层就会在 dlopen 或 undefined is not a function 上爆。

example/nativeApp 的 Bridge.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)
}
有三个地方我写错过:
baseName是String,不是Property<String>。 新版 Kotlin Gradle Plugin 写baseName.set("ohosqrcode")会在 configuration 阶段报Unresolved reference 'set'。写成baseName = "ohosqrcode"。@CName参数用String,不要用CPointer<ByteVar>。 KN 会生成const char*ABI。返回的 C 字符串必须DisposeString,NAPI 包装层已经做了,ArkTS 不用管。entry/build-profile.json5的abiFilters必须同时有arm64-v8a和x86_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.json5 里 file:./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 是 String | baseName = "ohosqrcode" |
import KN so 只有 Zip 三件套 | CAdapter 不导出业务符号 | ArkTS 改 import libqrcode_napi.so |
Cannot read property 'render' of undefined | 同上,还在调 QrcodeBridge | 走 @CName("QrcodeRender") 的扁平函数 |
9568347 安装失败 | HAP 缺 x86_64 | abiFilters 加上 x86_64,并链接 ohosX64 |
Error loading shared library libc++_shared.so | 只拷了 KN so | copy 任务同时带上 Konan 的 libc++_shared.so |
PackageHap 报 Could not create JVM | JAVA_HOME 指到 JDK 8 | 打包改用 DevEco jbr |
so 里出现 C:\Users\... 的 RUNPATH | CMake 把 IMPORTED 路径写进去 | $ORIGIN + IMPORTED_NO_SONAME |
| PNG 将近 1 MB | zlib 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=squares,dark=0xFF111827,light=0xFFFFFFFF,cellSize=16。数据区和定位角都是方块模块,定位角保持「外框 + 内心」的标准结构。
8.2 ofCircles() 圆点模块
同一个 payload,只改 shape=circles。数据区变成圆点,三个定位角仍保持「外框 + 内心」的结构,扫码器认的是这个结构,不是模块形状。
8.3 ofRoundedSquares() 圆角模块
shape=rounded。软件光栅的 fillRoundRect 把圆角拆成矩形加四个椭圆角,没有走 PixelMap 的抗锯齿。近看边缘是像素级台阶,扫码不受影响。
8.4 withColor / withBackgroundColor:青绿
调色板第二档:dark=0xFF0F766E,light=0xFFCCFBF1。形状仍是 squares。用来确认颜色通道没有把 ARGB 的 A 和 B 接反——PNG 编码器按 R、G、B、A 写,读错就会整图偏红或透明。
8.5 蓝色工业风
第三档:dark=0xFF1D4ED8,light=0xFFDBEAFE。
8.6 反色深色底
第五档:浅字深底,dark=0xFFF9FAFB,light=0xFF111827。很多「深色模式出码」会把前景背景一起压暗,对比度不够。这里是真反色,定位角依然清晰。
8.7 圆点 × 青绿
形状和颜色正交。circles + 青绿是 Demo 里最好看的一档,也是回归「改两个 JSON 字段不会互相踩」的用例。
8.8 圆角 × 酒红
rounded + dark=0xFF9F1239 / light=0xFFFFE4E6。圆角在深色模块上更明显。
8.9 圆点 × 反色
最后一组把 circles 和深色底叠在一起。如果椭圆填充把 alpha 混错,深色底上会出现一圈灰边。实拍没有灰边。
cellSize 用 Slider 覆盖 12–20。16px 时画布 464×464,对应 QR 版本和静区之后的模块数。改这个值只影响像素尺寸,不改变编码矩阵。空串在点「生成二维码」之前就被 ArkTS 拦住,不会打进 native。
调色板在 Demo 里是五个固定对,不是开放的色盘。这样验收时每个截图都能对上具体的 dark / light,不至于「我调了一下颜色」无法复现。真正接入业务时,把这两个 Int 从设计稿的 HEX 转成 ARGB8888 即可,JSON 字段不用改。
九、分层验收

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

| 层 | 命令 | 我这边的结果 |
|---|---|---|
| L1 | gradlew jvmTest | 公共 API 与编码矩阵在 JVM actual 上通过 |
| L2 | gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64 | 两个 ABI 的 so 都导出 QrcodeRender,并拷到 entry/libs/<abi>/ |
| L3 编译 | hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon | HAP 内 arm64-v8a / x86_64 各含 libqrcode_napi.so、libohosqrcode.so、libc++_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 里把这段改空。
十、已知限制
- CAdapter 现状。 业务符号至今不会出现在 KN so 的 ArkTS 表面上。新库不要再赌「import libxxx.so 就能调
@CName」,直接按 NAPI 包装层做。 - PNG 未压缩。 464×464 约 860 KB。列表页如果一次出很多码,应改编码器或改走
nativeImage()的 ARGB 缓冲。 - Logo / 自定义 ShapeFunction。 上游
commonMain有,Demo UI 没暴露。不是 actual 缺实现,是这次验收范围没铺到。 drawRoundRect描边。 ohos actual 的描边圆角暂时退化为普通矩形;填充圆角是完整的。Demo 用的是填充,所以截图里看不出来。- Windows 链不出可在本机加载的 ohos so。 ohosArm64/ohosX64 的产物只能进 HAP。对照测试走
jvmTest。 - 模拟器 ABI。 本地 DevEco 模拟器是 x86_64。只交 arm64 HAP,安装失败码经常被误判成证书问题。
- 仓库位置。 适配代码按 KMP 征文要求落到 CPF-KMP-CMP 组织,不往 Flutter 组织塞 KMP 库。
十一、如何提 Issue / PR
上游功能问题优先去 g0dkar/qrcode-kotlin。鸿蒙 actual、NAPI 包装、Demo 安装问题开在适配仓库。

请固定带这六项,否则我很难判断是编码器问题还是 so 没装进对的 ABI:
- ABI:真机
ohosArm64还是模拟器ohosX64 - 形状:
squares/circles/rounded cellSize- payload 原文,中文和超长 URL 尤其重要
hilog | grep QrcodeNapi,以及dlopen原文Image控件实拍,不要只贴 JSON
PR 建议拆开:src/ohosMain 的光栅/PNG 是库本体;example/nativeApp 的 @CName 是 C ABI;example/harmonyApp 的 CMake 是 NAPI。不要把三种改动揉进同一个 commit。示例工程保持 "signingConfig": "default"。
十二、小结
这次适配没有发明新的二维码算法。commonMain 里的 QRCodeProcessor 原样工作。鸿蒙侧真正要补的是三块:
- 一块不依赖 PixelMap 的
QRCodeGraphicsactual - 一个守住
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
更多推荐





所有评论(0)