开源鸿蒙平台 CMP 三方库「SaltUI」适配全流程
本文记录
SaltUI接入 OpenHarmony 的完整过程,覆盖纯 Compose UI 库的内核盘点、鸿蒙工具链钉版、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI / N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
SaltUI是从音乐播放器 Salt Player 孵化出的 Compose Multiplatform UI 组件库(上游3.0.0-beta02,Apache-2.0),覆盖主题系统(SaltTheme / SaltColors / SaltPalette / SaltDimens)、Item / Button / Dialog / TitleBar 等组件、lazy 列表 / 网格 / 瀑布流 / Pager,以及一套完整的 Sheet 底部弹层(detent 档位、拖拽吸附、独立关闭入口、状态保留)。它不是存储库那样的"零 UI 内核"——主库ui2的 216 个 commonMain 文件里 196 个深依赖 Compose。因此本次适配与 KStore 的"真编译整库"是相反方向的同一套方法论:把不依赖 UI 框架的那部分代码先编译到鸿蒙上跑起来——OS 探测、主题 token、Sheet detent 几何与拖拽 settle 规则,与 Android / iOS / 桌面保持同一份源码;ArkUI 页面作为验收宿主。Compose 组件本体冻结在非编译源码集composeMain中,等待鸿蒙侧 Compose 工具链与上游 CMP 1.12 线对齐后整体接回。
项目地址: AtomGit/oh-tpc/SaltUI.git
上游仓库: Moriafly/SaltUI
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
SaltUI 为 Kotlin Multiplatform 应用提供一整套视觉语言:SaltTheme 给出 11 个颜色 token 与明暗动态配对,SaltDimens 按平台分档(Android 56/24dp、iOS 52/22dp、桌面 32/20dp),core 模块的 OS.current 是这些分档的唯一开关;Sheet 组件在 Compose 之上实现了 detent 档位模型(Content / Medium / Large / Height / Fraction)、几何解析(inset 钳制、最小高度、等高档去重、就近偏高选择)与拖拽松手规则(位移关闭阈值、甩动关闭速度、方向越级、就近吸附)。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套设计语言,但上游构建(Kotlin 2.4.20 / Gradle 9.3.1 / CMP 1.12.1 / AGP 9.1.1)不会生成 OpenHarmony 变体。
把一个 UI 库带到鸿蒙,比存储库多出一堵墙:渲染层整体不可编译。鸿蒙社区 CMP 停在 1.9.2 线,与上游 CMP 1.12 / Kotlin 2.4 无法共存——这正是 cmp_qrose 适配条码库时遇到并记录过的同一堵墙。如果只把示例页面改成 ArkTS,页面可以显示数值,却无法证明库的逻辑模型在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证内核逻辑、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 渲染层断层 | 196/216 个 commonMain 文件深依赖 Compose(组件、SaltTheme、Foundation 级 lazy 拷贝),鸿蒙侧没有 1.12 线 CMP,任何 androidx.compose import 都编不进 ohos target。 |
| 类型绑定 UI 框架 | 主题 token 是 Color、尺寸是 Dp、几何解析吃 Density——都住在 androidx.compose.ui 里。 |
| AGP / 发布插件拖累 | 上游根构建挂 AGP 9.1.1、vanniktech 发布、kover,与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 交互语义跨不了桥 | 拖拽速度、档位吸附是 Kotlin 侧算法,C ABI 只能同步一来一回。 |
因此,本项目把边界放在:不依赖 Compose 的内核(OS 探测、主题 token、Sheet 几何与 settle 规则)的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI / N-API 桥接层。数值与算法语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和渲染。
1.2 库提供的能力
Demo 提供一个真实的组件内核会话,对应桥接的全部入口:
- 版本握手:
versionop 返回 fork 版本3.0.0-beta02-ohos.1与上游版本3.0.0-beta02,来自真实 Native 调用而非静态文字; - 平台探测:
osop 返回OS.current序列化,ohos 上是{"kind":"harmonyos","apiVersion":0}(纯 Kotlin/Native 无法不经 NAPI 查询 API 版本,宿主可用系统 API 自行展示真实值); - 主题 token:
themeop 按明暗与平台(鸿蒙 / Android / iOS / 桌面)返回 11 个颜色 token(#AARRGGBB,ArkTS 可直用)、12 个语义色与 8 项尺寸 token(dp 值 + density 换算 px); - Sheet 几何:
sheet/geometryop 把 detent 声明解析成吸附点列表(px + dp 双显)与初始选中档; - 拖拽 settle:
sheet/settleop 按上游SheetTransitionState.settle的同一套规则判定松手后关闭或落档; - 显示模式:
sheet/display-modeop 以 600x480dp 阈值判定 Auto 模式下底部吸附还是居中浮层。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
op 相关载荷 | 每个操作各自的返回体(见第三节桥协议)。 |
| 统一错误包 | 任意异常都以 {"ok":false,"error":"..."} 返回,Kotlin 异常永不跨 FFI。 |
内核侧完整能力(11 token 的明暗动态配对、sRGB 源覆盖混合、四平台尺寸分档、detent 几何解析、settle 四规则)全部在上游源码的抽取件里随编译进入 .so,JVM 侧保留 23 项 desktopTest 交叉验证用例,证明 ohos .so 里执行的逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 内核 19 个纯 Kotlin 文件与 Android / iOS / 桌面同一份源码:只做类型替换(Color→Long ARGB、Dp→Float)与注解剥离,数值与算法语义零改动,由 desktopTest 钉死。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm("desktop") 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);Compose 文件、平台 actual、资源与原测试整体迁入 composeMain 存档不编译;上游示例 app 与 ui-navigation、salt-adaptive-window 留盘不进构建。 |
| 桥接稳定 | 单个 @CName("SaltUiDispatch") C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 版本主体与上游完全一致,只把仓库换到 ohos 制品线(换线不换版);桥零依赖(手写 JSON 解析器)。 |
| UI 完整 | 单页覆盖全部桥接入口:三 Tab(主题 token 跨平台对比、按内核 settle 规则吸附的可拖拽 Sheet、版本握手与分层说明)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
SaltUI的价值九成在 Compose 渲染层,那部分在鸿蒙侧没有对应物可编译;ArkTS Demo 呈现的是与 Android / iOS / 桌面同一份源码编译出的内核数值。
二、实现路线图
第 1 阶段:项目初始化 ── 模块与依赖边界盘点、216 文件 Compose 依赖度分类、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.4.20-0.1.0-07 钉版(换线不换版)
第 3 阶段:源码集手术 ── 197 个 Compose 文件 git mv 进 composeMain 存档,19 个内核文件留 commonMain
第 4 阶段:内核抽取 ── Color→Long、Dp→Float、Density→Float、settle 纯函数化、internal→public
第 5 阶段:桥接 ── @CName C ABI、手写 MiniJson、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主 ── 三 Tab 页面、真实手势接 settle 判定、HAP、签名、设备安装和真机运行
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
core/ 上游 OS 探测(进 ohos 构建,jvm("desktop") + ohosArm64 + ohosX64)
core/src/ohosMain/ 新增:os() actual,返回 OS.HarmonyOS(apiVersion = 0)
ui2/src/commonMain/ 19 个纯 Kotlin 内核文件(tokens + sheet 数学,进 ohos 构建)
ui2/src/composeMain/ 197 个 Compose 文件 + androidMain/desktopMain/iosMain 平台 actual
+ composeResources 资源 + 原 commonTest/desktopTest 测试
(非源码集,不参与任何 target 编译,等待工具链接回)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
shared/ androidApp/ desktopApp/ iosApp/ ui-navigation/ salt-adaptive-window/
上游示例与深 Compose 模块,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :core、:ui2 与 :example:nativeApp,rootProject.name = "ohos_saltui"。上游的 AGP classpath、vanniktech / kover / dokka 发布插件、compose 编译器插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven) |
| JDK | 21(Temurin 21.0.12.101-hotspot,org.gradle.java.home 写入 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm("desktop") 保留作交叉验证) |
| Gradle | 9.6.1(上游 wrapper 为 9.3.1,本机下载停滞数小时,换用本地已缓存且 KStore 已验证的 9.6.1) |
| 依赖 | 零外部依赖:内核只用 Kotlin 标准库 + core 的 JNA(仅 desktop target) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 26.0.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.saltui |
1.3 源码依赖度盘点
对 ui2 的 216 个 commonMain 文件逐个检查 import(脚本扫描 androidx.compose.* / org.jetbrains.compose.*,非肉眼):
| 分类 | 数量 | 处置 |
|---|---|---|
| 深依赖 Compose(@Composable 组件、SaltTheme、lazy/** 的 Foundation 拷贝、Sheet 渲染层) | 196 | git mv 进 composeMain 原样存档 |
| 浅依赖(CompositionLocal 声明等) | 1 | 拆分:纯枚举留 commonMain,组合本地部分进 composeMain |
| 纯 Kotlin(数据类、接口、算法) | 19 | 留 commonMain 构成内核,做类型替换后进 ohos 构建 |
内核 19 文件的功能域:OS 探测(core 整体)、颜色 token(SaltColors / SaltPalette)、尺寸 token(SaltDimens)、Sheet 模型(SheetProperties / SheetDetent)、Sheet 几何(SheetGeometry / SheetDefaults)、settle 规则(新增 resolveSheetSettle)、枚举与纯数据结构(ControlSize、LazyStaggeredGridLaneInfo 等 232 行交错网格 lane 数据结构)。
两个"反向动作"值得一提:ControlSize.kt 是浅依赖文件,按 cmp_qrose 抽 Code128Type 的先例拆成 commonMain 的枚举与 composeMain 的 LocalControlSize / ProvideControlSize;SheetDefaults 的弹簧 MotionSpec 与 Dp 视图拆进 composeMain/SheetMotion.kt,dp 常量表与 settle 阈值留在内核。
1.4 页面边界
标题区 SaltUI · OpenHarmony / 副标题(Compose Multiplatform 组件内核 · Kotlin/Native ohosArm64/ohosX64)
Tab 切换 主题 / Sheet / 关于
主题页 深色开关、平台单选(鸿蒙/安卓/iOS/桌面)、11 颜色 token(色块+hex)、
12 语义色板网格、8 项尺寸 token(dp/px 双显)、8 个上游 PNG 图标
Sheet 页 detent 复选(Content/Medium/Large/Height320dp/Fraction0.4)、可关闭开关、内容高度滑杆、
吸附点列表(px+dp)、Auto 显示模式判定、可拖拽 Sheet 弹层、时间戳操作日志
关于页 版本握手、OS.current、架构分层图、边界声明、AtomGit 链接
页面启动时先做版本握手,再读屏幕(display.getDefaultDisplaySync())与避让区(window.getLastWindow)注入 density / 视口高 / 底部 inset。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.4.20-0.1.0-07)
KStore 验证过的"换线不换版"在本库零成本复用:上游源码本来就是 Kotlin 2.4.20 编的,HarmonyOS Kotlin 2.4.20-0.1.0-07 与之同代,commonMain 内核一行不改即可编译。ohosArm64() / ohosX64() 目标只存在于这套发行线,从 eazytec 社区 Maven 拉取。
Gradle 方向上游 wrapper 是 9.3.1,但本机从 services.gradle.org 下载该发行版停滞了两个多小时(每小时约 40MB);改用本地已缓存、且 KStore 在同一工具链线上验证过的 9.6.1,构建脚本同步适配(val x by getting 在 9.6 已是错误用法,改 getByName(...))。
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_saltui"
include(":core")
include(":ui2")
include(":example:nativeApp")
2.2 编译内核
:core 收敛为纯 KMP 模块:
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
// 交叉验证用:上游 desktopMain 的 JNA actual 原样编译
jvm("desktop")
ohosArm64()
ohosX64()
sourceSets {
commonTest.dependencies {
implementation(libs.kotlin.test)
}
getByName("desktopMain") {
dependencies {
implementation(libs.jna)
implementation(libs.jna.platform)
}
}
}
}
:ui2 同构,commonMain 依赖 api(project(":core"))。库模块不开 sharedLib——so 产物只在 example/nativeApp 生成,否则每个模块都会吐一份带运行时的 so,HAP 体积和符号都会乱。实测:
./gradlew :core:compileKotlinDesktop :core:compileKotlinOhosArm64 :core:compileKotlinOhosX64 # BUILD SUCCESSFUL
./gradlew :ui2:compileKotlinDesktop :ui2:compileKotlinOhosArm64 :ui2:compileKotlinOhosX64 # BUILD SUCCESSFUL
2.3 内核抽取:换表示不换语义
内核对上游只做四类动作,全部可审计:
| 动作 | 例子 | 不变的东西 |
|---|---|---|
| 类型替换 | Color(0xFF0470E6) → 0xFF0470E6L;Dp → Float(dp 数值);Density 参数 → Float density | 全部 token 数值、阈值常量 |
| 算法移植 | compositeOver / copyAlpha 以 Long 数学实现 sRGB 源覆盖;resolveSheetSettle 纯函数化 SheetTransitionState.settle | 混合公式、四条落点规则逐字对应 |
| 注解剥离 | @Immutable / @Stable / @JvmField / mutableStateOf(HarmonyOS Kotlin 不认 JVM 向注解;状态原语属 Compose) | 属性名与默认值 |
| 桥面公开 | sheet 内核 API 从 internal 升 public 并统一标注 @UnstableSaltUiApi(SaltUI 自己的不稳定标记) | 签名语义 |
resolveSheetSettle 是本次最有含量的抽取——上游 settle 藏在持有 AnchoredDraggableState 的状态机里,规则却全是纯数值判断:
internal fun resolveSheetSettle(
density: Float,
viewportHeight: Float,
points: List<Pair<SheetDetent, Float>>, // 解析后的吸附点(px,升序)
currentHeight: Float, // 松手时可见高度(px)
velocity: Float, // px/s,向下为正
dismissible: Boolean
): SheetSettleDecision
四条规则与上游一一对应:位移关闭阈值 min(48dp, 最低档 25%);低于最低档且下抛速度 > 1000dp/s 判关;|速度| ≥ 600dp/s 按方向越级选档(可跳过中间档);其余就近吸附。OS 侧则新增 HarmonyOS(apiVersion) 变体与 isHarmonyOS / ifHarmonyOS,SaltDimens 借 defaultFor(os) 补齐 HarmonyOS 移动档(52/22dp,与 iOS 同档)。
2.4 测试的处置:上游测试随 Compose 归档,内核测试重写为行为验收
ui2 的原 commonTest / desktopTest(4 + 19 个文件)测的是 Compose 组件(JustifiedRow 覆盖率门禁等),随源码集手术一起进 composeMain 存档——它们在内核构建里没有对应物。内核另建 23 项 desktopTest(2 项 core + 21 项 ui2),全部断言用户可观察行为:解析出的吸附点高度、settle 在每个阈值边界两侧的判定、token 混合值、四平台尺寸。根构建同样全局禁用 Ohos*Test* 任务,测试统一 JVM 跑。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.moriafly.salt.core / com.moriafly.salt.ui(内核,原源码抽取件)
↓ OS.current / SaltColors / SaltDimens / resolveSheetGeometry / resolveSheetSettle
Bridge.kt(@CName × 1,JSON 进出)
↓ UTF-8 JSON
N-API saltUiDispatch(request)
↓
ArkUI Index.ets(三 Tab 单页滚动)
六个 op 一口进:
请求 : {"op":"version"}
{"op":"os"}
{"op":"theme","dark":false,"os":"harmonyos"}
{"op":"sheet/geometry","density":3.0,"height":2229,"chromeHeight":192,
"bottomInset":132,"contentHeight":690.0,
"properties":{"detents":[{"kind":"content"},{"kind":"medium"},{"kind":"large"}],...},
"preferred":{"kind":"content"}}
{"op":"sheet/settle","density":3.0,"viewportHeight":2229.0,
"points":[{"detent":{...},"height":690.0},...],
"currentHeight":900.0,"velocity":1900.0,"dismissible":true}
{"op":"sheet/display-mode","mode":"auto","windowWidthPx":1080.0,
"windowHeightPx":2229.0,"density":3.0}
响应 : {"ok":true,...各 op 载荷...}
{"ok":false,"error":"..."}
两个设计继承自已审核仓库:detent 以 {"kind":"content"|"medium"|"large"|"height"|"fraction","value":...} 建模,sheet/geometry 的输出(吸附点 + detent 身份)原样作为 sheet/settle 的输入回传,桥不维护会话状态;颜色以 #AARRGGBB 字符串返回,ArkUI 的 ResourceColor 直接可用。
3.2 手写 MiniJson 为什么不上 kotlinx.serialization
桥载荷含嵌套数组与对象(detents、points),手写递归下降解析器约 300 行(含完整字符串转义)。这与 qrose / KStore 是同一决策:序列化运行时会通过依赖链拖进 so,而 C ABI 面要的是"一个 const char* 进、一个 const char* 出"的最小稳定面;载荷小,解析成本可忽略。
3.3 错误通道
导出整体 try/catch,非法 JSON、缺字段、不支持的 op / detent kind 全部走同一个 ok:false 通道,ArkTS 侧统一渲染:
@CName("SaltUiDispatch")
fun saltUiDispatch(request: String): String = try {
MiniJson.write(dispatch(MiniJson.parse(request) as Jv.Obj))
} catch (t: Throwable) {
MiniJson.write(Jv.obj("ok" to Jv.bool(false), "error" to Jv.str(t.message ?: t.toString())))
}
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libsaltui_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohossaltui.so)
│ SaltUiDispatch
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;ArkTS 又不能调 @CName 符号,所以 N-API 包装层是必经之路。
4.2 Kotlin/Native 导出函数
@CName("SaltUiDispatch")
fun saltUiDispatch(request: String): String
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
SaltUiDispatch | saltUiDispatch(req) | 版本握手 / OS 探测 / 主题 token / Sheet 几何 / settle / 显示模式 六 op 分发 |
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"saltUiDispatch", nullptr, NapiSaltUiDispatch, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohossaltui_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohossaltui_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号)。三个名字严格一致:nm_modname=saltui_napi、ArkTS import { saltUiDispatch } from 'libsaltui_napi.so'、index.d.ts 导出名。
4.4 CMake 和类型声明
add_library(ohossaltui SHARED IMPORTED)
set_target_properties(ohossaltui PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(saltui_napi SHARED napi_init.cpp)
target_link_libraries(saltui_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohossaltui)
OHOS_ARCH 为空或 libohossaltui.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。链接任务 finalizedBy 拷贝任务把 KN so 与匹配 triple 的 libc++_shared.so 一并送进 entry/libs/<abi>/。
export const saltUiDispatch: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 启动注入和数值拉取
aboutToAppear(): void {
const d = display.getDefaultDisplaySync();
this.density = d.densityPixels > 0 ? d.densityPixels : 1;
this.viewportPx = d.height;
window.getLastWindow(getContext(this)).then((mainWindow: window.Window) => {
const area = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
this.bottomInsetPx = area.bottomRect.height;
});
this.refreshVersion(); // 版本握手:来自真实 Native 调用
this.refreshOs();
this.refreshTheme();
this.refreshDisplayMode();
}
真机实测注入值:视口 1080x2229px(360x743dp)、density=3、底部避让 132px。
5.2 页面交互
主题 Tab 自上而下:深色开关与平台单选(切平台即重拉 theme op,四套 token 并排对比)、11 个颜色 token(色块 + 名称 + hex)、12 语义色板网格、8 项尺寸 token(dp 与 px 双显)、8 个上游 PNG 图标。Sheet Tab 是交互核心:
- 勾选 / 去选 detent、拖内容高度滑杆,实时重解析吸附点列表(px + dp)与初始选中;
- 点「显示 Sheet」打开自绘弹层:拖把手实时变高,松手调
sheet/settle得目标档或关闭,animateTo吸附; - 遮罩透明度 =
visibleHeight / 参考档高度 × 0.32(SCRIM_OPACITY 来自内核常量),拖拽全程连续变化; - 每步操作进带时间戳的日志(最新在前,上限 50 条),
→请求←响应成对留痕。
拖拽速度没有用系统手势的 velocity 字段,而是 onActionUpdate 里以 offsetY / 时间差自算(每 5ms 采样一次),松手值喂给 settle——这是 PanGesture 速度类型在当前 SDK 上的稳妥替代。
5.3 对比度和错误边界
整页配色全部来自 theme op 的 token(背景 / 卡片 / 主文 / 次文 / 强调),深浅色自动成立;ok:false 时错误原文进日志区,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进日志,页面永不白屏。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohossaltui)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ ├── Bridge.kt @CName × 1,六 op 分发
│ └── MiniJson.kt 递归下降 JSON 解析 / 序列化(零依赖)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.saltui、versionName 3.0.0-beta02-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/libohossaltui_api.h
├── src/main/cpp/types/libsaltui_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
./gradlew :core:desktopTest :ui2:desktopTest # 23 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hvigor 命令行有两个环境前提:PATH 必须同时含 DevEco 自带的 node 与 jbr/bin(缺 java 会 spawn java ENOENT),并设 DEVECO_SDK_HOME。签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机不进仓库(仓库内 signingConfigs 保持空数组)。
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.saltui -m entry
四、完整代码对照
4.1 整体架构
com.moriafly.salt.core.os/**(OS 探测内核 + OS.HarmonyOS 变体)
│ OS.current / isHarmonyOS / ifHarmonyOS
com.moriafly.salt.ui/**(19 文件内核)
│ SaltColors / SaltPalette(Long ARGB + sRGB 混合)
│ SaltDimens(Float dp + defaultFor(os) 四平台分档)
│ SheetProperties / SheetDetent / SheetDefaults
│ resolveSheetGeometry(detent → px 吸附点)
│ resolveSheetSettle(松手落点四规则)
Bridge.kt + MiniJson
│ SaltUiDispatch(六 op JSON 分发)
libsaltui_napi.so
│ saltUiDispatch
Index.ets
│ JSON parse + @State 更新 + 真实手势
▼
ArkUI 真机页面(三 Tab 单页滚动)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_saltui、只 include 三模块 |
gradle/libs.versions.toml | Kotlin 2.4.20-0.1.0-07 插件钉版 + kotlin-test / jna 库 |
gradle.properties | 版本 3.0.0-beta02-ohos.1、JDK 21 路径、kotlin.native 两个开关 |
build.gradle.kts(根) | 版本注入、全局禁用 Ohos*Test* 任务 |
core/build.gradle.kts / ui2/build.gradle.kts | 纯 KMP:jvm(“desktop”) + ohosArm64 + ohosX64,不开 sharedLib |
core/.../OS.kt + OS.ohos.kt | HarmonyOS 平台变体与 ohos actual(全库唯一新增 core 源文件) |
ui2/src/commonMain 19 文件 | 内核:tokens + sheet 数学(8 文件改写、其余原样) |
ui2/src/composeMain | 197 个 Compose 文件 + 平台 actual + 资源 + 原测试,存档不编译 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt / MiniJson.kt | 1 个 C ABI、六 op、零依赖 JSON |
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 | 鸿蒙适配主文档(20 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 内核 | SaltColors.defaultLight(os) / defaultDark(os) | 11 token 明暗配对(Long ARGB),含 compositeOver / copyAlpha 混合 |
| 内核 | SaltDimens.defaultFor(os) | 四平台尺寸分档(HarmonyOS 52/22dp) |
| 内核 | resolveSheetGeometry(density, ...) | detent → px 吸附点:inset 钳制、最小高度、去重、就近偏高选择 |
| 内核 | resolveSheetSettle(density, ...) | 松手落点:48dp/25% 关闭、1000dp/s 甩关、600dp/s 越级、就近吸附 |
| 内核 | SheetDisplayMode.resolveIsBottom(w, h) | 600x480dp 阈值判定底部 / 浮层 |
| Native | SaltUiDispatch | C ABI JSON 导出(六 op 一口进) |
| N-API | saltUiDispatch(req) | 向 ArkTS 暴露能力 |
| ArkTS | dispatch(op, args) | 触发操作并刷新 UI |
4.4 ArkTS 与 Kotlin 的边界
const response = JSON.parse(saltUiDispatch(JSON.stringify({
op: 'sheet/settle',
density: this.density, viewportHeight: this.viewportPx,
points: this.points, // sheet/geometry 回传结构原样回传
currentHeight: this.sheetHeightPx,
velocity: this.dragVelocity, dismissible: this.dismissible
}))) as Record<string, Object>;
// response.close === true → 关闭;否则按 response.target 回查吸附点并 animateTo 吸附
val decision = resolveSheetSettle(
density = density, viewportHeight = viewportHeight,
points = points, currentHeight = currentHeight,
velocity = velocity, dismissible = dismissible
)
两者之间只传输 JSON 字符串;吸附点表由桥解析、ArkTS 回传,桥不维护任何会话状态。
五、关键决策说明
决策 1:抽内核而不是真编译整库——与 KStore 相反方向的同一方法论
KStore 真编译整库,前提是 kotlinx 制品线齐了、引擎零 UI;SaltUI 的 216 个 commonMain 文件里 196 个是 Compose,“整库编译"这条路在工具链对齐前不存在。判断标准只有一条:该库的价值有多少不在 UI 框架里。SaltUI 的答案是主题 token + Sheet 数学 + OS 探测——恰好是设计语言的"数据层”,抽出来与 Android / iOS / 桌面同源;渲染层整体冻结,接回路径完整保留。
决策 2:Color→Long、Dp→Float——换表示不换语义
不引 Compose 也不引 Skia:Color 是 ARGB 打包值,Dp 是 Float 数值,内核需要的全部运算(sRGB 源覆盖混合、alpha 替换、dp×density)用 Long / Float 数学即可覆盖,desktopTest 用手工独立计算的期望值钉死(0x80FFFFFF over 0xFFF3F3F3 = #FFF9F9F9 等)。引图形运行时会把整棵依赖树拖进 C ABI 面,违背桥最小化原则。
决策 3:settle 规则纯函数化,状态机留在 Compose 侧
SheetTransitionState.settle 的四条规则是纯数值判断,跨桥价值最高;而 AnchoredDraggable / mutableStateOf 那层是 Compose 运行时语义,硬抽等于重写。落点判定进内核、动画与中断状态由 ArkTS 持有——真机上拖把手松手后的吸附行为与 Android 端走的是同一份规则源码。
决策 4:工具链换线不换版,Gradle 用本地已验证组合
Kotlin 钉 2.4.20-0.1.0-07 与上游同代、源码级零适配;Gradle 9.3.1 wrapper 在本机下载停滞,换成 KStore 在同一工具链线上验证过的 9.6.1——工具链经验按线传递,不按版本号想当然。
决策 5:composeMain 存档而不是删除
197 个文件、平台 actual、composeResources、原测试 git mv 进 composeMain(非源码集),提交历史保留改名轨迹。工具链对齐后的接回动作是"整体迁回 + 类型合流(Long↔Color、Float↔Dp)",而不是从上游重新对表——历史可审计,接回有清单。
决策 6:单 JSON 入口 + 零依赖桥
六个 op 共用一个 @CName 符号,未来加 op 不动 ABI;MiniJson 手写约 300 行,换的是 so 里不带任何序列化运行时。这是 qrose / KStore 一脉相承的纪律。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin)、DevEco Studio 26.0(7.0.0,API 26)、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64,1080x2229 / 360x743dp / density 3)。
6.2 JVM 交叉验证
./gradlew :core:desktopTest :ui2:desktopTest
23 项用例全部通过(内核与 ohos target 编译同一份源码):
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
OSTest(core) | 2 | 平台探测有效性 |
SheetGeometryTest | 5 | 内容/半高/全高解析、Medium 低于最小高度被丢弃而非钳制、等高去重、previousHeight 就近偏高、Height 档随密度缩放含 inset |
SheetSettleTest | 9 | 48dp 阈值边界(恰好关闭 / 差 1px 不关)、1000dp/s 关闭速度边界、非可关闭不判关、600dp/s 上抛/下抛越级、超最高档落最大、慢速就近吸附 |
SaltColorsTest | 4 | 明暗默认值(含手工独立计算的混合期望值)、iOS 分支、动态配对 |
SaltDimensTest | 3 | 四平台 item/itemIcon、default 匹合当前平台 |
期望值手工独立计算是关键纪律:混合色期望若由被测实现生成,测试只是同义反复。0x80FFFFFF over 0xFFF3F3F3 逐通道手算 255×0.50196 + 243×0.49804 = 249 得 #FFF9F9F9,测试通过即证明 Long 数学混合与 Compose 的 Color.compositeOver 结果一致。
6.3 HAP 验证
:core / :ui2 compileKotlinDesktop / OhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: SaltUiDispatch / libohossaltui_symbols 均为全局 T 符号(双 ABI)
BuildNativeWithCmake: finished(双 ABI 编出 libsaltui_napi.so)
CompileArkTS: finished(严格 ArkTS lint 通过)
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 7.0 MB(23 个文件),内含双 ABI 各三件套(libsaltui_napi.so / libohossaltui.so / libc++_shared.so);HAP 内 strip 后 KN so arm64 约 2.0 MB(debugShared 未 strip 时约 3.7 MB,含 Kotlin/Native 运行时)、x86_64 约 1.9 MB。
6.4 功能用例(真机)
真机(1080x2229,density 3,底部避让 132px)实测:
- 启动后「关于」页版本握手显示
3.0.0-beta02-ohos.1/ 上游3.0.0-beta02(来自真实 Native 调用);OS.current显示 HarmonyOS 变体; - 「主题」页深色开关切换,11 个 token 色块与 hex 同步变化;平台单选四档对比,HarmonyOS 档 item 为 52dp / itemIcon 22dp(移动档),与桌面 32/20dp 并排可辨;
- 「Sheet」页默认勾选 Content/Medium/Large、内容高度 230dp,解析出三个吸附点:Content 690px(230dp)、Medium 1181px(394dp)、Large 2229px(743dp),初始选中 Content——与内核推导一致(可用高度 2229−132=2097,Medium = 132 + 2097×0.5 ≈ 1181);
- 显示模式判定
Bottom(底部吸附),窗口 360x743dp、阈值 600x480dp——宽 360 < 600 命中底部档; - 点「显示 Sheet」弹层以 Content 档 690px 展开,遮罩随高度渐变;
- 快速上拖松手,日志记录
settle → Medium (1181px, velocity=-4479px/s)——负速度(向上)且 |4479| ≥ 1800px/s(600dp/s),按方向越级从 Content 落 Medium; - 慢速拖小幅松手就近吸附回原档;拖过最低档继续下拉松手即关闭(可关闭开时);
- 关闭「可关闭」开关后重复强甩,不判关、就近吸附;
- 每步操作按时间戳滚动留痕,深浅色全程对比度成立。
6.5 验证结论
内核逻辑(OS 探测 / 主题 token / Sheet 几何 / settle 规则)、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,三 Tab 页面展示了真实的内核会话:版本握手、跨平台 token 对比、detent 解析与拖拽吸附闭环(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/SaltUI.git
cd SaltUI
export JAVA_HOME="/path/to/jdk-21"
./gradlew :core:compileKotlinOhosArm64 :ui2:compileKotlinOhosArm64 # klib
./gradlew :core:desktopTest :ui2:desktopTest # 23 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon # 需 DevEco node + jbr 在 PATH
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.saltui -m entry
hdc shell hilog | grep SaltUi
八、遗留问题与改进方向
8.1 踩坑复盘
- 上游 Gradle 9.3.1 wrapper 在本机从 services.gradle.org 下载停滞两个多小时——换用本地已缓存且 KStore 验证过的 9.6.1,构建脚本按 9.6 语法适配;
val desktopMain by getting在 Gradle 9.6 是错误用法,改getByName("desktopMain")显式获取;settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- 内核 API 原为
internal,桥在独立模块调不到——升public并统一标注@UnstableSaltUiApi; - ArkTS 的
@Builder不支持 lambda 参数——抽成自定义组件(@Prop+@BuilderParam尾随闭包); - ArkTS 对象字面量必须对应显式类型——JSON 桥接一律
Record<string, Object>声明; - 自定义状态命名
tabIndex与组件内置属性冲突——换名currentTab; - PanGesture 的 velocity 在当前 SDK 上类型不可靠——onActionUpdate 里 offsetY / 时间差自算(5ms 采样);
- hvigor 打包报
spawn java ENOENT——PATH 必须含 DevEco 自带 jbr/bin(或任一 JDK 21); - 自绘 216px 应用图标初版渲染成彩条——生成脚本把 0-255 标度的像素又乘了一次 255,写缓冲溢出截断;修正后本地解码回验再入库;
- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); abiFilters必须双 ABI、每 ABI 三件套缺一不可(HAP 缺当前 ABI 的 so 报安装失败9568347);build-profile.json5的signingConfigs仓库内留空数组,签名由 DevEco 自动签名回填,证书绝不进入 AtomGit。
8.2 已知问题
- Compose 组件(197 文件)冻结在
composeMain,鸿蒙侧暂不可用,等待社区 CMP 到 1.12 线或上游支持 ohos target; OS.HarmonyOS.apiVersion恒为 0:纯 Kotlin/Native 无法不经 NAPI 查询,宿主需要真实值时走系统 API 展示;后续可在napi_init.cpp查询后注入;- Demo 的拖拽速度是 offsetY/时间差近似(5ms 采样),极端瞬时速度可能与系统手势估计有差;
AnchoredDraggable/ 动画中断状态未整体移植(留在 Compose 侧),ArkTS 持有动画状态;- android / ios / desktop 示例 app 与
ui-navigation、salt-adaptive-window留盘不进构建;本 fork 不发 Maven Central; - ArkTS 页面的
getContext在新 SDK 上有 deprecation 警告(功能正常,后续可换getUIContext().getHostContext())。
8.3 未来优化方向
- 二期把
AnchoredDraggable状态机按纯函数边界继续抽取(中断恢复、动画曲线参数),桥面同步扩展; apiVersion经 N-API 查询注入内核,替换恒 0 占位;- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和
uitest自动化截图。
九、总结
9.1 核心难点回顾
上游 core + ui2(216 commonMain 文件,196 个深 Compose)
→ 依赖度脚本盘点:19 文件内核 / 197 文件存档
→ 工具链对齐 Kotlin 2.4.20-0.1.0-07(换线不换版,Gradle 9.6.1)
→ 根构建替换(cmp_qrose 模式)+ 源码集手术(composeMain 存档)
→ 内核抽取:Color→Long、Dp→Float、settle 纯函数化
→ ohosArm64 / ohosX64 KLIB + C ABI(@CName × 1,六 op)
→ C++ N-API(DisposeString 纪律)+ CMake IMPORTED / $ORIGIN
→ ArkTS 三 Tab 宿主(真实手势 → settle 判定 → 动画吸附)
→ HAP 构建、签名、设备安装和真机验证
9.2 封装层次
- 内核层:上游源码抽取件——OS 探测(含 HarmonyOS 变体)、主题 token(Long ARGB + sRGB 混合)、尺寸四平台分档、Sheet 几何与 settle 四规则;
- Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、JSON 解析、手势速度估计、操作分发、日志与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 库适配的判断标准是"价值有多少不在 UI 框架里":KStore 整库真编译是因为引擎零 UI;SaltUI 九成文件是 Compose,正确答案就变成抽内核 + 存档渲染层。两条路线是同一方法论在不同库况下的两个方向,选择必须基于逐文件的依赖度盘点,而不是库的类型标签;
- 换表示不换语义,靠手工期望值钉死:
Color→Long、Dp→Float的每一步都可以漂移,desktopTest 里手工独立计算的混合值(#FFF9F9F9/#FF272727/ alpha 打包0x26)是"内核没漂"的唯一可信证明——期望值绝不能由被测实现生成; - 工具链经验按线传递:Kotlin 钉版跟依赖制品线走(2.4.20-0.1.0-07 与上游同代零成本),Gradle 组合跟已验证先例走(9.6.1),下载停滞就换本地缓存——环境问题不该消耗适配时间。
9.4 适配成果
当前适配已完成:
- SaltUI
3.0.0-beta02内核(OS 探测 + 主题 token + Sheet 几何 / settle 规则,19 文件)的双 ABI Kotlin/Native 编译,与 Android / iOS / 桌面同一份源码; - 1 个 C ABI 符号(六 op JSON 分发)和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 三 Tab 验收页面(版本握手、跨平台 token 对比、detent 解析、拖拽吸附、显示模式判定、操作日志);
- 23 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 7.0MB)、签名、设备安装和真机验证;
- 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)