本文记录 SpotlightOnboarding 接入 OpenHarmony 的完整过程,覆盖纯 UI 引导库盘点、Compose 依赖隔离决策、语义镜像核心编写、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 挖孔遮罩真机页面、HAP 构建和真机验收。

SpotlightOnboarding 是 JetBrains Compose Multiplatform 的聚光式新手引导库:在半透明遮罩上为注册的目标控件"挖出"圆角高亮孔洞,支持多页引导流程、每页多目标、圆角与 contentPadding 扩孔、进出动画与 Material3 底部面板变体。本次适配与社区早期路线(qrose / haze / PreCompose / voyager 的"零依赖内核提取")有本质区别,与 KStore 的"真编译整库引擎"也不同:上游全部源码(连 model 层的 SnapshotStateList / mutableStateOf / LayoutCoordinates / Dp)都粘在 androidx.compose 上,社区鸿蒙 CMP 1.9.2 / Kotlin 2.2.21 工具链编译不了上游 Compose 1.7.3 / Kotlin 2.1.10 源码。因此本仓走 "语义镜像"路线(ktoast 同款):把上游状态机与挖孔几何逐条对齐地重写为纯 Kotlin 核心并原样编译进 OpenHarmony 动态库,Compose API 原样停放到 src/composeMain 等工具链对齐后接回;ArkTS 演示页通过 C ABI 驱动这份核心,用 Canvas 按"库核心计算的几何"重绘遮罩,真机上跑的每一条翻页规则、每一个孔洞坐标都来自 Kotlin 侧的库代码而不是页面手写。

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

上游仓库: s4nchouz/SpotlightOnboarding

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

SpotlightOnboarding 为 Compose Multiplatform 应用提供"新手引导"这一件事的完整答案:SpotlightOnboarding { } 一个容器包住整屏内容,Modifier.spotlightItem(cornerRadius, contentPadding, pageIndex) 一个修饰符标记任意控件为目标,SpotlightOnboardingState(pageCount) 管理多页流程(首页/末页钳制翻页、显隐),底层一个全屏 Canvas 画半透明 scrim 并用 ClipOp.Difference 为当前页所有已挂载目标挖出圆角孔洞。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面、Web 共享。OpenHarmony 应用同样需要这套引导能力,但上游构建(Compose 1.7.3 / Kotlin 2.1.10 / AGP 8.7.3)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以显示按钮和卡片,却无法证明引导状态机在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证状态机语义、挖孔几何、Native 动态库、C ABI、N-API、ArkUI 挖孔遮罩和 HAP 交付链路。

障碍具体问题
Compose 全面依赖上游 7 个源文件 100% 依赖 androidx.compose:model 层用 SnapshotStateList/SnapshotStateMap/mutableStateOf,几何用 LayoutCoordinates/Dp/PaddingValues,绘制用 Canvas/Path/ClipOp.Difference,注册用 Modifier.Node/CompositionLocal。没有任何"纯 Kotlin 内核"可直接抽取。
工具链不一致社区鸿蒙线是 HarmonyOS Kotlin 2.2.21-1.0.0 + CMP 1.9.2;上游是 Kotlin 2.1.10 + Compose 1.7.3,两代工具链对不齐,composeMain 源码集无法为 ohos 编译。
语义跨不了桥Compose 重组、快照系统、onGloballyPositioned 回调是 Kotlin 侧概念,C ABI 只能同步一来一回。
几何单位陷阱ArkTS 侧 componentUtils 的 windowOffset 单位是 px、onAreaChange 的 Area 单位是 vp、Canvas 画布坐标又是 px——三套单位混用必然挖孔偏移。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:引导状态机与挖孔几何的纯 Kotlin 镜像(逐条对齐上游源码)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。引导语义完全由镜像核心负责,ArkTS 只负责测量目标、上报几何、调用桥和按返回的快照重绘遮罩。

1.2 库提供的能力

Demo 提供一次真实的多页聚光引导会话,对应桥接的全部入口:

  • 会话建立(configure):SpotlightOnboardingCore(pageCount = 2) 建会话——镜像上游 rememberSpotlightOnboardingState(pageCount = 2);
  • 目标注册(register/unregister):镜像上游 Modifier.spotlightItem 的 onGloballyPositioned 注册与 onDetach 摘除,Demo 把 chip 行第 3/5 个与卡片第 3/5 张注册为目标,card4 额外带 8vp 四向 contentPadding 专门演示扩孔;
  • 多页引导(next/prev/page):末页 next 与首页 prev 自动 no-op(上游 nextPage()/previewPage() 原文语义),page 跳页不钳制(上游 setPage() 同款);
  • 显隐(show/hide):镜像上游 show()/hide() 与 isVisible 参数联动,遮罩 200ms 淡入淡出近似上游 fadeIn/fadeOut 默认值;
  • 挖孔几何(holes):孔矩形由库核心用上游 Spotlight Canvas 同款数学计算——left = x - padLeft、top = y - padTop、right = x + width + padRight、bottom = y + height + padBottom、radius ≥ 0,当前页所有 attached 目标一次全挖;
  • 版本握手(spotlightVersion):返回 1.1.1-ohos.1,来自真实 Native 调用而非静态文字。

