本文记录 kompose-country-code-picker 接入 OpenHarmony 的完整过程,覆盖数据与 UI 的依赖度盘点、Compose UI 与纯 Kotlin 内核的源码集手术、真实核心编译、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 验收页面、HAP 构建和真机验证。

kompose-country-code-picker 是基于 Compose Multiplatform Material 3(M3)的国家区号选择器:KomposeCountryCodePicker 组件背后是一整套不碰 UI 的纯 Kotlin 内核——241 个国家/地区的数据集(名称、ISO 3166-1 alpha-2 码、电话区号、国旗资源)、重音不敏感的国家搜索(名字 / ISO 码 / 区号三路索引)、逐国号长规则的手机号校验、AsYouType 逐位格式化、locale → 时区 → 兜底的默认国家推断。本次适配与 KStore(零 UI 存储引擎,expect 只有 1 处)是两类极端:这是重 UI 的 CMP 组件库,内核与 Compose 资源系统深度纠缠(Country.flag 直接持有 DrawableResource,268 个资源文件在 commonMain)。适配把边界放在:纯 Kotlin 内核的 ohos 编译 + Compose UI 的源码隔离(composeMain 阁楼)+ C ABI/N-API 桥接层 + ArkTS 验收宿主——cmp_qrose 审核路径在"数据 + 算法型"CMP 库上的又一次落地,数据与搜索 / 校验 / 格式化语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

项目地址: AtomGit/oh-tpc/kompose-country-code-picker.git

上游仓库: joelkanyi/kompose-country-code-picker

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

kompose-country-code-picker 为 Compose Multiplatform 应用提供"国家区号选择 + 手机号校验"这一件事的完整答案:rememberKomposeCountryCodePickerState 一行建状态,KomposeCountryCodePicker 一个组件出输入框,CountrySelectionDialog 出搜索选择对话框,state 上的 getFullPhoneNumber / isPhoneNumberValid / getFullyFormattedPhoneNumber 覆盖采集与校验闭环。KMP 项目把这些能力放在 commonMain,由 Android、iOS、JVM、JS、WasmJS 共享。OpenHarmony 应用同样需要这套能力,但上游构建(Kotlin 2.4.10 / Gradle 9.7.0 / AGP 9.3.1 / CMP 1.11.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示国家列表,却无法证明公共数据集、搜索索引、校验规则、格式化引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证数据层、算法层、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.4.10 / Gradle 9.7.0 / AGP 9.3.1(com.android.kotlin.multiplatform.library)与鸿蒙工具链无法共存于一个 Gradle 构建。
Compose UI 编不进上游 CMP 1.11.1 / Material3 与社区 ohos 制品线版本对不齐,组件、VisualTransformation、DrawableResource 全部无法在 ohos target 上编译。
内核与资源纠缠Country.flag 直接持有 Compose 的 DrawableResource,PickerUtils.allCountries 引用生成的 Res 对象——数据集本身绑在资源插件上。
搜索的隐藏依赖重音不敏感搜索的 unaccent() 依赖 expect String.normalizeUnicode(),expect 裁剪不当会让 commonMain 编不过,ohos 又没有 ICU/NSString 归一化可用。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:数据与算法内核的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。数据与语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

1.2 库提供的能力

Demo 提供一个真实的国家区号选择会话,对应桥接的全部入口:

  • 版本握手:version 返回 fork 版本号与数据集规模,来自真实 Native 调用而非静态文字;
  • 国家库:countries 返回全量 241 国(或按 query 过滤),每国含 code / phoneNoCode / name / flagAsset,ArkTS 用 $rawfile 按名加载国旗;
  • 单国查询:country 按 ISO 码取国家,用于初始选中国家回填;
  • 搜索:query 走上游 CountrySearch 的重音不敏感三路索引(名字 / ISO 码 / 区号),cote 命中 Côte d’Ivoire;
  • 号码校验:validate 对国内号(配合选中国家)或国际号(+ 开头)给出逐国号长规则的判定,并回显匹配到的国家;
  • 格式化:format 输出 AsYouType 分组结果(如 712 345 678),fullNumber 输出 E.164 与 +254 712 345 678 式完整格式;
  • 默认国家:defaultCountry 接受 ArkTS 注入的设备时区与系统语言,按"locale 区域子标签 → IANA 时区映射 → 兜底 us"三级解析。

响应通用字段:

字段作用
ok操作是否成功,失败时 error 带原文。
total / countcountries 返回数据集总数与本次匹配数(驱动页面"共 N 个国家 / 匹配 M 个"统计行)。
countries[] / country国家对象数组或单对象:code、phoneNoCode、name、flagAsset。

引擎侧完整能力(241 国数据、CountrySearchIndex 索引、PhoneValidation 号长表、CommonPhoneFormatter 逐国 pattern、TimezoneCountryMap 时区映射、extractCountryCodeAndPhoneNumber 最长区号匹配)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留全套上游测试加两个新增测试类交叉验证,证明 ohos .so 里执行的数据与语义没有漂移。

1.3 实现适配

维度要求
代码复用数据、搜索、校验、格式化、时区映射的上游实现原样编译,不提取、不重写;新增的只有 1 个公共门面、1 张 Unicode 分解表、1 个 ohos actual 和桥接层。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);Compose UI 与 Res 查找整体移入 src/composeMain(未声明源集,ohos 不编译);上游 sample / docs / CI 留盘不进构建。
桥接稳定使用 1 个 C ABI(CcpExecute)和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律库内核零外部依赖(上游 iOS/JS/Wasm 同款路径,不需要给 libphonenumber 找 ohos 制品),工具链只钉 HarmonyOS Kotlin 2.4.20-0.1.0-07。
UI 完整单页四 Tab 覆盖全部桥接入口:国家库搜索、号码校验、默认国家、关于(版本握手)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 KomposeCountryCodePicker / CountrySelectionDialog 组件保留源码在 composeMain 隔离阁楼,等社区 CMP ohos 工具链对齐后原样接回;ArkTS Demo 把桥暴露的内核能力画出来。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与源码依赖度、确认内核/UI 边界、确定交付物
第 2 阶段:目标与源码打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.4.20-0.1.0-07 钉版、composeMain 隔离、公共门面
第 3 阶段:桥协议建模     ── 7 个 op 的 JSON 契约、无状态格式化语义、错误通道
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── callCcp 封装、四 Tab、国旗 rawfile、ArkTS 严格模式适配
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
komposecountrycodepicker/           上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
komposecountrycodepicker/src/
  commonMain/                       纯 Kotlin 内核:241 国数据、搜索、校验、格式化、时区映射
  composeMain/                      新增:Compose UI 隔离阁楼(未声明源集,ohos 不编译)
  ohosMain/                         新增:normalizeUnicode 的查表 actual
  jvmTest/                          新增:JVM 交叉验证(UnicodeCanonicalFoldTest + CountryCodePickerCoreTest)
