开源鸿蒙平台 kmp 三方库「expandable-player」适配全流程
本文记录
expandable-player-kmp接入 OpenHarmony 的完整过程,覆盖 UI-only CMP 库盘点、状态机抽取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
expandable-player-kmp是 SoundCloud 风格的可展开迷你播放器 Compose Multiplatform 库:Modifier.expandable(handler, scope)一行挂上手势,MinimizableHandler持有Animatable<Float>的 fraction,ExpandableSettings给出五项几何参数;收起时是 70dp 迷你条,上滑/点击展开成完整播放面板,松手按速度与 fraction 贴附。本次适配与 KStore「真编译整库」的路线有本质区别:这个库 100% 是 Compose UI——手势链在Modifier上、动画在Animatable里、几何量是Dp,没有可以直接编译的纯 Kotlin 内核。因此适配走 spinnable 先证的「UI-only 库」路线:先把状态机从 Modifier 里抽成纯 KotlinExpandableEngine,上游 Compose 源码原样隔离进composeMain不参与 ohos 编译,引擎编成 Kotlin/Native 动态库,ArkUI 页面作为验收宿主。
项目地址: AtomGit/oh-tpc/expandable-player-kmp.git
上游仓库: markst/expandable-player-kt
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
expandable-player-kmp 为 CMP 应用提供「迷你播放器手感」这一件事的完整答案:拖拽位移按 (maximizedHeight - minimizedHeight) 换算成 fraction,|v| < 100 px/s 时按 fraction < 0.4 分档贴附,fling 按方向弹向两端;展开内容以 4 倍速淡入、圆角随 fraction 收缩 20dp、scrim 与边框以 1 - fraction 渐隐——这些精确数值构成了「SoundCloud 手感模型」。上游在 Android / iOS 上由 composeMain 的三个文件(ExpandableModifier / MinimizableHandler / ExpandableSettings)承担全部实现。OpenHarmony 应用同样需要这套交互,但上游构建(Kotlin 2.0.21 / Compose 1.7.0 / AGP 8.13)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以显示一个会动的面板,却无法证明手感模型在 OpenHarmony ARM64 设备上完成编译、链接和执行——阈值抄错一个,手感就和 Android/iOS 漂移。适配需要同时验证状态机语义、Native 动态库、C ABI、N-API、ArkUI 手势与渲染、HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.0.21 + JetBrains Compose 1.7.0 与鸿蒙工具链无法共存;JetBrains 没有为 ohos 发布 compose klib,配置期即断。 |
| 无内核可编 | 与 qrose(编码器在 commonMain)、KStore(引擎 + ohos kotlinx 制品线)不同,本库全部实现依赖 androidx.compose,直接编译没有可行路径。 |
| 语义钉死成本 | 贴附阈值、速度口径、方向翻转、淡入倍率共 10 条手感语义,抽取时漏一条就是一次「手感分叉」。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 动画跨不了桥 | Compose Animatable.animateTo 是 spring 曲线,C ABI 只能同步一来一回,动画归属必须在桥设计时想清楚。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:状态机的纯 Kotlin 抽取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。手感语义完全由引擎单侧负责,ArkTS 只负责手势采集、调用 Native、按快照渲染和驱动缓动动画。
1.2 库提供的能力
Demo 提供一个真实的播放器手感会话,对应桥接的全部入口:
- 拖拽(drag):
op=drag携带当前 fraction 与手指位移offsetY(vp,正=下滑),引擎按reverseDirection=true语义内部取负换算,返回新 fraction 与全量几何快照; - 松手贴附(stopped):
op=stopped携带手指速度velocityY(px/s,正=下滑),引擎翻转后按上游规则给出decision(settle-low-fraction/settle-high-fraction/fling-toward-expand/fling-toward-collapse)与目标 fraction; - 程序化切换(expand / collapse / toggle):对应
MinimizableHandler.expand/collapse/toggle,返回决策与目标; - 几何推导(derive):任意 fraction 下的 8 项推导值——面板高度、scrim 透明度、圆角、水平边距、底边距、两层内容透明度、拖拽区间;
- 常量握手(config):返回上游五条手感常量(0.4 贴附分档、100 px/s 速度阈值、4 倍淡入、20dp 圆角收缩、10dp 水平边距),页面展示的阈值来自真实 Native 调用。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op / fraction / expanded / targetFraction | 回显操作与状态机状态。 |
settings | 本次生效的五项设置(回显,缺省用上游 Demo 默认值)。 |
derived | 8 项推导几何,渲染只读这里,ArkTS 不复算。 |
constants | 上游手感常量(config 响应携带)。 |
decision | 仅 stopped / expand / collapse / toggle 响应携带:expand + targetFraction + reason。 |
引擎侧完整语义(fraction 钳制、贴附分档、fling 翻转、推导几何)全部在 ExpandableEngine 与 jvmTest 的九条断言里钉死,证明 ohos .so 里执行的手感模型没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 状态机抽取 | ExpandableEngine 纯 Kotlin、零 androidx.compose import;上游 10 条手感语义逐条映射,包括上游「px/s 速度除以 dp 数值」的单位混用 quirk 也原样保留(修复属于上游 PR,fork 不改手感)。 |
| 上游源码零改动 | 三个 Compose 文件原样迁入 src/composeMain(不参与 ohos 编译),与上游仓库逐字节一致,留待工具链对齐后接回。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 Compose 示例 App 与 iosApp/ 留盘不进构建。 |
| 桥接稳定 | 1 个 C ABI + UTF-8 扁平 JSON,无状态设计(fraction 随请求往返),异常永远不跨 FFI。 |
| 动画归属 | Animatable 语义由 ArkTS 侧 16ms 心跳 + easeOutCubic 近似:中断语义(拖拽即停)、终点语义(必然收敛到 decision.targetFraction)与上游一致。 |
| UI 完整 | 三 Tab 覆盖全部桥接入口:播放器还原、参数面板、状态机验证。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游的 Compose Modifier 留在
composeMain原样保存,ArkTS Demo 渲染的是引擎吐出的 CMP 手感模型。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 UI-only 边界、确认「抽取引擎」路线、确定交付物
第 2 阶段:目标与工具链 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:引擎抽取 ── ExpandableEngine 纯 Kotlin 状态机、10 条语义映射、jvmTest 九条断言
第 4 阶段:桥协议建模 ── 无状态 7-op JSON 契约、快照 + 决策双通道、错误通道
第 5 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和 DisposeString
第 6 阶段:示例与验证 ── ArkUI 三 Tab、HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
expandable/ 上游库模块(改造成 ohos 构建面:jvm + ohosArm64 + ohosX64)
expandable/src/commonMain/ 新增:ExpandableEngine.kt(纯 Kotlin 状态机,全仓核心新增)
expandable/src/commonTest/ 新增:引擎语义 JVM 断言(九条,鸿蒙桥的对照基线)
expandable/src/composeMain/ 上游三个 Compose 文件原样迁入(ohos 不编,留待工具链对齐)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 三 Tab 页面
example/src + iosApp/ 上游 Compose 示例,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :expandable 与 :example:nativeApp,rootProject.name = "ohos_expandable_player"。上游根构建里的 AGP / JetBrains Compose 插件、androidX 依赖矩阵全部随根构建替换剥离——本 fork 不发 Android/iOS 制品。
这里有一个 Gradle 隐性陷阱:include(":example:nativeApp") 会让 Gradle 把 example/ 当作父项目并评估其构建脚本,而上游的 example/build.gradle.kts 恰好引用了已剥离的 Compose/AGP 插件,配置期直接炸出四个 Unresolved reference。处置是把上游示例的构建文件移出构建面(git 历史保留),示例源码留盘——qrose 模板的 example/ 下没有构建脚本,正是这个原因。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 写进 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 从上游 8.7 升级;2.2.21 旧线钉 8.14.1) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 26.0.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.expandable |
export JAVA_HOME="/path/to/jdk-21"
java -version
注意 org.gradle.java.home 只管 Gradle 主 JVM——wrapper 启动脚本在此之前就要找 java,shell 里必须先 export JAVA_HOME,否则直接报 JAVA_HOME is not set。
1.3 源码依赖度盘点
| 模块 | 源码形态 | 依赖 | 处置 |
|---|---|---|---|
expandable(composeMain 三文件) | 纯 Compose UI | androidx.compose.*(Modifier / Animatable / Dp) | 原样迁入 composeMain,ohos 不编 |
expandable(commonMain 引擎) | 纯 Kotlin 新增 | 零依赖 | 编译进 ohos(jvm + 双 ohos) |
| 上游 example / iosApp | Compose App | Compose + AGP | 留盘不进构建 |
抽取前先钉死上游 10 条手感语义,改了任何一条,鸿蒙手感就和 Android/iOS 漂移:
| 上游代码 | 语义 | 引擎对应 |
|---|---|---|
draggable(reverseDirection = true) | 手指上滑 = delta 正 = fraction 增 | dragBy 收手指系坐标,内部取负 |
dragAmountDp / totalDragRange | fraction 步长 = 位移 / (max−min) | dragRange = maximized − minimized |
fraction.snapTo(...).coerceIn(0,1) | 拖拽全程钳制 | snapTo |
velocity.absoluteValue < 100 | 低于 100 px/s 算贴附 | VELOCITY_SETTLE_THRESHOLD |
fraction.value < 0.4f | 贴附分档线 | FRACTION_EXPAND_THRESHOLD |
velocity > 0 → expand | fling 方向判定(已含翻转) | onDragStopped 内取负后判定 |
alpha = 1 − fraction | 背板/scrim/边框透明度 | scrimAlpha |
(20 * fraction).dp | 圆角随展开收缩 | RADIUS_SHRINK_DP,且钳到 ≥0 |
10 * (1 − fraction) | 收起态水平边距 | HORIZONTAL_PADDING_MAX_DP |
alpha(4f * fraction) | 展开内容 4 倍速淡入 | expandedContentAlpha / miniContentAlpha(钳到 0…1) |
上游把 px/s 的速度直接和 maximizedHeight.value(dp 数值)相除再当初速度传给 animateTo——严格说是单位混用,但它是上游行为,引擎原样保留。修复是上游 PR 的事,不是鸿蒙 fork 的事。
1.4 页面边界
标题区 expandable-player · OpenHarmony / SoundCloud 风格可展开迷你播放器
Tab 切换 播放器还原 / 参数面板 / 状态机验证
播放器还原 背景图 + scrim + 播放面板(高度/圆角/边距/边框透明度/双层内容透明度全来自 derived)
手势区 PanGesture 垂直拖拽(drag 每帧、stopped 松手)+ 点击 toggle
参数面板 五个设置滑条、展开/收起/切换按钮、推导值面板、常量面板
状态机验证 fraction 手动滑杆、六采样点对照表、非法参数拒绝区
状态行 fraction / expanded / 最近一次 decision
页面启动时先调 config 做握手(取回上游常量并尽早暴露 so 加载问题),迷你层进度环与播放键按上游 ProgressButton / PlayButton 布局还原。
第 2 阶段:目标与工具链打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
本库没有外部 kotlinx 依赖,工具链只由 HarmonyOS Kotlin 插件决定:ohosArm64() / ohosX64() 只存在于社区线 2.2.21-1.0.0(qrose / haze / spinnable 同款钉法),该线要求 Gradle 8.14.1(KStore 那种「2.4 线留 9.x」的经验不能套用——两条工具链线的 Gradle 诉求相反)。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_expandable_player"
include(":expandable")
include(":example:nativeApp")
gradle/libs.versions.toml 收敛到只有一个插件别名,gradle.properties 钉 JDK 与 konan 下载源:
[versions]
kotlin = "2.2.21-1.0.0"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
2.2 库模块收敛为纯 KMP
expandable/build.gradle.kts 剥掉 AGP 与 JetBrains Compose 四个插件:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
composeMain 不是 Kotlin 源集,不会被任何目标编译——它存在的意义是对照上游 API,以及未来 CMP 工具链到齐时整体接回。实测:
./gradlew :expandable:compileKotlinJvm # BUILD SUCCESSFUL
./gradlew :expandable:jvmTest # 九条断言全绿
2.3 引擎抽取(本适配的成败手)
ExpandableEngine 是全仓核心新增:纯 Kotlin、零 Compose import、长度全部用 Float(单位约定 dp),与 Compose 侧 Dp 只在 composeMain 相遇。状态与推导值一一对应上游:
class ExpandableEngine(val settings: Settings) {
var fraction: Float = 0f; private set
var expanded: Boolean = false; private set
val dragRange: Float get() = settings.maximizedHeight - settings.minimizedHeight
val height: Float get() = settings.minimizedHeight + dragRange * fraction
val scrimAlpha: Float get() = 1f - fraction
val cornerRadius: Float get() = (settings.cornerRadius - RADIUS_SHRINK_DP * fraction).coerceAtLeast(0f)
val horizontalPadding: Float get() = HORIZONTAL_PADDING_MAX_DP * (1f - fraction)
val bottomPadding: Float get() = settings.bottomPadding + settings.expandedBottomPadding * fraction
val expandedContentAlpha: Float get() = (CONTENT_FADE_MULTIPLIER * fraction).coerceIn(0f, 1f)
val miniContentAlpha: Float get() = (1f - CONTENT_FADE_MULTIPLIER * fraction).coerceIn(0f, 1f)
fun dragBy(fingerOffsetY: Float): Float = snapTo(fraction - fingerOffsetY / dragRange)
fun onDragStopped(fingerVelocityY: Float): Decision {
val velocity = -fingerVelocityY // 手指系 → 引擎系(reverseDirection 语义)
return if (kotlin.math.abs(velocity) < VELOCITY_SETTLE_THRESHOLD) {
if (fraction < FRACTION_EXPAND_THRESHOLD) Decision(false, 0f, "settle-low-fraction")
else Decision(true, 1f, "settle-high-fraction")
} else if (velocity > 0f) Decision(true, 1f, "fling-toward-expand")
else Decision(false, 0f, "fling-toward-collapse")
}
}
引擎 init 里 require(maximizedHeight > minimizedHeight),零拖拽区间在构造期就被拒绝——这条约束同时保护了桥(非法 settings 走 ok:false 而不是除零)。
第 3 阶段:桥协议建模
3.1 JSON 契约
dev.markturnip.expandable/ExpandableEngine(纯 Kotlin 状态机,原样编译)
↓ fraction 进出 / 快照 + 决策
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API expandableStep()
↓
ArkUI Index.ets(三 Tab)
与 KStore / spinnable 的有状态桥不同,本桥是无状态设计:fraction 随请求进来、快照随响应出去,so 里没有可变共享状态,ArkTS 永远不会拿到过期数字。「expanded 布尔位」由 ArkTS 显式传给 toggle——同一个语义换了个存放位置,换来的是动画归属干净:ArkTS 管动画和 fraction,桥只做纯计算。
请求 : {"op":"drag","fraction":0.1,"offsetY":-12.5, ...settings}
{"op":"stopped","fraction":0.3,"velocityY":400, ...settings}
{"op":"toggle","fraction":0.2,"expanded":false, ...settings}
{"op":"derive"|"expand"|"collapse"|"config", ...}
响应 : {"ok":true,"op":"...","fraction":...,"expanded":...,"targetFraction":...,
"settings":{...},"derived":{8 项},"constants":{5 项},
"decision":{"expand":...,"targetFraction":...,"reason":"..."}}
{"ok":false,"error":"..."}
decision 只在 stopped / expand / collapse / toggle 响应中出现;drag 与 derive 只回快照。目前两类错误文案:settings must be non-negative 与 maximizedHeight must be greater than minimizedHeight (drag range is zero)。
3.2 单位口径(最容易踩的坑)
引擎沿用上游口径:长度全部 dp,速度全部 px/s(阈值 100 是 px/s 口径)。ArkUI PanGesture 给的 offsetY 是 vp(与 dp 数值一致,直接传),但 velocityY 是 vp/s——不乘屏密度换算,高密度屏上 fling 会被系统性误判成贴附:
req.velocityY = event.velocityY * display.getDefaultDisplaySync().densityPixels;
方向语义:offsetY / velocityY 都是手指系(正=下滑),引擎内部取负——与 reverseDirection = true 完全一致,ArkTS 侧不做任何符号处理。
3.3 错误通道
导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("ExpandableStep")
fun expandableStep(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、未知 op、非法 settings、构造期 require 失败,全部走同一个 ok:false 通道,ArkTS 侧统一渲染到状态行与错误横幅。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libexpandable_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosexpandable.so)
│ ExpandableStep
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;把贴附规则抄进 ArkTS 又等于把手感模型 fork 一份,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("ExpandableStep")
fun expandableStep(request: String): String // 七个 op 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
ExpandableStep | expandableStep(req) | config / drag / stopped / expand / collapse / toggle / derive |
单符号设计:引擎是纯函数集合,没有版本握手之类的第二语义,一个 C ABI 足够;config op 返回的常量即充当版本/握手行。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"expandableStep", nullptr, NapiExpandableStep, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr}};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosexpandable_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosexpandable_api.h 由 linkDebugSharedOhosArm64 任务的 CAdapter 生成后拷入 cpp/include/(仓库内不手写生成头)。三个名字必须严格一致:NAPI so 名 libexpandable_napi.so、nm_modname = "expandable_napi"、index.d.ts 导出名 expandableStep——错一个,表现都是「能安装、一拖就崩」。
4.4 CMake 和类型声明
add_library(ohosexpandable SHARED IMPORTED)
set_target_properties(ohosexpandable PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(expandable_napi SHARED napi_init.cpp)
target_link_libraries(expandable_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosexpandable)
OHOS_ARCH 为空或 libohosexpandable.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const expandableStep: (request: string) => string;
KN so 的回填由 Gradle 侧 copyKnSo* 任务完成:linkDebugSharedOhosArm64 / linkDebugSharedOhosX64 之后自动把 libohosexpandable.so 与对应 triple 的 libc++_shared.so 拷进 entry/libs/<abi>/,不要手工从别的工程借 so。
第 5 阶段:ArkUI 宿主
5.1 手势采集和桥调用
PanGesture({ fingers: 1, direction: PanDirection.Vertical })
.onActionStart((event: GestureEvent) => {
this.stopAnim(); // 上游 onDragStarted → fraction.stop()
})
.onActionUpdate((event: GestureEvent) => {
const req = this.makeReq('drag', this.fraction);
req.offsetY = event.offsetY; // vp ≈ dp,正=下滑,引擎内部翻转
this.applySnapshot(this.callBridge(req));
})
.onActionEnd((event: GestureEvent) => {
const req = this.makeReq('stopped', this.fraction);
req.velocityY = event.velocityY * display.getDefaultDisplaySync().densityPixels;
if (this.applySnapshot(this.callBridge(req))) {
this.startAnimTo(this.lastTarget); // 朝 decision.targetFraction 缓动
}
})
统一入口与 KStore 同款:JSON.stringify -> expandableStep -> JSON.parse,异常收进 {ok:false},任何一步都不让页面崩。
5.2 动画归属:Animatable 的 ArkTS 近似
上游三件套在鸿蒙侧的映射:fraction.stop() → 清掉心跳定时器;fraction.snapTo(v) → drag op 每帧回写(无插值);fraction.animateTo(target) → 16ms setInterval 心跳从当前 fraction 按 easeOutCubic 缓动(约 300ms)逼近 decision.targetFraction,每帧 derive 刷快照。曲线是对 spring 的近似,但中断语义(拖拽随时打断动画)与终点语义(必然收敛到 targetFraction)与上游一致。页面销毁时 aboutToDisappear 里清定时器,避免泄漏。
5.3 渲染只读快照
播放面板的高度、圆角、水平/底边距、边框透明度(1 - fraction)、迷你层与展开层的交叉淡入淡出(±4 × fraction)全部来自 derived 快照,ArkTS 不复算任何几何——复算就是把手感模型 fork 回去,桥就白接了。面板本体是 Column 外层管边距、内层管边框圆角的三层结构:背景图 + scrim(ScGray.copy(alpha = scrimAlpha))+ 面板,迷你层(进度环 + 双行曲名)与展开层(封面、黑底白字文案、收起按钮、播放键、进度条)按上游 PlayerView 布局还原。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosexpandable)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ ├── Bridge.kt @CName + 七 op 分发 + 快照/决策组装
│ └── MiniJson.kt 扁平 JSON 解析(继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.expandable、versionName 1.0.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/libohosexpandable_api.h CAdapter 生成后拷入
├── src/main/cpp/types/libexpandable_napi/{index.d.ts, oh-package.json5}
├── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面
└── src/main/resources/base/media/ album_art.jpg / screengrab_1.png(上游 demo 资源)
6.2 构建、签名和安装
./gradlew :expandable:jvmTest # 九条引擎断言
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hvigor 侧需要 DevEco 自带工具链:PATH 前置 DevEco 的 node 与 jbr/bin(PackageHap 步骤要 spawn java,系统 PATH 没有 java 时报 spawn java ENOENT),DEVECO_SDK_HOME 指向 DevEco SDK。签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填)。
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.expandable -m entry
四、完整代码对照
4.1 整体架构
dev.markturnip.expandable/ExpandableEngine(纯 Kotlin 状态机)
│ fraction / drag / settle / fling / 8 项推导值
Bridge.kt + MiniJson
│ ExpandableStep(七 op 无状态分发)
libexpandable_napi.so
│ expandableStep
Index.ets
│ JSON parse + @State 更新 + 16ms 心跳缓动
▼
ArkUI 真机页面(播放器还原 / 参数面板 / 状态机验证)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_expandable_player、上游示例移出构建面 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单插件钉版 |
build.gradle.kts(根) | 版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务 |
gradle.properties / wrapper | JDK 21、konan 下载源、Gradle 8.14.1 |
expandable/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,剥离 AGP/Compose 插件 |
expandable/src/commonMain/.../ExpandableEngine.kt | 纯 Kotlin 状态机(10 条上游语义映射) |
expandable/src/commonTest/.../ExpandableEngineTest.kt | 九条 JVM 断言(鸿蒙桥的对照基线) |
expandable/src/composeMain/** | 上游三文件原样保存(ohos 不编) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、七 op、无状态快照、错误通道 |
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 | 鸿蒙适配主文档 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | ExpandableEngine(settings) | 五项设置构造,零拖拽区间构造期拒绝 |
| 引擎 | dragBy(fingerOffsetY) | 手指系位移 → 新 fraction(钳制 0…1) |
| 引擎 | onDragStopped(fingerVelocityY) | 贴附/fling 决策(Decision 三元组) |
| 引擎 | expand / collapse / toggle | 程序化切换,targetFraction 收敛点 |
| 引擎 | height / scrimAlpha / cornerRadius / … | 8 项推导几何(渲染唯一数据源) |
| Native | ExpandableStep | C ABI JSON 导出(七 op 一口进) |
| N-API | expandableStep(req) | 向 ArkTS 暴露能力 |
| ArkTS | callBridge / applySnapshot / startAnimTo | 触发操作、写回快照、驱动缓动 |
4.4 ArkTS 与 Kotlin 的边界
// 拖拽中:每帧一调,offsetY 直接给手指系 vp
const res = JSON.parse(expandableStep(JSON.stringify({
op: 'drag', fraction: this.fraction, offsetY: event.offsetY,
minimizedHeight: 70, maximizedHeight: 650, bottomPadding: 100,
expandedBottomPadding: 8, cornerRadius: 35
})));
// res.derived.height / scrimAlpha / cornerRadius 直接进渲染属性
@CName("ExpandableStep")
fun expandableStep(request: String): String = try {
val json = MiniJson.parse(request)
val engine = ExpandableEngine(parseSettings(json))
engine.snapTo(json.dbl("fraction", 0.0).toFloat())
when (json.str("op")) {
"drag" -> { engine.onDragStarted(); engine.dragBy(json.dbl("offsetY", 0.0).toFloat()) }
"stopped" -> engine.onDragStopped(json.dbl("velocityY", 0.0).toFloat())
"toggle" -> engine.toggle(json.bool("expanded"))
// expand / collapse / derive / config ...
}
ok(op, engine, decision)
} catch (t: Throwable) { err(t.message ?: t.toString()) }
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;渲染属性只读 derived,ArkTS 侧零手感公式。
五、关键决策说明
决策 1:抽取状态机而不是等 CMP 工具链
上游 100% 是 Compose UI,JetBrains 没有为 ohos 发布 compose klib,社区 CMP 1.9.2 与上游 Compose 1.7.0 的 API 面也对不齐。为一个小库把整条 Compose 栈换版本,风险和收益不成比例。spinnable 先证了「UI-only 库」的可行路线:把手感模型(这个库的全部价值)洗成纯 Kotlin,ArkUI 出手势和像素。等上游与社区工具链版本对齐,composeMain 原样接回,引擎降级成内部实现即可。
决策 2:引擎无状态,fraction 随请求往返
spinnable / KStore 的桥都把状态留在 Kotlin 侧,本桥反向选择:fraction 进出、so 内零共享可变状态。原因是本库的状态天然被 ArkTS 动画高频改写——跨语言共享可变状态会把「谁在动画、谁在中断」的时序问题放大成跨语言 bug;让 ArkTS 持有 fraction、桥只做纯函数,时序问题留在单侧。MinimizableHandler.expanded 布尔位由请求显式携带,同一个语义换了个存放位置。
决策 3:上游单位混用 quirk 原样保留
上游把 px/s 的速度除以 maximizedHeight.value(dp 数值)当初速度,阈值 100 也是 px/s 口径——严格说是 bug,但鸿蒙侧修了它,手感就和 Android/iOS 分叉。fork 的纪律是:修复属于上游 PR,fork 只保真。对应地,ArkTS 侧把 velocityY 从 vp/s 乘上 densityPixels 换算成 px/s 再过桥,口径在桥边界对齐。
决策 4:动画留在 ArkTS,桥只给终点
Animatable 的 spring 曲线过不了 C ABI,每帧反向回调也比 ArkTS 自 tick 复杂。引擎把「终点」(decision.targetFraction)和「中断」(onDragStarted 即清心跳)语义钉死,曲线留在 ArkTS 侧用 16ms easeOutCubic 近似——损失可控且有明确升级路径(CMP 工具链对齐后整体替换)。
决策 5:jvmTest 九条断言作为「手感没漂」的闸门
引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;九条断言钉住拖拽换算、边界钳制、贴附分档、fling 翻转、toggle 翻转、fraction=0.5 全部推导值(360 / 0.5 / 25 / 5 / 104 / 1 / 0)、圆角下限、零拖拽区间拒绝。Demo 的 Tab3 再加桥侧对照表(fraction = 0 / 0.2 / 0.4 / 0.6 / 0.8 / 1.0 六采样点 derive),三层证据链:JVM 断言 → 桥 JSON 对账 → 真机手感。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin,org.gradle.java.home 钉版)、DevEco Studio 26.0(内置 hvigor / ohpm / JBR / OpenHarmony SDK)、HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :expandable:jvmTest
九条用例全部通过(自写基线,钉住上游语义):
| 测试函数 | 钉住什么 |
|---|---|
dragRangeMatchesUpstreamDefaults | 拖拽区间 580、初始高度 70 |
dragByMirrorsReverseDirection | 手指系取负:上滑 58dp → fraction +0.1 |
dragClampsToBounds | 拖拽全程钳制 0…1 |
settleUsesFractionThreshold | 0.3 → collapse、0.4 → expand(贴附分档) |
flingFlipsFingerVelocity | 手指系速度翻转:上滑 fling → 展开 |
toggleFlipsTarget | toggle 翻转与 targetFraction |
derivedGeometryMatchesComposeSide | fraction=0.5:360 / 0.5 / 25 / 5 / 104 / 1 / 0 |
cornerRadiusNeverGoesNegative | 小圆角大 fraction 时钳到 0 |
rejectsZeroDragRange | 零拖拽区间构造期拒绝 |
引擎实现正确性与上游口径的一致性由这一层背书;期间修正过一处断言笔误(bottomPadding@0.5 应为 100 + 8×0.5 = 104),引擎本身零改动——这正是「断言钉语义」的价值:错的是测试还是实现,一目了然。
6.3 HAP 验证
:expandable:compileKotlinJvm: BUILD SUCCESSFUL
:expandable:jvmTest: 九条断言全绿
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(copyKnSo* 自动回填三件套)
llvm-nm -D: ExpandableStep / libohosexpandable_symbols 均为全局 T 符号
BuildNativeWithCmake / BuildNativeWithNinja: finished(双 ABI 编出 libexpandable_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
entry-default-unsigned.hap 约 8.2 MB,内含双 ABI 各三件套(libexpandable_napi.so / libohosexpandable.so / libc++_shared.so);HAP 内 strip 后 KN so arm64 约 1.9MB、x86_64 约 1.8MB(debugShared 未 strip 时约 3.6MB / 3.3MB,含 Kotlin/Native 运行时)。
6.4 功能用例
- 启动后
config握手取回上游五条常量(来自真实 Native 调用,页面常量面板非静态文字); - Tab1 收起态:迷你条显示进度环与曲名,背景 scrim 不透明;
- 上滑拖拽:面板逐帧长高、圆角收缩、scrim 渐隐、迷你层淡出、封面与文案淡入——全部几何来自
derived快照; - 拖到一半松手(低速):
fraction < 0.4弹回收起、≥ 0.4弹回展开,状态行显示settle-*-fraction → 1.0/0.0; - 快速上滑 fling:
fling-toward-expand,面板弹至展开态;快速下滑反向同理; - 点击面板或收起按钮:toggle 切换,动画可被下一次拖拽随时打断(
Animatable.stop语义); - Tab2 拖动五个设置滑条后回 Tab1,手感即时变化;展开/收起/切换按钮回显 decision;
- Tab3 对照表六个采样点与
jvmTest推导值逐项一致;「minimized ≥ maximized」「负数高度」两个非法参数按钮返回ok:false与指定错误文案,页面不崩; - 杀进程重启后再拖拽,手感语义不变——引擎无持久化状态,每次会话都从 fraction=0 开始,与上游一致。
6.5 验证结论
状态机语义、Native 动态库、C ABI、N-API、ArkUI 手势与渲染、HAP 交付链路已完成验证。应用在鸿蒙真机上成功安装并进入前台,真实还原了 SoundCloud 迷你播放器的拖拽展开 / 收起 / 贴附 / fling 全套手感(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/expandable-player-kmp.git
cd expandable-player-kmp
export JAVA_HOME="/path/to/jdk-21"
./gradlew :expandable:jvmTest # 九条交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
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.expandable -m entry
hdc shell hilog | grep Expandable
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 CMP 适配,必须让手感模型真正编译成
ohosArm64/ohosX64动态库——UI-only 库的前提是先把状态机从 Composable/Modifier 里洗出来; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;include(":example:nativeApp")会让 Gradle 把example/当父项目并评估其旧 Compose 构建脚本,四个Unresolved reference在配置期直接炸——上游示例的构建文件要移出构建面(源码留盘);org.gradle.java.home只管 Gradle 主 JVM,wrapper 启动脚本在此之前就要找java:shell 里先export JAVA_HOME,否则直接JAVA_HOME is not set;- hvigor
PackageHap需要 spawnjava:只设JAVA_HOME不够,DevEco JBR 的bin目录必须进PATH,否则报spawn java ENOENT(错误码00308018); jvmTest断言与引擎对不上时先分清谁错:本次bottomPadding@0.5断言写错(应为 104),引擎按上游公式无改动——「断言钉语义」的纪律让这类分歧一测便知;- ArkTS
@Builder不要传函数参数(编译器限制),用 tag 分发模式(SliderRow(label, value, min, max, which)+onSettingChange(which, v)); PanGesture.velocityY是 vp/s,引擎阈值是 px/s 口径:不乘densityPixels换算,高密度屏上 fling 会被系统性误判成贴附;- 面板宽度不要用
calc()字符串拼(易漏括号且语义含糊),外层Column管边距、内层100%管本体,布局语义更干净; - 三个名字严格一致:NAPI so 名
libexpandable_napi.so、nm_modname = "expandable_napi"、index.d.ts导出名expandableStep,错一个的表现都是「能安装、一拖就崩」; - CAdapter 生成头(
libohosexpandable_api.h)由 link 任务产出后拷入cpp/include/,不手写生成头; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 签名材料由 DevEco 自动写入
build-profile.json5,提交前保护好该文件,证书绝不进入 AtomGit。
8.2 已知问题
composeMain的 Compose Modifier 在鸿蒙上不可用:当前消费的是引擎模型,Modifier.expandable本体要等上游与社区 CMP 工具链对齐后接回;- 展开动画是 300ms easeOutCubic 心跳近似,不是上游 spring:终点与中断语义一致,逐帧曲线不同;
- 上游
onDragStopped里animateDecay分支是 TODO 未实现,桥同样只实现 expand/collapse 两分支——上游没做的事 fork 也不做; - 上游「px/s 除以 dp 数值」的单位混用 quirk 原样保留(见决策 3),修复留给上游 PR;
- android / iOS 目标剥离(源文件留在树里),上游 Compose 示例 App 与
iosApp/留盘不进构建,本 fork 不发 Android/iOS 制品; - HAP 安装必须使用与 bundle name
org.terminator.ohos.expandable匹配的签名。
8.3 未来优化方向
- 把动画曲线升级成可配(spring 参数透传引擎,或 NAPI 回调驱动逐帧插值),进一步逼近
Animatable手感; - 增加
minimizedHeight自适应(上游 README 遗留的「screen height minus min」思路),让展开高度按真机屏高推导; - 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)验收矩阵与收起态 / 参数面板 / 状态机验证 Tab 的自动化截图。
九、总结
9.1 核心难点回顾
上游 expandable(100% Compose UI)
→ 确认「UI-only 库」路线(spinnable 先例)
→ 工具链钉版 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1(cmp_qrose 模式根构建替换)
→ ExpandableEngine 纯 Kotlin 抽取(10 条手感语义映射 + 上游 quirk 保真)
→ jvmTest 九条断言(手感没漂的闸门)
→ ohosArm64 / ohosX64 KLIB + 双 ABI sharedLib
→ C ABI(@CName)+ 无状态七 op JSON 桥
→ C++ N-API(DisposeString 纪律)
→ ArkTS 快照渲染 + 16ms 心跳缓动(Animatable 语义近似)
→ ArkUI 三 Tab 真机验收(拖拽 / 贴附 / fling / 切换全链路)
9.2 封装层次
- 引擎层:纯 Kotlin
ExpandableEngine,10 条上游手感语义 + 全部推导几何,零平台依赖; - 保存层:上游 Compose 三文件原样隔离在
composeMain,与上游仓库逐字节一致; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、手势采集(vp/s→px/s 口径换算)、快照渲染、心跳缓动与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI-only CMP 库的适配价值在「模型」不在「皮肤」:把手感模型洗成纯 Kotlin 引擎后,鸿蒙侧拿到的是与 Android/iOS 同源的状态机,而不是 TypeScript 重写版;抽取时 10 条语义逐条列表钉死,包括上游 quirk——「保真」优先于「顺手修复」;
- 无状态桥是有高频动画的库的正解:fraction 随请求往返,so 内零共享可变状态,动画归属、中断时序全部留在单侧;KStore 式「有状态桥」适合低频 CRUD,两条经验不能互相套用;
- 交叉验证是「手感没漂」的唯一可信证明:jvmTest 九条断言钉语义、Tab3 桥侧对照表对账、真机手感走查,三层证据链缺一不可——「能拖能动」不算验收,阈值和曲线错了照样能动,只是手感不对。
9.4 适配成果
当前适配已完成:
ExpandableEngine纯 Kotlin 状态机的双 ABI Kotlin/Native 编译(上游 Compose 源码零改动隔离保存);- 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 三 Tab 验收页面(播放器还原、参数面板、状态机验证、非法参数拒绝);
- 九条 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 8.2MB)、签名、设备安装和真机验证;
- 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)