每个响应都是全量快照:

字段作用
ok操作是否成功,失败时 error 带原文。
op / version回显本次操作与 fork 版本号。
visible / page / pageCount显隐状态与当前页(驱动遮罩层与页点联动)。
isFirst / isLast首末页标志(驱动 Prev 禁用、Next→Close 切换)。
targets已注册目标总数。
scrim遮罩色 #66000000——来自库常量 SpotlightDefaults.SCRIM_COLOR_ARGB(上游 Color.Black.copy(alpha = 0.4f))。
holes当前页全部孔洞 {id, left, top, right, bottom, radius},几何由库核心计算。

1.3 实现适配

维度要求
代码复用上游 Compose API 7 个文件原样停放 src/composeMain(字节不动,git rename 保留历史);commonMain 新写纯 Kotlin 语义核心约 200 行,每条规则注明上游出处。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);spotlight-onboarding-bottomsheet(Material3 变体)与 sample/* 留盘不进构建。
桥接稳定2 个 C ABI(SpotlightDispatch / SpotlightVersion)+ 扁平 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
语义不漂移上游本无测试;新增 10 项 commonTest 用例,每条断言都对应上游源码原文,jvmTest 承担交叉验证。
UI 完整单页覆盖全部桥接入口:目标注册、多页翻页、扩孔、显隐、状态行、时间戳日志和版本握手。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 Spotlight 是 Compose Canvas;鸿蒙侧遮罩由 ArkTS Canvas 按库核心计算的几何重绘——挖孔坐标与上游数学一致,但绘制引擎换成了 ArkUI。这是纯 UI 库在当前社区工具链下的正确边界。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与 Compose 依赖边界、确定"语义镜像"路线、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:语义核心编写   ── SpotlightOnboardingCore 状态机 + SpotlightGeometry 几何 + 默认值,10 项用例钉住
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── 目标测量上报、Canvas 挖孔遮罩、底部引导面板和操作日志
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
spotlight-onboarding/               上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
spotlight-onboarding/src/commonMain/          新写:纯 Kotlin 语义核心(状态机 + 几何 + 默认值)
spotlight-onboarding/src/commonTest/          新写:10 项语义交叉验证用例
spotlight-onboarding/src/composeMain/         停放:上游 7 个 Compose 源文件(原样保留,不编)
spotlight-onboarding-bottomsheet/   上游 Material3 变体,留盘不进 settings
sample/                             上游 android/web/desktop 示例,留盘不进 settings
example/nativeApp/                  Kotlin/Native 动态库(Bridge.kt + MiniJson)
example/harmonyApp/                 DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md      鸿蒙适配说明(中英双份)
README.upstream.md                  上游原文保存,README.md 重写为中文主页

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :spotlight-onboarding 与 :example:nativeApp,rootProject.name = "ohos_spotlightonboarding"。上游根构建的 Compose / AGP / vanniktech 发布插件、Maven Central 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(org.gradle.java.home 指向 Eclipse Adoptium jdk-21.0.12.101)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.10.2 升级;2.2.21 线社区钉法要求 8.14.1)
版本1.1.1-ohos.1(上游 1.1.1)
DevEco product5.1.0(18) 兼容
DevEco Studio26.0.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.spotlightonboarding
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块源文件依赖形态处置
spotlight-onboarding7100% Compose:model 层 SnapshotStateList/mutableStateOf,几何 LayoutCoordinates/Dp,绘制 Canvas/ClipOp.Difference,注册 Modifier.Node语义镜像:核心进 commonMain,Compose API 停放 composeMain
spotlight-onboarding-bottomsheet1Compose + Material3 ModalBottomSheet留盘不进构建(鸿蒙侧由 Demo 底部面板近似)
sample/*12Compose 示例(android/web/desktop)留盘不进构建

上游没有任何 expect/actual——因为它没有任何平台分叉点,一切都压在 Compose 上。可移植的不是文件,是语义,逐条盘点如下:

上游源码(composeMain,停放)语义镜像核心(commonMain,编译)
SpotlightOnboardingState.nextPage()if (currentPageIndex != pageCount - 1) currentPageIndex++——末页 no-opSpotlightOnboardingCore.nextPage() 同款
previewPage()if (currentPageIndex != 0) currentPageIndex--——首页 no-oppreviewPage() 同款
setPage(index)直接赋值,不钳制setPage(index) 同款
show() / hide()isVisible 布尔切换同款
putItem / removeItemspotlightItem 节点 onGloballyPositioned 注册、onDetach 摘除字符串 id 注册表同款
Spotlight Canvas孔 = 目标 bounds + 方向 padding,radiusX/Y = cornerRadius;只画 isAttached 目标;当前页全部目标一次挖SpotlightGeometry.hole() 同款数学
SpotlightOnboardingDefaults.SCRIM_COLORColor.Black.copy(alpha = 0.4f)SCRIM_COLOR_ARGB = 0x66000000
1.4 页面边界
标题区          SPOTLIGHT ONBOARDING / 版本握手行(1.1.1-ohos.1 · Kotlin/Native ohosArm64/ohosX64)
内容区          横向 chip 行(First~Sixth,index 2/4 为目标)+ 纵向卡片列表(8 张,index 2/4 为目标)
扩孔演示        card4 带 8vp 四向 contentPadding,孔洞明显大于卡片本体
操作区          Show 主色按钮 + Prev / Snapshot / Hide 辅助按钮
状态行          最近一次全量快照(visible/page/isFirst/isLast/targets/scrim)
日志区          带时间戳的 op 流水(最新在前,上限 50 条)
遮罩层          Canvas scrim #66000000 + destination-out 挖孔 + 200ms 淡入淡出
引导面板        What is Lorem Ipsum? + 页点指示(可点击跳页)+ Close + Next/Close(isLast 驱动)
关于区          调用链分层说明、仓库与社区 AtomGit 链接

页面启动时先做版本握手,再 configure(pageCount=2) 建会话;目标组件布局稳定后经 onAreaChange 逐个上报几何。

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

2.1 仓库线验证(本路线的先决条件)

语义镜像是无奈之举而不是偷懒——先验证上游有没有可能整体编译:社区 Maven(https://maven.eazytec-cloud.com)上的鸿蒙 CMP 制品线是 CMP 1.9.2 / Kotlin 2.2.21,上游源码是 Compose 1.7.3 / Kotlin 2.1.10 时代产物,compose.ui 的 Modifier.Node、GlobalPositionAwareModifierNode 等 API 在两条线之间没有二进制兼容通道;qrose 适配时已经验证过"上游 Compose 源码集为 ohos 编译"这条路走不通,ktoast 进一步验证了纯 UI 库的正确姿势是语义镜像。结论:Compose API 停放,语义进核心,桥按 ktoast 模式开。

2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)

本库镜像核心零第三方依赖(连 kotlinx 都不引),工具链版本没有"跟着依赖走"的约束,直接采用社区已过审的钉法:HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze / ktoast 同款)。Gradle 方面,2.2.21 旧线必须用 8.14.1——上游 wrapper 是 8.10.2,distributionUrl 升到 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_spotlightonboarding"

include(":spotlight-onboarding")
include(":example:nativeApp")
2.3 编译语义核心

spotlight-onboarding/build.gradle.kts 收敛为纯 KMP 模块:

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

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

    sourceSets {
        commonMain.dependencies {}
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

实测:

./gradlew :spotlight-onboarding:compileKotlinOhosArm64 :spotlight-onboarding:compileKotlinOhosX64   # BUILD SUCCESSFUL

上游 Compose 文件一个字节没动(git mv 到 composeMain,100% rename);新增的只有核心三文件与测试一文件。

2.4 测试的处置:上游无测试,新增用例钉语义

上游仓库没有测试目录。语义镜像最大的风险是"抄错规则",处置是新增 10 项 commonTest 用例,每条断言对应上游源码原文(翻页钳制、setPage 不钳制、显隐、方向 padding 扩孔数学、每页多孔只取当前页、attached 过滤、摘除、越界抛错、pageCount 防御)。测试统一在 JVM 跑(jvmTest),根构建全局禁用 ohos 测试任务:

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

第 3 阶段:桥协议建模

3.1 JSON 契约
io.github.s4nchouz.spotlightonboarding/**(语义核心,ohos 编译)
        ↓ SpotlightOnboardingCore / SpotlightGeometry / SpotlightDefaults
Bridge.kt(@CName × 2,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API spotlightDispatch() / spotlightVersion()
        ↓
ArkUI Index.ets(单页滚动)

请求与响应:

请求 : {"op":"configure","pageCount":2}
       {"op":"show"} / {"op":"hide"}
       {"op":"next"} / {"op":"prev"} / {"op":"page","index":1}
       {"op":"register","id":"card2","page":0,"x":16,"y":320,"w":343,"h":84,
        "padL":0,"padT":0,"padR":0,"padB":0,"radius":8,"attached":true}
       {"op":"unregister","id":"card2","page":0}
       {"op":"snapshot"}
响应 : {"ok":true,"op":"next","version":"1.1.1-ohos.1","visible":true,"page":1,
        "pageCount":2,"isFirst":false,"isLast":true,"targets":4,"scrim":"#66000000",
        "holes":[{"id":"rowChip4","left":..,"top":..,"right":..,"bottom":..,"radius":8},...]}
       {"ok":false,"op":"...","error":"..."}
3.2 为什么每个响应都是全量快照

上游靠 mutableStateOf 驱动 Compose 重组——状态变了 UI 自动跟着变。这套通知机制过不了 C 签名。设计取舍是把通知责任交给快照:每次 dispatch(包括 register/snapshot 这类"非 UI"操作)都返回完整状态,ArkTS applySnapshot 一次性落到 @State——visible 变化触发淡入淡出动画、page/isFirst/isLast 驱动面板与页点、holes/scrim 驱动 Canvas 重绘。桥全程无状态同步难题,页面永远和库核心一致。

注册几何的单位是 vp(ArkTS 侧 vp ≈ 上游 Compose dp),坐标以页面根容器为原点——与铺满根容器的遮罩 Canvas 严格对齐。

3.3 错误通道

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

@CName("SpotlightDispatch")
fun spotlightDispatch(request: String): String {
    var op = "?"
    return try {
        val json = MiniJson.parse(request)
        op = json.str("op").ifBlank { "snapshot" }
        when (op) { /* 9 种 op 分发 */ }
        snapshot(op)
    } catch (t: Throwable) {
        """{"ok":false,"op":${jsonString(op)},"error":${jsonString(t.message ?: t.toString())}}"""
    }
}

