本文记录 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 里抽成纯 Kotlin ExpandableEngine,上游 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 默认值)。
derived8 项推导几何,渲染只读这里,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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(wrapper 从上游 8.7 升级;2.2.21 旧线钉 8.14.1)
DevEco product5.1.0(18) 兼容
DevEco Studio26.0.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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 UIandroidx.compose.*(Modifier / Animatable / Dp)原样迁入 composeMain,ohos 不编
expandable(commonMain 引擎)纯 Kotlin 新增零依赖编译进 ohos(jvm + 双 ohos)
上游 example / iosAppCompose AppCompose + AGP留盘不进构建

抽取前先钉死上游 10 条手感语义,改了任何一条,鸿蒙手感就和 Android/iOS 漂移:

上游代码语义引擎对应
draggable(reverseDirection = true)手指上滑 = delta 正 = fraction 增dragBy 收手指系坐标,内部取负
dragAmountDp / totalDragRangefraction 步长 = 位移 / (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 → expandfling 方向判定(已含翻转)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 方法覆盖内核
ExpandableStepexpandableStep(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.tomlKotlin 2.2.21-1.0.0 单插件钉版
build.gradle.kts(根)版本 1.0.0-ohos.1、全局禁用 Ohos*Test* 任务
gradle.properties / wrapperJDK 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.kt1 个 C ABI、七 op、无状态快照、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档

4.3 关键 API 对照

层次API作用
引擎ExpandableEngine(settings)五项设置构造,零拖拽区间构造期拒绝
引擎dragBy(fingerOffsetY)手指系位移 → 新 fraction(钳制 0…1)
引擎onDragStopped(fingerVelocityY)贴附/fling 决策(Decision 三元组)
引擎expand / collapse / toggle程序化切换,targetFraction 收敛点
引擎height / scrimAlpha / cornerRadius / …8 项推导几何(渲染唯一数据源)
NativeExpandableStepC ABI JSON 导出(七 op 一口进)
N-APIexpandableStep(req)向 ArkTS 暴露能力
ArkTScallBridge / 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
settleUsesFractionThreshold0.3 → collapse、0.4 → expand(贴附分档)
flingFlipsFingerVelocity手指系速度翻转:上滑 fling → 展开
toggleFlipsTargettoggle 翻转与 targetFraction
derivedGeometryMatchesComposeSidefraction=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 功能用例

  1. 启动后 config 握手取回上游五条常量(来自真实 Native 调用,页面常量面板非静态文字);
  2. Tab1 收起态:迷你条显示进度环与曲名,背景 scrim 不透明;
  3. 上滑拖拽:面板逐帧长高、圆角收缩、scrim 渐隐、迷你层淡出、封面与文案淡入——全部几何来自 derived 快照;
  4. 拖到一半松手(低速):fraction < 0.4 弹回收起、≥ 0.4 弹回展开,状态行显示 settle-*-fraction → 1.0/0.0;
  5. 快速上滑 fling:fling-toward-expand,面板弹至展开态;快速下滑反向同理;
  6. 点击面板或收起按钮:toggle 切换,动画可被下一次拖拽随时打断(Animatable.stop 语义);
  7. Tab2 拖动五个设置滑条后回 Tab1,手感即时变化;展开/收起/切换按钮回显 decision;
  8. Tab3 对照表六个采样点与 jvmTest 推导值逐项一致;「minimized ≥ maximized」「负数高度」两个非法参数按钮返回 ok:false 与指定错误文案,页面不崩;
  9. 杀进程重启后再拖拽,手感语义不变——引擎无持久化状态,每次会话都从 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 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让手感模型真正编译成 ohosArm64 / ohosX64 动态库——UI-only 库的前提是先把状态机从 Composable/Modifier 里洗出来;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. include(":example:nativeApp") 会让 Gradle 把 example/ 当父项目并评估其旧 Compose 构建脚本,四个 Unresolved reference 在配置期直接炸——上游示例的构建文件要移出构建面(源码留盘);
  4. org.gradle.java.home 只管 Gradle 主 JVM,wrapper 启动脚本在此之前就要找 java:shell 里先 export JAVA_HOME,否则直接 JAVA_HOME is not set;
  5. hvigor PackageHap 需要 spawn java:只设 JAVA_HOME 不够,DevEco JBR 的 bin 目录必须进 PATH,否则报 spawn java ENOENT(错误码 00308018);
  6. jvmTest 断言与引擎对不上时先分清谁错:本次 bottomPadding@0.5 断言写错(应为 104),引擎按上游公式无改动——「断言钉语义」的纪律让这类分歧一测便知;
  7. ArkTS @Builder 不要传函数参数(编译器限制),用 tag 分发模式(SliderRow(label, value, min, max, which) + onSettingChange(which, v));
  8. PanGesture.velocityY 是 vp/s,引擎阈值是 px/s 口径:不乘 densityPixels 换算,高密度屏上 fling 会被系统性误判成贴附;
  9. 面板宽度不要用 calc() 字符串拼(易漏括号且语义含糊),外层 Column 管边距、内层 100% 管本体,布局语义更干净;
  10. 三个名字严格一致:NAPI so 名 libexpandable_napi.so、nm_modname = "expandable_napi"、index.d.ts 导出名 expandableStep,错一个的表现都是「能安装、一拖就崩」;
  11. CAdapter 生成头(libohosexpandable_api.h)由 link 任务产出后拷入 cpp/include/,不手写生成头;
  12. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套缺一不可;
  13. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  14. 签名材料由 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 三条经验

  1. UI-only CMP 库的适配价值在「模型」不在「皮肤」:把手感模型洗成纯 Kotlin 引擎后,鸿蒙侧拿到的是与 Android/iOS 同源的状态机,而不是 TypeScript 重写版;抽取时 10 条语义逐条列表钉死,包括上游 quirk——「保真」优先于「顺手修复」;
  2. 无状态桥是有高频动画的库的正解:fraction 随请求往返,so 内零共享可变状态,动画归属、中断时序全部留在单侧;KStore 式「有状态桥」适合低频 CRUD,两条经验不能互相套用;
  3. 交叉验证是「手感没漂」的唯一可信证明: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。

参考文档

Logo

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

更多推荐