开源鸿蒙平台 CMP 三方库「CountryPicker-KMP」适配全流程
本文记录
CountryPicker-KMP接入 OpenHarmony 的完整过程,覆盖 Compose 组件库的数据/组件边界切分、鸿蒙工具链钉版、真实数据模型编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、255 面国旗矢量资源转换、ArkUI 真机页面、HAP 构建和真机验收。
CountryPicker-KMP是基于 Compose Multiplatform 的国家选择器库:一个Country枚举装着 170 个国家的电话区号、货币、ISO 代码与国旗资源,外加两个预制组件——可搜索的CountryPickerDialog与类 TextField 的CountryPickerField,配合CountryDisplayOption的七种展示格式与四路实时搜索过滤。本次适配属于社区早期路线(qrose / haze / PreCompose / voyager 的"可编译内核提取 + KN 出 so + NAPI 桥"):数据与业务逻辑(170 国枚举、filterByQuery()、formatedName()、七分支展示格式化)被原样编译成 OpenHarmony 动态库,ArkTS 侧零拷贝数据——不存在第二份国家列表真源;上游 Compose 组件源码原样保存在composeMain不参与 ohos 编译,等社区 CMP 工具链对齐后原样接回,ArkUI 页面作为验收宿主。
项目地址: AtomGit/oh-tpc/CountryPicker-KMP.git
上游仓库: stevdza-san/CountryPicker-KMP
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
CountryPicker-KMP 为 Compose Multiplatform 应用解决"选国家/区号/货币"这一件事的全部样板:注册页要手机区号、收银台要货币、物流要 ISO 代码,上游一个枚举全给,两个组件开箱即用。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、JS、WASM 共享。OpenHarmony 应用同样需要这套国家数据能力,但上游构建(Compose 1.8.2 / Kotlin 2.2.0 / AGP 8.13.0)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS 手抄一份国家列表,页面可以显示数据,却无法证明上游数据模型与过滤逻辑在 OpenHarmony ARM64 设备上完成编译、链接和执行——而且第二份数据真源必然随上游演进而漂移。适配需要同时验证数据模型、过滤/格式化逻辑、国旗资源链路、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony 动态库。 |
| 数据模型长在 Compose 里 | Country 枚举的第四个构造参数是 DrawableResource(Compose resources 类型),直接编 ohos 过不了编译。 |
| 工具链不一致 | 上游 Kotlin 2.2.0 / Compose 1.8.2 / AGP 8.13.0 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象与 Compose 组件,需要 C ABI、C++ N-API 和 JSON。 |
| 资源链路不同 | 上游国旗是 Android vector drawable + painterResource();ArkTS 的 $r() 不接受运行时字符串拼接。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:数据与业务逻辑的 ohos 编译、根构建替换(cmp_qrose 模式)、commonMain / composeMain 源码集切分和 C ABI/N-API 桥接层。国家数据与过滤/格式化语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。
1.2 库提供的能力
Demo 提供一次完整的国家选择会话,对应桥接的全部入口:
- 全量列表(list):
Country.entries170 国一次返回,含枚举名、格式化名、区号、货币、ISO 代码、国旗资源名与按当前选项渲染好的展示文本; - 四路过滤(filter):
filterByQuery()按格式化名称 / 区号 / 货币 / ISO 代码四个维度小写包含匹配,搜索框每敲一个字实时调用; - 单国查询(display):按
isoCode精确取国,返回七种CountryDisplayOption任意一种的展示文本; - 选择器交互闭环:字段点开半模态 → 搜索过滤 → 选中回填字段与详情卡,复刻上游
CountryPickerField+CountryPickerDialog的使用体验; - 桥接健康度:页脚实时显示最近一次请求与往返耗时(如
roundtrip 49ms),来自真实 Native 调用而非静态文字。
国家对象字段:
| 字段 | 作用 |
|---|---|
name / formatted | 枚举常量名(UnitedArabEmirates)与 formatedName() 拆词名(United Arab Emirates)。 |
dialCode / currency / isoCode | 上游区号(971)/ 货币(AED)/ ISO(AE)三字段。 |
flagAsset | 上游 drawable 资源名(united_arab_emirates),对应 HAP rawfile 的 SVG 旗。 |
display | displayText(option) 渲染好的展示文本,与上游组件逐字一致。 |
引擎侧完整能力(170 固化数据、四路过滤、驼峰拆词、七分支格式化——包括 ISO_CODE 带 + 前缀这一上游行为)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 5 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的数据逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游 Country 枚举、filterByQuery、formatedName、CountryDisplayOption 原样编译,仅把 flag: DrawableResource 字符串化为 flagAsset: String 并收敛出 displayText();零外部依赖,无需任何制品线。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块进 settings.gradle.kts,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 Compose 组件移入 composeMain 留盘不编译;jsMain、iosApp/ 留盘不进构建。 |
| 桥接稳定 | 单个 C ABI 入口 + UTF-8 JSON(MiniJson 零依赖解析),不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 工具链钉社区已验证的 HarmonyOS Kotlin 2.2.21-1.0.0 线(qrose 同款),Gradle 降 8.14.1;本库零第三方依赖,不牵动 kotlinx 制品线。 |
| UI 完整 | 三 Tab 覆盖全部桥接入口:选择器(字段+半模态列表)/ 搜索(四路过滤)/ 展示选项(7 种 option + 单国 display)。 |
| 资源完整 | 上游 255 个 drawable 全量转换:254 个 Android vector XML → SVG(工具化、可重放、全量校验),barbados 唯一 PNG 原样随包。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
CountryPickerDialog/CountryPickerField是 Material3 组件,与鸿蒙 CMP 工具链版本对不齐,源码保存在composeMain待接回;ArkTS Demo 把桥暴露的数据闭环画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认"零依赖数据内核"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、commonMain/composeMain 切分
第 3 阶段:桥协议建模 ── list/filter/display JSON 契约、七种 option、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 三 Tab 页面、rawfile 国旗、搜索联动、选择器闭环
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
library/ 上游库(进 ohos 构建,jvm + ohosArm64 + ohosX64)
library/src/commonMain/…/domain/ 纯化后的数据内核:Country(170 国)+ 过滤 + 格式化
library/src/composeMain/…/ 上游 CountryPickerDialog / CountryPickerField(git mv 保留历史,不编译)
library/src/jsMain/ 上游 JS 侧文件,留盘不进构建
library/src/commonMain/composeResources/ 上游 255 个国旗 drawable,原样保留
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API、ArkTS 页面和 256 面旗
tools/ split_country.py / convert_flags.py / gen_icon.py
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
iosApp/ ASSETS/ kotlin-js-store/ 上游遗留,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :library 与 :example:nativeApp,rootProject.name = "ohos_countrypicker"。上游的 AGP / Compose / 热重载 / vanniktech 发布插件、wasmJs/js 浏览器配置、desktop 应用打包段全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Gradle daemon 与 hvigor 打包都需要) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 钉住;2.2.21 旧线的配套版本) |
| 第三方依赖 | 零——数据内核无 coroutines / serialization / IO 依赖,无需制品线 |
| DevEco product | 5.1.0(18) 兼容(SDK 26.0.0.105 / API 26 实测) |
| DevEco Studio | 6.0.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.countrypicker |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 模块 | 文件 | 依赖形态 | 处置 |
|---|---|---|---|
domain/Country.kt | 1 | 枚举 + 两个顶层函数,唯一外部引用是 DrawableResource | 纯化后编译(flag → flagAsset: String) |
domain/CountryDisplayOption.kt | 1 | 纯枚举 × 7 | 原样编译 |
component/CountryPickerDialog.kt | 1 | androidx.compose.material3 / lazy / animation | 移入 composeMain 不编译 |
component/CountryPickerField.kt | 1 | androidx.compose.foundation / painterResource | 移入 composeMain 不编译 |
jsMain/InternalCountryPickerDialog.kt | 1 | compose.html.core,js 专属 | 留盘不进构建 |
与 KStore 那类"引擎无 UI"的库相反,这个库的数据模型本身长在 Compose 里——Country 枚举每个条目都写着 flag = Res.drawable.xxx。所以本适配的核心动作不是补 actual,而是把 DrawableResource 这个唯一的 Compose 引用从 commonMain 剥出去,让 170 国数据变成纯 Kotlin。
1.4 页面边界
标题区 COUNTRYPICKER-KMP · OPENHARMONY / 桥接状态行(桥接正常 · 170 国 · libohoscountrypicker.so)
Tab 切换 选择器 / 搜索 / 展示选项
选择器 Tab 字段复刻(旗 + display 文本 + ▾)→ 半模态列表(搜索框 + 170 国 + ✓ 选中态 + Country not found. 空态)→ 详情卡(五字段)
搜索 Tab 搜索框(四路匹配)+ 命中计数 + 全量列表(旗 + 名称 + 区号·货币·ISO)
展示选项 Tab 7 个 option 芯片 + 全量 display 预览 + 单国七种输出面板
页脚 最近一次 Native 请求回显 + roundtrip 耗时 + count
页面启动时先调 {"action":"list"} 完成握手并渲染状态行,初始选中 isoCode === 'CN'(纯演示行为,上游无默认选中)。
第 2 阶段:目标与依赖打通
2.1 依赖盘点(本适配的路线分岔点)
先查数据内核的依赖表:Country / CountryDisplayOption / filterByQuery / formatedName 只用 kotlin.text.Regex 与标准库——零第三方依赖。这与 KStore 必须验证 coroutines / serialization / kotlinx-io ohos 制品线的处境正好相反:本库不需要任何制品线,唯一障碍是 DrawableResource 这个编译期类型引用。结论:走社区早期已过审的"内核纯化 + 真编译"路线,工具链可以放心钉在 qrose 验证过的 2.2.21-1.0.0 旧线。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 / Gradle 8.14.1)
上游源码是 Kotlin 2.2.0 编的,2.2.21-1.0.0 与之同代,源码级兼容成本为零;零依赖意味着没有"stdlib 元数据反推版本"的压力,直接沿用社区 qrose / haze 批准过的钉法最稳。Gradle 方面 2.2.21 旧线必须降 8.14.1(wrapper 已钉),这与 KStore 的 2.4.20 线沿用 9.6.1 是两条工具链线各自的配套经验,不能互相套用。
settings.gradle.kts 把 eazytec 仓库写在最前:
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_countrypicker"
include(":library")
include(":example:nativeApp")
根构建统一 group / version 并全局禁用 ohos 测试任务(KGP 会为 ohos 目标生成不存在的 test 任务):
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
2.3 拆源码集:数据内核纯化(技术难点)
切分由 tools/split_country.py 机械完成——170 条枚举正则置换,零手工转录,拆完即编译验证:
// library/src/commonMain —— 纯 Kotlin,jvm + ohosArm64 + ohosX64 全编译
enum class Country(
val dialCode: Int,
val currency: String,
val isoCode: String,
val flagAsset: String // 上游 drawable 资源名,如 "united_arab_emirates"
) {
UnitedArabEmirates(dialCode = 971, currency = "AED", isoCode = "AE",
flagAsset = "united_arab_emirates"),
China(dialCode = 86, currency = "CNY", isoCode = "CN", flagAsset = "china"),
/* … 170 国,数据一字未改 … */
}
fun Country.formatedName(): String = // 上游原样
name.replace(Regex("([a-z])([A-Z])"), "$1 $2")
fun List<Country>.filterByQuery(query: String): List<Country> { /* 上游原样 */ }
// 新增:把上游两个组件里各重复一遍的七分支 when 收敛成单一函数,格式逐字对齐
fun Country.displayText(option: CountryDisplayOption): String = when (option) {
CountryDisplayOption.NAME -> formatedName()
CountryDisplayOption.CURRENCY -> currency
CountryDisplayOption.DIAL_CODE -> "+$dialCode"
CountryDisplayOption.ISO_CODE -> "+$isoCode" // 上游 quirk:ISO 也带 + 前缀,逐字保留
CountryDisplayOption.DIAL_CODE_AND_NAME -> "(+$dialCode) ${formatedName()}"
CountryDisplayOption.NAME_AND_CURRENCY -> "${formatedName()} ($currency)"
CountryDisplayOption.NAME_AND_ISO_CODE -> "${formatedName()} ($isoCode)"
}
Compose 侧真源移入 composeMain(git mv 保留历史),组件与 Country -> Res.drawable.* 的 170 条映射放在一起:
// library/src/composeMain —— 不参与 ohos 编译,工具链对齐后接回
val Country.flag: DrawableResource
get() = when (this) {
Country.UnitedArabEmirates -> Res.drawable.united_arab_emirates
Country.China -> Res.drawable.china
/* … 170 条,脚本从上游枚举生成 … */
}
两个上游组件文件的 diff 只有一行 import com.stevdza_san.library.domain.flag(扩展属性需要显式导入)。library/build.gradle.kts 收敛为纯 KMP 模块:
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
}
实测:
./gradlew :library:compileKotlinJvm # BUILD SUCCESSFUL
./gradlew :library:jvmTest # 5/5 通过
2.4 国旗资源:255 个 drawable → rawfile
上游国旗是 Android <vector>(512×512 viewport、pathData、实色填充,本套资源 0 个 aapt 渐变)。tools/convert_flags.py(纯标准库、幂等)转 SVG 1.1:
<vector>→<svg viewBox>;<path>的pathData/fillColor/fillAlpha/stroke*/fillType逐项映射,fillColor缺失时显式写fill="none"(SVG 默认黑填充,Android 默认无填充);<group>→<g>;<clip-path>→<defs><clipPath id>+clip-path="url(#id)"(本语料 250 个文件含 clip-path 且全是全画幅矩形,映射无歧义);#AARRGGBB拆成fill="#RRGGBB" fill-opacity;遇@color/、?attr/、渐变一律进失败清单——本语料实际为零。
产出 255 个 SVG + barbados.png 逐字节拷贝(上游唯一 PNG),全部输出经 xml.etree 重新解析校验;总计 1589 个 path、351.6 KB。产物进 entry/src/main/resources/rawfile/flags/,ArkTS 用 $rawfile() 动态拼接加载。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.stevdza_san.library.domain/**(上游数据内核,纯化后原样编译)
↓ Country.entries / filterByQuery / displayText
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API countryPickerBridge()
↓
ArkUI Index.ets(三 Tab)
请求与响应:
请求 : {"action":"list","option":"DIAL_CODE_AND_NAME"}
{"action":"filter","query":"chi","option":"…"}
{"action":"display","iso":"CN","option":"NAME_AND_CURRENCY"}
响应 : {"ok":true,"count":170,"option":"…","countries":[{ name, formatted,
dialCode, currency, isoCode, flagAsset, display }, …]}
{"ok":true,"country":{…},"display":"China (CNY)"}
{"ok":false,"error":"no country with isoCode XX"}
两个设计:option 合法值即 CountryDisplayOption 七个枚举名,非法值回落 DIAL_CODE_AND_NAME 而非报错(容错优先);display 字段由 Kotlin 侧渲染好——展示格式的真源只有上游 displayText() 一处,ArkTS 不复刻拼接逻辑。
3.2 载荷为什么是扁平 JSON
桥内分发一目了然:
@CName("CountryPickerBridge")
fun countryPickerBridge(request: String): String = try {
val json = MiniJson.parse(request)
when (json.str("action")) {
"list" -> countriesJson(Country.entries.toList(), option)
"filter" -> countriesJson(Country.entries.toList().filterByQuery(json.str("query")), option)
"display" -> singleByIso(json.str("iso"), option)
else -> """{"ok":false,"error":"unknown action …"}"""
}
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
请求是扁平键值对,MiniJson(60 行,与 cmp_qrose 桥同构)足够;不上 kotlinx.serialization——给零依赖内核引序列化运行时纯属增重,且与社区已审桥实现保持同一心智。
3.3 错误通道
唯一导出整体 try/catch,Kotlin 异常永远不跨 FFI;非法 JSON、未知 action、ISO 不存在,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libcountrypicker_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohoscountrypicker.so)
│ CountryPickerBridge
▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("CountryPickerBridge")
fun countryPickerBridge(request: String): String // list / filter / display 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
CountryPickerBridge | countryPickerBridge(req) | Country.entries 全量 + filterByQuery() 四路过滤 + displayText() 七格式 |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"countryPickerBridge", nullptr, NapiCountryPickerBridge, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr}};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscountrypicker_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoscountrypicker_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohoscountrypicker SHARED IMPORTED)
set_target_properties(ohoscountrypicker PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(countrypicker_napi SHARED napi_init.cpp)
target_link_libraries(countrypicker_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohoscountrypicker)
OHOS_ARCH 为空或 libohoscountrypicker.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const countryPickerBridge: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 启动握手和搜索联动
aboutToAppear(): void {
this.reloadAll(); // {"action":"list"} → 170 国 + 状态行
}
private onSheetQuery(query: string): void {
const action = query.length > 0
? `{"action":"filter","query":"${query}","option":"${this.option}"}`
: '{"action":"list"}';
const res = this.callBridge(action); // roundtrip 计时进页脚
this.sheetFiltered = res.ok && res.countries !== undefined ? res.countries : [];
}
搜索框 onChange 直接触发 Native 调用——170 国全量过滤是微秒级(真机实测列表随输入即时刷新),页脚的 roundtrip 计时让每次跨桥成本可见。
5.2 国旗加载(资源链路的关键决策)
// 上游 barbados 是唯一光栅 PNG,其余全部是转换产物 SVG
private flagSrc(asset: string): string {
return `flags/${asset}${asset === 'barbados' ? '.png' : '.svg'}`;
}
Image($rawfile(this.flagSrc(c.flagAsset))) // $rawfile 接受运行时字符串
.width(28).height(20).objectFit(ImageFit.Fill).borderRadius(2)
$r('app.media.' + name) 在 ArkTS 里不吃运行时拼接,所以国旗全放 rawfile/flags/ 走 $rawfile();桥返回的 flagAsset 与文件名一一对应,不需要第二份映射。
5.3 页面交互
选择器 Tab 自上而下:字段复刻(旗 + display + ▾)点开 bindSheet 半模态——搜索框、170 国列表(旗 + display + ✓ 选中态)、空态文案 Country not found.(上游原文)——点选回填字段并刷新详情卡(枚举名 / ISO / 区号 / 货币 / flagAsset 五行)。搜索 Tab 是四路过滤的裸验收:命中计数 + 全量列表。展示选项 Tab 用 7 个芯片切换 option 即时重拉全量 display 预览,点任意国家调 display action 展开该国七种输出面板。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohoscountrypicker)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 1 + MiniJson(继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.countrypicker、versionName 1.0.5-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/libohoscountrypicker_api.h
├── src/main/cpp/types/libcountrypicker_napi/{index.d.ts, oh-package.json5}
├── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面
└── src/main/resources/rawfile/flags/ 255 SVG + barbados.png
6.2 构建、签名和安装
./gradlew :library:jvmTest # 5 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
node <DevEco>/tools/hvigor/bin/hvigorw.js assembleHap --mode module -p product=default
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
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.countrypicker -m entry
四、完整代码对照
4.1 整体架构
com.stevdza_san.library.domain/**(上游数据内核,纯化后原样编译)
│ Country(170 国 × 4 字段) / filterByQuery / formatedName / displayText
library/src/composeMain(上游组件,留盘不编译)
│ CountryPickerDialog / CountryPickerField / Country.flag 映射
Bridge.kt + MiniJson
│ CountryPickerBridge
libcountrypicker_napi.so
│ countryPickerBridge
Index.ets
│ JSON parse + $rawfile(flags/*.svg) + @State 更新
▼
ArkUI 真机页面(三 Tab)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_countrypicker、js/ios 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单插件钉版 |
build.gradle.kts(根) | 版本 1.0.5-ohos.1、全局禁用 Ohos*Test* 任务 |
library/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 + jvmTest |
library/src/commonMain/…/Country.kt | 数据内核:flag → flagAsset 字符串化 + displayText() 收敛,其余逐字保留 |
library/src/composeMain/…/ | 上游组件(git mv)+ FlagResources.kt 全量映射(脚本生成) |
library/src/jvmTest/…/CountryJvmTest.kt | 5 项交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/…/Bridge.kt | 1 个 C ABI、三 action 分发、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 验收页面 |
tools/split_country.py | 170 条枚举机械拆分(拆源码集的可重放证明) |
tools/convert_flags.py | 255 个 drawable → SVG 转换(幂等、全量校验) |
tools/gen_icon.py | 应用图标生成(纯标准库画 PNG) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(19 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 数据 | Country.entries | 170 国枚举全量(区号 / 货币 / ISO / flagAsset) |
| 数据 | List<Country>.filterByQuery(q) | 四路小写包含:格式化名 / 区号 / 货币 / ISO |
| 数据 | Country.formatedName() | 驼峰拆词(UnitedArabEmirates → United Arab Emirates) |
| 数据 | Country.displayText(option) | 七分支展示格式化(上游两组件语义收敛) |
| 组件 | CountryPickerDialog / CountryPickerField | 上游 Material3 组件(composeMain 留盘待接回) |
| Native | CountryPickerBridge | C ABI JSON 导出(list / filter / display) |
| N-API | countryPickerBridge(req) | 向 ArkTS 暴露能力 |
| ArkTS | callBridge(request) | 触发调用、roundtrip 计时、刷新 @State |
4.4 ArkTS 与 Kotlin 的边界
const res = JSON.parse(countryPickerBridge(JSON.stringify({
action: 'filter', query: 'chi', option: 'DIAL_CODE_AND_NAME'
}))) as BridgeOk;
// res.countries: [{ formatted:'China', dialCode:86, currency:'CNY',
// isoCode:'CN', flagAsset:'china', display:'(+86) China' }, …]
// Image($rawfile(`flags/${c.flagAsset}.svg`))
"filter" -> countriesJson(
Country.entries.toList().filterByQuery(json.str("query")), // 上游原件
option
)
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;展示文本的拼接只发生在 Kotlin 侧。
五、关键决策说明
决策 1:拆源码集而不是抄数据
Country 枚举里长着 DrawableResource,直接编 ohos 过不了编译。可选答案有二:在 ArkTS 里重抄 170 国(产生第二份真源,上游改数据必漂),或把 flag 参数字符串化为 flagAsset(改动一个构造参数,数据一字不动)。选后者:资源名的字符串形式对所有非 Compose 消费方可用,且与上游资源名一一对应;Compose 侧真源是 composeMain 的 Country.flag 扩展(170 条映射脚本生成)。拆分动作由 tools/split_country.py 机械完成并入库——这是"数据未漂"的可重放证明。
决策 2:工具链钉 2.2.21-1.0.0 / Gradle 8.14.1,与 KStore 相反
KStore 因为 coroutines 制品线被迫升 2.4.20 线;本库数据内核零依赖,没有版本反推压力,钉社区 qrose / haze 已批准的 2.2.21-1.0.0 旧线最稳(上游 Kotlin 2.2.0 同代,零源码级改动)。两条线的经验印证同一条原则:工具链版本跟着依赖走——零依赖就钉已验证线,有制品线依赖就跟随其 stdlib 元数据。
决策 3:七分支 when 收敛为 displayText()
上游在 Dialog 和 Field 里各写了一遍完全相同的七分支 when。抽取是纯重构(格式逐字对齐,JVM 测试钉死),收益是三份消费者(两个组件 + ArkTS 桥)共用同一结果——鸿蒙 Demo 的展示文本与上游组件逐字一致,包括 ISO_CODE 的 +CN 前缀这一上游行为也原样保留(测试里显式断言,README 备案)。
决策 4:国旗走 rawfile + flagAsset 直拼
ArkTS 的 $r() 不接受运行时字符串拼接,$r('app.media.' + name) 编译期报错;$rawfile() 接受。所以 255 个转换产物全部进 entry/src/main/resources/rawfile/flags/,桥返回的 flagAsset 直接拼路径,不需要任何资源映射表。barbados 是上游唯一光栅 PNG,Demo 里特判后缀,转换脚本逐字节拷贝。
决策 5:jvmTest 交叉验证 + 数据零重写
数据内核在 commonMain,JVM 与 ohos 跑同一份代码;5 项 jvmTest(170 国计数、四路过滤语义、驼峰拆词、七格式期望串、资源名唯一性)钉住的就是 ohos .so 里执行的数据逻辑。测试还顺带钉死了两个上游冷知识:Bosnia 枚举短名(旗名是 bosnia_and_herzegovina)与 ISO_CODE 的 + 前缀——交叉验证写成期望串断言,冷知识就不再靠口口相传。
六、测试与验证
6.1 测试环境
本次验证使用 Windows 11(x86_64)、JDK 21(Temurin)、DevEco Studio 6.0.0 Release(SDK 26.0.0.105 / API 26)、OpenHarmony 命令行工具(hvigor / ohpm / hdc)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :library:jvmTest
5 项用例全部通过:
| 测试 | 钉住什么 |
|---|---|
upstreamCountryCount | 170 国,与上游枚举条数一致 |
filterMatchesUpstreamSemantics | “chi” 命中 CN/CL、“JPY” 唯一命中 JP、“86” 命中 CN、“de” 命中 DE |
formattedNamesInsertSpaces | UnitedArabEmirates → United Arab Emirates、SaoTomeAndPrincipe、Bosnia 短名 |
displayTextMirrorsUpstreamComponents | 七种 option 期望串全量断言(含 +CN quirk) |
flagAssetsAreUniqueAndNamed | 170 个资源名唯一且匹配 [a-z0-9_]+ |
6.3 HAP 验证
:library:compileKotlinJvm: BUILD SUCCESSFUL
:library:jvmTest: 5/5 PASSED
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: CountryPickerBridge / libohoscountrypicker_symbols 均为全局 T 符号
BuildNativeWithNinja: finished(双 ABI 编出 libcountrypicker_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 7.1 MB(271 个文件),内含双 ABI 各三件套(libcountrypicker_napi.so / libohoscountrypicker.so / libc++_shared.so)与 256 个国旗资源;HAP 内 strip 后 KN so arm64 约 1.95 MB、x86_64 约 1.89 MB(debugShared 未 strip 时约 3.6 MB / 3.3 MB)。
6.4 功能用例
- 启动后状态行出现
桥接正常 · 170 国 · libohoscountrypicker.so,页脚roundtrip显示首次冷启动调用耗时(真机实测 49ms,含 KN 运行时首次初始化); - 选择器 Tab:字段默认选中 China(演示行为),显示国旗与
(+86) China; - 点字段展开半模态,搜索框输入
chi,列表即时过滤为 Chile / China / …(KN 侧filterByQuery四路匹配); - 输入乱串出现空态
Country not found.(上游原文文案); - 点选国家,半模态收起,字段与详情卡(枚举名 / ISO / 区号 / 货币 / flagAsset)同步回填;
- 搜索 Tab:
86/JPY/de分别按区号 / 货币 / ISO 命中对应国家,计数行实时刷新; - 展示选项 Tab:切换 7 个 option,全量列表的 display 文本即时按新格式重渲染;点任意国家展开七种输出面板(每行一次真实
display调用); - 国旗在列表 / 字段 / 详情卡三处均正常渲染(SVG 矢量,rawfile 加载)。
6.5 验证结论
数据模型、过滤/格式化逻辑、国旗资源链路、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的国家选择会话:全量握手、四路搜索过滤、选择器回填闭环与七种展示格式(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/CountryPicker-KMP.git
cd CountryPicker-KMP
export JAVA_HOME="/path/to/jdk-21"
./gradlew :library:jvmTest # 5 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install
node <DevEco>/tools/hvigor/bin/hvigorw.js assembleHap --mode module -p product=default
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.countrypicker -m entry
hdc shell hilog | grep CountryPicker
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,更不能手抄国家列表——必须让
:library的数据内核真正编译成ohosArm64/ohosX64动态库,否则第二份数据真源必然随上游漂移; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 数据模型长在 Compose 里是这类 UI 库的通病:
flag = Res.drawable.xxx写在枚举构造参数里,剥这一个类型引用就是拆源码集的全部工作量——用脚本机械置换 170 条并入库,比手工编辑可靠; - ArkTS 的
$r()不接受运行时字符串拼接,动态资源只能走$rawfile()——国旗这类"按数据加载"的资源必须放 rawfile 而不是 media; - Android vector 转 SVG 最隐蔽的坑是
fillColor缺省:Android 默认无填充,SVG 默认黑填充,不显式写fill="none"就会多出黑色块; - 上游 250 个国旗带
<clip-path>(圆形旗裁剪),不是障碍——SVG 1.1 有精确等价映射(<defs><clipPath>+url(#id)),先查语义再决定是否进失败清单; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - 命令行跑 hvigor 打包到
PackageHap阶段报spawn java ENOENT——打包器要起 JVM,PATH 里得有 JDK(DevEco 内构建自带 JBR 不受影响); - 上游
.gitignore忽略了gradle.properties(Android 工程习惯),鸿蒙化的版本号必须写在这个文件里——忽略行要删掉,否则版本配置进不了库; - KGP 会为 ohos 目标生成不存在的测试任务,根构建全局禁用
Ohos*Test*(qrose 同款); - 上游
ISO_CODE分支写成"+${country.isoCode}"(区号样式串了 ISO),适配时逐字保留并用测试钉死,不擅自"修 bug"——跟随上游才是移植纪律; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free()。
8.2 已知问题
- 鸿蒙侧无 Compose 组件:Demo 的字段/半模态是 ArkTS 复刻,交互对齐上游,饱和度动画等细节不等价;
- 国旗是 vector → SVG 的等价转换,未逐旗人眼比对 254 面,复杂徽章旗(墨西哥、伊朗、斯威士兰类)建议真机抽查;
- KN so 以 debug 链接(HAP 打包阶段自动 strip);release 需换
linkReleaseShared*并同步改拷贝任务目录; composeMain组件在鸿蒙 CMP 工具链对齐前不可编译,仅作接回底稿;filterByQuery每次全量重算 170 项(无缓存),该量级为微秒级、无感知;- android / js / wasmJs / apple 目标剥离(源文件留在树里),发布插件一并剥离,本 fork 不发 Maven Central。
8.3 未来优化方向
- 复杂国旗抽样人眼比对与截图回归(uitest + capture 脚本);
- 桥增加分页 / 惰性加载入口,覆盖超大规模数据的调用形态;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 社区
compose.uiohos 制品可用后接回composeMain,ArkTS 复刻层降级为 fallback。
九、总结
9.1 核心难点回顾
上游 CountryPicker-KMP(数据内核长在 Compose 里)
→ 依赖盘点:数据内核零第三方依赖 → 钉 qrose 已验证工具链线(2.2.21-1.0.0 / Gradle 8.14.1)
→ 根构建替换(cmp_qrose 模式)
→ 拆源码集:flag → flagAsset 字符串化 + displayText() 收敛(脚本化,数据零改动)
→ ohosArm64 / ohosX64 动态库
→ C ABI(@CName × 1)+ 扁平 JSON(list / filter / display)
→ C++ N-API(DisposeString 纪律)
→ 255 面 Android vector → SVG(rawfile + $rawfile 动态加载)
→ ArkUI 真机三 Tab 验收(选择器闭环 / 四路搜索 / 七种展示)
9.2 封装层次
- 数据层:上游原版
Country(170 国 × 4 字段)、filterByQuery四路过滤、formatedName拆词、displayText七格式; - 资源层:254 个 Android vector 转 SVG + barbados PNG,rawfile 按资源名直拼加载;
- Native 层:双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、搜索联动、半模态选择器与展示文本渲染;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 库适配的第一刀切在数据与组件的边界上:这类库的值一半在数据(170 国)、一半在组件(Dialog/Field)。数据侧的障碍往往只是一个类型引用(
DrawableResource),脚本化剥掉它,数据内核就能全目标编译;组件侧不要硬编,留盘待接回才是可演进的姿势; - 工具链版本跟着依赖走:零依赖就钉社区已验证的旧线(2.2.21-1.0.0 / Gradle 8.14.1),有 kotlinx 制品线依赖就跟随其 stdlib 元数据升线(KStore 的 2.4.20-0.1.0-07 / Gradle 9.6.1)——路线选择必须基于本库当天的依赖形态,而不是上一个库的结论;
- 交叉验证要钉"冷知识":
Bosnia短名、ISO_CODE 的+前缀这类上游行为,靠人记必然丢;写成期望串断言,冷知识就变成测试资产,也是"数据没漂"最硬的证据。
9.4 适配成果
当前适配已完成:
- CountryPicker-KMP
1.0.5(MIT)数据内核的双 ABI Kotlin/Native 编译(170 国数据零重写,flag参数字符串化 +displayText()收敛是全部改动); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - 255 个国旗 drawable 的全量 SVG 化(工具化、幂等、全量 well-formed 校验,351.6 KB);
- DevEco Stage ArkUI 三 Tab 验收页面(选择器闭环、四路搜索、七种展示格式、roundtrip 计时);
- 5 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so + 256 旗,约 7.1 MB)、签名、设备安装和真机验证;
- 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)