本文记录 kmp-emoji-picker 接入 OpenHarmony 的完整过程,覆盖 Compose UI 库的依赖盘点、api 级三方依赖的阻塞验证、纯逻辑引擎抽取、composeMain 隔离、emoji 数据管线自建(emoji-test / CLDR / Apple 三源)、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

kmp-emoji-picker 是基于 Compose Multiplatform 与 kodein-emoji 的表情选择器:分类页签、可搜索网格、肤色选择、最近使用,名称与关键词覆盖 18 种语言。本次适配与 KStore 的"真编译整库"路线(社区 Maven 已出现完整 HarmonyOS kotlinx 制品线)有本质区别:本库的 api 级依赖 org.kodein.emoji 在社区 Maven 上没有 ohosArm64 / ohosX64 klib(实测 404),且公共 API 直接暴露 kodein 的 Emoji / SkinTone 类型,整库编译无从谈起。因此走 cmp_qrose 审核通过的"引擎复用 + UI 隔离"路线:把 UI 读取的一切——emoji 目录、18 语言本地化、搜索判定、肤色变体、最近使用策略——抽到 commonMain 纯 Kotlin 编译成动态库,依赖 Compose/kodein 的源码原样搬进未注册的 src/composeMain 目录不参与构建,鸿蒙侧由 ArkTS 重做选择器界面。其中最特殊的一块是数据管线:上游的 emoji 目录(哪个表情属于哪组、什么顺序)藏在 kodein-emoji 里,鸿蒙侧必须自建——本次从 Unicode emoji-test.txt 生成 CAT1 目录 blob(3781 个 emoji / 9 组),并解决了 CLDR 键不含 FE0F 变体选择符导致的 69% → 100% 覆盖率问题。

项目地址: AtomGit/oh-tpc/kmp-emoji-picker.git

上游仓库: digitalby/kmp-emoji-picker

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

kmp-emoji-picker 为 Compose Multiplatform 应用提供"选一个表情"这一件事的完整答案:EmojiPicker() 一个组件给全分类页签 + 搜索网格,rememberEmojiPickerState() 管理查询、分类与肤色偏好,RecentEmojiStore 把最近使用留给宿主持久化,长按弹出 SkinTone1/2Emoji 的肤色变体。名称与关键词按 18 种语言从 CLDR annotations 生成,分类标签来自 Apple EmojiFoundation.framework 本地化数据,启动时按系统输入法 locale 自动切换。上游把这些能力放在 commonMain,由 Android、iOS、桌面、wasmJs 共享。OpenHarmony 应用同样需要这套能力,但上游构建(Kotlin 2.2.21 / Compose 1.9.1 / AGP 8.12)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以画出网格,却无法证明上游的数据管线、搜索判定与状态策略在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证纯逻辑引擎、三源数据 blob、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
api 级依赖无制品org.kodein.emoji:emoji-kt:2.3.0 无 ohos klib(社区镜像与 Maven Central 均实测 404),而它是 api 依赖,公共 API 直接暴露 Emoji / SkinTone 类型——整库编译被一个第三方 UI 依赖卡死。
Compose 版本错位上游 Compose 1.9.1 / Kotlin 2.2.21;社区 CMP-for-OHOS 快照为 1.9.2-0.1.0(及 1.12.0-0.1.0-xx 系列)。即便用 CMP-for-OHOS,kodein-emoji 这关也过不去。
目录数据不在本仓库上游的 emoji 目录(分组、顺序)来自 kodein-emoji 的 Emoji.allGroups() / allOf(group),鸿蒙侧需要自建数据源并保证与上游 l10n blob 的键规范一致。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
UI 无法跨桥@Composable 函数、StateFlow、Compose 状态保存不可能过 C 签名,UI 必须在 ArkTS 侧重做。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:纯逻辑引擎的 ohos 编译、composeMain 隔离、数据管线自建和 C ABI/N-API 桥接层。搜索与状态语义完全由上游 commonMain 原版实现负责(blob 解析器、locale 归一化、humanize 回退均原样保留),ArkTS 只负责调用 Native、解析 JSON 和呈现界面。

1.2 库提供的能力

Demo 提供一个真实的表情选择器会话,对应桥接的全部入口:

  • 目录(init/groups/emojis):启动时从 rawfile 读入 CAT1 目录 blob 初始化引擎,9 组 3781 个 emoji 按上游一致的顺序铺满懒加载网格(LazyForEach),People & Body 组 2261 条同样流畅滚动;
  • 搜索(search):名称 + 关键词不区分大小写包含匹配,激活语言缺条目时回退英文,180ms 防抖,默认截断 64 条;
  • 本地化(loadLocale/resolveLocale):18 语言名称/关键词 + 分类标签,系统 locale 自动识别(zh-CN 归一化为 zh、zh-TW 归一化为 zh-hant),页面下拉随时切换;
  • 肤色(skinTones):长按 emoji 弹出 neutral + 5 档 fitzpatrick 变体,变体集合从目录数据本身推导,零硬编码表;
  • 最近使用(recentAdd):MRU 去重、容量 32、PersistentStorage 持久化,重启仍在,与上游 RecentEmojiStore.inMemory 策略一致。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
groups / emojis / results / variants / recent各 op 的结构化载荷(emoji、名称、分组、档位)。
localeloadLocale / resolveLocale 返回归一化后的语言标签。

引擎侧完整能力(CAT1/EMJ1/GRP1 三种 blob 解析、locale 归一化、humanize 回退、搜索判定、肤色索引、recents 策略)全部在 picker/commonMain 里编译进入 .so,JVM 侧保留 26 项纯逻辑交叉验证用例(含 8 项为引擎门面新写的 EmojiPickerEngineTest),证明 ohos .so 里执行的逻辑没有漂移。