example/nativeApp/                  Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                 DevEco Stage、CMake N-API 和 ArkTS 四 Tab 页面
sample/ docs/ .github/ spotless/    上游示例、文档站、CI 与格式化,留盘不进构建
README.OpenHarmony.md / _CN.md      鸿蒙适配说明(中英双份)
README.upstream.md                  上游原文保存
doc/                                适配全流程文章与效果图

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :komposecountrycodepicker 与 :example:nativeApp,rootProject.name = "ohos_komposecountrycodepicker"。上游的 spotless / dokka / binary-compatibility-validator / nmcp 发布插件、AGP classpath、Node/Yarn 仓库全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven)
JDK21(org.gradle.java.home 指向本机 JDK)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle9.6.1(上游 wrapper 为 9.7.0;本机 9.7.0 发行包未缓存且下载超时,9.6.1 已缓存且有 KStore / compottie 先例,2.4.20 线支持 Gradle 9.x)
依赖库内核零外部依赖;桥零依赖(手写 MiniJson)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.ccp
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

这是本次适配成败的手:上游把"数据"和"UI/资源"拧在同一个 commonMain 里,必须逐文件分类处置。

文件 / 目录依赖形态处置
data/Country.ktflag: DrawableResource 直接持有 Compose 类型改造:flag 变为纯字符串资源名(如 "ke"、"ic_do")
utils/PickerUtils.kt241 国数据 + getFlags / getCountryName / getNumberHint 三个 Res 查找 when 大表拆分:数据与纯函数留 commonMain;三个 Res 查找整体移入阁楼 ResourceLookups.kt
utils/CountrySearch.kt纯 Kotlin,但 unaccent() 依赖 expect保留 commonMain(expect 问题见 2.3)
utils/PhoneValidation.kt / CommonPhoneFormatter.kt / TimezoneCountryMap.kt纯 Kotlin 零依赖整体编译(iOS/JS/Wasm 同款路径)
utils/Platform.kt(expect)5 个 expect + 1 个接口裁剪:只留 normalizeUnicode expect 与 PhoneFormatter 接口
component/*.kt(702 行主组件 + 对话框)、PhoneNumberTransformation.kt、FlagSize.kt重度 Compose移入 composeMain 阁楼(含组件测试与 Fake)
commonMain/composeResources/268 个文件(265 面国旗 PNG + 3 个 xml 图标)+ 15 组语言 strings留盘;265 个 PNG 拷入 HAP 的 rawfile 供 ArkTS 按名加载
androidMain/jvmMain/iosMain/jsMain/wasmJsMain 的 Platform.*.kt5 个平台 actual(android/jvm 走 libphonenumber)留盘未编译;jvm actual 裁剪到只剩 normalizeUnicode

expect 声明全库只有 1 处文件(6 个声明),但它的处置比 KStore 那个 dispatcher expect 复杂得多——见 2.3。

1.4 页面边界
标题区          国家区号选择器 · Kompose CCP / 副标题(Kotlin/Native ohosArm64/ohosX64)
Tab 1 国家库    搜索框(即时调 countries)+ LazyForEach 列表(国旗 + 名称 + 区号)
                统计行「共 N 个国家 / 匹配 M 个」+ 点选设为当前国家(✓ 高亮)
Tab 2 号码校验  当前国家卡片 + 手机号输入 + 实时 format / fullNumber / validate
                绿「有效」/ 红「无效」徽标
Tab 3 默认国家  设备时区与系统语言(@ohos.i18n)+ defaultCountry 解析结果 + 顺序说明
Tab 4 关于      版本握手行、库简介、架构分层、AtomGit 仓库与社区链接、桥接日志

页面启动时先调 version 握手,成功后默认选中 ke(上游作者的家乡,也是上游测试的基准国家)。

第 2 阶段:目标与源码打通

2.1 依赖面验证:这个库比 KStore 还干净

KStore 适配的成败手是验证 kotlinx ohos 制品线;本库的成败手在别处——内核零外部依赖。commonMain 的依赖表里只有 compose 运行时(服务于 UI),数据 / 搜索 / 校验 / 格式化全部是纯 Kotlin:PhoneValidation 是逐国号长的 map,CommonPhoneFormatter 是逐国分组的 pattern 表,libphonenumber 只在 android/jvm 的平台 actual 里被引用(ohos 走 iOS/JS/Wasm 的 common 路径)。这意味着工具链钉版只需要 Kotlin 本身,不存在"依赖矩阵"问题。

2.2 工具链钉版(为什么是 2.4.20-0.1.0-07 + Gradle 9.6.1)

Kotlin 钉 2.4.20-0.1.0-07(eazytec 社区线):上游源码本来就是 Kotlin 2.4.10 编的,2.4.20 与之同代,源码级兼容成本为零。Gradle 方面上游 wrapper 是 9.7.0,但本机 9.7.0 发行包未缓存且 services.gradle.org 下载超时;9.6.1 已缓存,且 2.4.20 线支持 Gradle 9.x(KStore 用 9.6.1、compottie 用 9.7.1 都是先例)——wrapper 降到 9.6.1,一条命令都不用改。

settings.gradle.kts 把 eazytec 双仓写在最前(ohosArm64() 只在 HarmonyOS Kotlin 插件里,顺序错了配置期直接 Unresolved reference: ohosArm64):

pluginManagement {
    repositories {
        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_komposecountrycodepicker"

include(":komposecountrycodepicker")
include(":example:nativeApp")

gradle/libs.versions.toml 收敛到只剩 HarmonyOS Kotlin 插件;gradle.properties 删掉全部 Android / AGP / compose experimental 配置,补 kotlin.native.ignoreDisabledTargets=true 等三个 native 开关,版本改 2.1.1-ohos.1;根 build.gradle.kts 极简化并全局禁用 Ohos 测试任务。

2.3 源码集手术:composeMain 阁楼与三处关键改造

阁楼机制:src/composeMain/ 目录保留源码但不声明为源集,ohos 与 jvm 的编译都看不见它——这就是"源码在、编译不在"的隔离,等社区 CMP ohos 工具链对齐后把它声明回源集即可接回。移入阁楼的文件:KomposeCountryCodePicker.kt、CountrySelectionDialog.kt、PhoneNumberTransformation.kt、FlagSize.kt、ResourceLookups.kt(从 PickerUtils 切出的三个 Res 查找大表)、组件测试与 Fake。

改造一:Country.flag 字符串化。flag: DrawableResource → flag: String(资源名)。241 个条目的批处理有个真实的坑:前 5 个条目是命名参数(flag = Res.drawable.ad),后面 236 个是位置参数(裸 Res.drawable.al),且单行 / 多行混排——两条 sed 模式分别处理才清干净。多米尼加的文件名是历史遗留的 ic_do.png,字符串化后恰好以资源文件名为准。

改造二:Platform.kt 的裁剪比想象中纠结。最初的方案是把 5 个 expect 整体搬进阁楼,但 unaccent()(搜索的归一化入口,留在 commonMain 的 CountrySearch 依赖它)转而依赖 normalizeUnicode()——expect 不能全搬。最终裁剪:commonMain 只留 expect String.normalizeUnicode() 和 PhoneFormatter 接口(CommonPhoneFormatter 实现它),其余 4 个 expect(默认国家码、libphonenumber 校验、AsYouType 工厂、分隔符判断)只服务 Compose 组件,随组件进阁楼。

改造三:ohos 的 normalizeUnicode 没有轮子可用。JVM 有 java.text.Normalizer,iOS 有 NSString.decomposedStringWithCanonicalMapping,ohos 的 Kotlin/Native 什么都没有。解法是 UnicodeCanonicalFold:从 UCD 生成的 Latin-1 Supplement / Latin Extended 递归规范分解表(252 条),放在 commonMain 而不是 ohosMain——为了让 jvmTest 用 java.text.Normalizer 逐字符交叉验证(UnicodeCanonicalFoldTest 两个用例:U+0080…U+0250 全区间 + 全部 241 国名)。这张表第一次生成时还真被测试抓了现行:U+01D5 的分解产物 00DC 0304 本身还是预组字符,NFD 要求递归展开成 0055 0308 0304——改用递归全分解后全绿,这正是"表放 commonMain 让 JVM 钉住"的价值。

公共门面:上游 PickerUtils / PhoneValidation / CommonPhoneFormatter 全是 internal,跨模块不可见,而桥在 example/nativeApp——新增 CountryCodePickerCore 公共门面(qrose 加 Qrose object 的同款手法):

public object CountryCodePickerCore {
    public const val VERSION: String = "2.1.1-ohos.1"
    public val countryCount: Int get() = PickerUtils.allCountries.size
    public fun allCountries(): List<Country>
    public fun searchCountries(query: String): List<Country>
    public fun countryByCode(code: String): Country?
    public fun defaultCountryCode(locale: String? = null, timezone: String? = null): String
    public fun isValidFullPhoneNumber(fullPhoneNumber: String): Boolean
    public fun fullPhoneNumber(phoneNumber: String, countryCode: String): String
    public fun isValidPhoneNumber(phoneNumber: String, countryCode: String): Boolean
    public fun formatPhoneNumber(phoneNumber: String, countryCode: String): String
    public fun fullyFormattedPhoneNumber(phoneNumber: String, countryCode: String): String
    public fun extractCountryCodeAndPhoneNumber(fullPhoneNumber: String): Pair<String?, String>
}
2.4 测试的处置:零改动是底线,两处修复是例外

上游 commonTest 里 7 个纯内核测试类(CharSequenceExt / CommonPhoneFormatter / CountryData / CountrySearch / PhoneValidation / PickerUtils / TimezoneCountryMap)零改动保留,在 jvmTest 全绿。两处例外都因资源查找移出:CountrySearchTest / PickerUtilsTest 里构造 Country 的 Res.drawable.ke 换成字符串 "ke";getFlags / getCountryName 的资源测试换成 flagAssetsAreSane 纯数据测试(每个国旗资源名必须是合法文件名)。组件测试与 Fake 随组件进阁楼。根构建全局禁用 Ohos*Test*:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

库模块收敛为纯 KMP:

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {}
        commonTest.dependencies { implementation(kotlin("test")) }
    }
}

实测:

./gradlew :komposecountrycodepicker:compileKotlinJvm        # BUILD SUCCESSFUL
./gradlew :komposecountrycodepicker:jvmTest                 # 全绿(上游测试 + 2 个新增测试类)

第 3 阶段:桥协议建模

3.1 JSON 契约
com.joelkanyi.jcomposecountrycodepicker/**(上游内核,原样编译)
        ↓ 241 国数据 / CountrySearch / PhoneValidation / CommonPhoneFormatter / TimezoneCountryMap
CountryCodePickerCore(公共门面)
        ↓ internal → public 的唯一出口
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API ccpExecute()
        ↓
ArkUI Index.ets(单页四 Tab)

请求与响应(7 个 op):

请求 : {"op":"version"}
       {"op":"countries","query":"ken"}          # query 省略/空 = 全量
       {"op":"country","code":"ke"}
       {"op":"defaultCountry","timezone":"Africa/Nairobi","locale":"en-KE"}
       {"op":"validate","phoneNumber":"712345678","countryCode":"ke"}
       {"op":"format","phoneNumber":"712345678","countryCode":"ke"}
       {"op":"fullNumber","phoneNumber":"712345678","countryCode":"ke"}
响应 : {"ok":true,"name":"kompose-country-code-picker","version":"2.1.1-ohos.1","countryCount":241}
       {"ok":true,"total":241,"count":M,"countries":[{"code":"ke","phoneNoCode":"+254","name":"Kenya","flagAsset":"ke"},...]}
       {"ok":true,"found":true,"country":{...}}
       {"ok":true,"source":"locale","code":"ke","phoneNoCode":"+254",...}
       {"ok":true,"valid":true,"fullNumber":"+254712345678","countryCode":"ke"}
       {"ok":true,"formatted":"712 345 678"}
       {"ok":true,"e164":"+254712345678","formatted":"+254 712 345 678"}
       {"ok":false,"error":"..."}                 # 统一错误通道

两个语义设计:validate 的 phoneNumber 以 + 开头时直接按国际号校验(并回显最长区号匹配到的国家),否则用 countryCode 拼出国际号再校验;countries 的 total 是数据集总数、count 是本次匹配数,分别驱动统计行的两半。

3.2 无状态格式化:AsYouType 过不了 C 签名,重放可以

上游的 PhoneFormatter 是有状态的(inputDigit 逐位累积,VisualTransformation 靠它做边打边格式化),C ABI 的请求/响应模型带不动状态。桥的 format / fullNumber 用无状态整串重放:每次调用把整串数字从头喂进一个新开的 CommonPhoneFormatter,输出等价于用户逐位输完整串的最终形态——对"输入变化时实时格式化"的 ArkTS 场景语义完全一致。

3.3 错误通道

唯一导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@OptIn(ExperimentalForeignApi::class)
@CName("CcpExecute")
fun ccpExecute(request: String): String = try {
    CcpBridge.handle(request)
} catch (t: Throwable) {
    """{"ok":false,"error":${MiniJson.str(t.message ?: "unknown error")}}"""
}

非法 JSON、缺 op、不认识的 op、缺参数,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进日志区。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libccp_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosccp.so)
  │ CcpExecute
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;HarmonyOS Kotlin 的 CAdapter 又不会把 @CName 业务符号挂到 ArkTS 模块上,所以必须 CMake 再包一层 NAPI。

4.2 Kotlin/Native 导出函数
@CName("CcpExecute")
fun ccpExecute(request: String): String   // 7 个 op 一口进
C ABI 符号ArkTS 方法覆盖内核
CcpExecuteccpExecute(req)7 个 op 全部分发(数据 / 搜索 / 校验 / 格式化 / 默认国家 / 版本)
libohosccp_symbols(NAPI 层使用)DisposeString 释放 KN 返回串的运行时入口

桥不引任何序列化运行时:MiniJson 手写扁平 JSON 解析 / 转义(qrose 同款理由——C 签名永远不变,.so 里不拖 kotlinx-serialization)。国家名的转义只处理必须转义的字符(" \ 控制字符),UTF-8 原样直通。

4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"ccpExecute", nullptr, NapiCcpExecute, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 CcpExecute 并包装返回。KN 返回的字符串必须经 libohosccp_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosccp_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohosccp SHARED IMPORTED)
set_target_properties(ohosccp PROPERTIES
  IMPORTED_LOCATION ${KN_SO}          # entry/libs/<arch>/libohosccp.so
  IMPORTED_NO_SONAME TRUE)

add_library(ccp_napi SHARED napi_init.cpp)
target_link_libraries(ccp_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosccp)

OHOS_ARCH 为空或 libohosccp.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const ccpExecute: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 封装与环境注入
function callCcp(op: string, extra?: Record<string, Object>): Record<string, Object> {
  const request: Record<string, Object> = { 'op': op };
  if (extra !== undefined) {
    const keys: string[] = Object.keys(extra);
    for (let i = 0; i < keys.length; i++) {
      request[keys[i]] = extra[keys[i]];
    }
  }
  const raw: string = ccpExecute(JSON.stringify(request));
  return JSON.parse(raw) as Record<string, Object>;
}

设备环境在 aboutToAppear 注入:i18n.getTimeZone().getID() 取 IANA 时区(如 Asia/Shanghai)、i18n.System.getSystemLanguage() 取系统语言标签,作为 defaultCountry 的解析线索传给内核——Kotlin 侧不碰平台 API,时区 → 国家的映射由上游 TimezoneCountryMap 完成。

5.2 页面交互

国家库 Tab 自上而下:统计行(共 N 个国家 / 匹配 M 个)、搜索框(输入即时调 countries,重音不敏感)、List + LazyForEach 国家行(28vp 国旗 $rawfile('flags/' + flagAsset + '.png') + 名称 + 区号右对齐),点选设为当前国家(选中行蓝底 + ✓)。号码校验 Tab:当前国家卡片、手机号输入框(输入变化实时调 format / fullNumber / validate)、结果卡(格式化 / E.164 / 完整格式化 / 绿「有效」红「无效」徽标)。默认国家 Tab:设备时区与系统语言 + 解析出的默认国家 + 「locale 区域 → 时区映射 → 兜底 us」顺序说明。关于 Tab:版本握手行、库简介、Kotlin 核心 → libohosccp.so(C ABI)→ libccp_napi.so(NAPI)→ ArkTS 分层、AtomGit 仓库与社区链接、桥接日志(带错误红色标记,最新在前)。

5.3 ArkTS 严格模式是真实的坑

DevEco 的 ArkTS 编译器开严格模式后,首轮编译报了 8 错 1 警:arkts-no-untyped-obj-literals——对象字面量必须对应显式类型且 key 用字符串字面量,callCcp('countries', { query: this.query }) 这种内联字面量直接拒绝;TimeZone 没有 .id 属性,正确的是 getID() 方法;getSystemLanguage() 已废弃,换 i18n.System.getSystemLanguage()。修复模式:每个调用点先声明 const extra: Record<string, Object> = { 'query': this.query } 再传参,callCcp 内部构造同样用带引号 key。UI 属性位置的字面量(.margin({ top: 16 }))框架已类型化,不受影响。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosccp)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/com/joelkanyi/ccp/nativeapp/
│       ├── Bridge.kt                         @CName("CcpExecute") + 7 op 分发
│       └── MiniJson.kt                       手写扁平 JSON(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.ccp、versionName 2.1.1-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/libohosccp_api.h
        ├── src/main/cpp/types/libccp_napi/{index.d.ts, oh-package.json5}
        ├── src/main/ets/pages/Index.ets      ArkUI 四 Tab 验收页面
        └── src/main/resources/rawfile/flags/ 265 面国旗 PNG(按资源名动态加载)
6.2 构建、签名和安装
./gradlew :komposecountrycodepicker:jvmTest                                # 交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

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.ccp -m entry

四、完整代码对照

4.1 整体架构

com.joelkanyi.jcomposecountrycodepicker(上游内核原样编译)
    │ 241 国数据 / CountrySearch 重音索引 / PhoneValidation 号长表
    │ CommonPhoneFormatter 逐国 pattern / TimezoneCountryMap / 最长区号匹配
CountryCodePickerCore(公共门面)
    │ internal → public
Bridge.kt + MiniJson
    │ CcpExecute(@CName × 1)
libccp_napi.so
    │ ccpExecute
Index.ets
    │ JSON parse + @State 更新 + $rawfile 国旗
    ▼
ArkUI 真机页面(单页四 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_komposecountrycodepicker、sample/docs/CI 移出
gradle/libs.versions.toml只留 Kotlin 2.4.20-0.1.0-07 + kotlin-multiplatform 插件
build.gradle.kts(根)版本 2.1.1-ohos.1、全局禁用 Ohos*Test* 任务
komposecountrycodepicker/build.gradle.kts纯 KMP:jvm() + ohosArm64 + ohosX64,零依赖
.../data/Country.ktflag 字符串化(DrawableResource → 资源名)
.../utils/PickerUtils.kt241 国数据 + 纯函数保留;Res 查找切出
.../utils/UnicodeCanonicalFold.ktUCD 生成的递归 NFD 分解表(252 条,JVM 钉住)
.../CountryCodePickerCore.kt公共门面(桥的唯一入口)
src/composeMain/**Compose UI 隔离阁楼(组件 + ResourceLookups + 组件测试)
src/ohosMain/.../Platform.ohos.ktnormalizeUnicode 查表 actual
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt / MiniJson.kt1 个 C ABI、7 op 分发、手写 JSON、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 四 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(18 节)

4.3 关键 API 对照

层次API作用
内核PickerUtils.allCountries241 国数据集(名称排序,上游原件)
内核CountrySearchIndex.search重音不敏感三路索引(名字 / ISO 码 / 区号)
内核PhoneValidation.isValidPhoneNumber逐国号长校验(iOS/JS/Wasm 同款路径)
内核CommonPhoneFormatter.inputDigitAsYouType 逐位格式化(逐国 pattern)
内核TimezoneCountryMapIANA 时区 → 国家 + locale 区域子标签解析
内核extractCountryCodeAndPhoneNumber最长区号优先 + 共享区号首选国(+1 → us)
门面CountryCodePickerCoreinternal → public 的唯一出口 + 无状态格式化重放
NativeCcpExecuteC ABI JSON 导出(唯一符号)
N-APIccpExecute(req)向 ArkTS 暴露能力
ArkTScallCcp(op, extra)触发操作并刷新 UI

4.4 ArkTS 与 Kotlin 的边界

const extra: Record<string, Object> = { 'phoneNumber': this.phone, 'countryCode': 'ke' };
const res = JSON.parse(ccpExecute(JSON.stringify({ 'op': 'fullNumber', ...extra })));
// res.ok === true;res.e164 === '+254712345678',res.formatted === '+254 712 345 678'
"fullNumber" -> {
    val e164 = CountryCodePickerCore.fullPhoneNumber(phoneNumber, countryCode)
    val formatted = CountryCodePickerCore.fullyFormattedPhoneNumber(phoneNumber, countryCode)
    """{"ok":true,"e164":${MiniJson.str(e164)},"formatted":${MiniJson.str(formatted)}}"""
}

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:纯内核 + ArkTS 宿主,而不是把 Compose UI 硬编进鸿蒙

上游是 CMP 1.11.1 / Kotlin 2.4.10 的重 UI 组件库,社区 ohos 制品线对不齐;硬编组件意味着降版本、改 Material3 API、赌实验后端——每一项都是漂移风险。而组件背后的内核(数据 / 搜索 / 校验 / 格式化)零 UI 依赖,真编译进 .so 后语义与上游逐位一致。UI 用 ArkTS 验收宿主呈现,组件源码在阁楼等工具链。

决策 2:工具链升到 Kotlin 2.4.20-0.1.0-07,Gradle 落到 9.6.1

2.4.20 线与上游 2.4.10 同代零降级;Gradle 方面本机 9.7.0 发行包未缓存且下载超时,9.6.1 已缓存且有 KStore / compottie 先例(2.4.20 线支持 Gradle 9.x)。只有 2.2.21 旧线才必须降 8.14.1,两条工具链线的经验不能互相套用。

决策 3:根构建替换(cmp_qrose 模式)+ composeMain 阁楼

上游根构建(spotless / dokka / binary-compatibility-validator / nmcp 发布 / AGP)对鸿蒙没有保留价值,整个替换为极简 ohos 构建;上游 sample / docs / CI 留盘不进 settings.gradle.kts。Compose UI 不是删掉而是隔离——src/composeMain 未声明为源集,源码保留、编译不见,等 CMP ohos 工具链到位后一行声明接回。

决策 4:normalizeUnicode 查表 + JVM 钉住,而不是搬 expect

unaccent() 被搜索依赖,expect 必须留在 commonMain;ohos 没有归一化轮子,就生成 UCD 分解表。表放 commonMain 的唯一目的:jvmTest 用 java.text.Normalizer 全区间逐字符对照——U+01D5 非递归分解的 bug 就是它当场抓出来的。ohos 的搜索归一化因此与上游 JVM/iOS 路径有测试背书的一致性。

决策 5:公共门面 + 无状态桥

上游 internals 跨模块不可见,补 CountryCodePickerCore 门面而不是把 internals 改 public——改动面最小、意图清晰。桥保持无状态:AsYouType 的有状态语义用整串重放等价实现,每次调用自包含,C 签名永远 const char* → const char*。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21、Gradle 9.6.1(wrapper)、DevEco Studio 5.1.0 Release 与鸿蒙设备(arm64;模拟器为 x86_64)。

6.2 JVM 交叉验证

./gradlew :komposecountrycodepicker:jvmTest

全部用例通过(上游 7 个测试类零改动 + 2 个新增测试类):

测试类来源钉住什么
CharSequenceExtTest / CommonPhoneFormatterTest / PhoneValidationTest / TimezoneCountryMapTest / CountryDataTest上游原文件归一化入口、逐国 pattern、号长规则、时区映射、数据集完整性
CountrySearchTest / PickerUtilsTest上游原文件(2 处 Res 引用字符串化)三路搜索、getCountry 兜底、共享 +1 首选国、国旗资源名合法
UnicodeCanonicalFoldTest新增分解表 vs java.text.Normalizer NFD:U+0080…U+0250 全区间 + 241 国名逐字符
CountryCodePickerCoreTest新增门面 = 桥后面每一层:版本、大小写不敏感查询、cote→ci、默认国家三级解析、712345678→712 345 678、+254712345678、共享 +1→us

6.3 链接与符号验证

:komposecountrycodepicker:compileKotlinJvm: BUILD SUCCESSFUL
:komposecountrycodepicker:jvmTest: 全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
./gradlew build: BUILD SUCCESSFUL(38 任务)
llvm-nm -D: CcpExecute / libohosccp_symbols 均为全局 T 符号

libohosccp.so arm64 约 3.86MB、x86_64 约 3.41MB(debugShared,含 Kotlin/Native 运行时),libc++_shared.so 约 1.85MB / 1.73MB。

6.4 功能用例

  1. 启动后关于页出现 version → 2.1.1-ohos.1 / 241 个国家(来自真实 Native 调用,非静态文字);
  2. 国家库显示全量 241 国,统计行「共 241 个国家 / 匹配 241 个」;
  3. 搜索框输 cote,列表收敛到 Côte d’Ivoire(重音不敏感命中);输 254 命中 Kenya;输 ke 同样命中;
  4. 点选任意国家(如 China),选中行蓝底 + ✓,号码校验 Tab 的当前国家卡片同步刷新;
  5. 号码校验 Tab 输 712345678(选 Kenya):格式化 712 345 678、E.164 +254712345678、完整格式化 +254 712 345 678、徽标绿「有效」;改输 123 徽标变红「无效」;
  6. 默认国家 Tab 展示设备时区与系统语言,defaultCountry 解析出对应国家(如 Asia/Shanghai + zh-Hans-CN → China),并回显解析来源;
  7. 传非法 JSON 或未知 op,日志区出现红色 ok:false 错误原文,应用不崩溃;
  8. 桥接日志按操作滚动留痕,错误红色标记。

6.5 验证结论

数据层、搜索索引、校验规则、格式化引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙设备上成功安装并进入前台,页面展示了真实的国家区号选择会话:版本握手、国家库搜索、选中国家、号码校验与格式化、默认国家推断闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/kompose-country-code-picker.git
cd kompose-country-code-picker
export JAVA_HOME="/path/to/jdk-21"
./gradlew :komposecountrycodepicker:jvmTest                                # 交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.ccp -m entry
hdc shell hilog | grep CcpNapi

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :komposecountrycodepicker 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. expect 裁剪要先画依赖图:unaccent() 被留在 commonMain 的搜索依赖,normalizeUnicode expect 就不能随组件搬走——全搬会编译失败,留一半要想清楚留哪一半;
  4. UCD 分解表必须递归生成:U+01D5 的原始分解 00DC 0304 产物仍是预组字符,NFD 要求展开到 0055 0308 0304——JVM 交叉验证当场抓出,表放 commonMain 让 java.text.Normalizer 钉住是正确投资;
  5. 数据批处理要看清构造形态:241 个 Country 条目前 5 个命名参数、后 236 个位置参数且单行 / 多行混排,一条 sed 模式清不干净,分两条处理并用 grep Res\. 归零验证;
  6. String.format 在 Kotlin/Native 不存在,十六进制转义要手写(toString(16) + 补零);
  7. 上游 Gradle wrapper 9.7.0 在本机无缓存且下载超时,直接切已缓存的 9.6.1(2.4.20 线兼容 9.x,KStore / compottie 先例),不要跟发行包下载死磕;
  8. 桥响应字段要与 ArkTS 读取面对齐:首版桥的 countries 只回 total 没回 count,真机截图上统计行直接显示「匹配 undefined 个」——JSON 契约里写了的字段,桥与 UI 两边都要有人对账;
  9. ArkTS 严格模式拒绝内联对象字面量:arkts-no-untyped-obj-literals 要求先声明 Record<string, Object> 局部变量且 key 用字符串字面量;TimeZone 取 id 用 getID() 方法不是 .id 属性;getSystemLanguage() 废弃换 i18n.System.getSystemLanguage();
  10. Windows 的 python 是应用商店假桩(静默失败 rc=49),脚本一律用 py 启动器;
  11. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  12. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();签名材料由 DevEco 自动写入 build-profile.json5,提交前用 git update-index --skip-worktree 保护,证书绝不进入 AtomGit。

8.2 已知问题

  • Compose 组件(KomposeCountryCodePicker / CountrySelectionDialog / PhoneNumberTransformation)不在 ohos 侧,源码隔离在 composeMain 等社区 CMP ohos 工具链对齐后接回;
  • format 是无状态整串重放,等价于"输完整串的最终形态",不等价于上游 VisualTransformation 的光标位置保持(OffsetMapping 不跨 C ABI);
  • validate 是逐国号长规则(上游 iOS/JS/Wasm 同款),不是 libphonenumber 的全规则(运营商号段、号码类型判定);
  • Country.flag 已字符串化,阁楼里的组件源码引用旧签名,接回时需要在 UI 侧恢复 DrawableResource 解析(ResourceLookups.kt 保留了完整映射表);
  • android / js / wasmJs / apple 目标剥离(源文件留在树里),发布、dokka、spotless 一并剥离,本 fork 不发 Maven Central;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.ccp 匹配的签名。

8.3 未来优化方向

  • 社区 CMP ohos 制品线对齐后,把 composeMain 声明回源集,让真组件(而非仅内核)跑在鸿蒙上;
  • 桥补充分页参数(offset / limit),超长列表场景减少单次 JSON 体积;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 komposecountrycodepicker(commonMain 里内核与 Compose 纠缠)
    → 源码依赖度盘点(数据 / Res 查找 / 组件三分离)
    → 工具链对齐 Kotlin 2.4.20-0.1.0-07(Gradle 9.6.1)
    → 根构建替换(cmp_qrose 模式)+ composeMain 阁楼隔离
    → Country.flag 字符串化 + Platform.kt 裁剪 + 公共门面
    → normalizeUnicode 查表 actual(JVM 逐字符钉住)
    → ohosArm64 / ohosX64 KLIB + C ABI(@CName × 1)
    → C++ N-API(DisposeString 纪律)
    → ArkTS JSON 解析(严格模式适配)
    → ArkUI 真机四 Tab 验收(搜索 / 校验 / 格式化 / 默认国家)

9.2 封装层次

  • 内核层:上游原版 241 国数据、CountrySearch 重音索引、PhoneValidation 号长表、CommonPhoneFormatter 逐国 pattern、TimezoneCountryMap;
  • 门面层:CountryCodePickerCore 把 internal 能力以无依赖的公共 API 交给桥;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、JSON 解析、国旗加载、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 重 UI 的 CMP 库先找"内核面"再定边界:组件编不进 ohos 不等于库不能适配——把数据与算法从资源系统里剥出来(本库的 Country.flag 字符串化就是那一刀),剩下的就是一条已审核的路;边界画在哪,取决于内核与 UI 的纠缠程度,而不是库的名气或体量;
  2. expect 的裁剪要顺着依赖走:哪个 expect 被留在 commonMain 的代码引用,哪个就必须留且有 ohos actual(本库是 normalizeUnicode);其余随组件进阁楼。没有轮子就造数据(UCD 分解表),但数据必须放进 JVM 测试的射程里;
  3. 桥协议是双边契约,不是单边文档:total / count 字段真机上显示 undefined 的教训——契约里写了的字段,桥的实现与 ArkTS 的读取要有人对账;JSON 桥的验收要在真机页面上看真实渲染,而不是只看 ok:true。

9.4 适配成果

当前适配已完成:

  • kompose-country-code-picker 2.1.1-ohos.1 纯 Kotlin 内核的双 ABI Kotlin/Native 编译(数据 / 搜索 / 校验 / 格式化 / 时区映射上游原件零重写);
  • composeMain 隔离阁楼(Compose UI 源码完整保留,等工具链接回)与 CountryCodePickerCore 公共门面;
  • 1 个 C ABI 符号 + 手写 MiniJson 的零依赖桥(7 个 op)和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 四 Tab 验收页面(国家库搜索、号码校验、默认国家、版本握手)+ 265 面国旗 rawfile 动态加载;
  • JVM 交叉验证全绿(上游 7 个测试类零改动 + 2 个新增测试类)+ HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
  • 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。

参考文档

Logo

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

更多推荐