本文记录 ComposeMediaPlayer 接入 OpenHarmony 的完整过程,覆盖媒体库源码依赖度盘点、Compose 源码停靠、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkTS 播放宿主、HAP 构建和设备验收。

ComposeMediaPlayer 是 Compose Multiplatform 媒体播放库:视频模块 composemediaplayer 提供 VideoPlayerState 状态机、VideoPlayerSurface 渲染层、SRT/WebVTT 字幕解析与实时叠加、元数据读取,后端按平台落到 media3(Android)/ AVFoundation(iOS)/ 自研 JNI(桌面)/ HTML5(Web);音频模块 composemediaplayer-audio 提供 play/pause/seek/volume/rate 的轻量控制面。本次适配与 KStore 的"真编译整库"路线有本质区别:本库的公共 API 全部站在 androidx.compose 上,而鸿蒙社区 CMP 是 1.9.2、上游是 Compose 1.11.1 / Kotlin 2.4.10,UI 层无法整体编译;因此走社区早期已过审的"纯核心提取"路线(qrose / haze / PreCompose 同款)——字幕解析引擎(SrtParser、WebVttParser、SubtitleCueList.getActiveCues)与纯 Kotlin 模型原样编译成 OpenHarmony KLIB,Compose 源码停靠到 src/composeMain 不编译,实际播放由 ArkTS AVPlayer 承担,ArkUI 三 Tab 页面作为验收宿主。上游字幕解析器的 parse() 逐行零改动,JVM 侧保留 11 项交叉验证用例证明内核没有漂移。

项目地址: AtomGit/oh-tpc/ComposeMediaPlayer.git

上游仓库: kdroidFilter/ComposeMediaPlayer

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

ComposeMediaPlayer 为 Compose Multiplatform 应用提供"播放"这一件事的完整答案:rememberVideoPlayerState() 一行建状态机,openUri / openFile / openAsset 全来源覆盖,play / pause / seekTo / volume / playbackSpeed / loop 全控制面,VideoPlayerSurface 承载渲染,selectSubtitleTrack + ComposeSubtitleLayer 完成外挂字幕的解析与实时叠加,VideoMetadata 暴露时长/分辨率/码率。音频模块在此之上给出纯音频的 AudioPlayer expect 类。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套播放与字幕能力,但上游构建(Kotlin 2.4.10 / Gradle 9.4.1 / AGP 8.13.2 / Compose 1.11.1)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明字幕解析引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证解析引擎、Native 动态库、C ABI、N-API、ArkTS 播放宿主和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
工具链不一致上游 Kotlin 2.4.10 / Gradle 9.4.1 / AGP 8.13.2 与鸿蒙工具链无法共存于一个 Gradle 构建;鸿蒙 Kotlin 2.2.21-1.0.0 读不了 Compose 1.11.1 的 2.4 元数据。
Compose UI 耦合VideoPlayerState / VideoPlayerSurface / VideoMetadata 全部站在 androidx.compose.* 上;社区 CMP 1.9.2 与上游 Compose 1.11.1 不同代,UI 层整体编译不可行。
依赖矩阵commonMain 依赖 compose-runtime / foundation、kotlinx-coroutines、kotlinx-datetime、filekit-core——后两者与 Compose 在 ohos 上均无可用制品。
播放 surface 跨不了桥鸿蒙视频画面必须是 ArkTS XComponent 的 surfaceId 喂给系统 AVPlayer,Compose surface 拿不到也播不了系统流。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:纯 Kotlin 核心(字幕解析器、cue 模型、播放器配置模型、时间工具)的 ohos 编译、Compose 源码停靠(cmp_qrose 模式)和 C ABI/N-API 桥接层。解析行为完全由上游原版实现负责,播放引擎由 ArkTS AVPlayer 承担,ArkTS 只负责调用 Native、解析 JSON、驱动播放与展示。

1.2 库提供的能力

Demo 用三个 Tab 覆盖桥接的全部入口与播放控制面:

  • 字幕解析(parse):cmpSubtitleParse 把 SRT/WebVTT 全文交给上游 SrtParser / WebVttParser 原版 parse(),返回带毫秒时间轴的 cue 列表——format:"auto" 时按 WEBVTT 头自动识别格式;
  • 活跃字幕查询(query):cmpSubtitleQuery 在指定播放时刻调用上游 SubtitleCueList.getActiveCues(),视频 Tab 每 500ms 轮询一次(按秒节流),把命中 cue 叠加在画面底部;
  • 版本握手:cmpMediaVersion() 返回 fork 版本号,来自真实 Native 调用而非静态文字;
  • 播放控制面:视频 Tab 用 XComponent + AVPlayer 复刻上游 VideoPlayerState 的 open/play/pause/seek/volume/speed/loop 语义,音频 Tab 对应 AudioPlayer 的控制集。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
