开源鸿蒙平台 CMP 三方库「KMP-Liquid-Search」适配全流程
本文记录
KMP-Liquid-Search接入 OpenHarmony 的完整过程,覆盖动效库源码依赖度盘点、鸿蒙工具链钉版、纯逻辑动画核心提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
KMP-Liquid-Search是基于 Compose Multiplatform 的液态搜索组件库:一条圆角搜索栏,左侧一枚白色「液滴」,点击或聚焦时液滴在阻尼弹簧曲线里展开成放大镜、手柄线甩出并带挤压弹跳;图标随输入文本推进、在右边界钳住;失焦后按配置清空文本并延迟 200ms 回弹收起。本次适配与 KStore 的"真编译整库 + kotlinx 制品线"路线有本质区别:这是一个纯 UI 动效库,上游 commonMain 里除了几个常量全部站在androidx.compose.*上,不存在"可直接编译的引擎"。因此走的是社区最早的纯逻辑核心提取路线(qrose 模式)——把阻尼弹簧缓动、液滴几何模型、手柄线端点、光标跟随钳制、闪烁 keyframes、取消图标缩放全部从 Compose 文件里拆出来,写成时间 t 的确定函数LiquidSearchEngine,原样编译成 OpenHarmony KLIB;ArkTS 侧每 16ms 过一次桥拿当帧几何,用 Canvas 画出来。上游调好的动画数学一行没改,Compose 组件隔离到composeMain等工具链对齐后接回。
项目地址: AtomGit/oh-tpc/KMP-Liquid-Search.git
上游仓库: mejdi14/KMP-Liquid-Search
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
KMP-Liquid-Search 为 Compose Multiplatform 应用提供"带品牌动效的搜索条"这一件事的完整答案:LiquidSearch 一个 composable,LiquidSearchConfig 十几个字段(高度、内边距、起始间距、取消图标比例、浮起高度、颜色、动画时长、监听器)构成组件契约,LiquidSearchActionListener 暴露 onStateChange / onSearchIconClick / onCancelClicked / onTextChange 四个回调接埋点与联想请求。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套动效能力,但上游构建(Kotlin 2.1.20-RC / Compose 1.7.3 / AGP 8.5.2)不会生成 OpenHarmony 变体。
如果只在 ArkTS 里凭感觉重写一段"差不多"的动画,页面可以动起来,却无法证明上游调好的动画数学在 OpenHarmony ARM64 设备上完成编译、链接和执行——缓动振幅、几何比例、闪烁相位任何一处抄错,两端观感就会分叉,"多端一致动效"就成了空话。适配需要同时验证动画核心、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.1.20-RC / Compose 1.7.3 / AGP 8.5.2 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 源码全在 Compose 上 | 库的 12 个 commonMain 文件里 8 个直接 import androidx.compose.*(Canvas、updateTransition、animateColor、BasicTextField、graphicsLayer),没有"引擎"可整体编译。 |
| 动画不是属性动画 | 上游是每帧重算几何的自绘 Canvas,ArkUI animator 属性动画模型与之对不齐。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 逐帧性能疑虑 | 动画要 60fps,"每帧过一次 NAPI"是否可行需要实测。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:动画数学与几何模型的纯 Kotlin 化(LiquidSearchEngine)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。动画数值完全由 Kotlin 侧求值,ArkTS 只负责画布、输入框和事件接线。
1.2 库提供的能力
Demo 提供一个真实的液态搜索会话,对应桥接的全部入口:
- 静态几何(metrics):给定配置(高度 100、浮起 4、比例 1/5……),返回 switcherRadius=46、iconRadius=23、iconClipRadius=11.5、lineSize=12.65、cancelIconSize=20,以及七个时序常量(800/300/300/900/300/500/200)——ArkTS 侧失焦回弹、闪烁判断不用自己写数字;
- 逐帧几何(frame):每 16ms 提交状态(激活标志与时间戳、最近输入、文本宽度、容器宽度、取消目标),返回当帧液滴矩形、手柄线端点与线宽、中心圆、图标位移与透明度、取消图标缩放;
- 状态机:激活/失焦清空(200ms 延迟回弹)/取消重置,
LiquidSearchActionListener四回调契约在事件日志区实时留痕; - 配置实时性:高度 60–140、起始间距 0–60、取消比例 1/3–1/8、动画时长 300–1500ms、浮起 0–12 五组滑条与五套色板,改动即时经
metrics重算。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 求值是否成功,失败时 error 带原文。 |
kind | metrics 或 frame,回显请求类型。 |
icon / handle / clip | 液滴矩形、手柄线(端点+线宽)、中心圆(矩形+圆角+可见性)。 |
translationX / translationY / alpha | 图标整体位移(已含右边界钳制)与透明度。 |
cancelScale / cancelVisible / lineSize | 取消图标缩放、可见性与基准线宽。 |
上游动画数学的全部能力(阻尼弹簧缓动、几何推导链、挤压弹跳、闪烁 keyframes、输入阈值、钳制公式)都在 LiquidSearchEngine 里原样进入 .so,JVM 侧保留 7 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的动画数学没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 动画常量、几何比例、时序参数与上游逐字一致;LiquidSearchEngine 是它们的时间函数化重排,不是重写。上游 8 个 Compose 文件原样保留在 composeMain。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);composeApp / iosApp(上游示例工程)留盘不进构建。 |
| 桥接稳定 | 使用 1 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 性能达标 | 逐帧 JSON 往返实测 <1ms,60fps 定时器稳定驱动 Canvas 重绘。 |
| UI 完整 | 单页覆盖全部桥接入口:搜索条本体、五组滑条、五套色板、两个开关、事件日志和桥状态行。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。当前社区 CMP 制品(1.9.2)与上游 Compose 1.7.3 对不齐,
LiquidSearchcomposable 无法编进 ohos;ArkTS Demo 是动画数学的验收宿主,等compose.uiohos 制品出现后composeMain接回commonMain,Canvas 路径降级为 fallback。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、确认源码对 Compose 的依赖形态、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、源码集拆分
第 3 阶段:桥协议建模 ── metrics/frame 双请求、逐帧几何载荷、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 16ms 定时器、Canvas 重绘、交互回调对齐 ActionListener
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
liquidSearch/ 上游库模块(commonMain 留纯逻辑 + ohos 双目标,composeMain 隔离 Compose)
liquidSearch/src/commonMain/ 动画常量 + lerp + 枚举 + 监听器契约 + LiquidSearchEngine(新增)
liquidSearch/src/composeMain/ 上游 8 个 Compose 文件 + composeResources(ohos 不编译,留作接回参考)
liquidSearch/src/jvmTest/ 7 项交叉验证(新增)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
composeApp/ / iosApp/ 上游示例工程,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :liquidSearch 与 :example:nativeApp,rootProject.name = "ohos_KMP-Liquid-Search"。上游的 dokka / detekt / vanniktech 发布插件、AGP classpath、hot-reload 插件、foojay resolver 全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(org.gradle.java.home 写进 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 8.9 升钉;2.2.21 旧线必须 8.x) |
| 依赖 | commonMain 零第三方依赖(动画核心只用 kotlin.math) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.liquidsearch |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
| 文件 | 依赖形态 | 处置 |
|---|---|---|
helpers/AnimationConstants.kt | 纯常量(缓动振幅/频率、几何比例、时序) | 留在 commonMain(追加 3 个下沉常量) |
helpers/AnimationUtils.kt | 纯函数 lerp | 留在 commonMain |
data/LiquidSearchIconPosition.kt | 纯枚举 | 留在 commonMain |
data/listener/LiquidSearchActionListener.kt | 纯回调契约 | 留在 commonMain |
LiquidSearch.kt / LiquidSearchTextField.kt | Compose 布局、BasicTextField、graphicsLayer | 移入 composeMain |
AnimatedSearchIcon.kt / AnimatedCancelIcon.kt | Compose Canvas、updateTransition、animateColor | 移入 composeMain(数学已抽入 Engine) |
LiquidSearchConfig.kt / TextFieldConfig.kt / LiquidSearchController.kt / PlatformName.kt | Compose 类型(Dp/Color/Shape/@Composable/expect) | 移入 composeMain |
上游 commonMain 里散落三个魔法数字没有常量名:光标闪烁 StartOffset(300)、取消图标 tween(300)、失焦清空 delay(200L)。本次下沉为 CURSOR_BLINK_START_OFFSET / CANCEL_ICON_ANIMATION_DURATION / UNFOCUS_CLEAR_DELAY_MS,数值逐字一致——这与 qrose 把 Code128Type 从 Painter 文件抽回 commonMain 是同一类动作:纯类型/常量跟 UI 文件绑在一起时,拆源码集要手洗。
LiquidSearchEngine 是全仓唯一新增的库内源文件:metrics(spec) 出静态几何与全部时序常量,frame(spec, state) 出当帧绘制几何。缓动公式 -exp(-f/A)·cos(ω·f)+1(激活 A=0.1/ω=8.0,未激活 A=0.2/ω=14.5)、液滴推导链(switcherRadius → iconRadius → iconClipRadius → iconCollapsedWidth → 图标矩形)、手柄线端点、挤压系数 1 − exp(−p/0.1)·cos(8p)×0.2、钳制公式 translationX = min(currentEdge, maxEdge)、闪烁分段(p<100 线性灭、[100,400) 全灭、[400,500) 线性亮、其余常亮)全部来自上游文件。
1.4 页面边界
标题区 KMP-LIQUID-SEARCH · OPENHARMONY / 桥状态行(frame ok · iconProgress=… · line=… · …ms)
搜索条 圆角条 + TextInput(透明光标)+ 图标 Canvas(跟随/闪烁/位移)+ 取消 Canvas(缩放弹出)
提示行 点图标播放液态动画 · 点条身唤起键盘输入 · 右侧 × 清空
滑条区 高度 60–140 / 起始间距 0–60 / 取消图标 1/3–1/8 / 动画时长 300–1500ms / 图标浮起 0–12
色板区 默认紫 #6147FF / 墨黑 / 青绿 / 工业蓝 / 酒红
开关区 失焦清空文本 / 图标位置透传(上游 LEFT/RIGHT)
日志区 LiquidSearchActionListener 事件日志(带时间戳,最新在前,上限 8 条)
页面启动时先调 kind=metrics 做握手,然后 setInterval 16ms 持续跑 kind=frame。
第 2 阶段:目标与依赖打通
2.1 工具链选型(为什么是 2.2.21-1.0.0 而不是 2.4.20 线)
KStore 走"真编译整库"路线时被 kotlinx 制品线倒逼升到 Kotlin 2.4.20-0.1.0-07(coroutines 元数据是 2.4,旧钉法读不了)。本库没有这个压力:动画核心零第三方依赖,不需要任何 kotlinx ohos 制品。因此选社区最成熟、已过审仓库最多的 qrose 钉法:Kotlin 2.2.21-1.0.0 + Gradle 8.14.1——这条线在 qrose / haze / Reorderable 上验证充分,且 2.2.21 线必须配 Gradle 8.x(KStore 的 2.4.20 线才支持 9.x),上游 wrapper 8.9 升钉到 8.14.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_KMP-Liquid-Search"
include(":liquidSearch")
include(":example:nativeApp")
2.2 根构建替换
根构建收敛为 13 行模板 + 全局禁用 ohos 测试任务:
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
}
allprojects {
group = "org.mejdi14.search"
version = providers.gradleProperty("version").orElse("0.2.9-ohos.1").get()
}
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
gradle.properties 重写为:JDK21 路径、version=0.2.9-ohos.1、三个 kotlin.native 开关。同时删除了上游明文提交的 Maven Central 账号密码与 GPG keyId——发布凭据不属于仓库,本 fork 也不走 Central 发布。gradle/libs.versions.toml 从 44 行砍到 5 行,只剩 Kotlin 版本与 multiplatform 插件别名。
2.3 编译动画核心
liquidSearch/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
./gradlew :liquidSearch:compileKotlinJvm # BUILD SUCCESSFUL
./gradlew :liquidSearch:jvmTest # 7 项全绿
不要在库模块上开 binaries.sharedLib——sharedLib 只放 example/nativeApp,否则库模块会吐一份带 Kotlin/Native 运行时的 so,HAP 体积和符号都会乱。
2.4 上游 Compose 文件的处置
8 个 Compose 文件与 composeResources 用 git mv 迁到 src/composeMain(保留历史)。composeMain 不是 Kotlin 源集,ohos 编译器看不见它;androidMain / desktopMain / iosMain / wasmJsMain(上游平台 actual 与入口)留盘不编译。上游的 expect 声明(PlatformName.kt)只有 Compose 文件消费,随文件一起隔离——commonMain 里不再有 expect,ohos 侧无需写任何 actual,这是本库与 KStore(补 dispatcher actual)的又一个差异:适配面为零平台源码。
第 3 阶段:桥协议建模
3.1 JSON 契约
org.mejdi14.search/**(动画常量 + LiquidSearchEngine,纯 Kotlin)
↓ metrics(spec) / frame(spec, state)
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API liquidSearchFrame(request)
↓
ArkUI Index.ets(16ms 定时器 + Canvas)
请求与响应:
请求 : {"kind":"metrics","height":100,"pad":20,"startSpacing":30,"cancelRatio":5,
"elevation":4,"duration":800,"colorDuration":300,"bg":"#6147FF",...}
{"kind":"frame",...同上配置...,"nowMs":1760000000000,"isActive":true,
"activeSinceMs":...,"lastInputMs":...,"textWidthPx":42.5,
"containerWidthPx":320,"cancelTarget":true,"cancelSinceMs":...}
响应 : {"ok":true,"kind":"metrics","switcherRadius":46,...,"timing":{...}}
{"ok":true,"kind":"frame","iconProgress":0.83,"icon":{...},"handle":{...},
"clip":{...},"translationX":...,"alpha":...,"cancelScale":...}
{"ok":false,"error":"..."}
一个不对称设计:metrics 只依赖配置(配置变化时调一次),frame 依赖配置 + 状态(动画循环里每 16ms 调一次)。配置字段在两种请求里同名同义,ArkTS 侧一个 buildReq(kind) 函数统一拼装。
3.2 载荷为什么是逐帧几何而不是位图或参数
qrose 桥回 PNG(条码是静态产物),KStore 桥回 JSON 文档(存储是离散操作)。液态动画的本体是每帧重算的自绘几何——把几何当"数据"过桥,ArkTS 侧就不需要任何一处缓动公式、几何比例或时序参数,全部数值来自 Kotlin。备选方案对比:
| 方案 | 否决理由 |
|---|---|
| 桥回逐帧 PNG / 差分位图 | 60fps 编解码开销远超几何求值;文字层无法透明叠加。 |
| 只传参数,缓动在 ArkTS 实现 | 等于把上游算法翻译一遍,以后上游调参两端分叉。 |
| 动画下沉 ArkUI animator 属性动画 | 上游不是属性动画,是自绘 Canvas;模型对不齐。 |
| 逐帧几何(采纳) | 桥无状态纯函数;JSON 几百字节,同步 C 调用微秒级。 |
逐帧调用的性能疑虑用实测回答:x86_64 模拟器与 arm64 真机上单次 frame 往返均 <1ms(状态行实时显示耗时),16ms 定时器稳定驱动。
3.3 错误通道
导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("LiquidSearchFrame")
fun liquidSearchFrame(request: String): String {
return try {
val json = MiniJson.parse(request)
when (json.str("kind").ifBlank { "frame" }) {
"metrics" -> metricsJson(buildSpec(json))
else -> frameJson(buildSpec(json), buildState(json))
}
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
}
非法 JSON、越界数值(height 钳到 32–400、duration 钳到 50–5000)、颜色格式错误全部走同一个 ok:false 通道,ArkTS 侧状态行统一渲染 frame 失败: …,HAP 不崩。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string(16ms 一帧)
▼
C++ N-API entry(libliquidsearch_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosliquidsearch.so)
│ LiquidSearchFrame
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;HarmonyOS Kotlin 的 CAdapter 也不会把 @CName 业务符号挂到 ArkTS 模块上,所以必须 CMake 再包一层 NAPI so。
4.2 Kotlin/Native 导出函数
@CName("LiquidSearchFrame")
fun liquidSearchFrame(request: String): String // metrics / frame 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
LiquidSearchFrame | liquidSearchFrame(req) | LiquidSearchEngine.metrics + frame 全部几何与时序 |
一个符号覆盖全部入口——动效库没有 KStore 那样的版本握手需求,metrics 响应里的时序常量就是握手产物。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"liquidSearchFrame", nullptr, NapiLiquidSearchFrame, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosliquidsearch_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()——60fps 下每秒 62 次分配,泄漏或错删都会快速显形。libohosliquidsearch_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与一个业务符号)。
4.4 CMake 和类型声明
add_library(ohosliquidsearch SHARED IMPORTED)
set_target_properties(ohosliquidsearch PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(liquidsearch_napi SHARED napi_init.cpp)
target_link_libraries(liquidsearch_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosliquidsearch)
OHOS_ARCH 为空或 libohosliquidsearch.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const liquidSearchFrame: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 定时器与请求拼装
aboutToAppear(): void {
this.refreshMetrics(); // kind=metrics 握手
this.timerId = setInterval(() => {
this.tick(); // kind=frame 逐帧
}, TICK_MS); // 16ms
}
private tick(): void {
const t0 = new Date().getTime();
const raw = liquidSearchFrame(JSON.stringify(this.buildReq('frame')));
const res = JSON.parse(raw) as FrameResp;
if (!res.ok) { this.status = 'frame 失败: ' + (res.error ?? ''); return; }
this.frame = res;
this.drawIcon(); // Canvas 直接重绘,不走 @State
this.drawCancel();
}
frame 结果持有在普通字段而非 @State——每秒 62 次状态失效会拖累整页渲染,Canvas 经 CanvasRenderingContext2D 重绘自身即失效,不经过声明式管线。
5.2 页面交互
搜索条是 Stack(TextInput 铺底 + 图标 Canvas 叠左)加尾部取消 Canvas:图标点击触发 onSearchIconClick + onStateChange;TextInput onChange 记录 lastInputMs 并驱动取消目标,onFocus 激活、onBlur 走"清空 + timing.unfocusClearDelayMs 延迟回弹"链路;取消点击走"清空 + stopEditing() + 收起"。上游 graphicsLayer 的 translationX/Y 直接折算进 Canvas 坐标(不用 ctx.translate,规避变换栈重置问题);圆角矩形用四段圆弧手拼,lineCap='round' 对齐上游 StrokeCap.Round。文本宽度用 measureText 整串测量(上游是逐字 bounding box 累加,常规字体下差异可忽略)。
5.3 对比度和错误边界
五套色板均为深底白字,滑条/开关用系统默认对比度;ok:false 时错误原文进状态行。Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行,页面永不白屏。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosliquidsearch)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/
│ └── Bridge.kt @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.liquidsearch、versionName 0.2.9-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/libohosliquidsearch_api.h
├── src/main/cpp/types/libliquidsearch_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :liquidSearch:jvmTest # 7 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install --all # 首次需要(安装 NAPI 类型包)
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。
hdc list targets -v
hdc -t <设备序列号> install -r \
entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
-a EntryAbility -b org.terminator.ohos.liquidsearch -m entry
四、完整代码对照
4.1 整体架构
org.mejdi14.search.helpers/**(上游常量 + lerp,原样编译)
│ 振幅/频率/比例/时序常量
org.mejdi14.search.core.LiquidSearchEngine(新增,时间函数化)
│ metrics(spec) / frame(spec, state)
Bridge.kt + MiniJson
│ LiquidSearchFrame
libliquidsearch_napi.so
│ liquidSearchFrame
Index.ets
│ JSON parse + Canvas 2D
▼
ArkUI 真机页面(单页滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_KMP-Liquid-Search、composeApp 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + multiplatform 插件别名(5 行) |
build.gradle.kts(根) | 版本 0.2.9-ohos.1、全局禁用 Ohos*Test* 任务 |
gradle.properties | JDK21、KN 开关;删除上游明文发布凭据 |
liquidSearch/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 + jvmTest |
liquidSearch/.../core/LiquidSearchEngine.kt | 唯一新增库内源文件:几何/缓动/闪烁/钳制求值 |
liquidSearch/.../helpers/AnimationConstants.kt | 上游原样 + 3 个下沉常量(300/300/200) |
liquidSearch/src/jvmTest/ | 7 项交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(23 节,5782 字) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | LiquidSearchEngine.metrics(spec) | 静态几何 + 七个时序常量(配置变化时求一次) |
| 引擎 | LiquidSearchEngine.frame(spec, state) | 当帧液滴矩形、手柄线、中心圆、位移、透明度、取消缩放 |
| 引擎 | Spec / State | 上游 LiquidSearchConfig 数值化 / 宿主持有的运行状态 |
| 契约 | LiquidSearchActionListener | 四回调契约(ArkTS 事件日志对齐它) |
| 契约 | LiquidSearchIconPosition | LEFT/RIGHT 透传(上游 UI 未消费,已知问题) |
| Native | LiquidSearchFrame | C ABI JSON 导出(唯一符号) |
| N-API | liquidSearchFrame(req) | 向 ArkTS 暴露能力 |
| ArkTS | buildReq(kind) / tick() | 请求拼装 / 逐帧调用与重绘 |
4.4 ArkTS 与 Kotlin 的边界
const frame = JSON.parse(liquidSearchFrame(JSON.stringify({
kind: 'frame', height: 115, pad: 20, startSpacing: 39, cancelRatio: 6,
elevation: 7, duration: 1100, colorDuration: 300,
bg: '#0F766E', searchIconColor: '#FFFFFF', cancelIconColor: '#FFFFFF',
iconActiveColor: '#00000000', iconInactiveColor: '#0F766E',
nowMs: Date.now(), isActive: true, activeSinceMs: t0,
lastInputMs: t1, textWidthPx: 36.5, containerWidthPx: 301,
cancelTarget: false, cancelSinceMs: t2
}))) as FrameResp;
// frame.icon / frame.handle / frame.clip → Canvas 绘制
val frame = LiquidSearchEngine.frame(spec, state)
// frame.iconLeft/Top/Right/Bottom、handleStartX/Y、translationX——全部来自上游公式
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象、位图或未校验的动态结构。
五、关键决策说明
决策 1:提取纯逻辑核心而不是等 Compose ohos 制品
上游 12 个 commonMain 文件里 8 个站在 androidx.compose.* 上,社区 CMP 制品(1.9.2)与上游 Compose 1.7.3 至今对不齐。等制品意味着这个库在鸿蒙上长期缺席;而动画的本体是数学——缓动、几何、时序全部可写成时间 t 的确定函数。提取核心 + 隔离 Compose(qrose 模式)让适配当下可交付,composeMain 保留原件等接回,不是终态架构而是可验收的中间态。
决策 2:工具链钉 2.2.21-1.0.0(qrose 线),Gradle 8.14.1
与 KStore 相反:本库动画核心零依赖,没有 kotlinx 制品线的元数据倒逼,选社区过审最多的 qrose 钉法风险最小。2.2.21 旧线必须配 Gradle 8.x(上游 8.9 升钉 8.14.1),KStore 的"2.4.20 线留 Gradle 9.x"经验不能互相套用——工具链版本跟着依赖走,没有依赖压力就跟社区最成熟线走。
决策 3:根构建替换(cmp_qrose 模式),composeApp / iosApp 留盘
上游根构建(dokka / detekt / vanniktech 发布 / AGP / hot-reload / foojay)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。composeApp / iosApp 是上游示例工程,留盘不进 settings.gradle.kts——它们的存在方式只有这一种。
决策 4:逐帧几何过桥,状态归 ArkTS
焦点、键盘、文本输入是平台能力,天然归宿主;缓动、几何、时序是库的价值,天然归 Kotlin。桥因此设计成无状态纯函数:frame(spec, state) 不持有任何会话,多搜索条同屏各自持状态各自调用即可。备选的"参数过桥 + ArkTS 缓动"被否决——那等于把上游算法翻译一遍,以后上游调参两端分叉。
决策 5:jvmTest 交叉验证钉住"数学没漂"
动画数学在 commonMain,JVM 与 ohos 跑同一份代码;7 项 jvmTest 钉住静态几何(46/23/11.5/12.65/20)、激活展开、未激活收起(含 0.0024 阻尼过冲,与 Compose animateFloat 行为一致)、闪烁分段、输入阈值、钳制公式、取消缩放、颜色过渡。几何值是手算可对账的——这是"逐帧一致"的唯一可信证明,比"Demo 动起来了"强一个量级。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin)、DevEco Studio 5.1.0 Release(hvigor / ohpm / hdc)、OpenHarmony 真机(arm64)与 x86_64 模拟器。
6.2 JVM 交叉验证
./gradlew :liquidSearch:jvmTest
7 项用例全部通过:
| 测试用例 | 钉住什么 |
|---|---|
metricsMatchUpstreamProportions | switcherRadius=46、iconRadius=23、iconClipRadius=11.5、lineSize=12.65、cancelIconSize=20、时序 800/200 |
activationStartsCollapsedAndSettlesExpanded | t=0 图标宽=折叠宽;t≥800ms progress≈1、clip 可见、手柄甩出 |
deactivationReturnsToCollapsed | t≥800ms 回到折叠宽(含 0.0024 过冲容差,注释注明与 Compose 一致) |
cursorBlinkFollowsUpstreamKeyframes | p=300 全灭、p=450 半亮、p=500 全亮;499ms 判 typing、501ms 判 idle |
translationXClampsAtRightEdge | 自由段=currentEdge、超界段=maxEdge(上游公式逐项对账) |
cancelIconScalesOver300Ms | 150ms 处 scale=0.5(弹出与回缩对称) |
colorTransitionsToActiveTarget | 中心圆从 iconInactiveColor 过渡到 iconActiveColor,300ms 到目标 |
6.3 HAP 验证
:liquidSearch:compileKotlinJvm: BUILD SUCCESSFUL
:liquidSearch:jvmTest: 7 项全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: LiquidSearchFrame / libohosliquidsearch_symbols 均为全局 T 符号(双 ABI)
ohpm install --all: install completed
BuildNativeWithNinja / CompileArkTS / PackageHap: finished
hvigorw assembleHap: BUILD SUCCESSFUL
entry-default-unsigned.hap 约 6.8 MB,内含双 ABI 各三件套(libliquidsearch_napi.so / libohosliquidsearch.so / libc++_shared.so);debugShared 的 KN so arm64 约 3.7MB、x86_64 约 3.4MB(含 Kotlin/Native 运行时)。CAdapter 链接期的 api.cpp:448 clang 警告是生成胶水代码的已知问题,不影响产物。
6.4 功能用例
- 启动后状态行先出现
metrics ok · lineSize=12.7 · cancel=20.0(真实 Native 调用,非静态文字); - 点白色液滴:800ms 内展开成放大镜,带往复弹跳与手柄线甩出;再点回弹收起;事件日志出现
onSearchIconClick与onStateChange; - 点条身唤起键盘输入:液滴随文字右移,接近右端被钳住,不压 × 号;
- 停止输入超过 500ms:图标按 900ms 周期闪烁(300ms 相位偏移);输入中常亮不闪;
- 输入非空:右侧 × 以 300ms 缩放弹出;点 × 清空、收键盘、图标回弹,日志出现
onCancelClicked; - 保持「失焦清空」开启,点击页面空白:文本清空,200ms 后搜索条收起;关闭开关再试:文本保留、仅收起;
- 拖高度滑条到 115:条高、液滴尺寸、字号同步变化,
metrics状态行刷新;动画时长拖到 1100ms:弹跳明显变慢;切青绿色板:条背景即时切换; - 状态行耗时字段常态 <1ms,逐帧桥调用不拖累动画流畅度。
6.5 验证结论
动画核心、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的液态搜索会话:阻尼弹簧展开、光标跟随、闪烁、取消缩放与失焦回弹闭环(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/KMP-Liquid-Search.git
cd KMP-Liquid-Search
export JAVA_HOME="/path/to/jdk-21"
./gradlew :liquidSearch:compileKotlinJvm # klib(JVM 侧)
./gradlew :liquidSearch:jvmTest # 7 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
ohpm install --all
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.liquidsearch -m entry
hdc shell hilog | grep LiquidSearch
八、遗留问题与改进方向
8.1 踩坑复盘
- 嵌套类不能单独 import:
LiquidSearchEngine.Spec写成import ...LiquidSearchSpec会在 ohos 编译期报Unresolved reference,必须全名引用或提为顶层类型; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- ArkTS 报
Use explicit types instead of "any"指向 NAPI 导入行,真因是ohpm install没跑、类型包未解析——先装 oh_modules 再编译; - ArkUI
Area.width是Length联合类型,赋给 number 字段编不过,Number(newArea.width)显式转换; PackageHap报spawn java ENOENT是打包阶段要起 java 而 PATH 没有 JBR——DevEcojbr\bin必须进 PATH(只设 JAVA_HOME 不够);- 阻尼余弦缓动在 f=1 处轻微过冲(eased≈1.0024),Compose
animateFloat同样如此:jvmTest 容差要预留 0.005/0.1,不要"修复"过冲——它就是回弹手感的来源; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,逐帧调用下每秒 62 次分配,N-API 层不能free(); libohosliquidsearch.so的DT_NEEDED含libc++_shared.so,copyKnSo*必须连 libc++ 一起拷进entry/libs/<abi>/,否则运行时dlopen失败;- CMake 把 IMPORTED 绝对路径写进 RUNPATH 会泄漏本机盘符,
IMPORTED_NO_SONAME+$ORIGIN双保险; - 上游
gradle.properties明文提交了 Maven Central 密码与 GPG keyId——根构建替换时顺手删除,并建议上游轮换; - 签名材料由 DevEco 自动写入
build-profile.json5,仓库内保持signingConfigs空数组,证书绝不进入 AtomGit。
8.2 已知问题
- 鸿蒙侧无 Compose UI:搜索条是 ArkTS Canvas 按桥返回几何逐帧重绘;
composeMain等compose.uiohos 制品与 CMP 对齐后接回; - 圆角矩形用四段圆弧拼,未用 Skia
RoundRect;阴影用 ArkUI.shadow,与上游Modifier.shadow散射参数不逐项对齐; - 文本宽度测量是
measureText整串近似(上游逐字 bounding box 累加),极端字距下图标跟随位置有少量偏差; - 上游
LiquidSearchIconPosition(LEFT/RIGHT)在 UI 层未被消费(上游自身如此),桥透传字段但 Demo 不改布局; - 上游
onTextChange从未被调用(上游 bug),Demo 在 ArkTS 侧按契约补发该事件; - android / desktop / ios / wasmJs 目标剥离(源文件留在树里),发布、dokka、detekt 一并剥离,本 fork 不发 Maven Central;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.liquidsearch匹配的签名。
8.3 未来优化方向
- 等
compose.uiohos 制品出现,composeMain接回commonMain,让LiquidSearchcomposable 直接跑,Canvas 路径降级 fallback; - 补充逐帧几何的快照测试(golden frames),把"观感一致"变成可回归的数值断言;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和 uitest 自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 liquidSearch(8/12 文件站在 Compose 上)
→ 源码依赖度盘点:动画数学可拆,UI 不可编
→ 拆源码集:纯逻辑留 commonMain,Compose 隔离 composeMain
→ LiquidSearchEngine:缓动/几何/闪烁/钳制写成时间 t 的确定函数
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1,qrose 钉法)
→ 根构建替换(cmp_qrose 模式)
→ ohosArm64 / ohosX64 KLIB + 零平台源码
→ C ABI(@CName × 1)+ 逐帧几何载荷
→ C++ N-API(DisposeString 纪律 × 62 次/秒)
→ ArkTS Canvas 16ms 重绘
→ ArkUI 真机单页验收(展开 / 跟随 / 闪烁 / 取消 / 失焦回弹)
9.2 封装层次
- 引擎层:
LiquidSearchEngine——上游动画数学的时间函数化(缓动、几何、时序、钳制); - 契约层:上游原版
LiquidSearchActionListener回调契约与LiquidSearchIconPosition枚举; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、定时器、Canvas 绘制、事件分发与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 库适配的正确切面是"数学与绘制分离":Compose 库拆不出"引擎"时,先问哪些文件站在
kotlin.math上——缓动、几何、时序通常是纯的,把它们变成时间 t 的确定函数,单测和交叉验证才有抓手;切面找错,后面全是翻译活; - 工具链版本跟着依赖走,没有依赖压力就跟社区最成熟线走:KStore 被 coroutines 元数据倒逼升 2.4.20 线,本库零依赖选 qrose 的 2.2.21 线——两条线的 Gradle 要求(9.x vs 8.x)不同,经验不能互相套用;
- 动效库的交叉验证要钉数值而不是钉观感:
metrics的每个值都能手算(46/23/11.5/12.65/20),frame的每个分段都能构造边界时刻(p=300 全灭、499ms 判 typing)——"动起来了"只证明链路通,"数值对上了"才证明没漂;连过冲 0.0024 都要与 Compose 对齐并在用例里注明。
9.4 适配成果
当前适配已完成:
- KMP-Liquid-Search
0.2.9动画核心的双 ABI Kotlin/Native 编译(缓动/几何/时序与上游逐字一致,8 个 Compose 文件原样隔离待接回); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 单页验收页面(液态展开、光标跟随、闪烁、取消缩放、失焦回弹、五组滑条、五套色板、事件日志);
- 7 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 6.8MB)、签名、设备安装和真机验证;
- 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)