非法 op、越界页号(setPage(7) 会像上游一样在取页时抛 IndexOutOfBoundsException)、缺字段,全部走同一个 ok:false 通道,ArkTS 侧统一渲染进状态行与日志。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libspotlight_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosspotlight.so)
  │ SpotlightDispatch / SpotlightVersion
  ▼
UTF-8 JSON + DisposeString

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

4.2 Kotlin/Native 导出函数
@CName("SpotlightDispatch")
fun spotlightDispatch(request: String): String   // 9 种 op 一口进,返回全量快照

@CName("SpotlightVersion")
fun spotlightVersion(): String = "1.1.1-ohos.1"  // 版本握手
C ABI 符号ArkTS 方法覆盖语义
SpotlightDispatchspotlightDispatch(req)configure/show/hide/next/prev/page/register/unregister/snapshot
SpotlightVersionspotlightVersion()fork 版本握手(真机版本行的数据来源)
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"spotlightDispatch", nullptr, NapiSpotlightDispatch, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"spotlightVersion", nullptr, NapiSpotlightVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

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

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

add_library(spotlight_napi SHARED napi_init.cpp)
target_link_libraries(spotlight_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosspotlight)

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

export const spotlightDispatch: (request: string) => string;
export const spotlightVersion: () => string;

第 5 阶段:ArkUI 宿主