format实际识别的格式(srt / vtt)。
total / count解析出的 cue 总数 / 本次返回的条数。
cues / activeparse 返回全量 cue;query 返回命中时刻的活跃 cue。
start / end / textcue 三元组,时间单位毫秒,text 保留原始换行。

引擎侧完整能力(双格式时间轴解析、无小时格式、cue settings 兼容、命中区间判断)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 11 项 jvmTest 交叉验证用例,证明 ohos .so 里执行的解析逻辑没有漂移。

1.3 实现适配

维度要求
代码复用字幕解析器与纯模型的 parse() / 数据类逐行零改动;仅移出依赖平台 HTTP 的 loadFromUrl 成员(改为停靠侧扩展函数)与剥除 @Immutable 注解。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);Compose 源码停靠 src/composeMain、上游桌面实现 srcDirs 置空留盘;sample/composeApp 移出 settings。
桥接稳定使用 3 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律纯核心 commonMain 依赖清零(compose/coroutines/datetime/filekit 全部隔离到停靠侧),klib 不带任何第三方依赖。
UI 完整三 Tab 覆盖全部桥接入口:视频(XComponent + 字幕叠加 + 全控制面)、字幕(三 rawfile + 探针滑杆)、音频(无画面 AVPlayer)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit,全仓零第三方镜像站引用。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。鸿蒙侧视频渲染由 ArkTS XComponent + 系统 AVPlayer 承担;Kotlin 侧交付的是与其它平台逐位一致的解析内核。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点 21 个 commonMain 文件的依赖度、查重、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、依赖清零
第 3 阶段:桥协议建模     ── 扁平 JSON 契约、format 自动识别、无状态查询、错误通道
第 4 阶段:原生桥接       ── @CName C ABI × 3、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── XComponent + AVPlayer 状态机、字幕轮询、错误上屏
第 6 阶段:示例与验证     ── HAP、签名、设备安装、模拟器验收和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
mediaplayer/src/commonMain/           上游纯核心留守:SrtParser / WebVttParser / SubtitleCue(+List)、
                                      AudioMode / CacheConfig / InitialPlayerState / VideoPlayerError、
                                      util/{Constants, PipResult, TimeUtils}(进 ohos 构建,零第三方依赖)
mediaplayer/src/composeMain/          上游 Compose 源码停靠:VideoPlayerState / VideoPlayerSurface /
                                      VideoMetadata / SubtitleTrack / 字幕 UI 层 / Logger / Uri(不编译)
mediaplayer/src/composeTest/          停靠的 Compose 依赖测试(SubtitleTrackTest / VideoMetadataTest)
audioplayer/src/commonMain/           AudioPlayerState 枚举 + ErrorListener(进 ohos 构建)
audioplayer/src/composeMain/          expect class AudioPlayer + Compose 轮询助手(停靠)
example/nativeApp/                    Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 三 Tab 页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存
sample/ 、.github/ 、audioplayer 平台目录   上游遗留,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :mediaplayer、:audioplayer 与 :example:nativeApp,rootProject.name = "ohos_ComposeMediaPlayer"。上游的 dokka / vanniktech 发布 / detekt / ktlint / cocoapods 插件、AGP classpath、Exec 原生编译任务全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven,qrose 已过审钉法)
JDK21(Temurin 21.0.12,org.gradle.java.home 钉死路径)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 9.4.1 降级;2.2.21 线不支持 Gradle 9)
构建机Windows x64 + Git Bash
DevEcoStudio 26.0.0.851、product 5.1.0(18) 兼容、hvigor modelVersion 5.1.0
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.composemediaplayer
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点

对 mediaplayer/src/commonMain 的 21 个 Kotlin 文件逐个看 import,得到三张处置表:

依赖形态文件处置
纯 stdlib(零 import 或仅 kotlin.*)AudioMode、CacheConfig、InitialPlayerState、VideoPlayerError、util/Constants、util/PipResult、util/TimeUtils留守编译
纯 stdlib + coroutines(仅 loadFromUrl 成员)subtitle/SrtParser、subtitle/WebVttParser留守编译(移出 loadFromUrl)
仅 Compose 注解(@Immutable / @Stable)subtitle/SubtitleCue留守编译(剥注解)
Compose 重度(Composable / mutableStateOf / TextStyle / Color)VideoPlayerState、VideoPlayerSurface、VideoPlayerSurfacePreview、VideoMetadata、SubtitleTrack、ComposeSubtitleLayer、SubtitleDisplay、ContentScaleCanvasUtils、FullScreenLayout停靠 composeMain
filekit / kotlinx-datetime 耦合util/Uri、util/Logger停靠 composeMain

