本文记录 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.entries 170 国一次返回,含枚举名、格式化名、区号、货币、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 旗。
displaydisplayText(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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Gradle daemon 与 hvigor 打包都需要)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 钉住;2.2.21 旧线的配套版本)
第三方依赖零——数据内核无 coroutines / serialization / IO 依赖,无需制品线
DevEco product5.1.0(18) 兼容(SDK 26.0.0.105 / API 26 实测)
DevEco Studio6.0.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.countrypicker
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块文件依赖形态处置
domain/Country.kt1枚举 + 两个顶层函数,唯一外部引用是 DrawableResource纯化后编译(flag → flagAsset: String)
domain/CountryDisplayOption.kt1纯枚举 × 7原样编译
component/CountryPickerDialog.kt1androidx.compose.material3 / lazy / animation移入 composeMain 不编译
component/CountryPickerField.kt1androidx.compose.foundation / painterResource移入 composeMain 不编译
jsMain/InternalCountryPickerDialog.kt1compose.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 方法覆盖内核
CountryPickerBridgecountryPickerBridge(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.tomlKotlin 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.kt5 项交叉验证
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/…/Bridge.kt1 个 C ABI、三 action 分发、MiniJson、错误通道
harmonyApp/…/cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/…/cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/…/ets/pages/Index.etsArkUI 三 Tab 验收页面
tools/split_country.py170 条枚举机械拆分(拆源码集的可重放证明)
tools/convert_flags.py255 个 drawable → SVG 转换(幂等、全量校验)
tools/gen_icon.py应用图标生成(纯标准库画 PNG)
README.OpenHarmony_CN.md鸿蒙适配主文档(19 节)

4.3 关键 API 对照

层次API作用
数据Country.entries170 国枚举全量(区号 / 货币 / ISO / flagAsset)
数据List<Country>.filterByQuery(q)四路小写包含:格式化名 / 区号 / 货币 / ISO
数据Country.formatedName()驼峰拆词(UnitedArabEmirates → United Arab Emirates)
数据Country.displayText(option)七分支展示格式化(上游两组件语义收敛)
组件CountryPickerDialog / CountryPickerField上游 Material3 组件(composeMain 留盘待接回)
NativeCountryPickerBridgeC ABI JSON 导出(list / filter / display)
N-APIcountryPickerBridge(req)向 ArkTS 暴露能力
ArkTScallBridge(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 项用例全部通过:

测试钉住什么
upstreamCountryCount170 国,与上游枚举条数一致
filterMatchesUpstreamSemantics“chi” 命中 CN/CL、“JPY” 唯一命中 JP、“86” 命中 CN、“de” 命中 DE
formattedNamesInsertSpacesUnitedArabEmirates → United Arab Emirates、SaoTomeAndPrincipe、Bosnia 短名
displayTextMirrorsUpstreamComponents七种 option 期望串全量断言(含 +CN quirk)
flagAssetsAreUniqueAndNamed170 个资源名唯一且匹配 [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 功能用例

  1. 启动后状态行出现 桥接正常 · 170 国 · libohoscountrypicker.so,页脚 roundtrip 显示首次冷启动调用耗时(真机实测 49ms,含 KN 运行时首次初始化);
  2. 选择器 Tab:字段默认选中 China(演示行为),显示国旗与 (+86) China;
  3. 点字段展开半模态,搜索框输入 chi,列表即时过滤为 Chile / China / …(KN 侧 filterByQuery 四路匹配);
  4. 输入乱串出现空态 Country not found.(上游原文文案);
  5. 点选国家,半模态收起,字段与详情卡(枚举名 / ISO / 区号 / 货币 / flagAsset)同步回填;
  6. 搜索 Tab:86 / JPY / de 分别按区号 / 货币 / ISO 命中对应国家,计数行实时刷新;
  7. 展示选项 Tab:切换 7 个 option,全量列表的 display 文本即时按新格式重渲染;点任意国家展开七种输出面板(每行一次真实 display 调用);
  8. 国旗在列表 / 字段 / 详情卡三处均正常渲染(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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,更不能手抄国家列表——必须让 :library 的数据内核真正编译成 ohosArm64 / ohosX64 动态库,否则第二份数据真源必然随上游漂移;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 数据模型长在 Compose 里是这类 UI 库的通病:flag = Res.drawable.xxx 写在枚举构造参数里,剥这一个类型引用就是拆源码集的全部工作量——用脚本机械置换 170 条并入库,比手工编辑可靠;
  4. ArkTS 的 $r() 不接受运行时字符串拼接,动态资源只能走 $rawfile()——国旗这类"按数据加载"的资源必须放 rawfile 而不是 media;
  5. Android vector 转 SVG 最隐蔽的坑是 fillColor 缺省:Android 默认无填充,SVG 默认黑填充,不显式写 fill="none" 就会多出黑色块;
  6. 上游 250 个国旗带 <clip-path>(圆形旗裁剪),不是障碍——SVG 1.1 有精确等价映射(<defs><clipPath> + url(#id)),先查语义再决定是否进失败清单;
  7. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  8. 命令行跑 hvigor 打包到 PackageHap 阶段报 spawn java ENOENT——打包器要起 JVM,PATH 里得有 JDK(DevEco 内构建自带 JBR 不受影响);
  9. 上游 .gitignore 忽略了 gradle.properties(Android 工程习惯),鸿蒙化的版本号必须写在这个文件里——忽略行要删掉,否则版本配置进不了库;
  10. KGP 会为 ohos 目标生成不存在的测试任务,根构建全局禁用 Ohos*Test*(qrose 同款);
  11. 上游 ISO_CODE 分支写成 "+${country.isoCode}"(区号样式串了 ISO),适配时逐字保留并用测试钉死,不擅自"修 bug"——跟随上游才是移植纪律;
  12. 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.ui ohos 制品可用后接回 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 三条经验

  1. UI 库适配的第一刀切在数据与组件的边界上:这类库的值一半在数据(170 国)、一半在组件(Dialog/Field)。数据侧的障碍往往只是一个类型引用(DrawableResource),脚本化剥掉它,数据内核就能全目标编译;组件侧不要硬编,留盘待接回才是可演进的姿势;
  2. 工具链版本跟着依赖走:零依赖就钉社区已验证的旧线(2.2.21-1.0.0 / Gradle 8.14.1),有 kotlinx 制品线依赖就跟随其 stdlib 元数据升线(KStore 的 2.4.20-0.1.0-07 / Gradle 9.6.1)——路线选择必须基于本库当天的依赖形态,而不是上一个库的结论;
  3. 交叉验证要钉"冷知识":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。

参考文档

Logo

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

更多推荐