5.1 目标测量与触发
private registerOne(spec: TargetSpec): void {
  const rootRect = componentUtils.getRectangleById('spotRoot');
  const rect = componentUtils.getRectangleById(spec.id);
  const x: number = (rect.windowOffset.x - rootRect.windowOffset.x) / this.density;
  const y: number = (rect.windowOffset.y - rootRect.windowOffset.y) / this.density;
  const cached: number[] | undefined = this.sizeVp.get(spec.id);
  const w: number = cached !== undefined ? cached[0] : toVp(rect.size.width);
  /* dispatch register … */
}

三个单位纪律:componentUtils 的 windowOffset 单位是 px(d.ts 注释明示),除以 display.getDefaultDisplaySync().densityPixels 回 vp;尺寸以 onAreaChange 的 Area(单位 vp)为权威来源缓存;Canvas 画布坐标是 px,绘制时用"画布 px 宽 / 根容器 vp 宽"自校准换算。注册时机三保险:目标 onAreaChange(首帧布局)、根容器 onAreaChange + 300ms 兜底定时器(旋转/折叠)、onScrollStop 与 Show 点击前强制重测(滚动不触发 onAreaChange,但遮罩可见时页面滚动已被禁用,与上游 scrollEnabled = isVisible.not() 同款)。几何未变的重注册静默跳过,避免日志刷屏。