audioplayer 同法:AudioPlayerState 枚举与 ErrorListener 接口留守,expect class AudioPlayer(含 @Composable rememberAudioPlayerLiveState)停靠。

两处编辑是整个 fork 对上游源码的全部侵入:

// SrtParser.kt / WebVttParser.kt:移除 loadFromUrl 成员(依赖平台 HTTP actual),
// parse() 与时间轴解析逐行保留 —— 解析内核零漂移。
// SubtitleCue.kt:剥除 @Immutable 注解(来自 compose-runtime),数据类本体不变。

loadFromUrl 没有删除而是搬家:停靠侧新增 SubtitleLoaders.kt,以扩展函数形式保留完全一致的语义(取文本 → parse → 异常回落空列表),上游同步时对账成本为零。

1.4 页面边界
标题区          COMPOSEMEDIAPLAYER · OPENHARMONY / 版本握手行(CMP 媒体库 · Kotlin/Native ohosArm64/ohosX64 · 版本)
Tab 切换        视频 / 字幕 / 音频
视频 Tab        URL 输入 + 打开;XComponent 16:9 黑底;字幕叠加层(Kotlin 桥查询);
                播放/暂停、进度滑杆、音量滑杆、倍速 chips、循环与字幕 Toggle、时间文本、状态行 + 错误行
字幕 Tab        demo.srt / en.vtt / fr.vtt 三 rawfile 源;解析状态行(版本/格式/总数/耗时);
                cue 列表(前 50 条);探针滑杆驱动 cmpSubtitleQuery,命中条目高亮 + 大字展示
音频 Tab        URL 输入 + 打开;无 surface 的 AVPlayer;播放/暂停/进度/音量/倍速;状态行 + 错误行

页面启动时先调 cmpMediaVersion() 做握手,读取 rawfile en.vtt 存入内存供视频字幕联动。

第 2 阶段:目标与依赖打通

2.1 查重与依赖决策(本适配的成败手)

先做查重:AtomGit oh-tpc / CPF-KMP-CMP 社区内确认无 ComposeMediaPlayer 的既有鸿蒙移植;上游全仓 grep ohos / OpenHarmony / hvigor 零命中,本 fork 是该库首个鸿蒙适配。

再做依赖决策。KStore 走"真编译整库"的前提是它依赖的 kotlinx 四件套都有 ohos 制品线;本库 commonMain 依赖的 compose-runtime / foundation、kotlinx-coroutines、kotlinx-datetime、filekit-core 中,filekit 与 kotlinx-datetime 没有 ohos 制品,Compose UI 更编不了——整库编译此路不通。反过来,字幕解析器与纯模型的实际代码只用到 stdlib,把 loadFromUrl 与 Compose 层隔离后,纯核心可以做到 commonMain 依赖清零:不依赖任何 kotlinx / Compose / filekit 制品,klib 自闭合,工具链选择从此不受依赖元数据绑架。

2.2 工具链钉版(为什么回到 2.2.21-1.0.0)

社区两条已过审工具链线:KStore 用的 2.4.20-0.1.0-07(依赖 kotlinx 制品线反推,Gradle 9.x)和 qrose / haze 用的 2.2.21-1.0.0(Gradle 8.14.1)。本库纯核心零第三方依赖,不存在"stdlib 元数据读不了"的强约束,选择 qrose 已过审的旧线:2.2.21-1.0.0 + Gradle 8.14.1(上游 wrapper 9.4.1 需降级,2.2.21 线不支持 Gradle 9)。上游源码(Kotlin 2.4.10 编写)的留守部分只用到基础语法,2.2.21 编译通过。

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_ComposeMediaPlayer"

include(":mediaplayer")
include(":audioplayer")
include(":example:nativeApp")
2.3 编译纯核心

mediaplayer/build.gradle.kts 收敛为纯 KMP 模块:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()          // 交叉验证用
    ohosArm64()
    ohosX64()

    sourceSets {
        // 上游桌面实现(JNI/Swift/GStreamer 后端)与集成测试留盘不编译
        jvmMain { kotlin.setSrcDirs(emptyList<String>()) }
        jvmTest { kotlin.setSrcDirs(emptyList<String>()) }

        commonTest.dependencies {
            implementation(libs.kotlin.test)
        }
    }
}

audioplayer 同构。KMP 只编译已注册源集,src/composeMain / src/composeTest 不注册即自然不编译——停靠手法来自 qrose 过审工程。实测:

