本文记录 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 带原文。
kindmetrics 或 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 对不齐,LiquidSearch composable 无法编进 ohos;ArkTS Demo 是动画数学的验收宿主,等 compose.ui ohos 制品出现后 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 8.9 升钉;2.2.21 旧线必须 8.x)
依赖commonMain 零第三方依赖(动画核心只用 kotlin.math)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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.ktCompose 布局、BasicTextField、graphicsLayer移入 composeMain
AnimatedSearchIcon.kt / AnimatedCancelIcon.ktCompose Canvas、updateTransition、animateColor移入 composeMain(数学已抽入 Engine)
LiquidSearchConfig.kt / TextFieldConfig.kt / LiquidSearchController.kt / PlatformName.ktCompose 类型(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 方法覆盖内核
LiquidSearchFrameliquidSearchFrame(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.tomlKotlin 2.2.21-1.0.0 + multiplatform 插件别名(5 行)
build.gradle.kts(根)版本 0.2.9-ohos.1、全局禁用 Ohos*Test* 任务
gradle.propertiesJDK21、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.kt1 个 C ABI、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(23 节,5782 字)

4.3 关键 API 对照

层次API作用
引擎LiquidSearchEngine.metrics(spec)静态几何 + 七个时序常量(配置变化时求一次)
引擎LiquidSearchEngine.frame(spec, state)当帧液滴矩形、手柄线、中心圆、位移、透明度、取消缩放
引擎Spec / State上游 LiquidSearchConfig 数值化 / 宿主持有的运行状态
契约LiquidSearchActionListener四回调契约(ArkTS 事件日志对齐它)
契约LiquidSearchIconPositionLEFT/RIGHT 透传(上游 UI 未消费,已知问题)
NativeLiquidSearchFrameC ABI JSON 导出(唯一符号)
N-APIliquidSearchFrame(req)向 ArkTS 暴露能力
ArkTSbuildReq(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 项用例全部通过:

测试用例钉住什么
metricsMatchUpstreamProportionsswitcherRadius=46、iconRadius=23、iconClipRadius=11.5、lineSize=12.65、cancelIconSize=20、时序 800/200
activationStartsCollapsedAndSettlesExpandedt=0 图标宽=折叠宽;t≥800ms progress≈1、clip 可见、手柄甩出
deactivationReturnsToCollapsedt≥800ms 回到折叠宽(含 0.0024 过冲容差,注释注明与 Compose 一致)
cursorBlinkFollowsUpstreamKeyframesp=300 全灭、p=450 半亮、p=500 全亮;499ms 判 typing、501ms 判 idle
translationXClampsAtRightEdge自由段=currentEdge、超界段=maxEdge(上游公式逐项对账)
cancelIconScalesOver300Ms150ms 处 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 功能用例

  1. 启动后状态行先出现 metrics ok · lineSize=12.7 · cancel=20.0(真实 Native 调用,非静态文字);
  2. 点白色液滴:800ms 内展开成放大镜,带往复弹跳与手柄线甩出;再点回弹收起;事件日志出现 onSearchIconClick 与 onStateChange;
  3. 点条身唤起键盘输入:液滴随文字右移,接近右端被钳住,不压 × 号;
  4. 停止输入超过 500ms:图标按 900ms 周期闪烁(300ms 相位偏移);输入中常亮不闪;
  5. 输入非空:右侧 × 以 300ms 缩放弹出;点 × 清空、收键盘、图标回弹,日志出现 onCancelClicked;
  6. 保持「失焦清空」开启,点击页面空白:文本清空,200ms 后搜索条收起;关闭开关再试:文本保留、仅收起;
  7. 拖高度滑条到 115:条高、液滴尺寸、字号同步变化,metrics 状态行刷新;动画时长拖到 1100ms:弹跳明显变慢;切青绿色板:条背景即时切换;
  8. 状态行耗时字段常态 <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 踩坑复盘

  1. 嵌套类不能单独 import:LiquidSearchEngine.Spec 写成 import ...LiquidSearchSpec 会在 ohos 编译期报 Unresolved reference,必须全名引用或提为顶层类型;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. ArkTS 报 Use explicit types instead of "any" 指向 NAPI 导入行,真因是 ohpm install 没跑、类型包未解析——先装 oh_modules 再编译;
  4. ArkUI Area.width 是 Length 联合类型,赋给 number 字段编不过,Number(newArea.width) 显式转换;
  5. PackageHap 报 spawn java ENOENT 是打包阶段要起 java 而 PATH 没有 JBR——DevEco jbr\bin 必须进 PATH(只设 JAVA_HOME 不够);
  6. 阻尼余弦缓动在 f=1 处轻微过冲(eased≈1.0024),Compose animateFloat 同样如此:jvmTest 容差要预留 0.005/0.1,不要"修复"过冲——它就是回弹手感的来源;
  7. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  8. KN 返回字符串必须 DisposeString 归还运行时,逐帧调用下每秒 62 次分配,N-API 层不能 free();
  9. libohosliquidsearch.so 的 DT_NEEDED 含 libc++_shared.so,copyKnSo* 必须连 libc++ 一起拷进 entry/libs/<abi>/,否则运行时 dlopen 失败;
  10. CMake 把 IMPORTED 绝对路径写进 RUNPATH 会泄漏本机盘符,IMPORTED_NO_SONAME + $ORIGIN 双保险;
  11. 上游 gradle.properties 明文提交了 Maven Central 密码与 GPG keyId——根构建替换时顺手删除,并建议上游轮换;
  12. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内保持 signingConfigs 空数组,证书绝不进入 AtomGit。

8.2 已知问题

  • 鸿蒙侧无 Compose UI:搜索条是 ArkTS Canvas 按桥返回几何逐帧重绘;composeMain 等 compose.ui ohos 制品与 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.ui ohos 制品出现,composeMain 接回 commonMain,让 LiquidSearch composable 直接跑,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 三条经验

  1. UI 库适配的正确切面是"数学与绘制分离":Compose 库拆不出"引擎"时,先问哪些文件站在 kotlin.math 上——缓动、几何、时序通常是纯的,把它们变成时间 t 的确定函数,单测和交叉验证才有抓手;切面找错,后面全是翻译活;
  2. 工具链版本跟着依赖走,没有依赖压力就跟社区最成熟线走:KStore 被 coroutines 元数据倒逼升 2.4.20 线,本库零依赖选 qrose 的 2.2.21 线——两条线的 Gradle 要求(9.x vs 8.x)不同,经验不能互相套用;
  3. 动效库的交叉验证要钉数值而不是钉观感: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。

参考文档

Logo

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

更多推荐