5.2 挖孔遮罩

镜像上游 Spotlight 的 clipPath(ClipOp.Difference):先 source-over 铺 scrim(#66000000 经 rgba() 转换喂 fillStyle,规避 ArkUI #AARRGGBB 与 Canvas CSS 色序歧义),再切 globalCompositeOperation = 'destination-out' 对响应里现成的 holes 数组逐孔擦除——圆角用 arcTo(API 7+)手绘路径,不用 ctx.roundRect(它要求 SDK 20,工程兼容 5.1.0(18))。显隐切换走 getUIContext().animateTo + TransitionEffect.OPACITY(200ms,镜像上游 fadeIn/fadeOut 默认值);底部引导面板独立上滑+淡入动画。

5.3 对比度和错误边界

主操作绿色大按钮白字,Close/Hide 次按钮浅底深字,语义与上游 Show/Close 对齐;页点当前页绿色高亮;ok:false 时错误原文进状态行与日志。Native 调用本身抛错(理论不该发生)同样被 try/catch 收进日志区。点 scrim 仅在末页关闭——镜像上游 bottomsheet 变体的 onDismissRequest 条件。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosspotlight)+ copyKnSo*
│   └── src/ohosMain/kotlin/org/terminator/ohos/spotlightonboarding/
│       └── Bridge.kt                         @CName × 2 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.spotlightonboarding、versionName 1.1.1-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/libohosspotlight_api.h
        ├── src/main/cpp/types/libspotlight_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :spotlight-onboarding:jvmTest                                            # 10 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 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.spotlightonboarding -m entry

四、完整代码对照

4.1 整体架构

io.github.s4nchouz.spotlightonboarding.model.SpotlightOnboardingCore(状态机镜像)
io.github.s4nchouz.spotlightonboarding.model.SpotlightGeometry(挖孔几何镜像)
io.github.s4nchouz.spotlightonboarding.SpotlightDefaults(scrim 默认值)
    │ nextPage/previewPage/setPage/show/hide/putItem/removeItem/currentHoles
Bridge.kt + MiniJson
    │ SpotlightDispatch / SpotlightVersion
libspotlight_napi.so
    │ spotlightDispatch / spotlightVersion
Index.ets
    │ register(componentUtils + Area 测量)→ JSON dispatch → applySnapshot
    ▼
