本文记录 compose-sonner 接入 OpenHarmony 的完整过程,覆盖 CMP 三方库盘点、ohosArm64/ohosX64 目标、纯 Kotlin 内核提取、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

compose-sonner 是 Compose Multiplatform 的 toast 通知组件库(React sonner 的 Compose 实现)。本次适配把它的 toast 状态机提取为纯 Kotlin 内核进入 OpenHarmony ARM64 运行时,堆叠变换公式与配色/图标等设计令牌与上游同源下发,ArkUI 页面作为真机验收宿主;Compose UI 本体按社区规范隔离在 composeMain,不宣称在鸿蒙上运行 Compose。

项目地址: AtomGit/oh-tpc/compose-sonner

上游仓库: dokar3/compose-sonner

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

compose-sonner 为 Compose 应用提供 toast 通知:五类提示(Normal/Success/Info/Warning/Error)、堆叠与展开两种布局、下滑手势消dismiss、关闭按钮、Action 按钮、富色彩与暗色主题、六向对齐、三档时长与无限时长,以及"按 id 更新 toast"(Loading → Success 的基础)。这些能力上游全部实现为 Compose Multiplatform 代码,Android、iOS、桌面、Web 共享。

OpenHarmony 应用同样需要这套交互,但上游默认构建不会生成 OpenHarmony 的 Kotlin/Native 变体,而它的公共 API(Toaster composable、rememberToasterState)又完全构建在 androidx.compose 之上——这是与纯逻辑库(如协程库)不同的难点。

障碍具体问题
目标缺失必须增加 ohosArm64()/ohosX64() 才能生成 OpenHarmony KLIB。
UI 与逻辑耦合上游状态机埋在 Compose runtime(mutableStateListOf/snapshotFlow)里。
协程计时自动消dismiss依赖 coroutineScope.launch { delay(...) },桥上没有协程。
工具链不一致上游 Compose 1.11 + Kotlin 2.4 与鸿蒙 Kotlin 2.2.21 两条插件线不能共存。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
视觉同源堆叠公式、配色、图标若在 ArkTS 手抄一份,语义必然漂移。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在"纯 Kotlin 内核提取 + 令牌同源下发":toast 生命周期状态机编译进 ohosArm64/ohosX64,视觉模型(公式/颜色/图标)由 Kotlin 侧通过桥下发,ArkTS 只负责渲染与上报手势结果。

1.2 库提供的能力

适配后的鸿蒙侧可通过桥调用上游全部可桥接接口:

  • 五种类型:Normal/Success/Info/Warning/Error,富色彩开启时各自配色;
  • 四档时长:2s/4s/8s/Infinite(Duration.INFINITE 语义保留);
  • 按 id 更新:同 id 再 show 即更新内容并重置剩余时长;
  • 消dismiss:手动 dismiss(按 id/toast)、dismissAll、下滑手势(上游速度/距离双阈值)、关闭按钮、Action 按钮;
  • 暂停策略:ToastDismissPause.Never/OnNotFront/OnInvisible 三种计时暂停策略;
  • 布局:堆叠(最多 maxVisible+1 层,含透明度衰减)与展开两种模式、六向对齐;
  • 视觉令牌:明暗两套五类型配色、四个 material 图标矢量路径、圆角/边框/阴影/内边距等几何常量。

快照数据结构:

字段作用
id桥侧生成的 Long 序号(JSON 安全)。
message / type / state内容、类型、三态生命周期(visible/dismissing/dismissed)。
actionText文本 Action 按钮(lambda 不可跨语言)。
durationMs / displayedMs / remainingMs时长、已显示、剩余(null 表示计时暂停)。
layoutIndex堆叠层号(0 为最前),与上游 LazyToasterBox 同算法。

1.3 实现适配