./gradlew :mediaplayer:compileKotlinJvm :audioplayer:compileKotlinJvm        # BUILD SUCCESSFUL
./gradlew :mediaplayer:compileKotlinOhosArm64 :mediaplayer:compileKotlinOhosX64   # BUILD SUCCESSFUL

留守的 12 个纯核心文件(解析器 × 2、数据类 × 1、模型 × 4、工具 × 3、枚举/接口 × 2)一个没动。

2.4 测试的处置:停靠桌面测试、保留纯测试

上游 src/jvmTest 是桌面实现的集成测试(Mac/Windows VideoPlayerState),引用已停靠的 jvmMain,直接编译会报一片 Unresolved reference: MacVideoPlayerState——处置是 jvmTest 的 srcDirs 一并置空。commonTest 里依赖 Compose 的 SubtitleTrackTest / VideoMetadataTest 随被测类停靠到 composeTest;三个纯测试(WebVttParserTest / TimeUtilsTest / VideoPlayerErrorTest)留守,在 JVM 上跑交叉验证。ohos 测试任务全局禁用:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

第 3 阶段:桥协议建模

3.1 JSON 契约
io.github.kdroidfilter.composemediaplayer.subtitle/**(上游解析器,原样编译)
        ↓ SrtParser.parse / WebVttParser.parse / SubtitleCueList.getActiveCues
Bridge.kt(@CName × 3,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API cmpMediaVersion() / cmpSubtitleParse() / cmpSubtitleQuery()
        ↓
ArkUI Index.ets(三 Tab 验收页)

请求与响应:

请求 : {"format":"auto|srt|vtt","content":"<字幕全文>"}
       {"format":"...","content":"...","time":<毫秒>}          (query)
响应 : {"ok":true,"format":"vtt","total":12,"count":12,"cues":[{start,end,text},...]}
       {"ok":true,"format":"srt","time":1234,"total":4,"count":1,"active":[{...}]}
       {"ok":false,"error":"..."}

两个设计点:format:"auto" 按 WEBVTT 文件头自动识别,ArkTS 侧不用预判格式;桥是无状态的——query 每次携带全文与时间戳现场解析,不在 Native 侧保存会话,进程内每次调用自洽,错误恢复面最小(字幕文件毫秒级解析,实测开销可忽略)。

3.2 载荷与命中区间
private fun parseCues(formatRaw: String, content: String): Pair<String, SubtitleCueList> {
    val format = when (formatRaw.lowercase()) {
        "srt" -> "srt"
        "vtt", "webvtt" -> "vtt"
        else -> if (content.trim().startsWith("WEBVTT")) "vtt" else "srt"
    }
    val cues = if (format == "vtt") WebVttParser.parse(content) else SrtParser.parse(content)
    return format to cues
}

桥不重新发明任何解析语义:SRT 的逗号毫秒、VTT 的带/不带小时双格式、cue settings 兼容、多行文本合并,全部是上游 parse() 的原版行为;query 直接调上游 getActiveCues(timeMs),命中区间判断(currentTimeMs in startTime..endTime)也是上游原件。

3.3 错误通道

每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("CmpSubtitleParse")
fun cmpSubtitleParse(request: String): String = try {
    /* 解析并拼装响应 */
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、content 为空、不支持的 format,全部走同一个 ok:false 通道,ArkTS 侧统一渲染。

第 4 阶段:原生桥接(技术难点)

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libcmpmedia_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoscmpmedia.so)
  │ CmpSubtitleParse / CmpSubtitleQuery / CmpMediaVersion
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制解析语义,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("CmpSubtitleParse")
fun cmpSubtitleParse(request: String): String   // 全量解析

@CName("CmpSubtitleQuery")
fun cmpSubtitleQuery(request: String): String   // 时刻命中查询