ArkUI 挖孔遮罩 + 底部引导面板(真机单页验收)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_spotlightonboarding、bottomsheet/sample 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 钉版(镜像核心零第三方依赖)
build.gradle.kts(根)版本 1.1.1-ohos.1、全局禁用 Ohos*Test* 任务
spotlight-onboarding/build.gradle.kts纯 KMP:jvm + ohosArm64 + ohosX64
spotlight-onboarding/src/commonMain/.../SpotlightOnboardingCore.kt状态机镜像(翻页钳制/注册表/显隐/当前页孔洞)
spotlight-onboarding/src/commonMain/.../SpotlightHole.kt挖孔几何(上游 Canvas 数学)
spotlight-onboarding/src/commonMain/.../SpotlightDefaults.ktscrim 默认 0x66000000
spotlight-onboarding/src/commonTest/.../SpotlightOnboardingCoreTest.kt10 项语义用例(每条对应上游源码原文)
spotlight-onboarding/src/composeMain/**上游 7 文件原样停放(git rename 100%)
example/nativeApp/build.gradle.kts双 ABI sharedLib、copyKnSo* 回填
nativeApp/.../Bridge.kt2 个 C ABI、9 种 op、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鸿蒙适配主文档(12 节)

4.3 关键 API 对照

层次API作用
核心SpotlightOnboardingCore(pageCount)建会话(镜像 rememberSpotlightOnboardingState)
核心nextPage / previewPage / setPage翻页(末页/首页 no-op、setPage 不钳制——上游原文语义)
核心show / hide显隐
核心putItem / removeItem目标注册/摘除(镜像 spotlightItem 节点回调)
核心currentHoles()当前页全部 attached 目标的孔洞(镜像 Canvas 遍历)
核心SpotlightGeometry.hole(...)left = x - padL…(上游 onDraw 数学)
NativeSpotlightDispatch / SpotlightVersionC ABI JSON 导出
N-APIspotlightDispatch(req) / spotlightVersion()向 ArkTS 暴露能力
ArkTSregisterOne / applySnapshot上报目标几何 / 应用全量快照并重绘

4.4 ArkTS 与 Kotlin 的边界

// ArkTS:注册目标(card4 带 8vp 扩孔,镜像 spotlightItem(cornerRadius=8, contentPadding=8.dp))
const res = JSON.parse(spotlightDispatch(JSON.stringify({
  op: 'register', id: 'card4', page: 1,
  x: 16, y: 268, w: 343, h: 76,
  padL: 8, padT: 8, padR: 8, padB: 8, radius: 8, attached: true,
})));
// res.holes[0] === { left:8, top:260, right:367, bottom:352, radius:8 } ← 库核心计算的几何
// Kotlin:库核心的孔洞数学(上游 Spotlight.kt onDraw 同款)
fun hole(id: String, x: Float, y: Float, width: Float, height: Float,
         padLeft: Float, padTop: Float, padRight: Float, padBottom: Float,
         cornerRadius: Float): SpotlightHole = SpotlightHole(
    id = id,
    left = x - padLeft, top = y - padTop,
    right = x + width + padRight, bottom = y + height + padBottom,
    radius = cornerRadius.coerceAtLeast(0f),
)

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

五、关键决策说明

决策 1:语义镜像而不是重写 UI,也不是硬编 Compose

上游没有纯 Kotlin 内核可抽(连 model 都是 Compose 类型),硬把 composeMain 编进 ohos 需要等社区 CMP 制品线与上游 Compose 代际对齐。镜像核心只重写"规则"(状态机 + 几何,约 200 行),不重写"渲染";渲染交给 ArkTS Canvas,但坐标全部来自库核心——真机上每个孔洞位置都是 Kotlin 代码算出来的。

决策 2:工具链钉 2.2.21-1.0.0 + Gradle 8.14.1,根构建替换(cmp_qrose 模式)

镜像核心零第三方依赖,工具链没有"跟着依赖走"的约束,直接采用 qrose / ktoast 已过审钉法;上游 wrapper 8.10.2 升到 8.14.1。上游根构建的 Compose / AGP / 发布插件对鸿蒙没有保留价值,整体替换;spotlight-onboarding-bottomsheet 与 sample/* 留盘不进 settings.gradle.kts。

决策 3:全量快照桥协议替代重组通知

Compose 的状态通知过不了 C 签名。每次 dispatch 返回完整快照,ArkTS 一次 applySnapshot 落全部 @State——桥无状态、页面与核心永远一致,register/snapshot 这类操作也复用同一条路径。

决策 4:三套单位各自为政,测量侧一次性收敛到 vp

windowOffset(px)、Area(vp)、Canvas(px)三套单位混用必然挖孔偏移。测量侧统一收敛 vp(px 偏移除 densityPixels,尺寸取 Area 缓存),绘制侧用"画布 px 宽 / 根 vp 宽"单一换算比——两处换算,全链路自洽。

决策 5:新增 10 项用例钉住"镜像没抄错"

上游无测试,镜像的正确性必须有闸门。每条用例断言的规则都注明上游源码出处(末页 no-op、setPage 不钳制、方向 padding 扩孔、只取当前页、attached 过滤……),jvmTest 与 ohos KLIB 跑同一份 commonMain——JVM 全绿就是"核心没漂"的证据。

六、测试与验证

6.1 测试环境

本次验证使用 Windows 10(x64)、JDK 21(Eclipse Adoptium)、DevEco Studio 26.0、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 JVM 交叉验证

./gradlew :spotlight-onboarding:jvmTest

10 项用例全部通过(测试报告 tests="10" skipped="0" failures="0" errors="0"):

用例钉住什么(上游出处)
nextPageStopsOnLastPage末页 no-op(SpotlightOnboardingState.nextPage 原文条件)
previewPageStopsOnFirstPage首页 no-op(previewPage 原文条件)
setPageIsUnclampedLikeUpstream跳页不钳制(setPage 直接赋值)
showAndHideToggleVisibility显隐切换
holeExpandsBoundsByDirectionalPadding扩孔数学(Spotlight onDraw 四边加减)
currentHolesCutEveryAttachedTargetOfCurrentPageOnly每页多孔、只取当前页(Canvas 遍历当前页 items)
detachedTargetsAreSkippedLikeUpstreamIsAttachedCheckisAttached == false 过滤
removeItemUnregistersTheTargetonDetach 摘除
outOfRangePageThrowsInsteadOfCrossingFfi越界经 ok:false 通道(上游行为是抛异常)
pageCountIsAtLeastOne防御:coerceAtLeast(1)(唯一一处比上游更严)

6.3 HAP 验证

:spotlight-onboarding:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
:spotlight-onboarding:jvmTest: 10/10 通过
:example:nativeApp:linkDebugSharedOhosArm64 / OhosX64: BUILD SUCCESSFUL
  → arm64-v8a/libohosspotlight.so 约 3.6MB + libc++_shared.so(debug 未 strip)
  → x86_64/libohosspotlight.so 约 3.3MB + libc++_shared.so
llvm-nm --defined-only: SpotlightDispatch / SpotlightVersion / libohosspotlight_symbols 均为全局 T 符号(双 ABI)
CompileArkTS: finished(ArkTS 严格模式零 any)
BuildNativeWithCmake: finished(双 ABI 编出 libspotlight_napi.so)
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

6.4 功能用例

  1. 启动后标题下方出现版本握手行 1.1.1-ohos.1 · Kotlin/Native ohosArm64/ohosX64(真实 Native 调用);
  2. configure(pageCount=2) 建会话,日志出现 targets/scrim 回显;
  3. 首次布局后 chip 行第 3/5 个与卡片第 3/5 张自动注册,日志带坐标与尺寸;
  4. 点 Show:遮罩淡入,scrim #66000000,当前页(第 1 页)两个孔洞——chip3 与卡片 3 同时挖孔(每页多目标),圆角 8;
  5. 点 Next:孔洞切换到第 5 个 chip 与第 5 张卡片;card4 的孔明显大于卡片本体——8vp 四向 contentPadding 扩孔生效(几何来自库核心);
  6. 第 2 页时主按钮从 Next 变为 Close、Prev 恢复可用、页点第 2 颗高亮(isFirst/isLast 驱动);
  7. 第 1 页时点 Next 连续触发到末页后 no-op(上游 nextPage 语义);点页点可跳页;
  8. 点 Close(或末页点遮罩):遮罩淡出,页面滚动恢复(镜像上游 scrollEnabled);
  9. 全程每次操作在日志区留痕(时间戳 + op + 关键快照字段,最新在前,上限 50 条)。

6.5 验证结论

引导状态机、挖孔几何、Native 动态库、C ABI、N-API、ArkUI 挖孔遮罩和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,多页聚光引导全程由 Kotlin 侧库核心驱动:版本握手、目标注册、多目标同屏挖孔、contentPadding 扩孔、翻页钳制与末页 Close 切换全部符合预期(见下节效果图)。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/SpotlightOnboarding.git
cd SpotlightOnboarding
export JAVA_HOME="/path/to/jdk-21"
./gradlew :spotlight-onboarding:compileKotlinOhosArm64 :spotlight-onboarding:compileKotlinOhosX64   # klib
./gradlew :spotlight-onboarding:jvmTest                                                          # 10 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64   # 双 ABI so 自动拷入 libs
# 用 DevEco Studio 打开 example/harmonyApp 构建 HAP 并自动签名
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.spotlightonboarding -m entry
hdc shell hilog | grep Spotlight

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :spotlight-onboarding 真正编译成 ohosArm64 / ohosX64 产物,且真机跑的语义来自库代码;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 上游全部源码粘在 Compose 上(连 model 层都是 SnapshotStateList/LayoutCoordinates),"抽纯内核"路线不存在,纯 UI 库的正确姿势是语义镜像(ktoast 先例)——路线选择必须基于上游源码的真实依赖形态;
  4. ArkTS 侧 ComponentUtils 有大小写两个导出:大写是接口类型、小写 componentUtils 才是可调用命名空间;import 大写会报"Property ‘getRectangleById’ does not exist on type ‘typeof ComponentUtils’"并连锁产生 arkts-no-any-unknown(真实模块是 @ohos.arkui.componentUtils,kit 里以小写转出);
  5. 三套单位陷阱:componentUtils.windowOffset 是 px(d.ts 注释明示)、onAreaChange 的 Area 是 vp、Canvas 画布坐标是 px——不统一换算,高密度屏上孔洞必偏移;本仓测量侧收敛 vp(px ÷ densityPixels),绘制侧用"画布 px 宽 / 根 vp 宽"单一换算比;
  6. ctx.roundRect 要求 SDK 20,工程兼容 5.1.0(18)——编译期只是 WARN,真机上孔洞圆角可能整个失效;用 arcTo(API 7+)手绘圆角矩形路径替代;
  7. 全局 animateTo 已废弃,ArkTS 严格模式下用 this.getUIContext().animateTo(...);
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(libspotlight_napi.so / libohosspotlight.so / libc++_shared.so)缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝——仓库内留空数组且 product 不引用签名,勾选自动签名后由 IDE 回填;证书绝不进入仓库(.gitignore 挡 *.p12/*.cer/*.p7b);
  11. ArkTS 不能直接 import 'libohosspotlight.so'(CAdapter 不导出 @CName 业务符号),必须走 NAPI so——和 qrose/KStore 同款结论;
  12. 根构建的 .gitignore 是根锚定模式,盖不住嵌套目录——example/harmonyApp/{oh_modules,.hvigor,.idea,entry/build,entry/libs} 要单独追加,*.so 一并挡掉。

8.2 已知问题

  • 鸿蒙侧无 Compose Composable,聚光遮罩由 ArkTS Canvas 按"库核心计算的几何"重绘——绘制引擎不同,坐标数学相同(ktoast / qrose 同款边界);
  • 上游 spotlightItem 的 Modifier / CompositionLocal / LayoutCoordinates 体系不跨桥,注册改由 ArkTS 侧 onAreaChange + componentUtils 主动上报;滚动进行中坐标可能滞后,但遮罩可见时页面滚动已禁用且 Show 前强制重测,实际不可达;
  • spotlight-onboarding-bottomsheet(Material3 ModalBottomSheet)未桥接,Demo 以 ArkTS 底部面板近似(标题/副标题/页点/Next/Close 对齐上游 sample 文案);
  • setPage 越界与上游同样抛错,经 ok:false 通道返回,不跨 FFI;
  • 多并发会话未支持——Demo 单会话,与上游示例(单 rememberSpotlightOnboardingState)同构;
  • pageCount = 0 时核心 coerceAtLeast(1) 是唯一一处比上游更严的防御(上游 repeat(0) 得空页表、读取即越界)。

8.3 未来优化方向

  • composeMain 等社区 CMP 制品线与上游 Compose 代际对齐后接回,遮罩降级为 fallback;
  • 桥补 attached 半挂载态的独立上报入口(当前随 register 一次性带出);
  • bottomsheet 变体的桥接(sheet 语义映射 ArkUI semi-modal);
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 spotlight-onboarding(100% Compose,无纯内核可抽)
    → 路线决策:语义镜像(ktoast 线)而非抽取/硬编
    → 工具链对齐 Kotlin 2.2.21-1.0.0 / Gradle 8.14.1
    → 根构建替换(cmp_qrose 模式,bottomsheet/sample 留盘)
    → commonMain 状态机 + 几何镜像(约 200 行,逐条对齐上游源码)
    → 10 项 jvmTest 用例钉住语义(上游无测试,用例即契约)
    → C ABI(@CName × 2)+ 全量快照桥协议
    → C++ N-API(DisposeString 纪律)
    → ArkTS 三单位收敛(windowOffset px / Area vp / Canvas px)+ Canvas 挖孔
    → ArkUI 真机单页验收(多页引导 / 多目标同屏 / 8vp 扩孔 / 末页 Close)

9.2 封装层次

  • 核心层:SpotlightOnboardingCore 状态机、SpotlightGeometry 挖孔数学、SpotlightDefaults 默认值——全部镜像上游源码语义;
  • 验证层:10 项 commonTest 用例,JVM 与 ohos KLIB 共享同一份 commonMain;
  • Native 层:双 ABI 动态库并通过 2 个 C ABI 输出全量快照 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:目标测量上报(三单位收敛)、快照应用、Canvas 挖孔、引导面板、日志与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 纯 UI 库的适配对象是"语义"不是"源码":上游没有纯 Kotlin 内核时,把状态机与几何逐条对齐地镜像出来、渲染交给平台 Canvas,是当前社区工具链下唯一既保真又可验证的路线——真机上每个孔洞坐标都必须来自库代码,否则 Demo 只是一张皮;
  2. 单位先于像素:鸿蒙侧 px/vp 三处混用(windowOffset px、Area vp、Canvas px)是 UI 类适配最高频的坑,开工前先定"全链路统一单位 + 单一换算点",比事后对偏移量打补丁便宜一个数量级;
  3. 上游没测试时,新增用例就是契约:镜像/重写的正确性必须有闸门,每条断言注明上游出处,"jvmTest 与 ohos 跑同一份核心"才是"语义没漂"的可信证明。

9.4 适配成果

当前适配已完成:

  • SpotlightOnboarding 1.1.1(Apache-2.0)语义核心的双 ABI Kotlin/Native 编译(上游 Compose API 7 文件零改动停放,git rename 100% 保留历史);
  • 2 个 C ABI 符号和 C++ N-API 包装层(llvm-nm 验证双 ABI 全局 T 符号);
  • DevEco Stage ArkUI 单页验收页面(版本握手、目标注册、多页翻页、多目标同屏挖孔、8vp 扩孔、末页 Close、时间戳日志);
  • 10 项 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