维度要求
代码复用toast 状态机留在 KMP commonMain,算法语义与上游逐行对应。
平台目标:sonner 与桥模块增加 ohosArm64+ohosX64,双 ABI 动态库。
机制解包Compose 快照列表与协程计时替换为普通列表与截止时间戳。
桥接稳定单函数 C ABI + UTF-8 JSON,不传递 Kotlin 对象地址。
UI 完整ArkTS Demo 覆盖全部可桥接接口,视觉数据零手抄。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现,也不是 ArkTS 重写版 toast 库;它是上游同一状态机与设计令牌的渲染宿主。CMP 工具链对齐后,composeMain 中的上游 Compose UI 可整体接回。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点源集、判定内核边界和 OpenHarmony 交付物
第 2 阶段:目标与依赖打通 ── 钉工具链、加入 ohosArm64/ohosX64、双 ABI sharedLib
第 3 阶段:内核提取       ── composeMain 隔离 Compose UI、解包状态机、写回门面
第 4 阶段:原生桥接       ── C ABI、JSON 引擎、C++ N-API 和内存释放
第 5 阶段:ArkUI 宿主     ── 令牌下发、堆叠渲染、手势回传和轮询驱动
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
sonner/src/commonMain/       纯 Kotlin 内核(Toast/ToasterState/ToasterDefaults/Sonner 门面)
sonner/src/composeMain/      上游 Compose UI 原样隔离(不注册 source set,不编译)
sonner/src/{jvmMain,ohosMain}/  currentNanoTime 平台 actual
example/nativeApp/           桥接引擎 + @CName 导出,产出 libohossonner.so
example/harmonyApp/          DevEco Stage、N-API 和 ArkTS 页面
sample/ sample-android/      上游遗留,移出 settings.gradle.kts

Gradle 根工程只 include :sonner 与 :example:nativeApp;example/harmonyApp 是独立 hvigor 工程,Gradle 与 Hvigor 分开执行。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven 前置)
JDK / Gradle21 / 8.14.1(wrapper 钉死)
OpenHarmony 目标ohosArm64 + ohosX64(保留 jvm() 交叉验证)
DevEco product5.1.0(18),hvigor modelVersion 5.1.0
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.sonner
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 页面边界
标题区          compose-sonner 版本行(桥的 version op 下发)
类型区          Normal / Success / Info / Warning / Error / Multiline / Action
时长与策略区    2s/4s/8s/Infinite;Never/OnNotFront/OnInvisible;最大可见数
选项区          Dark theme / Rich colors / Close button / Expanded / Swipeable
位置区          TopStart/TopCenter/TopEnd/BottomStart/BottomCenter/BottomEnd
压力区          Show 5 连发 / Dismiss all
Toast 宿主      全屏 Stack 覆盖层,按快照 visibleIndices 渲染堆叠卡片

页面启动时拉取 colors/icons/defaults 三个令牌 op,再以 100ms 轮询 tick 驱动状态机。

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

2.1 仓库与工具链
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_sonner"

官方 Maven Central 的 Kotlin 在配置期直接报 Unresolved reference: ohosArm64,eazytec 社区 Maven 必须排在最前。gradle.properties 打开两个关键开关:

version=0.4.0-ohos.1
kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true

downloadFromMaven 让 Konan 的 ohos 交叉编译 sysroot 从 Maven 下载而不是 JetBrains CDN。

2.2 库模块目标
kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)
    jvm()
    ohosArm64()
    ohosX64()
    sourceSets {
        commonMain.dependencies {}
    }
}

库模块不开 binaries.sharedLib(否则每个模块吐一份带 KN 运行时的 so),jvm() 保留用于交叉验证。

2.3 桥模块产物
ohosArm64 { binaries.sharedLib { baseName = "ohossonner" } }
ohosX64 { binaries.sharedLib { baseName = "ohossonner" } }

tasks.register<Copy>("copyKnSoArm64") {
    from(layout.buildDirectory.dir("bin/ohosArm64/debugShared")) { include("libohossonner.so") }
    from(cxxShared("aarch64-linux-ohos"))
    into(rootProject.layout.projectDirectory.dir("example/harmonyApp/entry/libs/arm64-v8a"))
}

