开源鸿蒙平台 CMP 三方库「number-keyboard」适配全流程
本文记录
number-keyboard接入 OpenHarmony 的完整过程,覆盖数字键盘库盘点、输入策略从 Composable 中的 1:1 抽取、鸿蒙工具链钉版、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机四 Tab 页面、HAP 构建和真机验收。
number-keyboard是基于 Compose Multiplatform 的 KMP 数字键盘库:给定当前amount字符串、金额上限、小数位上限、货币符号和布局格式(Normal / Inverted / Scrambled / AlwaysScrambled),NumberKeyboardComposable 自己负责"这一按收不收",每次有效输入通过NumberKeyboardListener.onUpdated(NumberKeyboardData)把原始金额与八种读数(rawAmount/byte/short/int/long/float/double/currency)吐回给调用方。本次适配与社区两条既有路线都有本质区别:既不是 KStore 式的"整库真编译"——它的输入策略不是现成的纯 Kotlin 模块,而是内联在 Composable 监听器肚子里的三段逻辑加两个私有函数;也不是 qrose / haze 式的"零依赖内核提取"——让 ArkTS 重写接受/拒绝规则必然与 Android / iOS 行为漂移。实际做法是先做1:1 策略抽取(新增NumberKeyboardEngine+KeyboardLayout进commonMain,逐行对照原实现,连replace(',', '.')的上游怪癖都原样保留),再编成 OpenHarmony KLIB;上游 Compose UI 原样隔离进src/composeMain不参与 ohos 编译,ArkUI 四 Tab 页面作为验收宿主,12 项 JVM 测试锁住"策略没漂"。
项目地址: AtomGit/oh-tpc/number-keyboard.git
上游仓库: davidmigloz/number-keyboard
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
number-keyboard 为 KMP 应用提供"金额输入"这一件事的完整答案:键盘不碰业务状态,调用方持有 amount 字符串,按键的接受与拒绝全部由库内策略裁决——空串按 0 拒绝、首位零替换、小数位超限拒绝、超上限按 roundUpToMax 回弹到 formatMaxAmount 拼装的上限值、退格含整数键盘 x.00 尾零截断;NumberKeyboardData 在此之上给出八种读数,currency 的千分位分组与货币符前后缀也是上游实现。按钮长什么样由调用方通过 @Composable lambda 全权定制,NumberKeyboardFormat 四种格式覆盖电话拨号式、计算器式与两种乱序防窥键盘。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套金额输入能力(支付、转账、密码键盘),但上游构建(Kotlin 2.2.0 / Compose Multiplatform 1.8.2 / AGP 8.11.1)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以画出按钮,却无法证明输入策略在 OpenHarmony ARM64 设备上完成编译、链接和执行——"按了没反应"到底是策略拒绝还是 UI 没刷新,永远说不清。适配需要同时验证输入策略、数据模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.2.0 + Compose Multiplatform 1.8.2 + AGP 8.11.1 与鸿蒙工具链无法共存于一个 Gradle 构建;社区已审钉法是 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1。 |
| 策略位置 | 输入策略内联在 NumberKeyboard Composable 的 NumberKeyboardClickedListener 里,不抽出来就没有东西可编译。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 状态归属 | 上游语义是调用方持有 amount,桥必须保持无状态,否则"两份真相"会让拒绝语义失真。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:输入策略的 1:1 抽取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。接受/拒绝语义与金额格式化完全由上游原版逻辑负责,ArkTS 只负责持有 amount 状态、调用 Native、解析 JSON 和渲染按钮。
1.2 库提供的能力
Demo 提供四个真实键盘会话,对应上游 sample 的四个页面与桥接的全部入口(上游三平台效果与鸿蒙真机效果对照见第七节):
- Integer:
maxAllowedDecimals = 0,右辅助退格键,大字显示int——空串按 0 被拒、超上限回弹、退格到空显示 0; - Decimal:默认参数(上限 10000、2 位小数、
$),左辅助小数点、右辅助退格,大字显示currency——千分位分组、双小数点拒绝; - Biometric:上限 9999、0 位小数、
roundUpToMax = false,左辅助指纹占位(上游clicked = {},不发起调用)——到 9999 后再按被拒且不回弹; - Custom:上限 8888.888、3 位小数、
€、圆形按钮,Normal / Inverted / Scrambled / AlwaysScrambled 四格式切换——行序镜像、进页乱序、每键重排。
每次按键的响应包含:
| 字段 | 作用 |
|---|---|
ok | 桥通且参数合法,失败时 error 带原文。 |
accepted | 策略是否放行;false 对应上游监听器"不回调"的静默语义,UI 保持原状态。 |
amount | 接受时的新金额字符串('' 表示退格到空)。 |
rawAmount / byte / short / int / long / float / double | NumberKeyboardData 的读数 1:1 直出,不是 ArkTS 侧算的。 |
currency | 带千分位分组与货币符的格式化串,走上游 formatAmount。 |
引擎侧完整能力(首位零、上限钳制、尾零截断、四种布局格式、乱序置换校验)全部在 commonMain 里随编译进入 .so,JVM 侧保留 12 项 desktopTest 交叉验证用例,证明 ohos .so 里执行的输入策略没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 data/ / listener/ / expect.kt 原样编译,一行不改;新增仅三个纯 Kotlin 文件(NumberKeyboardEngine、KeyboardLayout、ohos actual),Compose UI 原样移入 composeMain 保留对照。 |
| 策略抽取 | 监听器逻辑逐行对照抽成 NumberKeyboardEngine,接受返回新 amount、拒绝返回 null,与上游"回调不触发"严格同构。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm("desktop") 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 sample/ Compose 示例与 androidMain / iosMain 源集留盘不进构建。 |
| 桥接稳定 | 单个 C ABI 和 UTF-8 JSON,桥无状态,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | commonMain 零第三方依赖(无 Compose、无 serialization),ohos 侧没有依赖矩阵问题。 |
| UI 完整 | 四 Tab 覆盖全部桥接入口:布局拉取、数字 / 小数点 / 退格、拒绝反馈、状态行与格式切换。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 Compose BOM 1.8.2 与社区 CMP 1.9.2 / HarmonyOS Kotlin 工具链对不齐,Composable 无法编进 ohos;ArkTS Demo 把桥暴露的输入策略画出来,
composeMain里的上游 Composable 保留,等compose.uiohos 制品对齐后接回。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与策略位置、确定抽取边界、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、源集重排
第 3 阶段:桥协议建模 ── amount 状态归属、press/decimal/delete/layout JSON 契约、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 布局拉取、四 Tab 键盘、拒绝反馈和状态行
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
lib/ 上游库模块(进 ohos 构建,jvm("desktop") + ohosArm64 + ohosX64)
lib/src/commonMain/.../data/ NumberKeyboardData + NumberKeyboardFormat(上游原样)
lib/src/commonMain/.../listener/ 两个监听接口(上游原样)
lib/src/commonMain/.../expect.kt 三个 expect 声明(上游原样)
lib/src/commonMain/.../engine/ 新增:NumberKeyboardEngine + KeyboardLayout(策略抽取)
lib/src/composeMain/ 上游 Compose UI(NumberKeyboard / 按钮,ohos 不编,对照基线)
lib/src/ohosMain/ 新增:expect 的 ohos actual(en_US 风格分隔符)
lib/src/desktopTest/ 引擎 JVM 测试(12 项,交叉验证)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 四 Tab 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
sample/ buildSrc/ androidMain/ iosMain/ 上游示例与平台源集,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :lib 与 :example:nativeApp,rootProject.name = "ohos_numberkeyboard"。上游的 AGP、Compose Multiplatform 插件、compose-bom 依赖、JitPack 发布与 buildSrc 版本号工具全部随根构建替换剥离——本 fork 不发 JitPack。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,社区已审钉法) |
| JDK | 21(Eclipse Temurin,org.gradle.java.home 钉进 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm("desktop") 保留作交叉验证) |
| Gradle | 8.14.1(2.2.21 旧线配套;上游 wrapper 8.14.3 降半版) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.numberkeyboard |
export JAVA_HOME="/path/to/jdk-21" # Windows 机用 gradle.properties 的 org.gradle.java.home 钉版
java -version
1.3 源码依赖度盘点
| 模块/源集 | 文件 | 依赖形态 | 处置 |
|---|---|---|---|
commonMain/data+listener+expect | 5 | 纯 Kotlin,零第三方依赖 | 原样编译(jvm + 双 ohos) |
commonMain/engine(新增) | 2 | 纯 Kotlin,从 Composable 1:1 抽取 | ohos 核心进编译 |
composeMain | 2 | androidx.compose.foundation/material3 全家桶 | 隔离,不接任何目标 |
ohosMain(新增) | 1 | expect actual | ohos 编译 |
androidMain / iosMain | 2 | java.text / Foundation actual | 留盘不编(目标已剥) |
desktopMain | 1 | java.text actual | 服务 jvm("desktop") |
sample/ + buildSrc/ | — | Compose 示例工程 + 版本号工具 | 留盘不进 settings |
expect 声明全库只有 3 处:getDecimalSeparator() / getGroupingSeparator() / isCurrencySymbolInFront()。上游给了 3 个平台的 actual——android 与 desktop 走 java.text.DecimalFormat,iOS 走 NSLocale。鸿蒙 Kotlin/Native 两头都没有,ohos 补第 4 个,固定 en_US 风格:
// lib/src/ohosMain/kotlin/com/davidmiguel/numberkeyboard/expect.ohos.kt
// HarmonyOS Kotlin/Native exposes no java.text / Foundation locale services,
// so the ohos line pins en_US-style separators to stay deterministic. Callers
// that need another locale pass explicit values to NumberKeyboardEngine /
// NumberKeyboardData instead of relying on these defaults.
actual fun getDecimalSeparator(): Char = '.'
actual fun getGroupingSeparator(): Char = ','
actual fun isCurrencySymbolInFront(): Boolean = true
策略抽取是本适配与"整库真编译"类仓库最大的工时差。NumberKeyboardClickedListener 的三段逻辑(数字 / 左辅助 / 右辅助)加 getNumberOfDecimals / formatMaxAmount 两个私有函数,全部内联在上游 NumberKeyboard.kt 里。抽取纪律只有一条:逐行对照,语义 1:1,不顺手优化——连 appended.replace(',', '.') 硬编码逗号转点(不吃自定义分隔符)这个上游怪癖也原样保留,composeMain 里的原文件保持上游原样不动作为对照基线:
// lib/src/commonMain/.../engine/NumberKeyboardEngine.kt(节选)
class NumberKeyboardEngine(
val maxAllowedAmount: Double = 10_000.0,
val maxAllowedDecimals: Int = 2,
val roundUpToMax: Boolean = true,
val decimalSeparator: Char = getDecimalSeparator(),
val groupingSeparator: Char = getGroupingSeparator()
) {
fun onNumberClicked(amount: String, number: Int): String? // 接受返回新 amount,拒绝返回 null
fun onLeftAuxButtonClicked(amount: String): String? // 小数点
fun onRightAuxButtonClicked(amount: String): String? // 退格(含整数键盘尾零截断)
}
// lib/src/commonMain/.../engine/KeyboardLayout.kt(节选)
KeyboardLayout.of(format, digits?) // rows 三行 + zero;乱序格式用 0..9 置换,可注入固定序列
1.4 页面边界
标题区 Number Keyboard / 页名 / 参数行(与上游 sample 逐字对齐)
Tabs 底栏 Integer / Decimal / Biometric / Custom 四页
金额大字区 int 或 currency(52sp 粗体,随 Tab 语义)
状态行 amount=… raw=… int=… double=… currency=…(验收对账行)
键盘区 4×3:前三行来自 layout.rows,第四行 [左辅助, zero, 右辅助]
格式切换 Custom 页顶部 4 段按钮(Normal / Inverted / Scrambled / AlwaysScrambled)
页面启动时先调 layout op 拉 Normal 布局,Custom 页首次进入时按当前格式再拉。
第 2 阶段:目标与依赖打通
2.1 依赖盘点:零第三方依赖是本适配最大的简化
KStore 的成败手是验证 kotlinx ohos 制品线,number-keyboard 不需要这一步:策略抽取后 commonMain 零第三方依赖——没有 Compose、没有 serialization、没有 coroutines,libs.versions.toml 收敛到只剩 Kotlin 插件一行。上游全部 Compose 依赖随 composeMain 隔离与 sample/ 留盘一起消失在 ohos 构建之外。依赖矩阵、制品换线、测试依赖缺口这三个存储类库的经典障碍在本库上天然不存在,适配重心完全落在"策略抽得准不准"上。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
社区此前批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / KMPSwipe / scaffolds 用的)配 Gradle 8.14.1。KStore 走的 2.4.20 线是为了读 kotlinx 制品的 2.4 元数据,本库 commonMain 零依赖,没有这层约束,按已审旧线走最稳。settings.gradle.kts 把 eazytec 仓库写在最前:
pluginManagement {
repositories {
// HarmonyOS Kotlin distribution must come FIRST, otherwise Gradle resolves
// the official Kotlin plugin and configuration fails with
// `Unresolved reference: ohosArm64`.
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_numberkeyboard"
include(":lib")
include(":example:nativeApp")
2.3 编译真实策略
lib/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm("desktop") // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {} // 零第三方依赖
val desktopTest by getting // 非默认命名源集没有快捷访问器
desktopTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
gradlew.bat :lib:desktopTest # 12 项全绿
gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
上游 data/ / listener/ / expect.kt 一个文件没动,新增只有 engine 两个文件与 ohos actual 一个文件。
2.4 测试的处置:desktopTest 交叉验证 + 全局禁用 ohos 测试
与社区各仓同款纪律:测试统一在 JVM 跑(desktopTest),根构建全局禁用 ohos 测试任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
两个本仓特有的坑都出在 jvm("desktop") 命名目标上:测试目录是 src/desktopTest/kotlin 而不是 src/jvmTest/kotlin(放错就是 NO-SOURCE 静默跳过);脚本里没有 desktopTest 快捷访问器,要 val desktopTest by getting 再挂依赖。详见 8.1。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.davidmiguel.numberkeyboard.engine/**(策略 + 布局,1:1 抽取)
com.davidmiguel.numberkeyboard.data/**(上游 NumberKeyboardData,原样编译)
↓
Bridge.kt(@CName × 1,扁平 JSON 进出,无状态)
↓ UTF-8 JSON
N-API numberKeyboardBridge()
↓
ArkUI Index.ets(四 Tab,amount 状态归 ArkTS)
请求与响应:
请求 : {"op":"press","key":5,"amount":"12","maxAllowedAmount":10000,"maxAllowedDecimals":2,"roundUpToMax":true}
{"op":"decimal","amount":"12"}
{"op":"delete","amount":"12.5","maxAllowedDecimals":2}
{"op":"layout","format":"Scrambled"}
响应 : {"ok":true,"accepted":true,"op":"press","amount":"12.5","rawAmount":"12.5",
"byte":12,"short":12,"int":12,"long":12,"float":12.5,"double":12.5,"currency":"$ 12.5"}
{"ok":true,"accepted":false,"op":"press"}
{"ok":true,"op":"layout","format":"Scrambled","rows":[[7,3,1],[8,4,2],[9,5,6]],"zero":0}
{"ok":false,"error":"..."}
两个不对称设计:其一,拒绝不是错误——ok 代表桥通、参数合法,accepted:false 代表策略拒绝,正好对应上游监听器"不回调"的静默语义,UI 侧据此保持原状态并给一行提示;其二,布局是数据——键盘三行行序与零键位置由 layout op 以纯数据推给 UI,ArkTS 永远不自己排键位,Scrambled 的洗牌也发生在 Kotlin/Native 里。
3.2 无状态桥:状态归 ArkTS
private fun config(json: MiniJson): Config = Config(
engine = NumberKeyboardEngine(
maxAllowedAmount = json.dbl("maxAllowedAmount", 10_000.0),
maxAllowedDecimals = json.int("maxAllowedDecimals", 2),
roundUpToMax = json.bool("roundUpToMax", true),
decimalSeparator = json.char("decimalSeparator", '.'),
groupingSeparator = json.char("groupingSeparator", ',')
),
currencySymbol = json.str("currencySymbol").ifBlank { "$" }
)
private fun press(json: MiniJson): String {
val key = json.int("key", -1)
if (key !in 0..9) return failure("key must be an integer 0..9")
val cfg = config(json)
return result("press", cfg, cfg.engine.onNumberClicked(json.str("amount"), key))
}
上游语义本来就是调用方持有 amount(Compose 里是 remember { mutableStateOf("") }),桥完全同构:每次请求带当前 amount 全量配置,桥算完即忘。这样"按了没反应"永远能二分定位——accepted:false 是策略拒绝,状态没刷是 UI 问题;桥也因此可重入、可并发、无会话泄漏。响应字段全部从 NumberKeyboardData 属性直出(构造一次数据类、逐字段拼接),ArkTS 侧不做第二次格式化——那会绕开上游分组逻辑,两端就对不上了。
3.3 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("NumberKeyboardBridge")
fun numberKeyboardBridge(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、缺 op、key 越界、未知格式、非法乱序置换(digits 不是 0…9 置换),全部走同一个 ok:false 通道,ArkTS 侧统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libnumberkeyboard_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosnumberkeyboard.so)
│ NumberKeyboardBridge
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让 ArkTS 复制输入策略,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("NumberKeyboardBridge")
fun numberKeyboardBridge(request: String): String // press / decimal / delete / layout 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
NumberKeyboardBridge | numberKeyboardBridge(req) | NumberKeyboardEngine 三按键 + KeyboardLayout 四格式 + NumberKeyboardData 八读数 |
HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂到 ArkTS 模块(libohosnumberkeyboard.so 里 llvm-nm 得到全局 T 符号,但 ArkTS import 看不到),必须再包一层 NAPI。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"numberKeyboardBridge", nullptr, NapiNumberKeyboardBridge, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosnumberkeyboard_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。API 26 的 NAPI 头把 napi_get_value_string_utf8 缓冲区标成非 const,先量长度再开 std::vector<char>。libohosnumberkeyboard_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosnumberkeyboard SHARED IMPORTED)
set_target_properties(ohosnumberkeyboard PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(numberkeyboard_napi SHARED napi_init.cpp)
target_link_libraries(numberkeyboard_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosnumberkeyboard)
OHOS_ARCH 为空或 libohosnumberkeyboard.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const numberKeyboardBridge: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 键盘行序来自桥
aboutToAppear(): void {
this.fetchNormalLayout(); // 布局一律来自 layout op,Normal/Inverted 也不例外
}
private fetchLayout(format: string, tab: string, apply: (r: LayoutResult) => void): void {
const req: BridgeRequest = { op: 'layout', format: format };
const parsed = JSON.parse(numberKeyboardBridge(JSON.stringify(req))) as LayoutResult;
if (!parsed.ok || parsed.rows === undefined || parsed.rows.length !== 3) {
this.setStatus(tab, `失败: ${parsed.error ?? 'layout 响应缺少 rows'}`);
return;
}
apply(parsed);
}
键盘三行由 layout.rows 渲染,第四行 [左辅助, zero, 右辅助]——证明摆位逻辑跑在 Kotlin/Native 里。AlwaysScrambled 在每次有效按键被接受后重拉 layout,实现"每键重排";Scrambled 在切入该格式时拉一次并存住。
5.2 页面交互
四 Tab 对应上游 sample 四页:按键统一 OutlineButton 风格(白底、灰边 #D1D5DB、圆角 8vp、weight(1) 高 48vp),Custom 页圆形键(borderRadius '50%' + aspectRatio(1))。数字 / 小数点 / 退格按 tabIndex(Tab 序号)分发到各页的请求构造器;每页大字显示 int 或 currency,状态行显示 amount=… raw=… int=… double=… currency=… 供与响应 JSON 逐字段对账。
5.3 拒绝反馈与错误边界
accepted:false 时状态行提示"已拒绝(超上限/超小数位)"——上游是静默拒绝,静默在验收里没法对账,业务侧同理建议给反馈。所有桥调用包 try/catch,Native 抛错(理论不该发生)显示 失败: … 并进 hilog,页面不崩。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosnumberkeyboard)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/
│ └── Bridge.kt @CName + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.numberkeyboard、versionName 5.0.2-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/libohosnumberkeyboard_api.h
├── src/main/cpp/types/libnumberkeyboard_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 四 Tab 验收页面
6.2 构建、签名和安装
gradlew.bat :lib:desktopTest # 12 项交叉验证
gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp(不要打开仓库根目录,那是 Gradle 工程)
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组且 product 引用 "default",勾选自动签名后由 IDE 回填完整配置)。
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.numberkeyboard -m entry
hdc shell hilog | findstr NumberKeyboard
四、完整代码对照
4.1 整体架构
composeMain/**(上游 Compose UI,保留对照,ohos 不编)
commonMain/engine/**(NumberKeyboardEngine + KeyboardLayout,1:1 抽取)
│ onNumberClicked / onLeftAuxButtonClicked / onRightAuxButtonClicked / of(format)
commonMain/data/**(上游 NumberKeyboardData 原样编译)
│ rawAmount / byte / short / int / long / float / double / currency
Bridge.kt + MiniJson
│ NumberKeyboardBridge
libnumberkeyboard_napi.so
│ numberKeyboardBridge
Index.ets
│ JSON parse + @State 更新(amount 状态归 ArkTS)
▼
ArkUI 真机四 Tab 页面
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_numberkeyboard、sample 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单行钉版 |
build.gradle.kts(根) | 版本 5.0.2-ohos.1、全局禁用 Ohos*Test* 任务 |
lib/build.gradle.kts | 纯 KMP:jvm(“desktop”) + ohosArm64 + ohosX64,commonMain 零依赖 |
lib/src/commonMain/.../engine/NumberKeyboardEngine.kt | 新增:策略 1:1 抽取 |
lib/src/commonMain/.../engine/KeyboardLayout.kt | 新增:四格式行序 + 乱序置换 |
lib/src/ohosMain/.../expect.ohos.kt | 新增:en_US 风格分隔符 actual |
lib/src/desktopTest/.../NumberKeyboardEngineTest.kt | 新增:12 项交叉验证 |
lib/src/composeMain/** | 上游 Compose UI 原样移入(git rename,零改动) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | C ABI、无状态分发、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 四 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | NumberKeyboardEngine(config) | 按配置构造策略(上限 / 小数位 / 回弹 / 分隔符) |
| 引擎 | onNumberClicked / onLeftAuxButtonClicked / onRightAuxButtonClicked | 三按键,接受返回新 amount,拒绝返回 null |
| 布局 | KeyboardLayout.of(format, digits?) | 四格式行序 + 零键位,digits 可注入固定置换 |
| 数据 | NumberKeyboardData | 八读数 + currency 千分位分组(上游原样) |
| Native | NumberKeyboardBridge | C ABI JSON 导出(单入口四 op) |
| N-API | numberKeyboardBridge(req) | 向 ArkTS 暴露能力 |
| ArkTS | dispatchDigit / dispatchDecimalKey / dispatchDelete | 触发操作并刷新 UI |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(numberKeyboardBridge(JSON.stringify({
op: 'press', key: 5, amount: this.amount,
maxAllowedAmount: 8888.888, maxAllowedDecimals: 3, currencySymbol: '€'
})));
// res.accepted === true 时 this.amount = res.amount;res.accepted === false 时状态不变
val engine = NumberKeyboardEngine(maxAllowedAmount = 8888.888, maxAllowedDecimals = 3)
val updated = engine.onNumberClicked(amount, 5) // null 即拒绝
val data = NumberKeyboardData(updated, engine.decimalSeparator,
engine.groupingSeparator, "€")
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;金额格式化只发生在 Kotlin 侧。
五、关键决策说明
决策 1:策略 1:1 抽取,而不是 ArkTS 重写或桥内藏状态
让 ArkTS 重写接受/拒绝规则,"按了没反应"就没有共同裁判,两端行为必然漂移;把策略藏进有状态桥,又会制造第二份 amount 真相。1:1 抽取成 NumberKeyboardEngine 后,Compose 侧与桥侧跑同一份算法,JVM 测试逐条锁定行为——连 replace(',', '.') 的上游怪癖都保留,因为抽取物的唯一使命是做上游语义的镜像,"顺手优化"等于引入回归。
决策 2:Compose UI 隔离 composeMain,ArkUI 重做验收页
上游 Compose BOM 1.8.2 / Kotlin 2.2.0 与社区 CMP 1.9.2 / HarmonyOS Kotlin 2.2.21 对不齐,Composable 强行编进 ohos 等于再打一套 Compose 发行版。composeMain 不是 Kotlin 源集、不接任何目标,上游文件以 git rename 零改动保留——既是对照基线,也是以后 compose.ui ohos 制品对齐时接回的现成材料。
决策 3:根构建替换(cmp_qrose 模式),sample 与平台源集留盘
上游根构建(AGP、Compose Multiplatform 插件、compose-bom、JitPack 发布)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。sample/ Compose 示例工程与 androidMain / iosMain actual 留盘不进 settings.gradle.kts——它们是以后给上游提 PR 的对照物,删除就找不回来了。
决策 4:桥无状态 + 响应字段 NumberKeyboardData 直出
每次请求带全量配置与当前 amount,桥算完即忘——与 Compose 调用方持有 amount 完全同构,可重入、可并发、无会话泄漏。响应里的八读数与 currency 从上游数据类属性直出,ArkTS 只展示不加工:千分位分组、货币符前后缀全部是上游实现,两端展示天然一致。
决策 5:desktopTest 交叉验证 + locale 显式化
策略逻辑在 commonMain,JVM 与 ohos 跑同一份代码;12 项 desktopTest 是"策略没漂"的闸门。ohos 侧 KN 没有 java.text / NSLocale,actual 固定 en_US 风格分隔符——不是丢功能,上游本来就允许显式传分隔符(Custom 页就是这么做的),桥协议里这三个值同样是请求字段,需要其他 locale 由调用方显式传入,没有隐藏的全局状态。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(win32)、JDK 21(Eclipse Temurin,org.gradle.java.home 钉版)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
gradlew.bat :lib:desktopTest
12 项用例全部通过(NumberKeyboardEngineTest,全部为本轮新增——上游没有测试,抽取物的行为锁只能自己建):
| 用例 | 钉住什么 |
|---|---|
leadingZeroIsNotAppended | 空串按 0 拒绝、"0" 按数字替换首位零 |
decimalsAreCapped | 小数位超限拒绝、恰好卡线接受 |
exceedingMaxRoundsUpToMaxWhenConfigured | 超上限回弹到 formatMaxAmount 拼装值(10.00) |
exceedingMaxIsRejectedWhenRoundUpDisabled | roundUpToMax=false 时静默拒绝(Biometric 语义) |
roundUpSnapsToMaxAmount | 回弹值 10000.00 的拼装格式 |
decimalSeparatorIsAppendedOnce | 空串补 0.、重复小数点拒绝 |
backspaceWalksBackToEmpty | 退格到空串、空串退格拒绝 |
backspaceTrimsTrailingZerosForIntegerKeyboards | 整数键盘 12.00 尾零截断 |
customSeparatorsAreHonoured | 自定义分隔符下全部规则生效 |
layoutsMatchUpstreamRows | Normal / Inverted 行序、乱序固定置换 |
scrambledLayoutRejectsNonPermutations | 非法置换直接抛参数异常 |
shuffledDigitsCoverAllTenDigits | 随机洗牌覆盖 0…9 全集 |
抽取即对照 + 测试锁行为,两层合起来才是"策略 100% 复用"的证据:能按键出数字不算验收——那只证明键能按,不证明拒绝语义和上游一致。
6.3 HAP 验证
:lib:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/<abi>/
llvm-nm -D: NumberKeyboardBridge 为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libnumberkeyboard_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
start ability successfully
debugShared 未 strip 的 KN so arm64 约 3.7MB、x86_64 约 3.5MB(含 Kotlin/Native 运行时),各 ABI 目录配 libc++_shared.so 约 1.8MB;HAP 内含双 ABI 各三件套(libnumberkeyboard_napi.so / libohosnumberkeyboard.so / libc++_shared.so)。
6.4 功能用例
- 启动后
aboutToAppear拉 Normal 布局,键盘行序123/456/789、零键居中——来自layoutop 的真实返回,不是 ArkTS 写死; - Integer 页空串按 0 无反应(
accepted:false,状态行提示已拒绝); - Integer 页从 1 连按到
10000(见真机截图),状态行amount=10000 raw=10000 int=10000 double=10000 currency=$ 10,000——千分位分组$ 10,000是 Kotlin/Native 侧上游formatAmount的输出; - Integer 页超上限再按一位,
formatMaxAmount回弹(上游对 0 位小数的拼装原样语义),大字int仍显示 10000; - Decimal 页按
1再按.得1.,连按第二个.被拒;输入1234.5显示$ 1,234.5; - Biometric 页按到
9999后再按任何数字被拒且数值不回弹——roundUpToMax=false与 Integer 页最大的行为差异; - Custom 页切 Inverted 行序镜像(
789/456/123),切 Scrambled 进页重洗一次、期间稳定,切 AlwaysScrambled 每次有效按键后整屏重排,零键位置跟随zero字段漂移; - 任何一步
ok:false在状态行显示失败: …,全程无崩溃。
6.5 验证结论
输入策略、数据模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,四个 Tab 展示真实的键盘会话:布局来自 Native、按键接受/拒绝与上游语义一致、金额格式化(千分位分组)由 Kotlin/Native 输出(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/number-keyboard.git
cd number-keyboard
export JAVA_HOME="/path/to/jdk-21"
gradlew.bat :lib:desktopTest # 12 项交叉验证(Windows;macOS/Linux 用 ./gradlew)
gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# DevEco Studio 打开 example/harmonyApp,勾选自动签名后构建运行
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.numberkeyboard -m entry
hdc shell hilog | findstr NumberKeyboard
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配——但这个库的策略长在 Composable 肚子里,第一步不是加
ohosArm64(),而是把监听器逻辑 1:1 抽成NumberKeyboardEngine;抽取纪律是逐行对照、不顺手优化,composeMain原文件零改动留作对照基线; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- Version catalog 的键名决定 Gradle 访问器:
kotlin-multiplatform对应libs.plugins.kotlin.multiplatform,从别的仓拷kotlinMultiplatform写法会报Unresolved reference——跨仓拷构建脚本的 alias 名要随 toml 键名改; jvm("desktop")命名目标的测试目录是src/desktopTest/kotlin,不是src/jvmTest/kotlin——放错目录desktopTest报NO-SOURCE静默跳过,看起来"构建通过"其实一条测试没跑;- 非默认命名源集没有快捷访问器,脚本里要
val desktopTest by getting再挂dependencies,直接写desktopTest.dependencies是脚本编译错误; - ArkTS 结构体成员变量不能与内置通用属性重名:
@State tabIndex撞CustomComponent.tabIndex()(焦点顺序方法),编译期报10505001 Property 'tabIndex' in type 'Index' is not assignable to the same property in base type 'CustomComponent'——改名currentTabIndex解决;同类雷区还有key/id/position/enabled/visibility; - 根锚定的 gitignore 模式盖不住嵌套目录:
example/harmonyApp/entry/oh_modules/(DevEco 同步生成的符号链接目录)要单独追加 gitignore,否则混进提交;.hvigor/、.cxx/、entry/build/同理; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 引擎行为有疑问时先怀疑测试断言再怀疑引擎:
"12,55"退格一次是"12,5"不是"12,"——首跑失败的断言算错了步数,引擎是上游语义的镜像,改的只能是测试; - 签名材料由 DevEco 自动写入本机配置,证书绝不进入 AtomGit。
8.2 已知问题
- 鸿蒙侧没有 Compose UI,四 Tab 是 ArkUI 重做,按钮观感与 Material3 有差异;
composeMain保留上游 Composable,等社区compose.uiohos 制品与 CMP 工具链对齐后接回,ArkUI 降级为 fallback; - ohos 的 locale actual 固定 en_US 风格(
./,/ 货币符在前);需要其他 locale 由调用方经桥协议显式传参,KN 侧 i18n 能力补齐后可改 actual,业务无感; - 桥不持久化配置:每次请求带全量配置(上限、小数位、分隔符),无会话状态——可重入的代价是请求体略长;
AlwaysScrambled的"每键重排"靠 ArkTS 在按键被接受后重拉layout:订阅式推送过不了同步 C ABI,这与updatesFlow 不跨桥是同一类边界;float/double是二进制浮点的字符串化,金额计算业务应使用rawAmount或double配合 decimal 库,这与上游语义一致,不是鸿蒙特有;- 上游 sample 的 Material Icons 未搬运,指纹键以文字占位、退格键以
⌫符号呈现; - HAP 安装必须使用与 bundle name
org.terminator.ohos.numberkeyboard匹配的签名。
8.3 未来优化方向
- 把
NumberKeyboardData之外的扩展读数(如格式化整数字符串)补进桥响应; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)与其他设备 ABI 的验收矩阵和 uitest 自动化截图脚本(四 Tab 几十条拒绝路径,手点容易漏);
- 若社区 CMP /
compose.uiohos 制品与上游 Compose 版本对齐,把composeMain接回编译目标。
九、总结
9.1 核心难点回顾
上游 number-keyboard(策略内联在 Composable、UI 即全部依赖)
→ 1:1 策略抽取(NumberKeyboardEngine + KeyboardLayout 进 commonMain)
→ 工具链对齐 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 8.14.1,cmp_qrose 已审钉法)
→ 根构建替换(cmp_qrose 模式,sample 与 android/ios 源集留盘)
→ ohosArm64 / ohosX64 KLIB + en_US actual
→ C ABI(@CName 单入口四 op)+ 无状态桥
→ C++ N-API(DisposeString 纪律)
→ ArkTS JSON 解析(amount 状态归 ArkTS)
→ ArkUI 真机四 Tab 验收(布局 / 拒绝 / 回弹 / 乱序重排 / 千分位分组)
9.2 封装层次
- 策略层:
NumberKeyboardEngine三按键 +KeyboardLayout四格式(1:1 抽取,JVM 测试锁定); - 数据层:上游原版
NumberKeyboardData八读数与currency千分位分组; - UI 层:上游 Compose UI 原样保留在
composeMain(不编 ohos)+ ArkUI 四 Tab 验收页; - Native 层:生成双 ABI 动态库并通过单个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:持有
amount状态、页面状态、操作分发、拒绝反馈与错误文本; - DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 库的"值钱部分"长在哪,决定适配的第一刀:qrose 的编码器天然在
commonMain,KStore 整库纯 Kotlin 只补 8 行 actual,number-keyboard 的策略长在 Composable 肚子里——必须先做 1:1 抽取再谈编译。抽取物是上游语义的镜像,抽取质量(逐行对照 + JVM 测试锁行为)决定鸿蒙侧是否真的"复用了上游",而不是"看起来像上游"; - 工具链线经验不能互相套用:KStore 为读 kotlinx 制品 2.4 元数据走
2.4.20线留 Gradle 9.x;本库commonMain零第三方依赖,没有版本元数据约束,按社区已审旧线2.2.21-1.0.0+ Gradle 8.14.1 走最稳。选线依据是上游依赖形态,不是"越新越好"; - 无状态桥 + 状态归 ArkTS 是交互类库的正确桥形:与 Compose 调用方持有
amount完全同构,ok与accepted两级响应把"桥坏了"和"策略拒绝"干净二分;交互库的验收重点不在渲染闭环,而在拒绝路径——空串按 0、双小数点、超上限不回弹,每一条都要在对账状态行上看到accepted:false。
9.4 适配成果
当前适配已完成:
- number-keyboard
5.0.2(5.0.2-ohos.1)输入策略的双 ABI Kotlin/Native 编译(上游data//listener/零改动,新增仅 engine 两文件 + actual 一文件); - 输入策略 1:1 抽取与 12 项 JVM 交叉验证全绿(首位零、上限回弹、
roundUpToMax=false、尾零截断、自定义分隔符、四格式行序、乱序置换); - 单个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 四 Tab 验收页面(Integer / Decimal / Biometric / Custom,布局拉取、拒绝反馈、AlwaysScrambled 每键重排、千分位分组对账);
- HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证(Integer 页真机截图,
currency=$ 10,000为 Kotlin/Native 输出); - 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。
参考文档
更多推荐

所有评论(0)