@CName("CmpMediaVersion")
fun cmpMediaVersion(): String = "0.11.4-ohos.1" // 版本握手
C ABI 符号ArkTS 方法覆盖内核
CmpSubtitleParsecmpSubtitleParse(req)SrtParser.parse + WebVttParser.parse + format 自动识别
CmpSubtitleQuerycmpSubtitleQuery(req)SubtitleCueList.getActiveCues
CmpMediaVersioncmpMediaVersion()fork 版本握手(页面版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"cmpSubtitleParse", nullptr, NapiCmpSubtitleParse, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"cmpSubtitleQuery", nullptr, NapiCmpSubtitleQuery, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"cmpMediaVersion", nullptr, NapiCmpMediaVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscmpmedia_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoscmpmedia_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与三个业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohoscmpmedia SHARED IMPORTED)
set_target_properties(ohoscmpmedia PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(cmpmedia_napi SHARED napi_init.cpp)
target_link_libraries(cmpmedia_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohoscmpmedia)

OHOS_ARCH 为空或 libohoscmpmedia.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const cmpMediaVersion: () => string;
export const cmpSubtitleParse: (request: string) => string;
export const cmpSubtitleQuery: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 版本握手与字幕联动
aboutToAppear(): void {
  this.version = cmpMediaVersion();                    // 版本握手:真实 Native 调用
  this.loadRawFile('en.vtt').then(t => this.vttContent = t);
  this.timer = setInterval(() => this.refreshSubtitle(), 500);
}

private refreshSubtitle(): void {
  if (!this.subtitleOn || !this.vttContent) return;
  const sec = Math.floor(this.currentTime / 1000);
  if (sec === this.lastQuerySec) return;               // 按秒节流
  const req: QueryRequest = { format: 'vtt', content: this.vttContent, time: this.currentTime };
  const res = JSON.parse(cmpSubtitleQuery(JSON.stringify(req))) as ParseResult;
  this.subtitleText = res.ok && res.active ? res.active.map(c => c.text).join('\n') : '';
}
5.2 AVPlayer 状态机
player.on('stateChange', (state: string) => {
  if (state === 'initialized') {
    player.surfaceId = this.surfaceId;                 // XComponent onLoad 时取得
    player.prepare();
  } else if (state === 'prepared') {
    this.duration = player.duration;
    player.play();
  } /* playing / paused / completed / stopped / released / error ... */
});

控制面逐项映射上游 API:seek(value, media.SeekMode.SEEK_PREV_SYNC) 对应 seekStart+seekFinished,setSpeed(media.PlaybackSpeed.*) 对应 playbackSpeed,setVolume(v) 对应 volume,loop 属性对应 loop。

5.3 错误上屏与画面保活

两条实测反哺的设计:其一,on('error') 的 err.code / err.message 直接上屏(红色状态行),5400106 额外提示"模拟器软解常见,请用 arm64 真机验证"——没有这条,任何环境问题都要抓 hilog 才能定位;其二,视频面板用 visibility(Visible/None) 隐藏切换而不是条件渲染——条件渲染会销毁 XComponent 的 surface,而 surfaceId 只能在 initialized 态设置,切 Tab 回来就永久黑屏。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohoscmpmedia)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName × 3 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.composemediaplayer、versionName 0.11.4-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 导出 × 3、参数检查、DisposeString
        ├── src/main/cpp/include/libohoscmpmedia_api.h
        ├── src/main/cpp/types/libcmpmedia_napi/{index.d.ts, oh-package.json5}
        ├── src/main/resources/rawfile/       demo.srt / en.vtt / fr.vtt(后两个来自上游 assets)
        └── src/main/ets/pages/Index.ets      ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
.\gradlew.bat :mediaplayer:jvmTest                                     # 11 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

# DevEco Studio 打开 example/harmonyApp
# File → Project Structure → Signing Configs 勾选自动签名后 Run
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.composemediaplayer -m entry

四、完整代码对照

4.1 整体架构