tasks.matching { it.name == "linkDebugSharedOhosArm64" }.configureEach {
    finalizedBy("copyKnSoArm64")
}

链接任务 finalizedBy Copy 任务,把 KN so 与匹配 triple 的 libc++_shared.so 一起送进 HAP 工程;双 ABI 三件套缺一,装机报 9568347。

第 3 阶段:内核提取(技术难点)

3.1 composeMain 隔离

上游 commonMain 里 15 个 Kotlin 文件逐个判定:Toaster、LazyToasterBox、ToastTransformHelper、CloseButton、图标与配色等依赖 androidx.compose 的文件 git mv 到 src/composeMain/——它只是一个不注册进 KGP 的目录名,ohos 编译器完全看不见,上游代码零修改保留待接回。

3.2 解包状态机

上游 ToasterState 把算法埋在三个机制里,内核逐项替换、语义保留:

mutableStateListOf        →  普通 MutableList(无 Compose 快照系统)
coroutineScope+delay 计时 →  dismissAtMs 截止时间戳 + advanceTimers(nowMs)
snapshotFlow 观察          →  桥轮询快照(Flow 扩展随 UI 进 composeMain)
internal fun advanceTimers(nowMs: Long = nowMillis()) {
    for (i in _toasts.indices) {
        val item = _toasts[i]
        val deadline = item.dismissAtMs ?: continue
        if (nowMs >= deadline && item.isVisible) {
            _toasts[i] = item.copy(
                state = VisibleState.Dismissing,
                dismissAtMs = null,
                displayedTime = item.toast.duration,
            )
        }
    }
}

暂停即冻结 displayedTime 并清空截止,恢复按剩余时长重算截止;按 id 更新保留已显示时长并重置剩余——上游测试注释"Should last another 1700ms"钉住的时间线,由 JVM 测试逐点复刻。计时由 ArkTS tick 驱动(社区先例把 delay 计时交给 UI 层),内核零协程、零第三方依赖。

3.3 写回门面

桥模块在库模块外,够不到 internal 状态访问器,仿照已过审 cmp_qrose 适配的 Qrose 门面补一个 Sonner 门面:

class SonnerToaster internal constructor(
    internal val state: ToasterState = ToasterState(),
) {
    fun show(message: String, id: Long = nextId(), type: ToastType = ToastType.Normal,
             duration: Duration = ToasterDefaults.DurationDefault,
             actionText: String? = null, nowMs: Long = nowMillis()): Toast
    fun dismiss(id: Long)
    fun dismissAll()
    fun toastDismissed(id: Long)   // 退场动画结束
    fun pause(id: Long, nowMs: Long = nowMillis())
    fun resume(id: Long, nowMs: Long = nowMillis())
    fun advanceTimers(nowMs: Long = nowMillis())
    fun views(nowMs: Long = nowMillis()): List<ToastView>
}

id 用桥侧 Long 自增(currentNanoTime() 约 1e18,超出 ArkTS number 精度;上游 id 本是不透明 Any,语义兼容)。

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

4.1 三层桥接
ArkTS
  │ JSON string(op 分发)
  ▼
C++ N-API entry
  │ const char*
  ▼
Kotlin/Native C ABI
  │ SonnerEngine(门面 + 引擎)
  ▼
UTF-8 JSON + DisposeString 释放

选择 C ABI + JSON 而不是导出对象或逐字段函数:toast 的字段和操作会随上游演进,单函数 String → String 契约增删字段不动二进制接口。

4.2 Kotlin/Native 导出函数
@CName("SonnerVersion")
fun sonnerVersion(): String = engine.versionJson()

@CName("SonnerToaster")
fun sonnerToaster(request: String): String {
    return try {
        engine.handle(request)
    } catch (t: Throwable) {
        JsonWriter.obj(listOf("ok" to false, "error" to (t.message ?: t.toString())))
    }
}

