开源鸿蒙平台 CMP 三方库「KToast」适配全流程
本文记录
KToast接入 OpenHarmony 的完整过程,覆盖 Toast 状态机库盘点、Compose 源码集隔离、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
KToast是基于 Compose Multiplatform 的 Toast 组件库:业务侧持有一个ToastState,在任意协程里suspend showToast(message, icon, duration);布局里放一个ToastHost,Toast 的进出场动画、Material3 配色、自动消失、串行排队全部由库托管。本次适配走的是社区早期已审路线(qrose / haze / PreCompose / voyager 的「Compose 隔离 + 模型下沉」):上游 Compose 1.7.0 / Kotlin 2.0.20 与鸿蒙工具链(HarmonyOS Kotlin2.2.21-1.0.0/ 社区 CMP 1.9.2)对不齐,ToastHost依赖AnimatedContent/ImageVector/ material3,无法整库编进 ohos——因此把纯 Kotlin 的时长模型(ToastDuration)与配色调和内核(blendARGB)留在commonMain原样编译成 OpenHarmony KLIB,九个 Compose 文件隔离进composeMain不参与 ohos 构建,串行队列状态机与配色计算在 Kotlin/Native 桥里复刻ToastState的 Mutex 语义,ArkUI 页面按桥返回的视觉参数渲染——3500/6500/4500ms 时长档位与 Material 调和配色在真机上与 Android/iOS 逐位一致。
项目地址: AtomGit/oh-tpc/ktoast.git
上游仓库: tarifchakder/ktoast
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
KToast 为 Compose Multiplatform 应用提供"一条 Toast 从发起到消失"这一件事的完整答案:ToastState() 一行建状态,showToast / showEmpty 全场景覆盖;ToastHost 挂在任意布局位置,alignment 决定弹出方位,transitionSpec 决定进出场;ToastDefaults 给出 SLIDE / FADE 两套转场预设和 Material3 逆表面配色(inverseSurface / inverseOnSurface 各自向 primary 调和 20%);ToastVisuals 承载消息、图标、时长。上游把这些能力放在 kmp-toast/src/commonMain,由 Android、iOS、桌面、Wasm 共享。OpenHarmony 应用同样需要这套带队列语义与品牌化配色的 Toast 能力,但上游构建(Kotlin 2.0.20 / Compose 1.7.0 / AGP 8.9.1)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS promptAction.showToast,页面可以弹系统提示,却无法证明 ktoast 的时长档位、调和配色、串行队列这些 CMP 编程模型在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证时长/配色模型、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| Compose 依赖 | 上游十个源文件全部 import androidx.compose:ToastHost 用 AnimatedContent,ToastState 用 mutableStateOf + ImageVector,ToastContent 用 material3 Card——社区 CMP 1.9.2 对不上上游 Compose 1.7.0 的这套 API。 |
| 工具链不一致 | 上游 Kotlin 2.0.20 / Gradle 8.13 / AGP 8.9.1 与鸿蒙工具链无法共存于一个 Gradle 构建。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象与 Compose 状态,需要 C ABI、C++ N-API 和 JSON。 |
| 状态语义跨不了桥 | showToast 的挂起排队(内部 Mutex)、ToastHost 的自动消失(LaunchedEffect + delay + dismiss)都是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:纯 Kotlin 模型的 ohos 编译、根构建替换(cmp_qrose 模式)、Compose API 隔离和 C ABI/N-API 桥接层。时长数值与配色算法完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和渲染结果。
1.2 库提供的能力
Demo 提供一个真实的 Toast 会话,对应桥接的全部入口:
- 常规提示(Common TOAST):
host=bottom走底部宿主,上滑进出场——对应 Compose 版ToastDefaults.SLIDE转场; - 居中提示(Center TOAST):
host=center走居中宿主,缩放 0.8→1 淡入——对应ToastDefaults.FADE转场; - 时长三档:
short3500ms /long6500ms / 自定义毫秒(500–30000 钳制);消息为空时自动回落 4500ms——对应上游showEmpty()的默认时长; - 图标五选:无 / 成功 / 错误 / 信息 / 星标,映射为
SymbolGlyph系统符号,颜色始终与文字同色; - 串行队列:连发 3 条时第 2、3 条返回
queued:true入队,前一条消失瞬间next顶上——对应ToastState内部Mutex的挂起排队语义; - 提前关闭:点击 Toast 卡片本体立即退场并交接下一条。
响应包含:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
show / queued | 立即展示还是进入排队。 |
id / host | Toast 编号与宿主,dismiss 凭 id 交接。 |
durationMs | 解析后的真实毫秒数(来自库的 ToastDuration,不是 ArkTS 抄的数字)。 |
containerColor / contentColor | Material 调和配色(库的 blendARGB 在 Native 侧算出)。 |
next / queueDepth | dismiss 响应里的下一条与剩余队列深度。 |
引擎侧完整能力(时长常量、ARGB 调和数学、空 Toast 语义)全部在上游 commonMain 里原样随编译进入 .so,JVM 侧保留 compileKotlinJvm 交叉验证出口,证明 ohos .so 里执行的模型逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | ToastDuration 与 blendARGB 原样编译不重写;桥的时长解析直接读库、配色直接调库,魔法数字不出现在 ArkTS。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);九个 Compose 文件迁入 src/composeMain 不进编译;composeApp / iosApp 上游示例留盘不进构建。 |
| 桥接稳定 | 使用 2 个 C ABI 和 UTF-8 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 库模块零第三方依赖(上游 commonMain 本就只依赖 Compose,隔离后归零),桥只用 Kotlin 标准库。 |
| UI 完整 | 单页覆盖全部桥接入口:消息输入、时长三档、图标五选、双宿主按钮、连发队列和状态回显。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
ToastHost/ToastContent仍留在composeMain等工具链对齐;ArkTS Demo 是把桥暴露的视觉参数按上游样式(28vp 圆角卡片、Material 调和配色、SLIDE/FADE 节奏)画出来。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与源码依赖边界、按「纯 Kotlin / Compose」给十个文件分类、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1、composeMain 隔离
第 3 阶段:桥协议建模 ── show/dismiss JSON 契约、串行队列语义、时长档位与调和配色
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 双宿主覆盖层、SLIDE/FADE 转场复刻、定时器与 next 交接
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
kmp-toast/src/commonMain/ 上游时长模型 + 配色调和内核(ToastDuration.kt、ToastColor.kt 的 blendARGB)
kmp-toast/src/composeMain/ 上游 Compose API ×10(ToastState/ToastHost/ToastContent/…,ohos 不编)
kmp-toast/src/androidMain|wasmJsMain 上游遗留平台源集,留盘不进编译
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson + HostQueue)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
composeApp/ iosApp/ 上游 CMP 示例工程,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :kmp-toast 与 :example:nativeApp,rootProject.name = "ohos_ktoast"。上游的 compose / AGP / vanniktech 发布 / dokka 插件与 composeApp 示例全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 写入 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 8.13 升级;2.2.21 线验证过的版本) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.ktoast |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
上游 kmp-toast/src/commonMain 十个文件,逐个过 import 表分类:
| 文件 | 依赖形态 | 处置 |
|---|---|---|
ToastDuration.kt | 零依赖纯 Kotlin | 留在 commonMain(ohos 编译) |
ToastColor.kt 的 blendARGB | 纯 Int 数学 | 留在 commonMain(ohos 编译) |
ToastColor.kt 的 Color.blend / harmonizeWithPrimary | compose.runtime + material3 | 拆出,迁 composeMain |
ToastState.kt | mutableStateOf + ImageVector + coroutines | 迁 composeMain |
ToastData.kt / ToastDataImpl.kt / ToastVisuals.kt / ToastVisualsImpl.kt | @Stable / ImageVector / CancellableContinuation | 迁 composeMain |
ToastHost.kt | AnimatedContent + LaunchedEffect | 迁 composeMain |
ToastContent.kt / ToastDefaults.kt / ToastExtension.kt | material3 Card / ContentTransform / 动画 | 迁 composeMain |
这一刀的关键在 ToastColor.kt:模型和 UI 扩展写在同一个文件里,必须手动拆开——blendARGB(ARGB 线性插值,ToastDefaults 配色调和的数学内核)下沉 commonMain,两个 @Composable 扩展上移 composeMain,同包名零 import 改动。九个整文件用 git mv 迁移保留历史,拆分文件单独成文。
ToastState 的语义值得单独记录:showToast 内部是 mutex.withLock { suspendCancellableCoroutine { currentToastData = ToastDataImpl(visuals, continuation) } }——挂起直到 ToastHost 的 LaunchedEffect 到时 dismiss()。也就是说同一宿主一次只显示一条,后续调用在 Mutex 上排队。这个语义是桥协议的设计依据(见 3.1)。
1.4 页面边界
标题区 KTOAST · OPENHARMONY(ktoast · OpenHarmony / CMP Toast 库 · Kotlin/Native ohosArm64/ohosX64)
消息区 Toast 消息输入框(默认 Hi this is a common toast)
时长区 Short 3500ms / Long 6500ms / 自定义 2000ms 三档芯片
图标区 无 / 成功 / 错误 / 信息 / 星标 五选芯片
操作区 Common TOAST(bottom)/ Center TOAST(center)/ 连发 3 条(队列) 三按钮
状态行 最近一次调用结果(如 show id=4 host=bottom 3500ms 队列=1)
覆盖层 底部宿主(上滑转场 + 20vp 边距)与居中宿主(缩放淡入)两个互不干扰的 Toast 层
页面点击按钮即调 ktoastShow,show:true 渲染并按 durationMs 起定时器,到时 ktoastDismiss 取 next 交接。
第 2 阶段:目标与依赖打通
2.1 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
社区已批准的 UI 类库钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze / Reorderable 用的同款)。ktoast 的桥只依赖 Kotlin 标准库(不用 coroutines、不用 serialization),没有"制品线元数据反推版本"的问题,直接落最稳的 2.2.21 线;Gradle 相应从上游 8.13 升到 8.14.1(2.2.21 线的验证版本)。JDK 钉 21 并写入 org.gradle.java.home。
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_ktoast"
include(":kmp-toast")
include(":example:nativeApp")
gradle/libs.versions.toml 收敛为一行插件:
[versions]
kotlin = "2.2.21-1.0.0"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
2.2 编译纯模型
kmp-toast/build.gradle.kts 从"android + ios + desktop + wasm + compose + 发布"收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
}
}
上游的 androidLibrary / composeMultiplatform / composeCompiler / maven-publish / dokka 插件全部剥离;src/androidMain(空 Manifest)与 src/wasmJsMain(demo 页面)留盘不进编译。实测:
.\gradlew.bat :kmp-toast:compileKotlinJvm # BUILD SUCCESSFUL
Compose API 隔离在 composeMain 之后,ohos 编译器看不到任何 androidx.compose 引用——commonMain 只剩 ToastDuration 与 blendARGB,一个外部依赖都没有。
2.3 源码集隔离的细节
composeMain 不是注册的源集,Gradle 不会编译它;文件留在仓库里有两个目的:对照上游 API、等社区 compose.ui / compose.animation ohos 制品与 CMP 对齐后一键迁回 commonMain。上游遗留的 composeApp / iosApp 示例同理——它们还引用着 toml 里已经不存在的插件别名,绝不能进 settings.gradle.kts。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.tarifchakder.ktoast/**(上游模型:ToastDuration + blendARGB,原样编译)
↓ 时长档位 / ARGB 调和数学
Bridge.kt(@CName × 2,HostQueue 串行状态机)
↓ UTF-8 JSON
N-API ktoastShow() / ktoastDismiss()
↓
ArkUI Index.ets(双宿主覆盖层)
请求与响应:
请求 : {"host":"bottom","message":"Hi this is a common toast","duration":"short","icon":""}
{"id":1} // dismiss
响应 : {"ok":true,"show":true,"id":1,"host":"bottom","message":"…","durationMs":3500,
"icon":"","containerColor":"#FF3D364B","contentColor":"#FFD9CFE6","queueDepth":0}
{"ok":true,"queued":true,"id":2,"host":"bottom","queueDepth":1}
{"ok":true,"id":1,"host":"bottom","next":{…展示字段…}|null,"queueDepth":0}
{"ok":false,"error":"…"}
三个设计要点:其一,show 与 queued 是互斥分支——宿主忙时入队返回 queued:true,对应上游 showToast 在 Mutex 上挂起,排队语义放在 Native 侧、ArkTS 不自己维护队列;其二,duration 用字符串统一承载三档(short / long / 毫秒数字符串),桥内 toLongOrNull 兜底,ArkTS 侧 JSON.stringify 怎么写都不踩坑;其三,dismiss 响应把下一条作为原生 JSON 对象嵌入 next 字段,ArkTS 一次 JSON.parse 直接拿到展示模型,不做双重转义。
3.2 时长与配色为什么必须调库
桥的时长解析直读上游常量:
private fun resolveDuration(raw: String, message: String): Long {
if (message.isBlank()) return EMPTY_TOAST_DURATION // 4500,对应 showEmpty
return when (raw.trim().lowercase()) {
"short" -> ToastDuration.Short.time // 3500,来自 commonMain
"long" -> ToastDuration.Long.time // 6500,来自 commonMain
"" -> ToastDuration.Short.time
else -> raw.trim().toLongOrNull()?.coerceIn(500, 30_000)
?: ToastDuration.Short.time
}
}
配色同理:ToastDefaults.color 在 Compose 侧是 MaterialTheme.colorScheme.inverseSurface.harmonizeWithPrimary(),而 harmonizeWithPrimary 的内核就是 commonMain 里的 blendARGB。桥持 Material3 基线色板常量(primary 0xFF6750A4 / inverseSurface 0xFF322F35 / inverseOnSurface 0xFFF5EFF7),调和交给库:
val container = blendARGB(INVERSE_SURFACE, PRIMARY, 0.2f) // → 0xFF3D364B
val content = blendARGB(INVERSE_ON_SURFACE, PRIMARY, 0.2f) // → 0xFFD9CFE6
结果是默认 Material 亮色 scheme 下,鸿蒙真机上的 Toast 配色与 Android 上 ToastDefaults 产出逐位一致——常量在桥里、算法在库里,职责分明且可对账。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("KtoastShow")
fun ktoastShow(request: String): String {
return try {
/* host 分发 + 时长解析 + 入队/展示 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
}
未知 host、非法 duration、dismiss 不在展示中的 id,全部走同一个 ok:false 通道;图标走白名单(success / error / info / star 之外的值一律按无图标处理),ArkTS 侧零校验也不会崩。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libktoast_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosktoast.so)
│ KtoastShow / KtoastDismiss
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只返回整数又会让 ArkTS 复制排队逻辑,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("KtoastShow")
fun ktoastShow(request: String): String // 展示 / 入队一口进
@CName("KtoastDismiss")
fun ktoastDismiss(request: String): String // 消退并交接下一条
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
KtoastShow | ktoastShow(req) | 时长解析(ToastDuration)+ 配色调和(blendARGB)+ 每宿主串行队列 |
KtoastDismiss | ktoastDismiss(req) | 到时/点击消退 + next 交接 + 队列深度 |
Native 侧状态机:queues = { "bottom": HostQueue, "center": HostQueue },每个 HostQueue 持 current + waiting 双端队列。ArkTS 主线程串行调用,无并发竞争;全局可变状态在 Kotlin/Native 新内存模型下直接可用。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"ktoastShow", nullptr, NapiKtoastShow, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
{"ktoastDismiss", nullptr, NapiKtoastDismiss, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosktoast_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosktoast_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与两个业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosktoast SHARED IMPORTED)
set_target_properties(ohosktoast PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(ktoast_napi SHARED napi_init.cpp)
target_link_libraries(ktoast_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosktoast)
OHOS_ARCH 为空或 libohosktoast.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const ktoastShow: (request: string) => string;
export const ktoastDismiss: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 调用与定时器
private requestShow(host: string, message: string): void {
const raw = ktoastShow(JSON.stringify({
host: host, message: message, duration: this.duration, icon: this.icon
}));
const resp = JSON.parse(raw) as ShowResp;
if (resp.ok !== true) { this.status = `失败: ${resp.error ?? ''}`; return; }
if (resp.show === true) { this.present(resp, host); return; } // 渲染 + setTimeout(durationMs)
if (resp.queued === true) { // Native 排队,本条不渲染
this.status = `queued id=${resp.id} 队列=${resp.queueDepth}`;
}
}
private onExpire(host: string, id: number): void {
const resp = JSON.parse(ktoastDismiss(JSON.stringify({ id: id }))) as DismissResp;
if (resp.next) { this.presentNext(resp.next, host); } // 顶上并重设定时器
else { animateTo(this.animFor(host), () => { this.clearHost(host); }); }
}
定时器纪律:重新计前 clearTimeout 旧句柄、aboutToDisappear 全局清理、点击卡片提前 dismiss 走同一条 onExpire 路径——三条一起保证「换条 / 销毁 / 手点」互不串台。
5.2 转场复刻
上游 ToastExtension.kt 的两套 ContentTransform 在 ArkTS transition 上按节奏复刻:
| 宿主 | Compose 上游 | ArkTS 复刻 |
|---|---|---|
| bottom(SLIDE) | fadeIn(300) + slideInVertically(500){ it/2 } + scaleIn(origin 底部) | OPACITY + translate({y:40}),500ms Curve.Friction,退场 250ms |
| center(FADE) | scaleIn(0.8, 300) + fadeIn(300) | OPACITY + scale({x:0.8, y:0.8}),300ms Curve.EaseInOut |
卡片样式对齐上游 ToastContent:Row 内边距水平 15 / 垂直 10、图标 fontSize 18 与文字间距 8、borderRadius 28(material3 extraLarge)、opacity 0.95、浅阴影;配色与文字色直接取响应里的 containerColor / contentColor——UI 只认参数,不写死颜色。
5.3 覆盖层与错误边界
根 Stack 上叠两个互不干扰的覆盖层(底部宿主再包一层 Bottom 对齐 Stack 并加 20vp 边距,居中宿主 Center 对齐),均设 HitTestMode.None 不挡页面交互;卡片本体可点(提前关闭)。ok:false 时错误原文进状态行,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosktoast)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/Bridge.kt @CName × 2 + HostQueue + MiniJson(继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.ktoast、versionName 1.0.5-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/libohosktoast_api.h
├── src/main/cpp/types/libktoast_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
.\gradlew.bat :kmp-toast:compileKotlinJvm # 交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 `
:example:nativeApp:linkDebugSharedOhosX64 # 双 ABI so 回填
cd example\harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填完整配置)。
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.ktoast -m entry
四、完整代码对照
4.1 整体架构
com.tarifchakder.ktoast/**(上游模型原样编译)
│ ToastDuration(3500/6500)/ blendARGB(调和数学内核)
kmp-toast/src/composeMain/**(上游 Compose API,ohos 不编)
│ ToastState(Mutex 挂起)/ ToastHost(AnimatedContent)/ ToastContent / ToastDefaults
Bridge.kt + MiniJson + HostQueue × 2
│ KtoastShow / KtoastDismiss(串行队列 + 配色计算)
libktoast_napi.so
│ ktoastShow / ktoastDismiss
Index.ets
│ JSON parse + animateTo + 定时器
▼
ArkUI 真机页面(双宿主 Toast 覆盖层)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_ktoast、composeApp 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 单插件钉版 |
build.gradle.kts(根) | 版本 1.0.5-ohos.1、全局禁用 Ohos*Test* 任务 |
kmp-toast/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,compose/发布插件剥离 |
kmp-toast/src/commonMain/ | ToastDuration.kt 原样 + ToastColor.kt(拆分后仅 blendARGB) |
kmp-toast/src/composeMain/ | 上游 Compose API ×10(9 个 git mv + 1 个拆分文件) |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 2 个 C ABI、HostQueue 串行队列、MiniJson、错误通道 |
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 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(20 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 模型 | ToastDuration.Short / Long | 3500 / 6500ms 时长档位(commonMain 原样编译) |
| 模型 | blendARGB(c1, c2, ratio) | ARGB 线性插值,配色调和的数学内核 |
| Compose | ToastState.showToast | Mutex 挂起排队(隔离在 composeMain,语义由桥复刻) |
| Compose | ToastHost / ToastContent / ToastDefaults | 宿主组件 / 卡片 / SLIDE·FADE 转场(待工具链接回) |
| Native | KtoastShow / KtoastDismiss | C ABI JSON 导出(串行队列 + 配色计算) |
| N-API | ktoastShow(req) / ktoastDismiss(req) | 向 ArkTS 暴露能力 |
| ArkTS | requestShow / onExpire | 触发展示、到时交接 |
4.4 ArkTS 与 Kotlin 的边界
const raw = ktoastShow(JSON.stringify({
host: 'bottom',
message: 'Hi this is a common toast',
duration: 'short', // 'short' | 'long' | '2000'(毫秒字符串)
icon: 'success'
}));
const res = JSON.parse(raw);
// res.show===true:渲染卡片(res.containerColor='#FF3D364B'),setTimeout(res.durationMs)
// res.queued===true:Native 排队中,等前一条 dismiss 时经 next 交接
val pending = Pending(++nextId, hostName, message, durationMs, icon)
val active = queue.current
if (active == null) {
queue.current = pending
showResponse(pending, queue.waiting.size) // {"ok":true,"show":true,…}
} else {
queue.waiting.addLast(pending)
queuedResponse(pending, queue.waiting.size) // {"ok":true,"queued":true,…}
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象、Compose 状态或未校验的动态结构。
五、关键决策说明
决策 1:模型下沉 + Compose 隔离,而不是整库编译
KStore 那批库能整库编译,是因为社区 Maven 出现了与上游同版本的 kotlinx ohos 制品线;ktoast 挡路的是 Compose 本身——社区 CMP 1.9.2 / HarmonyOS Kotlin 2.2.21 与上游 Compose 1.7.0 / Kotlin 2.0.20 互相够不着,AnimatedContent / ImageVector / material3 Card 没有 ohos 制品。强行走整库等于再打一套 Compose 发行版。所以走 qrose 已审路线:能编的(ToastDuration、blendARGB)下沉 commonMain 原样编译,不能编的隔离 composeMain 留档待接回。
决策 2:工具链落 2.2.21-1.0.0 线,Gradle 升 8.14.1
桥零第三方依赖,没有"依赖元数据反推 Kotlin 版本"的约束,直接落社区验证最充分的 2.2.21-1.0.0;该线配 Gradle 8.14.1,上游 8.13 升一格。UI 类库与存储类库的工具链经验要分开记:2.2.21 线配 8.14.1,2.4.20 线才留 9.x。
决策 3:串行队列放 Native 侧,ArkTS 不复制排队逻辑
ToastState 的本质是"每宿主一次一条、后续挂起排队"。如果桥做成无状态的请求-响应,ArkTS 就得自己维护队列与交接——等于把 CMP 状态机翻译一遍,必然漂移。桥内 HostQueue(current + waiting)把排队语义钉在 Kotlin 侧,ArkTS 只认 show / next 两个信号换画面,连 queueDepth 都由 Native 回显。
决策 4:时长直读库、配色直调库,魔法数字不出现在 ArkTS
3500 / 6500 / 4500 来自 ToastDuration 与 showEmpty 的上游常量,#FF3D364B / #FFD9CFE6 由 blendARGB 在 Native 现算——ArkTS 侧一个数字都不写死。这让"鸿蒙 Toast 和 Android 上一个节奏一个颜色"变成可对账的事实,而不是看起来像。
决策 5:根构建替换(cmp_qrose 模式),上游示例留盘
上游根构建(AGP / compose / 发布 / dokka)对鸿蒙没有保留价值,整个替换为极简 ohos 构建;composeApp / iosApp 是上游 CMP 示例,引用着已剥离的插件别名,留盘不进 settings.gradle.kts——它们的存在方式只有这一种。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin 21.0.12)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。
6.2 编译与符号验证
.\gradlew.bat :kmp-toast:compileKotlinJvm # BUILD SUCCESSFUL
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosX64 # BUILD SUCCESSFUL ×2
llvm-nm -D example\harmonyApp\entry\libs\x86_64\libohosktoast.so | findstr Ktoast
| 检查项 | 结果 |
|---|---|
:kmp-toast:compileKotlinJvm | BUILD SUCCESSFUL(composeMain 隔离后零 compose 引用) |
libohosktoast.so(arm64-v8a) | ≈2.4MB,随附同 triple libc++_shared.so(≈1.9MB) |
libohosktoast.so(x86_64) | ≈2.1MB,随附 libc++_shared.so(≈1.7MB) |
| ELF 符号 | KtoastShow / KtoastDismiss / libohosktoast_symbols 均为全局 T 符号 |
| CAdapter 链接警告 | api.cpp:450/465 非返回值警告(HarmonyOS Kotlin 生成胶水,社区已知,不影响产物) |
6.3 JVM 交叉验证
模型在 commonMain,JVM 与 ohos 跑同一份代码。对账三组值:
| 输入 | JVM 期望 | 桥返回 |
|---|---|---|
ToastDuration.Short.time | 3500 | durationMs: 3500 |
ToastDuration.Long.time | 6500 | durationMs: 6500 |
blendARGB(0xFF322F35, 0xFF6750A4, 0.2f) | 0xFF3D364B | containerColor: "#FF3D364B" |
blendARGB(0xFFF5EFF7, 0xFF6750A4, 0.2f) | 0xFFD9CFE6 | contentColor: "#FFD9CFE6" |
四个值逐位一致,"时长与配色没有漂移"成立——Demo 好看只证明 ArkTS 样式没写错,对得上 JVM 才证明模型没改坏。
6.4 功能用例(真机 arm64)
- 安装启动,页面标题、消息输入框、时长/图标芯片与三个操作按钮全部就位;
- 点 Common TOAST:底部滑出深色圆角卡片,3500ms 后下滑退场,状态行
show id=1 host=bottom 3500ms 队列=0; - 点 Center TOAST:居中缩放淡入(300ms),与底部宿主同时在场、互不干扰;
- 时长切 Long 6500ms / 自定义 2000ms:状态行毫秒值分别 6500 / 2000;清空消息再点:自动回落 4500(
showEmpty语义); - 图标切 成功/错误/信息/星标:SymbolGlyph 出现,颜色与文字同色;
- 点 连发 3 条(队列):状态行依次
show id=…、queued id=… 队列=1、queued id=… 队列=2,第一条消失瞬间第二条无缝顶上,串行排队成立; - 展示期间点卡片本体:立即退场并交接下一条;
hdc shell hilog | findstr Ktoast可见每次调用的字节日志。
6.5 验证结论
时长模型、配色调和内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,串行队列、双宿主转场、时长档位与 Material 调和配色全部按上游语义运行(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/ktoast.git
cd ktoast
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
.\gradlew.bat :kmp-toast:compileKotlinJvm # 模型交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example\harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.ktoast -m entry
hdc shell hilog | findstr Ktoast
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:kmp-toast真正编译出ohosArm64/ohosX64产物; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;commonMain编译报androidx.compose缺失就是 Compose 文件没挪干净——特别注意ToastColor.kt这种模型与@Composable扩展同文件的情况,必须手动拆分而不是整文件迁移;- 上游
composeApp/iosApp还引用 toml 里已剥离的插件别名,绝不能留在settings.gradle.kts,否则整个配置期失败; - ArkTS import KN so 拿不到
ktoastShow:HarmonyOS Kotlin 的 CAdapter 只导出 stdlib zip 类型,业务符号必须走 CMake NAPI 包装层; - dismiss 响应里
queueDepth是next的兄弟字段不是内嵌字段——Demo 首版写成next.queueDepth显示 undefined,交叉验收时抓出;契约字段层级必须在桥和页面两侧同时核对; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(libktoast_napi.so/libohosktoast.so/libc++_shared.so)缺一不可; - NEEDED 里出现 Windows 盘符就是 CMake 把 IMPORTED 绝对路径写进 RUNPATH——
IMPORTED_NO_SONAME+$ORIGIN双保险; hvigorw assembleHap用 DevEco 自带 JBR,系统 JDK 8 会让打包半路报不相干的错;- KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 根
.gitignore要追加example/harmonyApp/{oh_modules,.hvigor,entry/build,entry/libs,entry/.cxx}与*.so,否则 DevEco 打开工程后生成物混进提交; - 签名材料由 DevEco 自动写入
build-profile.json5,仓库内保持signingConfigs空数组,证书绝不进入 AtomGit。
8.2 已知问题
- 鸿蒙侧没有
ToastHostcomposable:Toast UI 由 ArkTS 按桥返回参数渲染,SLIDE/FADE 是 transition 节奏复刻,不是同一个动画引擎; toMillis(accessibilityManager)的无障碍时长调整没有鸿蒙对应物,桥按原始时长返回;- 图标是字符串 →
SymbolGlyph映射,不是 ComposeImageVector;扩图标要同时改桥的白名单和 ArkTS 映射表; ToastState的挂起返回值(showToastsuspend 到消失)不跨 C ABI,鸿蒙侧表现为入队 +next交接;- android / ios / desktop / wasm 目标剥离(源文件留在树里),发布、dokka 一并剥离,本 fork 不发 Maven Central;
composeMain里的 Compose API 等社区compose.ui/compose.animationohos 制品与 CMP 对齐后迁回commonMain,届时 KN 队列降级为 fallback。
8.3 未来优化方向
- 增加 Toast 展示/消失的 Native → ArkTS 事件推送(N-API 线程安全回表),替代 setTimeout 驱动;
- 把图标白名单升级为注册制(桥侧注册 symbol 名,ArkTS 侧自动发现);
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 补充真机(x86_64)验收矩阵与自动化截图脚本。
九、总结
9.1 核心难点回顾
上游 kmp-toast(commonMain ×10,全部依赖 Compose)
→ 源码依赖度盘点:纯 Kotlin 模型 ×2 / Compose API ×10 分类
→ 工具链对齐 HarmonyOS Kotlin 2.2.21-1.0.0(Gradle 8.14.1)
→ 根构建替换(cmp_qrose 模式)+ composeMain 隔离(git mv 保历史)
→ ohosArm64 / ohosX64 KLIB(ToastDuration + blendARGB)
→ C ABI(@CName × 2)+ HostQueue 串行状态机(复刻 Mutex 语义)
→ C++ N-API(DisposeString 纪律)
→ ArkTS 双宿主转场复刻 + 定时器/next 交接
→ ArkUI 真机验收(双宿主 / 时长三档 / 图标 / 连发队列)
9.2 封装层次
- 模型层:上游原版
ToastDuration(3500/6500)与blendARGB(调和数学内核); - 状态层:桥内
HostQueue复刻ToastState的每宿主串行排队与交接; - Native 层:生成双 ABI 动态库并通过 2 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、双宿主覆盖层、转场动画、定时器与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 类库的适配面按"依赖方向"切:ktoast 十个文件里只有两个是纯 Kotlin,但恰恰是这两个(时长、配色数学)定义了库的跨端契约——模型下沉 commonMain、UI 隔离 composeMain,边界画在依赖方向上而不是文件数量上;
- 有状态组件的桥必须有状态:条码、图表类库的桥是请求-响应,Toast 的本质是"一次一条、后续排队"——把队列状态机放 Native 侧,ArkTS 只认
show/next信号,排队逻辑才不会在两端各写一份; - 契约字段要两侧对账:
queueDepth的层级(next兄弟还是内嵌)这种细节,桥写对了页面照样能跑出 undefined——跨语言 JSON 契约必须逐字段核对,最好像 6.3 那样列对账表。
9.4 适配成果
当前适配已完成:
- ktoast
1.0.5时长模型与配色调和内核的双 ABI Kotlin/Native 编译(上游commonMain零重写,Compose APIgit mv隔离保历史); - 2 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 双宿主验收页面(常规/居中提示、时长三档、图标五选、连发队列、提前关闭、状态回显);
- JVM 交叉验证(时长与配色逐位对账)+ HAP 构建(双 ABI 三件套 so)、签名、设备安装和真机验证;
- AtomGit 项目文档、OpenHarmony README(中英双份、中文主文档 20 节)和真机效果图。
适配过程 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)