1.3 实现适配

维度要求
代码复用上游纯逻辑(l10n blob 解析器、locale 归一化、resolveGroupLabel/humanizeGroupId)原样留在 commonMain;新增 EmojiPickerEngine 门面与 EmojiCatalogData 模型;14 个依赖 Compose/kodein 的源文件原样搬进未注册的 src/composeMain,一个字不改。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块编纯引擎,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);picker-codegen(JVM 数据生成器)与 sample/composeApp 留盘不进构建。
桥接稳定1 个 C ABI、8 个 op、UTF-8 JSON 进出,不传递 Kotlin 对象地址;异常永远不跨 FFI。
数据自建新增 CAT1 目录 blob(emoji-test 16.0)与可复现生成脚本;复用上游 EMJ1/GRP1 blob;FE0F 键规范化保证 100% 覆盖。
UI 完整三 Tab 覆盖全部桥接入口:Picker(搜索/页签/网格/肤色/最近)、Engine(协议 JSON 直读)、About(版本与数据来源)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 的移植,而是上游交互契约的 ArkTS 实现。被复用的是数据与策略层——上游 UI 读什么,ArkTS 就从同一个引擎里读什么。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、验证 kodein-emoji 阻塞、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、composeMain 隔离
第 3 阶段:数据管线自建   ── emoji-test 16.0 → CAT1 blob、FE0F 键规范化、生成脚本与覆盖率校验
第 4 阶段:桥协议建模     ── 8 op JSON 契约、宿主推数据设计、错误通道
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── 懒加载网格、长按肤色、recents 持久化、语言切换
第 7 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
picker/                               上游选择器(进 ohos 构建,jvm + ohosArm64 + ohosX64)
picker/src/commonMain/                纯逻辑引擎:EmojiPickerEngine、CatalogBlob、l10n 解析器
picker/src/composeMain/               上游 Compose UI + kodein 类型(14 个文件原样搬入,未注册不编译)
picker/src/composeTest/               依赖 Compose/kodein 的测试(未注册不编译)
picker/src/{android,ios,jvm,wasmJs}Main/  上游平台源码(目标已剥离,文件留盘)
picker/src/commonMain/composeResources/files/
    emoji-catalog/catalog.bin         新增:CAT1 目录 blob(emoji-test 16.0)
    emoji-l10n/<locale>.bin           上游:EMJ1 名称/关键词(18 语言)
    emoji-group-l10n/<locale>.bin     上游:GRP1 分类标签(18 语言)
picker-codegen/                       上游 JVM 数据生成器,留盘不进 settings
sample/composeApp/                    上游 Compose 示例,留盘不进 settings
scripts/generate_emoji_catalog.py     新增:CAT1 生成器(下载 + 解析 + 覆盖率校验)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :picker 与 :example:nativeApp,rootProject.name = "ohos_emoji_picker"。上游的 compose / AGP / vanniktech 发布 / roborazzi 截图 / spotless 插件、wasmJs 实验开关、Maven Central 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Adoptium jdk-21.0.12)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 9.1.0 降级——2.2.21 旧线的既定钉法,与 cmp_qrose 相同)
第三方依赖零运行时第三方依赖(上游的 kodein-emoji / coroutines / compose 全部隔离在 composeMain)
DevEco product5.1.0(18) 兼容,hvigor CLI 6.26.8 实测可用
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.emojipicker
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点

上游 commonMain 共 19 个源文件,逐一检查 import 后分成三类:

类别文件依赖形态处置
纯逻辑EmojiL10nBlob / EmojiGroupL10nBlob / LocalizedEntry / GroupLabel零依赖,手写字节流解析原样留在 commonMain
Compose/kodein 耦合EmojiPicker / EmojiPickerState / RecentEmojiStore / EmojiCatalog / EmojiSearch / CategoryTabs / EmojiGrid / SearchField / SkinToneSelector / Haptics / PlatformInputLocale(expect) / EmojiL10n / EmojiGroupL10nandroidx.compose.* / kodein.emoji.* / compose Res.readBytes原样搬进 composeMain(14 个文件)
拆分EmojiPickerState.kt 里的 RECENT_CATEGORY_ID 常量、EmojiL10n.kt 里的 resolveLocale / SUPPORTED_LOCALES纯值/纯函数被 Compose 文件连累抽到 commonMain 新文件(EmojiPickerIds.kt / LocaleResolver.kt),composeMain 侧引用之

两个决定这个盘点的关键事实:其一,commonMain 对 kodein 的依赖全部经过类型(Emoji / SkinTone / allGroups()),没有一处可以靠删注解解决——不像 qrose 只需剥 @Immutable;其二,l10n blob 解析器、locale 归一化、humanize 回退这些数据层逻辑本来就是纯的,上游已经把"数据"与"UI"分开了,适配只需要把这条缝撬开。新增的公共面只有三个文件:EmojiPickerEngine(门面)、EmojiCatalogData(目录模型 + 肤色索引)、internal/CatalogBlob(CAT1 解析器)。

测试同样按此拆分:commonTest 保留 5 个纯测试类(26 项),依赖 Compose/kodein/roborazzi 的 11 个测试文件(含截图金样)搬进 composeTest,上游文件零改动。