异常永远不跨语言边界,全部转成 {"ok":false,"error":...}。

4.3 引擎 op 分发
op入参(摘要)返回
showmessage、type?、durationMs?(-1=无限)、actionText?快照(含新 id)
dismiss / dismissAllid / —快照
toastDismissedid快照(退场动画结束)
actionClickid快照(按上游 sample 语义 dismiss)
tick—快照(推进计时→回收不可见 Dismissing→应用暂停策略)
configuremaxVisibleToasts?、dismissPause?快照
layoutheights(px)、densityPx、expanded、bottomAlign每层 scale/translationY/alpha
swipeCheckdragY、velocity、heightPx是否达到消dismiss阈值
colors / icons / defaults—配色 / 图标路径 / 时长与几何令牌

tick 依次镜像上游三段逻辑:协程 job 完成(advanceTimers)、"Dismissing 但不可见直接标记已消dismiss"的 LaunchedEffect、ApplyToastDismissPause 策略。

4.4 C++ N-API 与 CMake
static napi_value NapiSonnerToaster(napi_env env, napi_callback_info info) {
    // ...校验 string、两段式 UTF-8 转换...
    const char *raw = SonnerToaster(request.c_str());
    napi_value out = nullptr;
    napi_status st = napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
    DisposeKnString(raw); // 拷入 JS string 后立即经 libohossonner_symbols()->DisposeString 释放
    return out;
}
add_library(ohossonner SHARED IMPORTED)
set_target_properties(ohossonner PROPERTIES IMPORTED_LOCATION ${KN_SO} IMPORTED_NO_SONAME TRUE)
target_link_options(sonner_napi PRIVATE "-Wl,--no-undefined" "-Wl,-rpath,\$ORIGIN")
target_link_libraries(sonner_napi PUBLIC libace_napi.z.so libhilog_ndk.z.so ohossonner)

KN 的 CAdapter 不会把业务 @CName 挂到 ArkTS 模块上,所以 ArkTS 永远只 import CMake 包装的 libsonner_napi.so。

第 5 阶段:ArkUI 宿主

5.1 令牌加载与轮询驱动
import { sonnerToaster, sonnerVersion } from 'libsonner_napi.so';

this.colors = JSON.parse(this.call({ 'op': 'colors' })) as ColorsData;
this.icons = JSON.parse(this.call({ 'op': 'icons' })) as IconsData;
this.defaults = JSON.parse(this.call({ 'op': 'defaults' })) as DefaultsData;
this.pollTimer = setInterval(() => this.tick(), 100);

每帧渲染所需的配色(明暗两套五类型)、图标 SVG 路径、几何常量全部来自 Kotlin 下发,ArkTS 不手抄任何上游数值。

5.2 堆叠渲染

ArkTS 量出各层 toast 高度(vp×density 转 px)回传 layout op,按返回的 scale/translationY/alpha 摆放卡片——公式与上游 ToastTransformHelper 逐行一致(1-0.4*index/maxVisible 缩放、12dp*index+高度差 折叠偏移、展开时按前卡片高度累加)。进出场动画 300/255ms、滑动方向随上下对齐翻转,数值同样来自 defaults op。

5.3 手势回传
.onActionEnd((event: GestureEvent) => {
  const resp = JSON.parse(this.call({
    'op': 'swipeCheck', 'dragY': dragPx, 'velocity': event.velocityY, 'heightPx': heightPx,
  })) as SwipeResp;
  if (resp.ok && resp.dismiss) { this.onCloseClick(vm.id); }
})

上游 isSwipedToDismiss 的四档速度/距离阈值在 Kotlin 侧判定,ArkTS 只回传手势原始值;退场动画结束后回 toastDismissed,内核才真正清除条目。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/src/commonMain/.../SonnerEngine.kt    14 个 op + 快照 + 令牌
├── nativeApp/src/commonTest/.../SonnerEngineTest.kt 协议/公式/令牌/阈值测试
├── nativeApp/src/ohosMain/.../Bridge.kt            @CName 导出
└── harmonyApp/
    ├── entry/src/main/cpp/CMakeLists.txt           IMPORTED + $ORIGIN rpath
    ├── entry/src/main/cpp/napi_init.cpp            N-API 转发与释放
    └── entry/src/main/ets/pages/Index.ets          ArkUI Demo