io.github.kdroidfilter.composemediaplayer.subtitle/**(上游解析器原样编译)
    │ SrtParser.parse / WebVttParser.parse / SubtitleCueList.getActiveCues
io.github.kdroidfilter.composemediaplayer(纯模型:AudioMode / CacheConfig / VideoPlayerError / PipResult / TimeUtils)
Bridge.kt + MiniJson
    │ CmpSubtitleParse / CmpSubtitleQuery / CmpMediaVersion
libcmpmedia_napi.so
    │ cmpSubtitleParse / cmpSubtitleQuery / cmpMediaVersion
Index.ets
    │ JSON parse + @State 更新 + AVPlayer 控制
    ▼
ArkUI 三 Tab 验收页面(视频 / 字幕 / 音频)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_ComposeMediaPlayer、sample 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 钉版 + kotlin-test(纯核心无其它依赖)
gradle.propertiesJDK 21 路径、version=0.11.4-ohos.1、KN 下载开关
build.gradle.kts(根)版本、全局禁用 Ohos*Test* 任务
mediaplayer/build.gradle.kts / audioplayer/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64,jvmMain/jvmTest srcDirs 置空
nativeApp/.../Bridge.kt3 个 C ABI、MiniJson、format 识别、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出 × 3、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(24 节)

4.3 关键 API 对照

层次API作用
解析引擎SrtParser.parse(content)SRT 全文 → SubtitleCueList(逗号毫秒、序号、多行合并)
解析引擎WebVttParser.parse(content)VTT 全文 → cue 列表(WEBVTT 头校验、双时间格式、cue settings 兼容)
解析引擎SubtitleCue.isActive(ms) / SubtitleCueList.getActiveCues(ms)命中区间判断(桥的 query 内核)
纯模型AudioMode / CacheConfig / VideoPlayerError / PipResult播放器配置与错误语义(随编译进 .so)
工具TimeUtils.formatTime / toTimeMs时间文本格式化(上游 internal,JVM 测试钉住)
NativeCmpSubtitleParse / CmpSubtitleQuery / CmpMediaVersionC ABI JSON 导出
N-APIcmpSubtitleParse(req) / cmpSubtitleQuery(req) / cmpMediaVersion()向 ArkTS 暴露能力
ArkTSrefreshSubtitle() / AVPlayer 状态机字幕联动与播放控制

4.4 ArkTS 与 Kotlin 的边界

const req: ParseRequest = { format: 'auto', content: rawfileText };
const res = JSON.parse(cmpSubtitleParse(JSON.stringify(req))) as ParseResult;
// res.ok === true && res.format === 'vtt' && res.cues.length === 12

const q: QueryRequest = { format: 'vtt', content: rawfileText, time: 1234 };
const active = JSON.parse(cmpSubtitleQuery(JSON.stringify(q))) as ParseResult;
// active.active[0].text 是 1234ms 时刻命中的字幕原文
val (format, cues) = parseCues(json.str("format"), json.str("content"))
"parse"  -> cuesJson(format, cues.cues.size, "cues", cues.cues)
"query"  -> cuesJson(format, cues.cues.size, "active", cues.getActiveCues(time), time)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。

五、关键决策说明

决策 1:纯核心提取而不是真编译整库

KStore 整库编译的前提是它的依赖(kotlinx 四件套)都有 ohos 制品。本库 commonMain 站在 Compose runtime / foundation、filekit、kotlinx-datetime 上,其中 filekit 与 datetime 无 ohos 制品、Compose UI 与社区 CMP 1.9.2 不同代——整库编译不存在可行性。而字幕解析器(本库跨平台价值最密集的部分)只依赖 stdlib,提取它不是"手抄内核"而是原样编译原文件:parse() 逐行零改动,getActiveCues() 原件复用。

决策 2:工具链回到 2.2.21-1.0.0 旧线,Gradle 降 8.14.1

纯核心依赖清零后,工具链不再被依赖元数据绑架(不存在"stdlib 2.4 元数据读不了"的硬约束),于是选 qrose 已过审的旧线:2.2.21-1.0.0 + Gradle 8.14.1。KStore 用 2.4.20 新线是它依赖 kotlinx ohos 制品线的反推结果——两条线的经验不能互相套用,路线选择基于本库当天的依赖形态。

决策 3:composeMain 停靠而不是删除

12 个 Compose 文件、2 个 Compose 测试、上游全部平台实现目录留盘不删:git mv 到 src/composeMain / src/composeTest(包名不变),jvmMain/jvmTest 用 srcDirs 置空。KMP 只编译注册过的源集,未注册目录自然不进构建。这样对上游的文件级改动全部是纯移动,后续同步上游新版本(或社区 CMP 对齐后接回 Compose)时 diff 面最小。

决策 4:无状态桥,query 每次携带全文

桥不在 Native 侧保存解析会话:cmpSubtitleQuery 每次带全文 + 时间戳现场解析。代价是每次查询重新 parse(字幕文件毫秒级,实测无感),换来的是无生命周期、无并发状态、无失效策略——对演示与嵌入两种场景都是最稳的形态。

决策 5:播放引擎放 ArkTS,jvmTest 钉住内核

鸿蒙视频画面只能是 ArkTS XComponent 的 surfaceId 喂给系统 AVPlayer,Kotlin 侧既拿不到 surface 也不该复制一份播放器;播放语义(open/play/pause/seek/volume/speed/loop)在 ArkTS 复刻、解析内核在 Kotlin——解析正确性由 11 项 jvmTest(与 ohos 同一份 commonMain)钉住,"内核没漂"有测试背书。

六、测试与验证

6.1 测试环境

本次验证使用 Windows x64(Git Bash)、JDK 21(Temurin 21.0.12)、DevEco Studio 26.0.0.851(SDK 26.0.0 / API 26,工程声明 5.1.0(18) 兼容)、OpenHarmony 命令行工具(hvigor 6.26.8 / hdc 3.2.0f)与 DevEco 模拟器(x86_64)。

6.2 JVM 交叉验证

./gradlew :mediaplayer:jvmTest

11 项用例全部通过(上游原测试文件,零改动):

测试类用例数钉住什么
WebVttParserTest1cue settings 行的解析(上游最新修复的行为)
TimeUtilsTest4formatTime / toTimeMs 时间格式化
VideoPlayerErrorTest6错误密封类语义

依赖 Compose 的 SubtitleTrackTest / VideoMetadataTest 随被测类停靠,不参与本 fork 构建。JVM 与 ohos 跑同一份 commonMain,这 11 项就是"解析器没漂"的闸门。

6.3 HAP 验证

:mediaplayer:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:audioplayer:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/{arm64-v8a,x86_64}
llvm-nm -D: CmpMediaVersion / CmpSubtitleParse / CmpSubtitleQuery / libohoscmpmedia_symbols 均为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libcmpmedia_napi.so)
CompileArkTS: finished
PackageHap / 签名 / install bundle successfully / start ability successfully

KN so arm64 约 3.4MB(3,512,200 B)、x86_64 约 3.1MB(3,200,640 B),各配 libc++_shared.so(1.8MB / 1.7MB),由 Gradle copy 任务回填、gitignore 排除出库。

6.4 功能用例

  1. 启动后副标题出现 0.11.4-ohos.1(来自 cmpMediaVersion() 真实 Native 调用,非静态文字);
  2. 视频 Tab 打开默认源,状态行走 idle → initialized → prepared → playing,画面出现在 XComponent,进度条与时间文本走动;
  3. 字幕 Toggle 开启时,画面底部出现 en.vtt 的字幕条并随播放切换(每 500ms 轮询、按秒节流查询);
  4. 字幕 Tab 依次加载 demo.srt / en.vtt / fr.vtt,状态行显示识别格式、cue 总数与解析耗时;拖动探针滑杆,命中 cue 高亮并大字展示;
  5. 音频 Tab 打开 mp3 源自动播放(无画面),play/pause/seek/音量/倍速逐项生效;
  6. 拖进度条后字幕立即对齐新时间;切到其它 Tab 再切回视频,画面不黑屏(visibility 保活);
  7. 模拟器上视频源触发 5400106 时,错误行红字上屏并提示"请用 arm64 真机验证"。

6.5 验证结论

解析引擎、Native 动态库、C ABI、N-API、ArkTS 播放宿主和 HAP 已完成链路验证。应用在 x86_64 模拟器上成功安装并进入前台:版本握手、字幕解析与实时叠加、AVPlayer 状态机全部为真机化运行(见下节效果图);视频画面解码在模拟器上受软解编解码限制(5400106,见 8.1 第 4 条),arm64 真机画面验收由使用方按 6.2 节流程执行。

七、运行效果

7.1 运行截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/ComposeMediaPlayer.git
cd ComposeMediaPlayer
export JAVA_HOME="/path/to/jdk-21"
./gradlew :mediaplayer:compileKotlinOhosArm64 :audioplayer:compileKotlinOhosArm64   # klib
./gradlew :mediaplayer:jvmTest                                                     # 11 项交叉验证
./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.composemediaplayer -m entry
hdc shell hilog | grep -E "CmpMedia|AVPlayer"

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :mediaplayer / :audioplayer 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游 src/jvmTest 的桌面集成测试会引用停靠的 jvmMain,jvmTest 的 srcDirs 必须一并置空——漏掉时 compileTestKotlinJvm 报一片 Unresolved reference: MacVideoPlayerState / CurrentPlatform;
  4. DevEco 模拟器播 H.264 mp4 报 5400106(VID_DEC_ERR ... unsupport video decoder type, video/avc)是模拟器软解的编解码局限,网络、surfaceId、状态机均正常——错误上屏 + arm64 真机验证是正解,不要误判为桥或库的问题;
  5. 新 SDK 的 AVPlayer 删除了可写 volume 属性,player.volume = v 编译期报 Property 'volume' does not exist,必须用 player.setVolume(v)(返回 void);
  6. 视频 Tab 用条件渲染切换会销毁 XComponent 的 surface,而 surfaceId 只能在 initialized 态设置——切 Tab 回来永久黑屏;用 visibility(Visible/None) 保活组件树;
  7. hdc file recv 在 Git Bash 下会被 MSYS 路径转换改写远端路径,需 MSYS_NO_PATHCONV=1;目标路径要用反斜杠 Windows 形式;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();顺序是先 napi_create_string_utf8 再 Dispose;
  10. Demo 外链媒体源要先测连通:上游示例用的 download.samplelib.com 已 301 失效(重定向到 HTML),换 soundhelix.com 的 mp3(HTTP 200 / audio/mpeg);archive.org 在部分网络超时不可用;
  11. 工程声明 compatibleSdkVersion 5.1.0(18) 而本机默认 SDK 是 26.0.0(API 26) 时,首次要用 DevEco 打开工程让 IDE 按声明拉 SDK,命令行需配 DEVECO_SDK_HOME;
  12. 签名材料由 DevEco 自动写入 build-profile.json5,仓库内保持 signingConfigs 空数组,证书绝不进入 AtomGit;oh-package-lock.json5 提交与否与参考工程保持一致(提交)。

8.2 已知问题

  • Compose UI(VideoPlayerSurface 等)不编译进鸿蒙,Demo 的播放器 UI 是 ArkTS 实现;
  • VideoPlayerState 接口本体(含 TextStyle / Color / PlatformFile 成员)停靠,不在鸿蒙 klib 里;
  • SrtParser.loadFromUrl / WebVttParser.loadFromUrl 移到停靠侧扩展函数;鸿蒙侧取字幕文本由 ArkTS 完成(rawfile 或网络)再以 content 传入桥;
  • util/Logger.kt 停靠(依赖 kotlinx-datetime,无 ohos 制品);
  • android / ios / js / wasmJs 目标剥离(源文件留在树里),发布、dokka、detekt 一并剥离,本 fork 不发 Maven Central;
  • query 每次携带全文重新解析(无状态设计),超大字幕文件(万级 cue)的高频查询未做缓存;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.composemediaplayer 匹配的签名。

8.3 未来优化方向

  • 把 VideoMetadata 的字段读取补上桥接入口(prepared 态的 width/height/bitrate 透出);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 评估新 SDK 的系统级字幕能力(addSubtitleFromUrl / on('subtitleUpdate'))与 Kotlin 解析桥的双轨对照;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充 arm64 真机验收矩阵与自动化截图脚本(uitest + snapshot_display)。

九、总结

9.1 核心难点回顾

上游 mediaplayer + audioplayer(commonMain 21 + 3 个文件)
    → 依赖度盘点:纯 stdlib / 仅注解 / Compose 重度 / filekit·datetime 四类分治
    → 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1 降级,qrose 已过审旧线)
    → 根构建替换(cmp_qrose 模式,sample 移出、jvmMain/jvmTest 置空)
    → composeMain 停靠(git mv 纯移动,包名不变)
    → ohosArm64 / ohosX64 KLIB + 解析器逐行零改动
    → C ABI(@CName × 3)+ 扁平 JSON + format 自动识别
    → C++ N-API(DisposeString 纪律)
    → ArkTS AVPlayer 状态机 + 字幕轮询 + 错误上屏
    → ArkUI 三 Tab 验收(视频 / 字幕 / 音频)+ HAP + 模拟器实测

9.2 封装层次

  • 引擎层:上游原版 SrtParser / WebVttParser / SubtitleCue / SubtitleCueList.getActiveCues;
  • 模型层:上游原版 AudioMode / CacheConfig / VideoPlayerError / PipResult / TimeUtils;
  • Native 层:生成双 ABI 动态库并通过 3 个 C ABI 输出 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理播放器状态机、字幕轮询节流、JSON 解析、控制面与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和模拟器运行。

9.3 三条经验

  1. 适配路线由"UI 耦合度 × 依赖制品线"共同决定:KStore 依赖全有 ohos 制品且不碰 UI,正确答案是整库编译;本库 API 全站在 Compose 上且 filekit/datetime 无制品,正确答案是纯核心提取——两条路线都是"原样编译上游文件",区别只在编译哪些文件。路线选择必须基于本库当天的依赖形态,而不是上一个库的结论;
  2. 停靠不是删除:Compose 文件与平台实现全部 git mv 纯移动 + srcDirs 置空,fork 对上游的文件级 diff 全是 rename,后续同步与"接回 Compose"的成本被压到最低——这是 qrose 模式里最值得复用的一手;
  3. 播放类库的验收要分层:画面解码依赖设备编解码器(模拟器 5400106 属环境限制),而解析内核的正确性可以用 JVM 交叉验证 + 桥上屏闭环钉死——把"环境问题"和"内核问题"在验收矩阵里分开,错误才不会被误判。

9.4 适配成果

当前适配已完成:

  • ComposeMediaPlayer v0.11.4(fork 0.11.4-ohos.1)纯核心的双 ABI Kotlin/Native 编译(字幕解析器 parse() 逐行零改动,12 个纯文件留守,15 个文件停靠);
  • 3 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 三 Tab 验收页面(视频播放 + Kotlin 字幕叠加、字幕解析探针、音频控制面、版本握手、错误上屏);
  • 11 项 JVM 交叉验证全绿(上游原测试文件)+ HAP 构建(双 ABI 六件套 so)、签名、模拟器安装与运行验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