1.4 页面边界
标题区          EmojiPicker · OpenHarmony(三 Tab:Picker / Engine / About)
Picker Tab     搜索框(180ms 防抖)+ 语言下拉(18 项)+ 分类页签横滚(有最近时首项为"最近/Recent")
               + Grid×LazyForEach 八列懒加载网格(名称小字随语言切换)+ 最近选择状态栏
Engine Tab     groups / search / skinTones / recentAdd 四按钮,请求与响应 JSON 原文滚动展示
About Tab      版本握手、数据来源(emoji-test / CLDR / Apple)、架构分层、仓库链接
弹层           长按 emoji 底部肤色弹层(neutral + 5 变体,取消按钮)
启动流程       读 rawfile catalog.bin → init → resolveLocale(系统 locale) → 读对应 EMJ1/GRP1 → loadLocale → groups → 首组 emojis

页面启动失败(缺 so、坏数据)有专门错误态,提示先跑 link 任务再回编 HAP。

第 2 阶段:目标与依赖打通

2.1 阻塞点验证(本适配的路线分岔手)

先查社区 Maven(https://maven.eazytec-cloud.com)上 kodein-emoji 有没有 ohos 制品:

org.kodein.emoji:emoji-kt:2.3.0
  maven-metadata.xml            → 200,版本齐全(镜像自 Maven Central)
  emoji-kt-2.3.0-ohosArm64.klib → 404   ← 路线在此分岔

对照 KStore 适配时 kotlinx 四件套(coroutines / serialization / kotlinx-io)全部有 -0.1.0-07 ohos 制品线,kodein-emoji 没有社区重建线——它是 KMP 生态里的"UI 配套库",不在 kotlinx 基础设施清单里。而本库 api(libs.kodein.emoji.kt) 把它暴露在公共 API 上,EmojiPickerState.resolveWithSkinTone(emoji: Emoji) 这种签名决定了没有 kodein 就没有 Compose UI。

结论:整库编译不可行,走 cmp_qrose 审核路线——引擎抽取 + composeMain 隔离。同时验证社区 CMP-for-OHOS 快照存在(1.9.2-0.1.0 系列),即便未来接 Compose UI,kodein-emoji 这一关仍然是前置条件,这条信息写进了 README.OpenHarmony_CN.md 的接回条件里。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

上游本来就是 Kotlin 2.2.21,与社区 HarmonyOS Kotlin 2.2.21-1.0.0 同代——这是本库的运气:引擎源码零版本迁移成本。工具链直接采用社区已审核的旧线钉法(qrose / haze 同款):

  • Kotlin 钉 2.2.21-1.0.0(eazytec Maven);
  • Gradle 降级 9.1.0 → 8.14.1(2.2.21 线的既定 wrapper,wrapper jar 与脚本整套随仓库提交);
  • gradle.properties 增补 kotlin.native.ignoreDisabledTargets=true、kotlin.native.distribution.downloadFromMaven=true(llvm 与 ohos sysroot 走 Maven 下载,本机 Konan 目录里 sysroot-ohos-aarch64-6.0.2.640-04 / sysroot-ohos-26.0.2.640-01 即由其拉取)。

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()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
    repositories { /* 同上双仓置前 */ }
}

rootProject.name = "ohos_emoji_picker"

include(":picker")
include(":example:nativeApp")

libs.versions.toml 收敛为一行版本 + 一个插件别名,build.gradle.kts(根)只剩版本号声明与 ohos 测试禁用:

plugins {
    alias(libs.plugins.kotlin.multiplatform) apply false
}

allprojects {
    group = "me.digitalby"
    version = providers.gradleProperty("version").orElse("0.1.0-ohos.1").get()
}

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}
2.3 编译纯引擎

picker/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()
    ohosArm64()
    ohosX64()

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

注意 commonMain.dependencies 是空的——上游的全部第三方依赖(compose 五件套 + kodein-emoji×2 + coroutines)都随 composeMain 隔离剥离,鸿蒙引擎是真正的零依赖纯 Kotlin。实测:

./gradlew :picker:compileKotlinJvm :picker:compileKotlinOhosArm64 :picker:compileKotlinOhosX64
# BUILD SUCCESSFUL

composeMain / composeTest 没有在任何构建脚本里注册,也不在默认层级模板里——Gradle 直接忽略这两个目录,源码原样留在树里等工具链对齐后接回。

2.4 测试依赖的处置:测试拆分而不是改测试

上游 commonTest 依赖 compose.uiTest、kodein allOf() 取真实 emoji 数据;jvmTest 依赖 roborazzi + robolectric 出截图金样。这些依赖在 ohos 构建里全部不存在。处置与 KStore 的"turbine 禁用"不同,是物理拆分:

  • 5 个纯测试类留在 commonTest(EmojiPickerEngineTest 新写、GroupLabelTest / EmojiL10nBlobTest / EmojiGroupL10nBlobTest 上游原样、EmojiL10nResolveLocaleTest 仅把 EmojiL10n.resolveLocale(...) 调用改为顶层 resolveLocale(...)——函数本体从对象里抽出来了,测试跟着改前缀);
  • 11 个依赖 Compose/kodein/roborazzi 的测试文件 git mv 到 composeTest,零改动;
  • 根构建全局禁用 *Ohos*Test* 任务(ohos 侧无测试运行器)。

这样 :picker:jvmTest 跑的 26 项就是引擎行为的闸门,上游 UI 测试在 composeTest 里完整保留。

第 3 阶段:数据管线自建(本适配最重的一块)

3.1 为什么需要 CAT1:目录不在仓库里

上游 Compose 侧的目录来自 kodein-emoji:

val groups = Emoji.allGroups().filter { it != "Component" }   // ["Smileys & Emotion", ...]
val map = groups.associateWith { group -> Emoji.allOf(group) } // 组 → 有序 emoji 列表

鸿蒙侧没有 kodein,目录(哪个 emoji 属于哪组、什么顺序)必须自建。数据源选 Unicode 官方的 emoji-test.txt(16.0)——RGI fully-qualified 清单本身就是"分组 + 顺序"的规范答案,kodein 的分组同样源于此:

  • 只取 fully-qualified 序列(保留 FE0F,保证渲染正确);
  • 排除 Component 组(肤色修饰符、ZWJ 部件)——与上游 filter { it != "Component" } 一致;
  • 组名 snake_case 化(Smileys & Emotion → smileys_emotion),与上游 GRP1 blob 的组 id 逐字对齐(9 组全等);
  • 组顺序、组内顺序 = 出现顺序 = 规范展示序。

CAT1 字节布局(大端):

magic      : 4 bytes "CAT1"
groupCount : uint32
groups     : groupCount × {
  groupIdLen  : uint16 + UTF-8
  emojiCount  : uint32
  emojis      : emojiCount × { emojiLen:uint16 + UTF-8 }
}

生成脚本 scripts/generate_emoji_catalog.py 带磁盘缓存与覆盖率校验(生成后对照 en.bin 报告缺失),提交产物 catalog.bin(48,860 字节,3781 emoji / 9 组):

py scripts/generate_emoji_catalog.py --emoji-version 16.0
# groups: 9, emojis: 3781, blob: 48860 bytes
#   smileys_emotion 169 / people_body 2261 / animals_nature 159 / food_drink 131
#   travel_places 218 / activities 85 / objects 264 / symbols 224 / flags 270
3.2 FE0F 键规范化:69% → 100%

首次生成后覆盖率校验报警:2621/3781(69%),缺的正是 ☺️、❤️、🧔‍♂️ 这类。逐字节检查 en.bin 发现:4342 个键没有一个含 U+FE0F——CLDR annotations 的键规范是"无变体选择符"形式(2764、2764 200D 1F525),而 emoji-test 的 fully-qualified 序列带 FE0F(2764 FE0F)。

解法是引擎查表统一走一条规范化:

public fun l10nKey(emoji: String): String = emoji.replace("\uFE0F", "")

search 与名称查表全部经 l10nKey 中转,覆盖率回到 3781/3781(100%)。这个函数是 public 的——NAPI 桥在另一个模块,需要同一规则。生成脚本的校验同样先 strip 再比对,未来数据升级时这道闸会自动报警。

