开源鸿蒙平台 CMP 三方库「compose-sonner」适配全流程
本文记录
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 Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven 前置) |
| JDK / Gradle | 21 / 8.14.1(wrapper 钉死) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(保留 jvm() 交叉验证) |
| DevEco product | 5.1.0(18),hvigor modelVersion 5.1.0 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.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 | 入参(摘要) | 返回 |
|---|---|---|
show | message、type?、durationMs?(-1=无限)、actionText? | 快照(含新 id) |
dismiss / dismissAll | id / — | 快照 |
toastDismissed | id | 快照(退场动画结束) |
actionClick | id | 快照(按上游 sample 语义 dismiss) |
tick | — | 快照(推进计时→回收不可见 Dismissing→应用暂停策略) |
configure | maxVisibleToasts?、dismissPause? | 快照 |
layout | heights(px)、densityPx、expanded、bottomAlign | 每层 scale/translationY/alpha |
swipeCheck | dragY、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.kts | eazytec 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.kt | 14 个 op、快照、堆叠数学、令牌 |
example/nativeApp/.../Json.kt | 手写 MiniJson + JsonWriter(零依赖) |
example/nativeApp/.../Bridge.kt | @CName C ABI 导出 |
example/harmonyApp/.../napi_init.cpp | N-API 转发与 DisposeString |
example/harmonyApp/.../Index.ets | ArkUI Demo(全接口覆盖) |
README.OpenHarmony_CN.md | 24 节适配主文档 |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| KMP | ToasterState.show/dismiss/dismissAll | 上游生命周期入口(时间参数化) |
| KMP | ToasterState.advanceTimers/pause/resume | 截止时间戳计时模型 |
| KMP | SonnerToaster.views() | 桥友好的快照视图 |
| Native | SonnerToaster | op 分发单函数(JSON 进/出) |
| Native | SonnerVersion | 版本信息 |
| N-API | sonnerToaster / sonnerVersion | 向 ArkTS 暴露 |
| ArkTS | tick() | 100ms 轮询推进状态机 |
| ArkTS | requestLayout() | 高度回传换取堆叠变换 |
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 功能用例
- 五种类型 toast 依次弹出,富色彩开时配色来自
colorsop,关时全部按 Normal(上游规则); - Multiline 两行、Action 黑色按钮点击即消dismiss;
- 2s/4s/8s 到点自动退场,Infinite 需手动关闭;
- 同 id 再 show 内容更新且剩余时长重置;
- Dark theme 切换明暗两套;Close button 出现在卡片左上角;
- Expanded 切换堆叠/纵向列表;Position 六向切换且滑动方向翻转;
- Never/OnNotFront/OnInvisible 下,非前台 toast 的
remainingMs为 null 且不再递减; - Show 5 连发看堆叠层数与第四层 alpha=0;Dismiss all 全部退场后列表清空;
- 下滑手势按速度/距离阈值消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 踩坑复盘
- 鸿蒙 Kotlin 插件要求 JetBrains 厂商 JDK 21 工具链,foojay 下载源不可达时需在
gradle.properties注册本地 JBR(org.gradle.java.installations.paths); - 该 fork 的 stdlib 缺
Duration.inWholeNanos与kotlin.concurrent原子类,String.format/synchronized也是 JVM 专属——公共代码要用最保守的 stdlib 子集; - 手写 JSON writer 输出嵌套对象时要区分"预序列化片段"与"字符串值",否则快照里的对象会变成转义字符串;
- ArkTS 严格模式禁用
delete、未类型化对象字面量,Alignment枚举没有TopCenter/BottomCenter(用Top/Bottom),drawingPath的填充是.fill(); - KN so 的返回串必须经
<baseName>_symbols()->DisposeString释放,ArkTS 绝不直接 import KN so; - 签名密钥不进仓库,真机用 DevEco 自动签名回填。
8.2 已知问题
- 鸿蒙侧没有 Compose
Toastercomposable,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 三条经验
- UI 密集型 CMP 库的适配边界画在"状态机与视觉模型"上:逻辑编译进内核、令牌同源下发,而不是在 ArkTS 复刻一套数值;
- 把埋在框架机制里的算法解包成显式参数(时间、观察方式),比绕过框架或重写框架更容易被测试钉住;
- 每一层都要有独立验收: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。
参考文档
更多推荐


所有评论(0)