开源鸿蒙平台 CMP 三方库「kmp-emoji-picker」适配全流程
本文记录
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/ohosX64klib(实测 404),且公共 API 直接暴露 kodein 的Emoji/SkinTone类型,整库编译无从谈起。因此走 cmp_qrose 审核通过的"引擎复用 + UI 隔离"路线:把 UI 读取的一切——emoji 目录、18 语言本地化、搜索判定、肤色变体、最近使用策略——抽到commonMain纯 Kotlin 编译成动态库,依赖 Compose/kodein 的源码原样搬进未注册的src/composeMain目录不参与构建,鸿蒙侧由 ArkTS 重做选择器界面。其中最特殊的一块是数据管线:上游的 emoji 目录(哪个表情属于哪组、什么顺序)藏在 kodein-emoji 里,鸿蒙侧必须自建——本次从 Unicodeemoji-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、名称、分组、档位)。 |
locale | loadLocale / 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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 指向 Eclipse Adoptium jdk-21.0.12) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 9.1.0 降级——2.2.21 旧线的既定钉法,与 cmp_qrose 相同) |
| 第三方依赖 | 零运行时第三方依赖(上游的 kodein-emoji / coroutines / compose 全部隔离在 composeMain) |
| DevEco product | 5.1.0(18) 兼容,hvigor CLI 6.26.8 实测可用 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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 / EmojiGroupL10n | androidx.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.bin | CAT1(新增) | Unicode emoji-test 16.0 | 9 组 / 3781 emoji / 48.8 KB |
emoji-l10n/<locale>.bin | EMJ1(上游) | CLDR release-48-2 annotations | 18 语言 × ~4300 条 |
emoji-group-l10n/<locale>.bin | GRP1(上游) | applelocalization.com 镜像的 Apple EmojiFoundation | 18 语言 × 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 方法 | 覆盖内核 |
|---|---|---|
EmojiPickerCall | emojiPickerCall(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.kt | CAT1 解析器(与生成脚本字节布局同步) |
picker/.../internal/l10n/LocaleResolver.kt | 从 EmojiL10n 抽出的 resolveLocale / SUPPORTED_LOCALES |
scripts/generate_emoji_catalog.py | CAT1 生成器:下载、解析、snake_case 组名、覆盖率校验 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* + copyKnBlobs 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、8 op 分发、l10n 缓存、错误通道 |
nativeApp/.../MiniJson.kt | 手写 JSON 解析/写出(零依赖) |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 三 Tab 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(22 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | EmojiPickerEngine.parseCatalogBlob(bytes) | CAT1 → EmojiCatalogData(9 组有序目录) |
| 引擎 | EmojiPickerEngine.parseEmojiL10nBlob / parseGroupL10nBlob | EMJ1 / 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:桥同规则) |
| Native | EmojiPickerCall | C ABI JSON 导出(8 op) |
| N-API | emojiPickerCall(req) | 向 ArkTS 暴露能力 |
| ArkTS | runSearch(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(上游) | 4 | EMJ1 解析(magic/count/键值/关键词) |
EmojiGroupL10nBlobTest(上游) | 6 | GRP1 解析与 serialize 往返 |
EmojiL10nResolveLocaleTest(上游) | 4 | 精确 tag、区域回退、简繁分流、不支持回退 en |
GroupLabelTest(上游) | 4 | humanizeGroupId / 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 功能用例
- 启动自动识别系统 locale(真机
zh-CN→ 引擎归一化zh),分类标签显示"表情与情感 / 人物 / 动物与自然…",网格名称为中文; - 搜索框输入"心"命中 ❤️/💔/💗…(名称与关键词双通道),输入"heart"同样命中(英文回退);
- 分类页签切换九组,People & Body 组 2261 条滚动流畅(LazyForEach);
- 长按 👍 弹出 neutral + 5 档肤色变体,选择后 Toast 预览并记入最近;
- 连续点按若干 emoji,"最近"页签出现、MRU 去重、上限 32;杀进程重启后仍在(PersistentStorage);
- 语言下拉切
zh-hant/ja/ar,标签与名称即时切换(阿拉伯语 RTL 正常); - Engine Tab 四个按钮,
groups/search/skinTones/recentAdd的请求与响应 JSON 与第 3.4 节契约逐字一致; - 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 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:picker的纯引擎真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- FE0F 键规范:CLDR l10n blob 的键全部不含 U+FE0F(4342 键实测 0 个),emoji-test 的 fully-qualified 序列带 FE0F——不规范化时搜索/名称覆盖率只有 69%(2621/3781),
l10nKey()统一剥离后 100%。数据键规范比代码更容易在两个数据源之间漂移,覆盖率校验必须进生成脚本; - ArkTS 保留属性名:状态变量不能叫
tabIndex——它是CustomComponent基类的属性((index: number) => CommonAttribute),编译报 “not assignable to the same property in base type”,改名currentTab; - 长按不是通用事件:该 SDK(API 18 d.ts)的
common.d.ts里没有通用onLongPress(只有 RichEditor 等组件自带),Column 上要用.gesture(LongPressGesture({repeat:false}).onAction(...)); - Scroll 构造签名收紧:
Scroll({horizontal:true})报"参数应为 Scroller",改Scroll()+.scrollable(ScrollDirection.Horizontal); getConfiguration()是异步:resourceManager.getConfiguration().locale直接取属性报"locale does not exist on Promise",需await;- SelectOption 是接口:不能
new SelectOption(...),要声明SelectOption[]后 push 类型化字面量{ value: locale }; - hvigor daemon 陈旧环境:CLI 打包报
spawn java ENOENT而java -version明明可用——残留的 hvigor daemon 进程持有启动时的旧 PATH(首次无 java),taskkill掉 daemon 后秒过;CLI 构建要把JAVA_HOME/PATH/DEVECO_SDK_HOME三件配齐; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 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 三条经验
api级第三方依赖决定适配路线:KStore 那批库的依赖(kotlinx 系)已被社区重建,答案是"真编译整库";本库的依赖(kodein-emoji)没有 ohos 制品且写在公共 API 上,答案是"引擎抽取 + UI 隔离"。同一个 oh-tpc 仓里两种路线并存,取舍依据只有一个——阻塞点当天的制品可达性,而不是历史结论;- 数据键规范是最隐蔽的漂移源:CLDR 键不含 FE0F、emoji-test 的 fully-qualified 带 FE0F,两边各自都"正确",拼在一起就丢 31% 的 emoji。跨数据源适配必须先做键规范的逐字节对齐,并把覆盖率校验固化进生成脚本;
- 能用数据推导就不要硬编码:肤色变体支持表(哪些 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。
参考文档
更多推荐


所有评论(0)