6.2 构建、签名和安装
export JAVA_HOME="/path/to/jdk-21"
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-unsigned.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.sonner

真机走 DevEco Studio 自动签名(仓库只预置 "signingConfig": "default" 引用,p12/cer/profile 不落盘)。

四、完整代码对照

4.1 整体架构

ToasterState.kt(内核,解包后)
    │ show/dismiss/pause/resume/advanceTimers
    ▼
Sonner.kt(门面)+ SonnerEngine.kt(op 分发/快照/令牌)
    │ SonnerToaster / SonnerVersion
    ▼
libsonner_napi.so
    │ sonnerToaster / sonnerVersion
    ▼
Index.ets
    │ JSON parse + tick 轮询 + 手势回传
    ▼
ArkUI 真机页面

4.2 文件清单

文件职责
settings.gradle.ktseazytec Maven 前置,只 include 内核与桥模块
gradle/libs.versions.toml只留 HarmonyOS Kotlin 2.2.21-1.0.0
sonner/src/commonMain/.../ToasterState.kt内核状态机(头注释逐条列差异)
sonner/src/commonMain/.../Sonner.kt桥接门面(Long id、快照视图)
sonner/src/ohosMain/.../Platform.ohos.kt单调时钟 actual(TimeSource)
example/nativeApp/.../SonnerEngine.kt14 个 op、快照、堆叠数学、令牌
example/nativeApp/.../Json.kt手写 MiniJson + JsonWriter(零依赖)
example/nativeApp/.../Bridge.kt@CName C ABI 导出
example/harmonyApp/.../napi_init.cppN-API 转发与 DisposeString
example/harmonyApp/.../Index.etsArkUI Demo(全接口覆盖)
README.OpenHarmony_CN.md24 节适配主文档

4.3 关键 API 对照

层次API作用
KMPToasterState.show/dismiss/dismissAll上游生命周期入口(时间参数化)
KMPToasterState.advanceTimers/pause/resume截止时间戳计时模型
KMPSonnerToaster.views()桥友好的快照视图
NativeSonnerToasterop 分发单函数(JSON 进/出)
NativeSonnerVersion版本信息
N-APIsonnerToaster / sonnerVersion向 ArkTS 暴露
ArkTStick()100ms 轮询推进状态机
ArkTSrequestLayout()高度回传换取堆叠变换

4.4 ArkTS 与 Kotlin 的边界

const snap = JSON.parse(sonnerToaster(JSON.stringify({ 'op': 'tick' }))) as SnapshotData;
internal fun tick() {
    toaster.advanceTimers()
    reapInvisibleDismissing()
    applyDismissPause()
}

两者之间只传输 JSON 和原始数值(高度 px、速度 px/s),不传输 Kotlin 对象;状态语义全部在 Kotlin 侧裁决。

五、关键决策说明

决策 1:不为 ohos 编译 Compose,隔离到 composeMain

社区 8 个已过审适配零先例在 ohos 上编 Compose,两条工具链插件线也不能共存于一个 Gradle build。上游 Compose UI 原样移入不编译的 composeMain,保留待社区 CMP ohos 制品可用时接回。

决策 2:提取"埋在 Compose 里"的状态机内核

toast 的价值在生命周期语义(三态、按 id 更新、暂停策略、剩余时长记账),它们是逻辑不是 UI。解包机制(快照列表→普通列表、协程→时间戳)而保留算法,比 ArkTS 重写或降级重写上游都更可验证。

决策 3:计时交给 ArkTS tick 驱动

桥是同步请求/响应,KN 侧无协程运行时;截止时间戳 + 显式 advanceTimers(nowMs) 让时间可控可测——JVM 测试用虚拟时钟逐点复刻上游时间线。