3.3 三源数据的最终形态
blob格式来源规模
emoji-catalog/catalog.binCAT1(新增)Unicode emoji-test 16.09 组 / 3781 emoji / 48.8 KB
emoji-l10n/<locale>.binEMJ1(上游)CLDR release-48-2 annotations18 语言 × ~4300 条
emoji-group-l10n/<locale>.binGRP1(上游)applelocalization.com 镜像的 Apple EmojiFoundation18 语言 × 9 组标签
3.4 桥协议建模
me.digitalby.emojipicker/**(上游纯逻辑 + 新引擎门面,原样/新增编译)
        ↓ EmojiCatalogData / LocalizedEntry / search / skinTones / recordRecent
Bridge.kt(@CName × 1,8 op 分发,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API emojiPickerCall()
        ↓
ArkUI Index.ets(三 Tab)

8 个 op:init(推 CAT1 Base64)、loadLocale(推 EMJ1/GRP1 并切换激活语言)、groups、emojis、search、skinTones、recentAdd、resolveLocale。

一个关键设计:数据由宿主推送而不是编进 so。init / loadLocale 的载荷是 Base64 的 blob 原文,ArkTS 从 rawfile 读取后推入引擎解析缓存。so 因此保持数据源无关——换数据不重编 so,同一份数据既服务 Demo 也服务二次开发;代价是 HAP 里要有 rawfile(11 MB / 18 语言全量)。

错误通道与 KStore 同款:每个导出整体 try/catch,Kotlin 异常永不跨 FFI:

@CName("EmojiPickerCall")
fun emojiPickerCall(request: String): String = try {
    val req = MiniJson.parse(request) as? MiniJson.Obj
        ?: throw MiniJson.JsonException("request must be a JSON object")
    respond(handle(req))
} catch (t: Throwable) {
    respond(MiniJson.Obj(linkedMapOf(
        "ok" to MiniJson.Bool(false),
        "error" to MiniJson.Str(t.message ?: "error"),
    )))
}

未 init、坏 Base64、坏 magic、未知 op,全部走同一个 ok:false 通道。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libemojipicker_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosemojipicker.so)
  │ EmojiPickerCall
  ▼
UTF-8 JSON + DisposeString
4.2 Kotlin/Native 导出函数
@CName("EmojiPickerCall")
fun emojiPickerCall(request: String): String   // 8 op 一口进

只导出一个符号:搜索、目录、肤色、recents 都是"同步一问一答",没有版本握手的必要(版本走 About Tab 的静态元信息 + rawfile 数据本身)。桥内持有可变状态(catalog + 双 l10n 缓存 + 激活语言),全部由 ArkTS JS 线程串行调用,无需加锁。

C ABI 符号ArkTS 方法覆盖内核
EmojiPickerCallemojiPickerCall(req)CAT1/EMJ1/GRP1 解析 + 搜索 + 肤色 + recents + locale 归一化

MiniJson 是 ~180 行手写递归下降解析器 + 转义完备的写出器(对象/数组/字符串/数字/布尔/null、\uXXXX 代理对合并),与 cmp_qrose 的 MiniJson 同一决策:桥协议只有扁平字符串字段加一个字符串数组,不值得为此引入 kotlinx.serialization 运行时。

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

回调统一三步:napi_typeof 校验必须是 string、两段式 napi_get_value_string_utf8 取参、调 KN 函数并 napi_create_string_utf8 包装返回。KN 返回的字符串必须经 libohosemojipicker_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosemojipicker_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与一个业务符号)。

4.4 CMake 和类型声明
add_library(ohosemojipicker SHARED IMPORTED)
set_target_properties(ohosemojipicker PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(emojipicker_napi SHARED napi_init.cpp)
target_link_libraries(emojipicker_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosemojipicker)

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

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

Gradle 侧的回填任务链(继承自 cmp_qrose 审核模式):linkDebugSharedOhosArm64/X64 finalizedBy copyKnSoArm64/X64(so + libc++_shared.so → entry/libs/<abi>/,Konan 依赖目录按 triple 定位)与 copyKnBlobs(三个 blob 目录 → entry/src/main/resources/rawfile/emoji-data/),全部 gitignore。

第 5 阶段:ArkUI 宿主

5.1 初始化流程
aboutToAppear(): void {
  this.abilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  PersistentStorage.persistProp<string>('emojiPickerRecents', '[]');   // recents 宿主持久化
  this.initEngine();
}

private async initEngine(): Promise<void> {
  const catalog = await this.readRawfileBase64('emoji-data/emoji-catalog/catalog.bin');
  this.call('', { 'op': 'init', 'catalog': catalog });
  await this.switchLocale(await this.detectLocale());   // resolveLocale(系统 locale) → 读 EMJ1/GRP1 → loadLocale
  this.ready = true;
}

detectLocale 读 resourceManager.getConfiguration() 的 locale(该 SDK 返回 Promise,注意 await),系统 zh-CN 被引擎归一化为 zh,直接命中中文名称与标签。

5.2 页面交互

Picker Tab 自上而下:搜索框 + 语言下拉、分类页签横滚(Recent/最近 仅在 recents 非空时出现)、八列 Grid + LazyForEach 网格(EmojiDataSource implements IDataSource,标准 BasicDataSource 监听器模式)、底部最近选择状态栏。搜索 180ms 防抖(setTimeout 句柄管理),空查询回到当前分类全量。Engine Tab 四按钮直接打 groups / search("heart") / skinTones(👍) / recentAdd,请求与响应 JSON 原文滚动展示(上限 6 条)——桥协议的活文档。About Tab 是版本握手、三源数据说明与 AtomGit 仓库链接。

5.3 手势、持久化与错误边界

长按肤色用 LongPressGesture().onAction() 弹出底部变体层(该 SDK 没有通用 onLongPress 事件,见 8.1);点按变体即 onPick。onPick 三步:更新 lastPicked → recentAdd(引擎纯函数算 MRU 去重截断)→ PersistentStorage 写回 JSON 字符串。ok:false 与 Native 抛错统一进 Toast;启动失败显示错误态与修复提示(先跑 link 任务)。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosemojipicker)+ cxxShared + copyKnSo* + copyKnBlobs
│   └── src/ohosMain/kotlin/me/digitalby/emojipicker/ohos/
│       ├── Bridge.kt                         @CName × 1 + 8 op 分发 + 状态缓存
│       └── MiniJson.kt                       手写 JSON 解析/写出(继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.emojipicker、versionName 0.1.0-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/libohosemojipicker_api.h
        ├── src/main/cpp/types/libemojipicker_napi/{index.d.ts, oh-package.json5}
        ├── src/main/resources/rawfile/emoji-data/   copyKnBlobs 回填的三源数据(gitignore)
        └── src/main/ets/pages/Index.ets      ArkUI 三 Tab 验收页面
6.2 构建、签名和安装(Windows 命令行)
set JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot
gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

set DEVECO_SDK_HOME=E:\DE\DevEco Studio\sdk
set PATH=%JAVA_HOME%\bin;E:\DE\DevEco Studio\tools\node;E:\DE\DevEco Studio\tools\hvigor\bin;%PATH%
cd example\harmonyApp
hvigorw.bat --mode module -p module=entry@default -p product=default -p buildMode=debug assembleHap --analyze=normal --parallel --incremental --daemon=false

签名在 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.emojipicker -m entry

四、完整代码对照

4.1 整体架构

Unicode emoji-test 16.0(scripts/generate_emoji_catalog.py)
    │ CAT1 catalog.bin(3781 emoji / 9 组)
CLDR release-48-2 annotations(上游 picker-codegen)
    │ EMJ1 emoji-l10n/*.bin(18 语言名称/关键词)
Apple EmojiFoundation(上游 picker-codegen)
    │ GRP1 emoji-group-l10n/*.bin(18 语言分类标签)
rawfile/emoji-data/(宿主读取,Base64 推入)
    ▼
me.digitalby.emojipicker/**(picker/commonMain:上游纯逻辑 + EmojiPickerEngine)
    │ parseCatalogBlob / parseEmojiL10nBlob / parseGroupL10nBlob / search / skinToneVariants / recordRecent
Bridge.kt + MiniJson(example/nativeApp)
    │ EmojiPickerCall(@CName)
libemojipicker_napi.so
    │ emojiPickerCall
Index.ets
    │ JSON parse + @State + LazyForEach
    ▼
ArkUI 真机页面(Picker / Engine / About 三 Tab)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_emoji_picker、codegen/sample 移出
gradle/libs.versions.toml仅 Kotlin 2.2.21-1.0.0 + multiplatform 插件别名
build.gradle.kts(根)版本 0.1.0-ohos.1、全局禁用 Ohos*Test* 任务
picker/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,commonMain 零依赖
picker/src/composeMain/(14 文件)上游 Compose UI + kodein 类型原样隔离(未注册)
picker/.../EmojiPickerEngine.kt引擎门面:搜索/肤色/recents/三 blob 解析/locale
picker/.../EmojiCatalogData.kt目录模型 + 数据驱动肤色索引(strip U+1F3FB…FF)
picker/.../internal/CatalogBlob.ktCAT1 解析器(与生成脚本字节布局同步)
picker/.../internal/l10n/LocaleResolver.kt从 EmojiL10n 抽出的 resolveLocale / SUPPORTED_LOCALES
scripts/generate_emoji_catalog.pyCAT1 生成器:下载、解析、snake_case 组名、覆盖率校验
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* + copyKnBlobs 回填
nativeApp/.../Bridge.kt1 个 C ABI、8 op 分发、l10n 缓存、错误通道
nativeApp/.../MiniJson.kt手写 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鸿蒙适配主文档(22 节)

4.3 关键 API 对照

层次API作用
引擎EmojiPickerEngine.parseCatalogBlob(bytes)CAT1 → EmojiCatalogData(9 组有序目录)
引擎EmojiPickerEngine.parseEmojiL10nBlob / parseGroupL10nBlobEMJ1 / GRP1 → 名称关键词 / 分组标签映射
引擎EmojiPickerEngine.search(catalog, query, localized, fallback)名称+关键词包含匹配、英文回退、空查询全量
引擎EmojiPickerEngine.skinToneVariants(catalog, emoji)neutral + 1…5 档变体(数据推导)
引擎EmojiPickerEngine.recordRecent(current, emoji, capacity)MRU 去重截断(纯函数)
引擎EmojiPickerEngine.resolveLocale(tag) / groupLabel(id, l10n)locale 归一化 / 本地化标签(humanize 回退)
引擎l10nKey(emoji)FE0F 剥离,CLDR 键规范(public:桥同规则)
NativeEmojiPickerCallC ABI JSON 导出(8 op)
N-APIemojiPickerCall(req)向 ArkTS 暴露能力
ArkTSrunSearch(q) / openTonePopup(e) / onPick(e)搜索防抖 / 长按肤色 / 选择+recents

上游 Compose API(EmojiPicker() / rememberEmojiPickerState() / RecentEmojiStore)在 composeMain 里原样保留,其行为契约由上表逐项承接。

4.4 ArkTS 与 Kotlin 的边界

const groups = JSON.parse(emojiPickerCall(JSON.stringify({ op: 'groups' })));
// groups.ok === true;groups.groups = [{id:'smileys_emotion', label:'表情与情感', count:169}, ...]

const hits = JSON.parse(emojiPickerCall(JSON.stringify({
  op: 'search', query: '心', limit: 32
})));
// hits.results = [{e:'❤️', n:'红心', g:'symbols'}, {e:'💔', n:'心碎', g:'smileys_emotion'}, ...]
"search" -> {
    val results = EmojiPickerEngine.search(
        catalog = cat,
        query = query,
        localized = emojiL10nByLocale[activeLocale].orEmpty(),
        fallbackLocalized = emojiL10nByLocale["en"].orEmpty(),
    )
    ok("results" to MiniJson.Arr(results.take(limit).map { hit -> ... }))
}

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

五、关键决策说明

决策 1:引擎抽取 + composeMain 隔离,而不是整库编译或重写

路线由依赖形态决定:kodein-emoji 无 ohos klib 且是 api 依赖(404 实测),整库编译无从谈起;而上游已经把数据层(blob 解析、locale、humanize)写成纯 Kotlin,抽取的缝是现成的。重写则更糟——搜索判定、肤色集合、MRU 策略任何一处手抄漂移都是行为回归。composeMain 不注册的隔离法让上游 UI 源码一个字节不动地留在树里,接回条件(kodein 发布 ohos klib)写进文档。

决策 2:目录数据自建(emoji-test),不做 kodein 数据的逆向搬运

目录的规范源头是 Unicode emoji-test.txt:分组名与上游 GRP1 组 id 逐字对齐、顺序即展示序、fully-qualified 保证渲染。从 kodein-emoji 的 jar/源码里逆向搬运数据反而引入版本耦合。生成脚本带覆盖率校验入仓,数据升级可复现(换版本号重跑,校验不过会报警)。

决策 3:数据由宿主推送(rawfile → Base64 → init/loadLocale),不编进 so

so 内嵌数据(codegen 生成 Kotlin 源)会把二进制与数据耦合,单英文 names 就 +450 KB。宿主推送让 so 数据源无关:换 CLDR 版本、裁剪语言子集都不需要重编 so;rawfile 里的同一份数据直接服务二次开发。代价是 HAP 多 11 MB 数据(全 18 语言),文档里给出了裁剪方案。

决策 4:肤色变体数据驱动推导,不硬编码支持表

kodein 用类型系统(SkinTone1/2Emoji)标记可调色 emoji;鸿蒙侧没有类型元数据,而 CAT1 目录里 toned 序列本身就是独立条目——剥掉 U+1F3FB…FF 得 neutral、取首修饰符档位,即可建 neutral → [1..5] 索引。零硬编码、随数据升级自动更新;ZWJ 序列(👨🏾‍👩🏾‍👧🏾 → 👨‍👩‍👧)天然正确。局限与上游一致:数据里没有"两手不同色"的组合。

决策 5:jvmTest 交叉验证 + 测试物理拆分

引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;26 项 jvmTest 是"内核没漂"的闸门,其中 8 项是为引擎门面新写的(搜索/回退/全量/肤色/recents/CAT1 字节往返)。依赖 Compose/kodein/roborazzi 的 11 个测试文件零改动搬进 composeTest——上游测试不动,交叉验证的说服力才成立。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Eclipse Adoptium 21.0.12)、DevEco Studio 5.1.0(18)(含 hvigor CLI 6.26.8 与 hdc)、鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :picker:jvmTest

26 项用例全部通过:

测试类用例数钉住什么
EmojiPickerEngineTest(新写)8搜索命中名称/关键词、英文回退、空查询全量序、肤色 6 变体与档位、toned→neutral、不可调色单变体、recents 去重截断、CAT1 字节级往返
EmojiL10nBlobTest(上游)4EMJ1 解析(magic/count/键值/关键词)
EmojiGroupL10nBlobTest(上游)6GRP1 解析与 serialize 往返
EmojiL10nResolveLocaleTest(上游)4精确 tag、区域回退、简繁分流、不支持回退 en
GroupLabelTest(上游)4humanizeGroupId / resolveGroupLabel 回退链

6.3 HAP 验证

:picker:compileKotlinJvm / compileKotlinOhosArm64 / compileKotlinOhosX64: BUILD SUCCESSFUL
:picker:jvmTest: 26 项全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
二进制符号检查: EmojiPickerCall / libohosemojipicker_symbols 均在 so 内
CompileArkTS: finished(三 Tab 页面 + LazyForEach 数据源)
BuildNativeWithCmake: finished(双 ABI 编出 libemojipicker_napi.so)
PackageHap: finished(hvigor CLI assembleHap)
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

entry-default-unsigned.hap 约 17.7 MB,HAP 内容逐项核验:libs/{arm64-v8a,x86_64}/ 各三件套(libemojipicker_napi.so / libohosemojipicker.so / libc++_shared.so),resources/rawfile/emoji-data/ 下 catalog.bin + 18 语言 EMJ1 + 18 语言 GRP1 齐全。KN so debug arm64 约 3.9 MB / x64 约 3.6 MB。

6.4 功能用例

  1. 启动自动识别系统 locale(真机 zh-CN → 引擎归一化 zh),分类标签显示"表情与情感 / 人物 / 动物与自然…",网格名称为中文;
  2. 搜索框输入"心"命中 ❤️/💔/💗…(名称与关键词双通道),输入"heart"同样命中(英文回退);
  3. 分类页签切换九组,People & Body 组 2261 条滚动流畅(LazyForEach);
  4. 长按 👍 弹出 neutral + 5 档肤色变体,选择后 Toast 预览并记入最近;
  5. 连续点按若干 emoji,"最近"页签出现、MRU 去重、上限 32;杀进程重启后仍在(PersistentStorage);
  6. 语言下拉切 zh-hant / ja / ar,标签与名称即时切换(阿拉伯语 RTL 正常);
  7. Engine Tab 四个按钮,groups / search / skinTones / recentAdd 的请求与响应 JSON 与第 3.4 节契约逐字一致;
  8. About Tab 显示 fork 版本、三源数据与 AtomGit 仓库链接。

6.5 验证结论

纯逻辑引擎、三源数据管线、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的选择器会话:中文目录、搜索、长按肤色与重启后仍在的最近使用闭环(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/kmp-emoji-picker.git
cd kmp-emoji-picker
export JAVA_HOME="/path/to/jdk-21"
./gradlew :picker:compileKotlinOhosArm64 :picker:compileKotlinOhosX64   # 引擎 klib
./gradlew :picker:jvmTest                                              # 26 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
# Windows 下构建 HAP:
#   set DEVECO_SDK_HOME=<DevEco SDK> & set PATH=%JAVA_HOME%\bin;<node>;<hvigor>;%PATH%
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.emojipicker -m entry
hdc shell hilog | grep EmojiPicker

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :picker 的纯引擎真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. FE0F 键规范:CLDR l10n blob 的键全部不含 U+FE0F(4342 键实测 0 个),emoji-test 的 fully-qualified 序列带 FE0F——不规范化时搜索/名称覆盖率只有 69%(2621/3781),l10nKey() 统一剥离后 100%。数据键规范比代码更容易在两个数据源之间漂移,覆盖率校验必须进生成脚本;
  4. ArkTS 保留属性名:状态变量不能叫 tabIndex——它是 CustomComponent 基类的属性((index: number) => CommonAttribute),编译报 “not assignable to the same property in base type”,改名 currentTab;
  5. 长按不是通用事件:该 SDK(API 18 d.ts)的 common.d.ts 里没有通用 onLongPress(只有 RichEditor 等组件自带),Column 上要用 .gesture(LongPressGesture({repeat:false}).onAction(...));
  6. Scroll 构造签名收紧:Scroll({horizontal:true}) 报"参数应为 Scroller",改 Scroll() + .scrollable(ScrollDirection.Horizontal);
  7. getConfiguration() 是异步:resourceManager.getConfiguration().locale 直接取属性报"locale does not exist on Promise",需 await;
  8. SelectOption 是接口:不能 new SelectOption(...),要声明 SelectOption[] 后 push 类型化字面量 { value: locale };
  9. hvigor daemon 陈旧环境:CLI 打包报 spawn java ENOENT 而 java -version 明明可用——残留的 hvigor daemon 进程持有启动时的旧 PATH(首次无 java),taskkill 掉 daemon 后秒过;CLI 构建要把 JAVA_HOME/PATH/DEVECO_SDK_HOME 三件配齐;
  10. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. gitignore 嵌套目录:根构建的 /build/ 锚定模式盖不住 example/harmonyApp/entry/build、entry/libs、rawfile/emoji-data、.hvigor、.cxx、oh_modules——全部单独追加,否则 Gradle 回填产物混进提交;scripts/.cache/(emoji-test 下载缓存)同样要忽略。

8.2 已知问题

  • Compose UI 未编入鸿蒙(composeMain 未注册),UI 由 ArkTS 呈现;接回前置条件是 kodein-emoji 发布 ohos klib(或上游去 kodein 化)+ CMP-for-OHOS 与上游版本对齐;
  • 搜索覆盖本地化名称 + 关键词;上游额外的 emoticon(:)、<3)匹配未包含——kodein 私有数据源,CLDR 无此项;
  • 双人 emoji 左右手不同色调的组合不在 Unicode 数据内(上游 withSkinTone(tone, tone) 同样只给统一色调);
  • 搜索无排序/加权,与上游一致(纯包含匹配、目录序输出);
  • 上游的键盘无障碍(Tab/方向键/Alt+Enter)是 Compose 侧实现,ArkTS Demo 未复刻(触摸交互完整);
  • 上游 Wasm/桌面的 Noto SVG 字形回退在鸿蒙不适用(系统 emoji 字体覆盖良好);
  • 数据版本固定:目录 = emoji-test 16.0、名称/关键词 = CLDR release-48-2,升级需重跑生成脚本并过覆盖率校验;
  • android / ios / wasmJs 目标剥离(源文件留在树里),发布、spotless、截图链一并剥离,本 fork 不发 Maven Central。

8.3 未来优化方向

  • 跟踪 kodein-emoji 与 CMP-for-OHOS 的 ohos 制品进度,条件满足后把 composeMain/composeTest 注册回构建,ArkTS UI 退位为验收宿主;
  • 搜索增加 CLDR annotationsDerived 通道与 emoticon 词典,补齐上游匹配面;
  • recentAdd 之外的 recents 语义(容量配置、清除)补桥接入口;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查、覆盖率校验(生成脚本)和 HAP 构建;
  • 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 picker(commonMain 19 文件盘点)
    → 验证 api 级依赖阻塞(kodein-emoji ohos klib 404)
    → 路线分岔:引擎抽取 + composeMain 隔离(cmp_qrose 审核模式)
    → 工具链对齐 Kotlin 2.2.21-1.0.0 / Gradle 8.14.1(旧线钉法)
    → 数据管线自建:emoji-test 16.0 → CAT1(3781/9 组)
    → FE0F 键规范化:en 覆盖率 69% → 100%
    → ohosArm64 / ohosX64 KLIB(commonMain 零第三方依赖)
    → C ABI(@CName × 1,8 op)+ 宿主推数据设计
    → C++ N-API(DisposeString 纪律)
    → ArkTS 三 Tab(LazyForEach / LongPressGesture / PersistentStorage)
    → ArkUI 真机验收(目录 / 搜索 / 肤色 / recents 持久化)

9.2 封装层次

  • 引擎层:上游原版 blob 解析器、locale 归一化、humanize 回退 + 新增 EmojiPickerEngine 门面;
  • 数据层:CAT1 目录(emoji-test 16.0)+ 上游 EMJ1 / GRP1(18 语言),FE0F 键规范统一;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、防抖搜索、手势、持久化、JSON 解析与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. api 级第三方依赖决定适配路线:KStore 那批库的依赖(kotlinx 系)已被社区重建,答案是"真编译整库";本库的依赖(kodein-emoji)没有 ohos 制品且写在公共 API 上,答案是"引擎抽取 + UI 隔离"。同一个 oh-tpc 仓里两种路线并存,取舍依据只有一个——阻塞点当天的制品可达性,而不是历史结论;
  2. 数据键规范是最隐蔽的漂移源:CLDR 键不含 FE0F、emoji-test 的 fully-qualified 带 FE0F,两边各自都"正确",拼在一起就丢 31% 的 emoji。跨数据源适配必须先做键规范的逐字节对齐,并把覆盖率校验固化进生成脚本;
  3. 能用数据推导就不要硬编码:肤色变体支持表(哪些 emoji 可调色)没有写进代码,而是从目录数据实时建索引——数据升级(Unicode 17/18 新 emoji)时变体集合自动更新,硬编码表则必然过期。

9.4 适配成果

当前适配已完成:

  • kmp-emoji-picker 0.1.0 纯逻辑引擎的双 ABI Kotlin/Native 编译(上游纯逻辑文件零重写,Compose/kodein 耦合文件零改动隔离);
  • 三源数据管线落地:CAT1 目录 blob(emoji-test 16.0,3781 emoji / 9 组,可复现生成脚本入库)+ 上游 EMJ1 / GRP1(18 语言),FE0F 规范化 100% 覆盖;
  • 1 个 C ABI 符号(8 op JSON 协议)和 C++ N-API 包装层(二进制符号实测在 so 内);
  • DevEco Stage ArkUI 三 Tab 验收页面(中文目录、搜索、长按肤色、重启持久化、18 语言切换、协议直读);
  • 26 项 JVM 交叉验证全绿(含 8 项新写引擎用例)+ HAP 构建(双 ABI 六件套 so + 全量数据,约 17.7MB)、签名、设备安装和真机验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