决策 4:JSON 单函数桥 + 令牌同源下发

堆叠公式、明暗配色、图标路径、几何常量全部从 Kotlin 侧经 layout/colors/icons/defaults op 下发,ArkTS 零手抄,保证 Demo 展示的是上游模型而不是复刻品。

决策 5:双 ABI 三件套 + JVM 交叉验证

ohosArm64+ohosX64 双 ABI(真机/模拟器),每个 ABI 的 napi so、KN so、libc++_shared.so 三件套由 Copy 任务自动齐备;同时保留 jvm() 目标,:sonner:jvmTest 与 :example:nativeApp:jvmTest 把内核行为与协议/公式/令牌钉在上游值上。

六、测试与验证

6.1 测试环境

本次验证使用 macOS(Apple Silicon)、JDK 21、Gradle 8.14.1、DevEco Studio 26(command-line-tools 6.0.1,SDK 6.0.1 API 21)和 HarmonyOS 真机(arm64-v8a)。

6.2 共享内核测试

./gradlew :sonner:jvmTest :example:nativeApp:jvmTest

内核测试把上游 ToasterStateTest 六个场景重写为虚拟时钟(delay(x) 变成 advanceTimers(t)),关键用例:0ms 起 1000ms 计时、800ms 时更新为 2000ms → 1100ms 仍可见、2800ms 进入 Dismissing——与上游注释"Should last another 1700ms"同一时间线。另有暂停/恢复(1000ms 暂停、9000ms 恢复、12000ms 到期)与 INFINITE 用例。引擎测试钉住协议转义、堆叠公式数值(-21.6/-30.6/-68 等手算对值)、五档滑动阈值、配色 hex 与图标路径。

6.3 HAP 验证

CompileArkTS: finished
PackageHap: finished
SignHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

产物 entry-default-unsigned.hap(双 ABI debug,约 7.6MB),内含 libs/{arm64-v8a,x86_64}/ 各三件 so;llvm-nm -D 确认 SonnerToaster/SonnerVersion 为全局导出,llvm-readelf -d 确认 NEEDED 全为短名。

6.4 功能用例

  1. 五种类型 toast 依次弹出,富色彩开时配色来自 colors op,关时全部按 Normal(上游规则);
  2. Multiline 两行、Action 黑色按钮点击即消dismiss;
  3. 2s/4s/8s 到点自动退场,Infinite 需手动关闭;
  4. 同 id 再 show 内容更新且剩余时长重置;
  5. Dark theme 切换明暗两套;Close button 出现在卡片左上角;
  6. Expanded 切换堆叠/纵向列表;Position 六向切换且滑动方向翻转;
  7. Never/OnNotFront/OnInvisible 下,非前台 toast 的 remainingMs 为 null 且不再递减;
  8. Show 5 连发看堆叠层数与第四层 alpha=0;Dismiss all 全部退场后列表清空;
  9. 下滑手势按速度/距离阈值消dismiss,未达标回弹。

6.5 验证结论

Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路已完成验证。应用在 HarmonyOS 真机(arm64-v8a)上成功安装并进入前台,页面顶部显示桥下发的版本行 compose-sonner 0.4.0-ohos.1 (upstream 0.4.0, Kotlin 2.2.21-1.0.0),堆叠 toast、全部控制项与轮询驱动的生命周期均正常工作。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/compose-sonner.git
cd compose-sonner
export JAVA_HOME="/path/to/jdk-21"
./gradlew :sonner:jvmTest :example:nativeApp:jvmTest
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
llvm-nm -D example/harmonyApp/entry/libs/arm64-v8a/libohossonner.so | grep SonnerToaster
cd example/harmonyApp && hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install entry/build/default/outputs/default/entry-default-unsigned.hap

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 鸿蒙 Kotlin 插件要求 JetBrains 厂商 JDK 21 工具链,foojay 下载源不可达时需在 gradle.properties 注册本地 JBR(org.gradle.java.installations.paths);
  2. 该 fork 的 stdlib 缺 Duration.inWholeNanos 与 kotlin.concurrent 原子类,String.format/synchronized 也是 JVM 专属——公共代码要用最保守的 stdlib 子集;
  3. 手写 JSON writer 输出嵌套对象时要区分"预序列化片段"与"字符串值",否则快照里的对象会变成转义字符串;
  4. ArkTS 严格模式禁用 delete、未类型化对象字面量,Alignment 枚举没有 TopCenter/BottomCenter(用 Top/Bottom),drawing Path 的填充是 .fill();
  5. KN so 的返回串必须经 <baseName>_symbols()->DisposeString 释放,ArkTS 绝不直接 import KN so;
  6. 签名密钥不进仓库,真机用 DevEco 自动签名回填。

8.2 已知问题

  • 鸿蒙侧没有 Compose Toaster composable,Demo 是同模型 ArkTS 渲染,阴影为近似值;
  • 桥只接受字符串消息与文本 Action,Kotlin lambda(TextToastAction.onClick)不可跨语言,Action 点击按上游 sample 语义处理为 dismiss;
  • toast 的自定义 icon 槽与 Composable 内容槽是 Compose 概念,鸿蒙侧不提供;
  • 计时由 100ms 轮询驱动,粒度毫秒级但非事件驱动;
  • 暂停后的 displayedTime 记账为毫秒精度,比上游协程"完成段累计"更细(有意为之,差异已记录在内核头注释)。

8.3 未来优化方向

  • 把轮询 tick 换成 N-API 线程通知,降低空转;
  • 社区 CMP ohos 制品可用后接回 composeMain 的上游 Compose UI,桥整体退役;
  • 增加 bridge 版本字段与 ABI 兼容检查;
  • 在 CI 中加入 Native 链接、ELF 检查和 HAP 构建;
  • 补充不同设备 ABI、API 版本与暂停策略的验收矩阵。

九、总结

9.1 核心难点回顾

KMP commonMain toast 状态机(埋在 Compose runtime 里)
    → 解包机制、保留算法(纯 Kotlin 内核)
    → ohosArm64/ohosX64 Kotlin/Native
    → C ABI(单函数 JSON)
    → C++ N-API
    → ArkTS 令牌渲染 + 手势回传
    → ArkUI 真机页面

9.2 封装层次

  • KMP 层:Toast 模型、三态生命周期、按 id 更新、暂停策略、剩余时长记账;
  • Native 层:双 ABI 动态库,@CName 导出 14 个 op 的 JSON 引擎;
  • N-API 层:参数检查、字符串转换与 KN 内存释放;
  • ArkTS 层:令牌加载、快照渲染、堆叠变换与手势结果回传;
  • DevEco 层:CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. UI 密集型 CMP 库的适配边界画在"状态机与视觉模型"上:逻辑编译进内核、令牌同源下发,而不是在 ArkTS 复刻一套数值;
  2. 把埋在框架机制里的算法解包成显式参数(时间、观察方式),比绕过框架或重写框架更容易被测试钉住;
  3. 每一层都要有独立验收:JVM 钉上游语义、llvm-nm 查符号、HAP 查三件套、真机查全接口——编译通过不等于设备链路可用。

9.4 适配成果

当前适配已完成:

  • compose-sonner 上游 0.4.0 的 ohosArm64+ohosX64 双 ABI 适配(fork 版本 0.4.0-ohos.1);
  • Compose UI 隔离到 composeMain(零修改保留)与纯 Kotlin 状态机内核;
  • 单函数 JSON 桥(14 个 op)与 C++ N-API 包装;
  • 覆盖全部可桥接接口的 ArkUI Demo;
  • JVM 交叉验证测试(内核时间线 + 协议/公式/令牌)全绿;
  • HAP 构建与 HarmonyOS 真机(arm64-v8a)运行验证;
  • 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