KMP/CMP 接入鸿蒙沉浸光感:相册操作菜单与确认弹窗实战
KMP/CMP 鸿蒙社区:CPF-KMP-CMP。
源码:Immersive Overlays 三方库 · 光感相册接入示例。
1. 前言
在相册里点一次“删除照片”,共享页面要等确认窗口返回,才能改动照片计数。用 Compose 画相册、用 ArkUI 显示带沉浸光感的系统浮层时,这个看似简单的动作要经过两次跨层调用。尤其是从菜单选择删除以后,菜单自己还会发出关闭通知。要是它和确认窗共用一个请求,关菜单就可能顺手把新弹窗也当成取消处理。
这篇把菜单、确认和结果回传做成 `Immersive Overlays`。它由 Kotlin Multiplatform 模块和鸿蒙侧 HAR 组成。Kotlin 负责请求、菜单选项和回调,HAR 负责绘制原生浮层。相册通过 Gradle 和 OHPM 引入这两部分,页面里只保留照片数量、收藏状态和按钮点击之后的业务选择。换到别的页面时,菜单文案和确认内容可以由接入方重新传入。
示例用 Compose Canvas 画一张晚霞图卡。海面、岸线和反光提供了几块明暗不同的背景,浮层盖上去时,颜色变化比纯色背景上更容易看见。删除只修改内存里的照片计数。接入真实相册时,共享层要维护照片列表和选中标识,等删除成功后再更新列表。
“打开系统菜单”和“请求删除确认”放在图卡底部。共享按钮沿用 Compose 的点击语义,增加半透明底、渐变描边和按压高光。系统菜单和确认弹窗另外使用 ArkUI 沉浸材质。下面先看这两层效果,再核对收藏、取消和确认有没有回到共享页面。
2. 运行效果
先看 MatePad Edge 上的效果。左侧入口打开收藏和删除菜单,右侧入口直接打开删除确认。确认窗盖在晚霞图卡下沿,浮层背后的冷暖色仍可分辨。玻璃按钮的颜色由 Compose 绘制,浮层材质由系统渲染。


MatePad Edge 相册依赖接入演示
菜单里的收藏会立即回传到共享页面。先打开确认窗点取消,计数仍为 3 张;再从菜单选择删除并确认,计数变为 2 张。页面下方可以核对最近一次回传和有效回传次数。


模拟器
API 26 平板模拟器也跑完了同一组操作。打开系统菜单收藏后,计数仍是 3 张。直接打开确认窗并取消,页面仍保留这 3 张。从菜单发起删除时,点击菜单项只打开确认窗,等点下确认按钮才变成 2 张。视频结尾的有效回传计数为 4,可以和日志中的四个动作对照。

API 26 模拟器相册依赖接入演示
两端使用同一份材质配置。对照设备画面时,可以分别看面板上沿的背景色和按钮文字的对比度。系统材质等级、组件下方的背景和设备的显示设置都会影响观感,移植到自己的页面时应在目标设备上检查这两项。
3. 环境与目录
编辑器用于查看源码,编译、原生链接和打包使用 HarmonyOS Command Line Tools。复现时先核对命令行工具和 SDK 的版本,再按表中的 Kotlin、Compose、Skiko 和 OHPM 组件版本安装依赖。真机和模拟器的系统版本以各自的“关于本机”为准。
工程声明的最低兼容版本是 HarmonyOS 6.0.0(API 20)。编译版本、目标版本和本文的沉浸效果验证均为 API 26。华为的启用指南要求应用的目标版本至少为 26.0.0。应用通过运行时检查决定是否加载材质实现,菜单与确认窗口分别配置系统材质。开启沉浸光感
框架依赖对应 Kotlin 鸿蒙分支 和 Compose 鸿蒙分支。
|
项目 |
本例使用值 |
|
编辑器 |
DevEco Studio 6.1.1.300 |
|
实际构建工具 |
HarmonyOS Command Line Tools 26.0.0.865 |
|
SDK |
26.0.0.105 Release,compile 和 target 为 26.0.0,compatible 为 6.0.0(20) |
|
真机 |
HUAWEI MatePad Edge(QXS-W00),HarmonyOS 7.0.0,软件版本 7.0.0.111(SP8C00E111R6P5patch13),API 26 |
|
模拟器 |
HarmonyOS Emulator 26.0.0.402,MatePad Pro 13 平板配置,2880×1920 |
|
模拟器镜像 |
HarmonyOS 7.0.0,API 26,软件版本 7.0.0.107(SP8DEVC00E16R1P1) |
|
Gradle / JDK / Node |
8.9 / 21 / 24.14.1 |
|
Kotlin / CMP |
2.2.21-1.2.0 / 1.9.2-1.2.0 |
|
Skiko / OHPM 组件 |
0.9.22.2-1.2.0 / @cpf-kmp-cmp/compose 1.9.2-1.2.0 |
|
渲染与架构 |
Skia;ohosArm64、ohosX64 |




总仓库将库和相册放在两个目录。`libraries/immersive-overlays/kotlin` 提供 Kotlin 调用接口,可以单独编译鸿蒙 ARM64 和 X64 的 KLIB。`libraries/immersive-overlays/ohos` 是 HAR 模块,入口文件只导出宿主需要的类型与组件。相册目录是实际接入方,准备工程时会把两套源码放进各自的构建路径。
构建产生的动态库和头文件需要成套复制。它们共同描述导出符号,不能只把新的动态库放过去,却继续使用上一次生成的头文件。本例同时保留模拟器与 ARM64 的输出位置;两种架构目录各自对应自己的产物,不能只改文件夹名称来复用同一份二进制。
composeApp/
src/commonMain/kotlin/com/example/cmp_hello/App.kt
src/ohosMain/kotlin/com/example/cmp_hello/AlbumBridge.kt
src/ohosMain/kotlin/com/example/cmp_hello/MainArkUIViewController.kt
example/ohos/
entry/src/main/ets/pages/Index.ets
immersive_overlays/Index.ets
immersive_overlays/src/main/ets/OverlayHost.ets
immersive_overlays/src/main/ets/Material26.ets
entry/src/main/cpp/napi_init.cpp
entry/src/main/cpp/CMakeLists.txt
entry/src/main/cpp/types/libentry/Index.d.ts
entry/libs/arm64-v8a/libkn.so
entry/libs/x86_64/libkn.so
4. 共享页面
每个页面创建一个 `ImmersiveOverlays` 实例,平台桥接就绪时调用 `attach`。`showMenu` 接收选项编号和文案,`confirm` 接收标题、正文和两个按钮的标签。公开入口只用 Kotlin 类型。宿主将选中、确认或关闭等操作转换成 `OverlayResult` 传给 `deliver`,后者返回布尔值,表示该结果是否被当前请求接收。
库的每个实例只允许一个未完成请求,打开浮层前分配递增编号。未连接宿主或已有请求时,调用返回空值。`deliver` 先核对编号,再检查结果能否用于该请求。确认结果只能交给确认窗,菜单结果的选项编号必须出现在发出时的列表里。传入的选项也会复制一份,使宿主显示的内容与库用来校验的内容保持一致。
收到有效结果后,库先清掉待处理项,再通知相册。这个顺序允许相册在菜单回调里立即发起确认。如果等回调执行完才清状态,回调发起的新请求就会被误判成已有请求。取消、关闭和发送失败都能结束当前请求,接入方根据结果决定提示和后续操作。页面退出时调用 `detach`,未完成请求随宿主解绑而失效。
相册的业务状态由 `AlbumProbe` 保存。选中 `favorite` 时修改收藏标记,选中 `delete` 时只发起确认。收到 `Confirmed` 后才扣减数量。库里没有相册标题、删除业务和照片计数,接入方可以换成其他菜单选项,确认弹窗也能用于别的业务。先看库的完整控制器,再看相册怎么调用。
package io.github.aasd23.immersiveoverlays
data class OverlayOption(val id: String, val label: String)
sealed interface OverlayRequest {
val id: Long
data class Menu(override val id: Long, val options: List<OverlayOption>) : OverlayRequest
data class Confirmation(
override val id: Long,
val title: String,
val message: String,
val confirmLabel: String,
val cancelLabel: String
) : OverlayRequest
}
sealed interface OverlayResult {
data class Selected(val optionId: String) : OverlayResult
data object Confirmed : OverlayResult
data object Cancelled : OverlayResult
data object Dismissed : OverlayResult
data object Failed : OverlayResult
}
/** One controller per host page. Call it and deliver its results on the UI thread. */
class ImmersiveOverlays {
private data class Pending(val request: OverlayRequest, val complete: (Long, OverlayResult) -> Unit)
private var serial = 0L
private var pending: Pending? = null
private var presenter: ((OverlayRequest) -> Unit)? = null
val pendingId: Long? get() = pending?.request?.id
fun attach(presenter: (OverlayRequest) -> Unit) {
check(this.presenter == null) { "Detach the current host before attaching another" }
this.presenter = presenter
}
fun detach() {
presenter = null
pending = null
}
fun showMenu(options: List<OverlayOption>, complete: (Long, OverlayResult) -> Unit): Long? {
require(options.isNotEmpty() && options.all { it.id.isNotBlank() && it.label.isNotBlank() })
require(options.map { it.id }.toSet().size == options.size) { "Menu option IDs must be unique" }
return launch(complete) { OverlayRequest.Menu(it, options.toList()) }
}
fun confirm(
title: String,
message: String,
confirmLabel: String,
cancelLabel: String,
complete: (Long, OverlayResult) -> Unit
): Long? {
require(title.isNotBlank() && confirmLabel.isNotBlank() && cancelLabel.isNotBlank())
return launch(complete) { OverlayRequest.Confirmation(it, title, message, confirmLabel, cancelLabel) }
}
fun deliver(id: Long, result: OverlayResult): Boolean {
val current = pending ?: return false
if (current.request.id != id) return false
val valid = when (result) {
is OverlayResult.Selected -> (current.request as? OverlayRequest.Menu)?.options?.any { it.id == result.optionId } == true
OverlayResult.Confirmed -> current.request is OverlayRequest.Confirmation
OverlayResult.Cancelled, OverlayResult.Dismissed, OverlayResult.Failed -> true
}
if (!valid) return false
// Release before the callback so a menu choice can request a confirmation immediately.
pending = null
current.complete(id, result)
return true
}
private fun launch(complete: (Long, OverlayResult) -> Unit, make: (Long) -> OverlayRequest): Long? {
val host = presenter ?: return null
if (pending != null) return null
val request = make(++serial)
pending = Pending(request, complete)
try {
host(request)
} catch (error: Exception) {
if (pendingId != request.id) throw error
deliver(request.id, OverlayResult.Failed)
}
return request.id
}
}
package com.example.cmp_hello
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.aasd23.immersiveoverlays.ImmersiveOverlays
import io.github.aasd23.immersiveoverlays.OverlayOption
import io.github.aasd23.immersiveoverlays.OverlayResult
/** 共享 Kotlin 维护业务和界面;ArkTS 只显示原生系统菜单、确认弹窗。 */
object AlbumProbe {
private val overlays = ImmersiveOverlays()
private var dispatch: ((String) -> Unit)? = null
var apiLevel by mutableStateOf(0)
var materialSupported by mutableStateOf(false)
var favorite by mutableStateOf(false)
var photoCount by mutableStateOf(3)
var nativeResults by mutableStateOf(0)
var rejectedResults by mutableStateOf(0)
var status by mutableStateOf("等待系统宿主连接")
var lastResult by mutableStateOf("尚未收到原生返回")
fun connect(sender: ((String) -> Unit)?) {
overlays.detach()
dispatch = sender
if (sender != null) overlays.attach { sender(it.toWire()) }
}
fun configure(api: Int, supported: Boolean) {
apiLevel = api
materialSupported = supported
status = if (api >= 26 && supported) "系统支持沉浸材质" else "兼容模式:普通系统菜单与弹窗"
publishState()
}
fun request(kind: String) {
if (overlays.pendingId != null) { status = "请先结束当前系统操作"; return }
if (photoCount == 0) { status = "示例照片已删除,可先恢复示例"; return }
if (dispatch == null) { status = "系统桥接尚未就绪"; return }
status = if (kind == "menu") "已请求 ArkUI 系统菜单" else "等待 ArkUI 系统确认"
if (kind == "menu") {
overlays.showMenu(listOf(OverlayOption("favorite", if (favorite) "取消收藏" else "收藏照片"),
OverlayOption("delete", "删除照片"), OverlayOption("cancel", "取消")), ::complete)
} else {
overlays.confirm("删除这张照片?", "将从示例相册中移除“海边的晚霞”。", "确认删除", "取消", ::complete)
}
}
fun submit(json: String) {
val id = Regex("\\\"requestId\\\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toLongOrNull()
val action = Regex("\\\"action\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"").find(json)?.groupValues?.get(1)
val result = when (action) {
"favorite", "delete" -> OverlayResult.Selected(action)
"confirmDelete" -> OverlayResult.Confirmed
"cancel" -> OverlayResult.Cancelled
"dismiss" -> OverlayResult.Dismissed
"error" -> OverlayResult.Failed
else -> null
}
if (id == null || result == null || !overlays.deliver(id, result)) {
rejectedResults++; lastResult = "忽略过期或重复结果"; publishState(); return
}
}
private fun complete(id: Long, result: OverlayResult) {
val action = when (result) {
is OverlayResult.Selected -> result.optionId
OverlayResult.Confirmed -> "confirmDelete"
OverlayResult.Cancelled -> "cancel"
OverlayResult.Dismissed -> "dismiss"
OverlayResult.Failed -> "error"
}
nativeResults++
lastResult = "原生返回 #$id:$action"
when (action) {
"favorite" -> { favorite = !favorite; status = if (favorite) "已收藏,结果已回到 CMP" else "已取消收藏,结果已回到 CMP" }
"delete" -> { request("confirm"); return }
"confirmDelete" -> { photoCount--; favorite = false; status = "确认完成:共享状态中的照片数已更新" }
"cancel", "dismiss" -> status = "操作已取消,照片保持不变"
else -> status = "系统操作未完成:$action"
}
publishState()
}
fun reset() {
if (overlays.pendingId != null) return
photoCount = 3; favorite = false; status = "示例已恢复"; publishState()
}
fun publishState() {
dispatch?.invoke("{\"kind\":\"state\",\"api\":$apiLevel,\"materialSupported\":$materialSupported,\"favorite\":$favorite,\"photoCount\":$photoCount,\"nativeResults\":$nativeResults,\"rejectedResults\":$rejectedResults,\"pendingId\":${overlays.pendingId ?: 0}}")
}
fun dispose() { connect(null) }
}
@Composable
internal fun App() {
MaterialTheme(colorScheme = darkColorScheme(primary = Color(0xFF98EEE2))) {
Column(Modifier.fillMaxSize().background(Color(0xFF101822)).safeContentPadding()
.verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("光感相册", fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color(0xFFF4F8FC))
Text("光落在海面,也落在操作栏上", color = Color(0xFFB3C2D0), fontSize = 14.sp)
Box(Modifier.fillMaxWidth().height(320.dp).clip(RoundedCornerShape(26.dp))) {
DuskScene(Modifier.matchParentSize())
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth()
.background(Brush.verticalGradient(listOf(Color.Transparent, Color(0xD90D1C29))))
.padding(horizontal = 18.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("海边的晚霞", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("照片 ${AlbumProbe.photoCount} 张 · ${if (AlbumProbe.favorite) "已收藏" else "未收藏"}",
color = Color(0xFFF1F6F8), fontSize = 14.sp)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GlassAction("打开系统菜单", Color(0xFF8FE6DE), Modifier.weight(1f)) { AlbumProbe.request("menu") }
GlassAction("请求删除确认", Color(0xFFFFC7AC), Modifier.weight(1f)) { AlbumProbe.request("confirm") }
}
}
}
Surface(shape = RoundedCornerShape(18.dp), color = Color(0xFF1A2835), contentColor = Color(0xFFE4EDF5)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("桥接状态", fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text("系统 API:${AlbumProbe.apiLevel}")
Text(if (AlbumProbe.apiLevel >= 26 && AlbumProbe.materialSupported) "材质能力:系统支持" else "材质能力:普通系统组件兼容路径")
Text(AlbumProbe.status)
Text(AlbumProbe.lastResult)
Text("有效回传 ${AlbumProbe.nativeResults} 次 · 拒绝过期 ${AlbumProbe.rejectedResults} 次")
}
}
TextButton(onClick = { AlbumProbe.reset() }) { Text("恢复示例") }
Text("共享页面由 Compose 绘制,菜单与弹窗由 ArkUI 显示。", fontSize = 12.sp, color = Color(0xFFA6B7C8))
}
}
}
@Composable
private fun GlassAction(label: String, accent: Color, modifier: Modifier, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val highlight by animateFloatAsState(if (pressed) 0.50f else 0.28f)
val scale by animateFloatAsState(if (pressed) 0.98f else 1f)
val shape = RoundedCornerShape(18.dp)
Box(modifier.heightIn(min = 50.dp).graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape).background(Color(0xBF142735))
.background(Brush.verticalGradient(listOf(accent.copy(alpha = highlight), Color.Transparent,
accent.copy(alpha = highlight / 3))))
.border(1.dp, Brush.linearGradient(listOf(accent.copy(alpha = .85f), Color.White.copy(alpha = .18f),
accent.copy(alpha = .34f))), shape)
.clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
.padding(horizontal = 8.dp, vertical = 10.dp), contentAlignment = Alignment.Center) {
Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun DuskScene(modifier: Modifier) {
Canvas(modifier) {
val w = size.width
val h = size.height
drawRect(Brush.verticalGradient(listOf(Color(0xFF30345F), Color(0xFF845373), Color(0xFFF39B79),
Color(0xFFF9BB91), Color(0xFF185B73), Color(0xFF102A3A)), endY = h))
val sun = Offset(w * .68f, h * .48f)
drawCircle(Brush.radialGradient(listOf(Color(0xCFFFE0A9), Color(0x5BFFB986), Color.Transparent),
center = sun, radius = h * .42f), radius = h * .42f, center = sun)
drawCircle(Color(0xFFFFE5BD), h * .077f, sun)
val coast = Path().apply {
moveTo(0f, h * .43f)
cubicTo(w * .17f, h * .46f, w * .24f, h * .61f, w * .38f, h * .57f)
cubicTo(w * .62f, h * .52f, w * .71f, h * .62f, w, h * .51f)
lineTo(w, h * .64f)
cubicTo(w * .60f, h * .69f, w * .25f, h * .60f, 0f, h * .72f)
close()
}
drawPath(coast, Brush.horizontalGradient(listOf(Color(0xFF183751), Color(0xD0524964), Color(0xEC193951))))
for (i in 0..9) {
val y = h * (.635f + i * .023f)
val reach = w * (.018f + i * .006f)
drawLine(Color(0xFFFFC695).copy(alpha = .43f - i * .031f),
Offset(sun.x - reach, y), Offset(sun.x + reach, y), strokeWidth = 1.6.dp.toPx())
}
}
}

玻璃按钮的高光来自 Compose 绘制。每个按钮先画半透明底色,再叠一层沿垂直方向变淡的高光,最后用渐变描边勾出轮廓。按下后通过同一个交互源抬高高光透明度,并轻微收缩到原尺寸的 98%。保留按钮语义和至少 50 dp 的点击高度,点击回调仍然走 AlbumProbe.request。

背景也需要留出适合观察的位置。示例用画布宽高的比例定位太阳和岸线,因此从真机换到模拟器,太阳仍在画面右侧,反光仍落在暖色按钮附近。按钮区域有一层由浅到深的遮罩,亮部可以露在上沿,文字下方仍有深色底。把半透明底全部拿掉时,白字会和晚霞的浅色区域抢对比,继续增加发光反而会让按钮更难读。
两个入口的按压反馈用同一个 GlassAction 实现,只传入标签、强调色和回调。这里由交互源提供按下状态,松手或手势取消时会恢复。按钮也保留了可点击的语义,自动化脚本能按按钮名称操作它们,页面布局改变以后无需重新写坐标。
相册通过 `OverlayWire.kt` 把类型化请求编码成 JSON,再从原生回传中提取请求号和固定动作。请求编码处理引号、反斜杠及控制字符,结果解析只用于示例的固定动作。库的公开接口只处理类型化请求和结果,接入方可以保留自己的桥接方式,也可以在跨语言边界换成更完整的序列化实现。
另外,恢复示例只恢复数量和收藏状态,不倒退请求序号。否则一个迟到结果可能碰巧与恢复后的新请求编号相同。界面恢复与消息身份是两件事,分别管理会比“一键把所有字段清零”更可靠。
5. 原生菜单与弹窗
鸿蒙侧 HAR 从 `Index.ets` 导出 `ImmersiveOverlayHost`、`OverlayMenuAnchor` 和几个输入类型。相册从 OHPM 包名导入这些接口。菜单项由库按传入的列表构造,锚点的位置由页面决定。确认窗由 `ImmersiveOverlayHost.confirm` 打开,页面负责传入当前窗口的 `UIContext`。普通菜单和弹窗也由 HAR 提供,API 26 材质不可用时可以沿用同一套调用。
初始化先读取支持情况,再读取应用材质状态,并记录系统材质等级。三者回答的问题不同:系统是否具有这项能力,当前应用是否禁用了它,以及系统选择什么等级的效果。程序没有把等级当成越大越好的分数,也没有在日志中写“开启成功”就跳过实际画面核对。
菜单在 bindMenu 的选项中设置 THICK,让列表文字下面保留较厚的底色。确认窗在 showAlertDialog 中设置 THIN,并给材质层加一层低透明度的暖色。两个浮层分别配置冷绿和暖橙的触摸反馈色。交互形变由系统处理,实际观感也受设备和系统材质设置影响。
确认按钮单独用暖红色底和浅色字,和旁边的取消操作拉开距离。两枚 Compose 按钮的位置固定在图卡底部,原生菜单的锚点放在左侧入口附近。这里把锚点设在横屏示例的固定比例位置。页面若要支持任意窗口尺寸,可以从共享层传递触发组件的实际位置,再由宿主把坐标换算到原生浮层所在的容器。
模块中的运行版本检查仍然保留。构建工具能识别接口,只能说明编译这一步具备条件;运行时必须由系统提供对应实现。宿主先检查版本,再动态导入新模块,模块内部还使用可用性分支保护具体调用。没有满足条件时回到普通组件路径,这条路径不能被描述为新材质已经生效。
export { ImmersiveOverlayHost, OverlayMenuAnchor } from './src/main/ets/OverlayHost';
export { OverlayConfirmation, OverlayMaterialState, OverlayMenuItem } from './src/main/ets/OverlayTypes';
export interface OverlayMenuItem {
id: string;
label: string;
}
export interface OverlayConfirmation {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
}
export interface OverlayMaterialState {
supported: boolean;
enabled: boolean;
level: number;
state: number;
}
export interface OverlayMaterial extends OverlayMaterialState {
menu(options: MenuOptions): void;
dialog(options: AlertDialogParamWithButtons): void;
}
import { deviceInfo } from '@kit.BasicServicesKit';
import { UIContext } from '@kit.ArkUI';
import { OverlayConfirmation, OverlayMaterial, OverlayMaterialState, OverlayMenuItem } from './OverlayTypes';
export class ImmersiveOverlayHost {
private material: OverlayMaterial | undefined = undefined;
async prepare(): Promise<OverlayMaterialState> {
if (deviceInfo.sdkApiVersion >= 26) {
try {
const materialModule = await import('./Material26');
this.material = materialModule.createMaterial();
} catch (_) {
this.material = undefined;
}
}
const value = this.material;
return value ? { supported: value.supported, enabled: value.enabled, level: value.level, state: value.state }
: { supported: false, enabled: false, level: -1, state: -1 };
}
menuOptions(onDisappear: () => void): MenuOptions {
const options: MenuOptions = { placement: Placement.BottomRight, onDisappear: onDisappear };
this.material?.menu(options);
return options;
}
confirm(context: UIContext, input: OverlayConfirmation, complete: (confirmed: boolean) => void): void {
let finished = false;
const finish = (confirmed: boolean): void => {
if (finished) return;
finished = true;
complete(confirmed);
};
const options: AlertDialogParamWithButtons = {
title: input.title,
message: input.message,
autoCancel: true,
primaryButton: { value: input.cancelLabel, action: () => finish(false) },
secondaryButton: { value: input.confirmLabel, style: DialogButtonStyle.HIGHLIGHT,
backgroundColor: '#8F4638', fontColor: '#FFF6ED', action: () => finish(true) },
cancel: () => finish(false)
};
this.material?.dialog(options);
context.showAlertDialog(options);
}
}
@Component
export struct OverlayMenuAnchor {
@Prop shown: boolean = false;
@Prop items: OverlayMenuItem[] = [];
@Prop options: MenuOptions = { placement: Placement.BottomRight };
onSelect: (id: string) => void = (_: string): void => {};
@Builder
private content() {
Menu() {
ForEach(this.items, (item: OverlayMenuItem) => {
MenuItem({ content: item.label }).onClick(() => this.onSelect(item.id))
}, (item: OverlayMenuItem): string => item.id)
}
}
build() {
Text(' ').width(1).height(1).bindMenu(this.shown, this.content, this.options)
}
}
import { uiMaterial } from '@kit.ArkUI';
import { deviceInfo } from '@kit.BasicServicesKit';
import { OverlayMaterial } from './OverlayTypes';
class Material26 implements OverlayMaterial {
readonly supported: boolean = false;
readonly enabled: boolean = false;
readonly level: number = -1;
readonly state: number = -1;
constructor() {
if (deviceInfo.apiAvailable('26.0.0')) {
this.supported = uiMaterial.isImmersiveMaterialSupported();
this.state = uiMaterial.getMaterialInfo().state;
this.enabled = this.supported && this.state !== uiMaterial.MaterialState.DISABLE;
this.level = this.supported ? uiMaterial.getGlobalMaterialLevel() : -1;
}
}
menu(options: MenuOptions): void {
if (deviceInfo.apiAvailable('26.0.0') && this.enabled) {
options.systemMaterial = new uiMaterial.ImmersiveMaterial({ style: uiMaterial.ImmersiveStyle.THICK,
interactive: true, lightEffect: { color: '#FFB8F3E9' } });
}
}
dialog(options: AlertDialogParamWithButtons): void {
if (deviceInfo.apiAvailable('26.0.0') && this.enabled) {
options.systemMaterial = new uiMaterial.ImmersiveMaterial({ style: uiMaterial.ImmersiveStyle.THIN,
materialColor: '#24FFD2B0', interactive: true, lightEffect: { color: '#FFFFDFCB' } });
}
}
}
export function createMaterial(): OverlayMaterial {
return new Material26();
}

真正承载 CMP 的是 Index 页面。它先创建控制器,再注册消息回调,随后将系统信息送给共享层。异步材质检测完成以后再次配置能力状态,因此启动时短暂出现等待或普通路径提示,并不表示页面要重新创建。共享状态收到新配置后会自行更新。
菜单需要一个原生锚点,例子在共享页面上方放了一个很小的原生节点。它只用于定位系统浮层,用户真正点击的两个按钮仍由 Compose 绘制。实际产品里可以让共享层随请求传入按钮位置,再在平台层统一换算坐标;这个例子固定锚点,重点验证组件调用和消息往返,不把它扩展成完整的跨渲染坐标系统。
关闭回调有一个容易忽略的细节。菜单点击删除后,新确认请求可能已经发出,而旧菜单随后才触发消失通知。菜单锚点为每轮菜单保存原来的请求编号,消失时用这个编号提交关闭结果。共享层只接收与当前请求匹配的编号,旧菜单的消失就不会取消新弹出的确认窗口。
import { ArkUIViewController, Compose } from '@cpf-kmp-cmp/compose';
import { ImmersiveOverlayHost, OverlayConfirmation, OverlayMenuAnchor, OverlayMenuItem } from '@aasd23/immersive-overlays';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import nativeApi from 'libentry.so';
interface OverlayMessage extends OverlayConfirmation {
kind: string;
requestId: number;
options: OverlayMenuItem[];
}
@Entry
@Component
struct Index {
private controller: ArkUIViewController | undefined = undefined;
private host = new ImmersiveOverlayHost();
private menuId: number = 0;
private completed: number = 0;
private disposed: boolean = false;
@State private menuVisible: boolean = false;
@State private menuItems: OverlayMenuItem[] = [];
@State private menuOptions: MenuOptions = { placement: Placement.BottomRight };
@State private errorMessage: string = '';
aboutToAppear(): void {
try {
this.controller = nativeApi.MainArkUIViewController();
nativeApi.setDispatcher((json: string) => this.receive(json));
nativeApi.configure(deviceInfo.sdkApiVersion, false);
this.prepareHost();
hilog.info(0xA126, 'ImmersiveProbe', 'host_ready api=%{public}d library=immersive-overlays/0.1.0', deviceInfo.sdkApiVersion);
} catch (error) {
this.errorMessage = `CMP 初始化失败:${JSON.stringify(error)}`;
}
}
private async prepareHost(): Promise<void> {
const material = await this.host.prepare();
if (this.disposed) return;
nativeApi.configure(deviceInfo.sdkApiVersion, material.enabled);
hilog.info(0xA126, 'ImmersiveProbe',
'material_probe api=%{public}d supported=%{public}s enabled=%{public}s level=%{public}d state=%{public}d',
deviceInfo.sdkApiVersion, `${material.supported}`, `${material.enabled}`, material.level, material.state);
}
private receive(json: string): void {
if (this.disposed) return;
let id = 0;
try {
const request = JSON.parse(json) as OverlayMessage;
if (request.kind === 'state') {
hilog.info(0xA126, 'ImmersiveProbe', 'event %{public}s', json);
return;
}
id = request.requestId;
if (!Number.isSafeInteger(id) || id <= this.completed) return;
if (request.kind === 'menu') {
const menuRequestId = id;
this.menuId = menuRequestId;
this.menuItems = request.options;
this.menuOptions = this.host.menuOptions(() => this.reply(menuRequestId, 'dismiss'));
this.menuVisible = true;
} else if (request.kind === 'confirm') {
const dialogRequestId = id;
this.host.confirm(this.getUIContext(), request, confirmed => {
this.reply(dialogRequestId, confirmed ? 'confirmDelete' : 'cancel');
});
} else {
this.reply(id, 'error');
}
} catch (error) {
hilog.error(0xA126, 'ImmersiveProbe', 'request_failed %{public}s', JSON.stringify(error));
if (id > 0) this.reply(id, 'error');
}
}
private reply(id: number, action: string): void {
if (this.disposed || id <= this.completed) return;
this.completed = id;
if (this.menuId === id) {
this.menuVisible = false;
this.menuId = 0;
}
nativeApi.submitResult(JSON.stringify({ requestId: id, action: action }));
}
build() {
Stack() {
if (this.controller) {
Compose({ controller: this.controller, libraryName: 'entry', onBackPressed: () => this.controller!.onBackPress() })
.width('100%').height('100%')
} else {
Text(this.errorMessage).fontSize(18).padding(24)
}
// 接入方决定锚点位置,菜单构造由 HAR 的组件完成。
OverlayMenuAnchor({ shown: this.menuVisible, items: this.menuItems, options: this.menuOptions,
onSelect: id => this.reply(this.menuId, id) }).position({ x: '48%', y: '44%' })
}.width('100%').height('100%')
}
onPageHide(): void { this.controller?.onPageHide(); }
onPageShow(): void { this.controller?.onPageShow(); }
onBackPressed(): boolean { return this.controller?.onBackPress() ?? false; }
aboutToDisappear(): void { this.disposed = true; nativeApi.disposeBridge(); }
}

6. 双向桥接
共享界面不是由原生页面套一张图片得到的。入口使用 ComposeArkUIViewController,先初始化主线程处理器,再创建承载 App 的控制器。鸿蒙页面将控制器交给配套的 Compose 组件,系统菜单则走另一条原生路径。这两条路径在同一个宿主中协作,渲染职责没有混在一起。
Kotlin 向平台发消息时,通过导出的 C 函数保存一个回调入口。字符串只在临时作用域里有效,不能把指针直接保存到异步队列后再去读取。本例在 C++ 的 Dispatch 中立即复制成独立字符串,然后通过线程安全函数排到 ArkTS 回调。这个复制动作解决的是内存生命周期,线程安全函数解决的是调用线程,它们处理的是两个不同的问题。
回传从 ArkTS 调用 submitResult,经过 NAPI 将字符串传回 Kotlin,再解析为请求编号和操作。照片标识留在相册业务层,共享库只处理浮层请求和用户选择。平台层在发送前拦截已经完成的编号,共享层再核对正在等待的编号,避免一次选择因为多个关闭通知而重复生效。
释放过程也需要沿着这条链看。页面退出时调用 disposeBridge,先撤销 Kotlin 保存的回调,再释放线程安全函数。C++ 对回调句柄的读取和释放使用互斥保护,已经交给队列的消息由接收函数负责释放。不能只把页面上的一个回调变量设为空,就认为底层的句柄和待处理字符串都会自动消失。
示例只保存一个待处理请求。多个页面同时发起系统操作时,需要按页面实例保存桥接状态。页面销毁时通过 `detach` 清除待处理项。如果业务还需要等待超时,接入方可以维护自己的计时器,超时后用当前请求编号提交 `Failed`,让迟到的结果失效。
package com.example.cmp_hello
import androidx.compose.ui.window.ComposeArkUIViewController
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.initMainHandler
import kotlin.experimental.ExperimentalNativeApi
import platform.ArkTS.ArkTS_Napi_NativeModule.napi_env
import platform.ArkTS.ArkTS_Napi_NativeModule.napi_value
@OptIn(ExperimentalNativeApi::class, ExperimentalForeignApi::class)
@CName("MainArkUIViewController")
fun MainArkUIViewController(env: napi_env): napi_value {
initMainHandler(env)
return ComposeArkUIViewController(env) {
App()
}
}
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlin.experimental.ExperimentalNativeApi::class)
package com.example.cmp_hello
import kotlinx.cinterop.*
private var nativeDispatcher: CPointer<CFunction<(CPointer<ByteVar>?) -> Unit>>? = null
@CName("AlbumSetDispatcher")
fun albumSetDispatcher(callback: CPointer<CFunction<(CPointer<ByteVar>?) -> Unit>>?) {
nativeDispatcher = callback
AlbumProbe.connect(if (callback == null) null else { message ->
memScoped { nativeDispatcher?.invoke(message.cstr.getPointer(this)) }
})
}
@CName("AlbumConfigure")
fun albumConfigure(apiLevel: Int, materialSupported: Int) { AlbumProbe.configure(apiLevel, materialSupported != 0) }
@CName("AlbumSubmitResult")
fun albumSubmitResult(json: CPointer<ByteVar>?) { json?.toKString()?.let(AlbumProbe::submit) }
@CName("AlbumDispose")
fun albumDispose() { nativeDispatcher = null; AlbumProbe.dispose() }
package com.example.cmp_hello
import io.github.aasd23.immersiveoverlays.OverlayRequest
/** Transport for the demo's N-API boundary. The library accepts typed requests. */
internal fun OverlayRequest.toWire(): String = when (this) {
is OverlayRequest.Menu -> {
val items = options.joinToString(",") { "{\"id\":${it.id.json()},\"label\":${it.label.json()}}" }
"{\"kind\":\"menu\",\"requestId\":$id,\"options\":[$items]}"
}
is OverlayRequest.Confirmation -> "{\"kind\":\"confirm\",\"requestId\":$id,\"title\":${title.json()}," +
"\"message\":${message.json()},\"confirmLabel\":${confirmLabel.json()},\"cancelLabel\":${cancelLabel.json()}}"
}
private fun String.json(): String = buildString {
append('"')
for (char in this@json) when (char) {
'"', '\\' -> { append('\\'); append(char) }
in '\u0000'..'\u001F' -> append("\\u" + char.code.toString(16).padStart(4, '0'))
else -> append(char)
}
append('"')
}
#include "libkn_api.h"
#include "napi/native_api.h"
#include "hilog/log.h"
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace {
constexpr unsigned int DOMAIN = 0xA126;
constexpr const char* TAG = "ImmersiveProbe";
std::mutex callbackMutex;
napi_threadsafe_function dispatcher = nullptr;
void CallJs(napi_env env, napi_value callback, void*, void* data) {
std::unique_ptr<std::string> message(static_cast<std::string*>(data));
if (env == nullptr || callback == nullptr || !message) return;
napi_value arg, receiver, result;
napi_create_string_utf8(env, message->c_str(), message->size(), &arg);
napi_get_undefined(env, &receiver);
if (napi_call_function(env, receiver, callback, 1, &arg, &result) != napi_ok) {
bool pending = false;
napi_is_exception_pending(env, &pending);
if (pending) napi_get_and_clear_last_exception(env, &result);
OH_LOG_Print(LOG_APP, LOG_ERROR, DOMAIN, TAG, "ArkTS callback failed");
}
}
void Dispatch(const char* json) {
napi_threadsafe_function current;
{
std::lock_guard<std::mutex> lock(callbackMutex);
current = dispatcher;
if (!current || napi_acquire_threadsafe_function(current) != napi_ok) return;
}
auto* message = new std::string(json ? json : "{}");
if (napi_call_threadsafe_function(current, message, napi_tsfn_nonblocking) != napi_ok) delete message;
napi_release_threadsafe_function(current, napi_tsfn_release);
}
void ReleaseDispatcher() {
std::lock_guard<std::mutex> lock(callbackMutex);
if (dispatcher) {
napi_release_threadsafe_function(dispatcher, napi_tsfn_abort);
dispatcher = nullptr;
}
}
napi_value JsController(napi_env env, napi_callback_info) {
return reinterpret_cast<napi_value>(MainArkUIViewController(env));
}
napi_value JsSetDispatcher(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1], name, result;
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
napi_valuetype type = napi_undefined;
if (argc) napi_typeof(env, args[0], &type);
if (type != napi_function) {
napi_throw_type_error(env, nullptr, "setDispatcher requires a function");
return nullptr;
}
AlbumSetDispatcher(nullptr);
ReleaseDispatcher();
napi_create_string_utf8(env, TAG, NAPI_AUTO_LENGTH, &name);
napi_threadsafe_function created = nullptr;
napi_status status = napi_create_threadsafe_function(env, args[0], nullptr, name, 64, 1,
nullptr, nullptr, nullptr, CallJs, &created);
if (status != napi_ok) {
napi_throw_error(env, nullptr, "Cannot create native callback bridge");
return nullptr;
}
{
std::lock_guard<std::mutex> lock(callbackMutex);
dispatcher = created;
}
AlbumSetDispatcher(reinterpret_cast<void*>(Dispatch));
napi_get_undefined(env, &result);
return result;
}
napi_value JsConfigure(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2], result;
int32_t api = 0;
bool supported = false;
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc == 2) {
napi_get_value_int32(env, args[0], &api);
napi_get_value_bool(env, args[1], &supported);
}
AlbumConfigure(api, supported ? 1 : 0);
napi_get_undefined(env, &result);
return result;
}
napi_value JsSubmit(napi_env env, napi_callback_info info) {
size_t argc = 1, length = 0;
napi_value args[1], result;
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc == 0 || napi_get_value_string_utf8(env, args[0], nullptr, 0, &length) != napi_ok || length > 8192) {
napi_throw_type_error(env, nullptr, "submitResult requires a JSON string of at most 8192 bytes");
return nullptr;
}
std::vector<char> buffer(length + 1);
napi_get_value_string_utf8(env, args[0], buffer.data(), buffer.size(), &length);
OH_LOG_Print(LOG_APP, LOG_INFO, DOMAIN, TAG, "reply %{public}s", buffer.data());
AlbumSubmitResult(buffer.data());
napi_get_undefined(env, &result);
return result;
}
napi_value JsDispose(napi_env env, napi_callback_info) {
AlbumDispose();
ReleaseDispatcher();
napi_value result;
napi_get_undefined(env, &result);
return result;
}
napi_value Init(napi_env env, napi_value exports) {
androidx_compose_ui_arkui_init(env, exports);
napi_property_descriptor properties[] = {
{"MainArkUIViewController", nullptr, JsController, nullptr, nullptr, nullptr, napi_default, nullptr},
{"setDispatcher", nullptr, JsSetDispatcher, nullptr, nullptr, nullptr, napi_default, nullptr},
{"configure", nullptr, JsConfigure, nullptr, nullptr, nullptr, napi_default, nullptr},
{"submitResult", nullptr, JsSubmit, nullptr, nullptr, nullptr, napi_default, nullptr},
{"disposeBridge", nullptr, JsDispose, nullptr, nullptr, nullptr, napi_default, nullptr}
};
napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties);
return exports;
}
}
static napi_module module = {1, 0, nullptr, Init, "entry", nullptr, {0}};
extern "C" __attribute__((constructor)) void RegisterEntryModule() { napi_module_register(&module); }
import { ArkUIViewController } from '@cpf-kmp-cmp/compose/src/main/cpp/types/libcompose_arkui_utils';
export const MainArkUIViewController: () => ArkUIViewController;
export const setDispatcher: (callback: (json: string) => void) => void;
export const configure: (apiLevel: number, materialSupported: boolean) => void;
export const submitResult: (json: string) => void;
export const disposeBridge: () => void;

NAPI 参数声明要与实际导出保持一致。共享控制器不是任意数字,回调也不是可以随时跨线程直接调用的普通函数。本例使用生成的 libkn_api.h 和配套类型声明连接两边,没有手写控制器对象的内存布局。升级工具链以后应重新生成、复制并编译,不能沿用“上一个版本能链接”的结论。
下面的 CMake 按目标架构选择动态库和头文件,并链接配套的 Skia 桥接库。工程采用的是自渲染配置,不应同时打开另一套渲染后端参数。原生组件能够显示,并不意味着可以随意混用不同渲染模式的二进制依赖。
# the minimum version of CMake.
cmake_minimum_required(VERSION 3.5.0)
project(harmonyApp)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
set(MODULE_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
if(DEFINED PACKAGE_FIND_FILE)
include(${PACKAGE_FIND_FILE})
endif()
# 按 ABI 选 libkn.so / libkn_api.h(须与 libs/<abi>/libkn.so 来自同一次 K/N link)
if(CMAKE_OHOS_ARCH_ABI STREQUAL "x86_64" OR OHOS_ARCH STREQUAL "x86_64")
set(LIBKN_ABI_DIR "x86_64")
else()
set(LIBKN_ABI_DIR "arm64-v8a")
endif()
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include
${NATIVERENDER_ROOT_PATH}/include/${LIBKN_ABI_DIR})
set(COMPOSE_MODULE_PATH ${MODULE_ROOT_PATH}/oh_modules/@cpf-kmp-cmp/compose)
list(APPEND CMAKE_PREFIX_PATH ${COMPOSE_MODULE_PATH})
find_package(compose REQUIRED CONFIG)
add_library(entry SHARED napi_init.cpp)
# Link against ComposeApp shared library to resolve exported symbols
#add_library(ComposeApp SHARED IMPORTED)
target_link_libraries(entry PUBLIC libace_napi.z.so)
target_link_libraries(entry PUBLIC libhilog_ndk.z.so)
target_link_libraries(entry PUBLIC libdeviceinfo_ndk.z.so)
target_link_libraries(entry PUBLIC librawfile.z.so)
#target_link_libraries(entry PUBLIC libicu.so)
target_link_libraries(entry PUBLIC ${NATIVERENDER_ROOT_PATH}/../../../libs/${LIBKN_ABI_DIR}/libkn.so)
# 先链接 libkn.so,再链接 compose里面的skikobridge
target_link_libraries(entry PUBLIC compose::skikobridge)
target_link_libraries(entry PUBLIC ${EGL-lib} ${GLES-lib} ${hilog-lib} ${libace-lib} ${libnapi-lib} ${libuv-lib} libc++_shared.so)
7. 编译运行
从总仓库生成工程后,可以分别构建 Kotlin 库、HAR 和示例。相册的 `commonMain` 通过 `implementation(project(":immersive-overlays"))` 引入 Kotlin 控制器。Gradle 会按相同目标架构解析库产物。库也支持将 ARM64 和 X64 的 KLIB、POM 及多平台元数据输出到工作区的 Maven 目录,供其他工程配置依赖坐标。本文的相册使用 Gradle 项目依赖,便于读者顺着类型定义调试。
鸿蒙侧可以从源码目录安装,也可以把独立产出的 HAR 作为文件依赖。为了验证打包后的边界,本次双端运行用的是第二种方式。构建 HAR 后,把相册 `entry/oh-package.json5` 的依赖指向实际 `.har` 文件,再执行 OHPM 安装并检查锁文件。构建只含 entry 的 HAP 时,菜单和确认窗由已安装的 HAR 提供。
[versions]
kotlin = "2.2.21-1.2.0"
composeMultiplatform = "1.9.2-1.2.0"
kotlinx-coroutines = "1.10.2-0.4.0"
atomicFu = "0.31.0-0.4.0"
[libraries]
compose-multiplatform-export = { module = "org.jetbrains.compose.export:export", version.ref = "composeMultiplatform" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
atomicFu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicFu" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
rootProject.name = "cmp_hello"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
// mavenLocal()
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
// mavenLocal()
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
}
}
include(":composeApp")
include(":immersive-overlays")
plugins {
kotlin("multiplatform")
`maven-publish`
}
group = "io.github.aasd23"
version = "0.1.0"
kotlin {
ohosArm64()
ohosX64()
sourceSets.commonTest.dependencies { implementation(kotlin("test")) }
}
publishing.repositories.maven {
name = "BuildLocal"
url = layout.buildDirectory.dir("repository").get().asFile.toURI()
}
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
// 共享界面分别生成平板 ARM64 与模拟器 X64 动态库。
listOf(ohosArm64(), ohosX64()).forEach { target ->
target.binaries.sharedLib {
baseName = "kn"
if (buildType == NativeBuildType.RELEASE) {
optimized = false
}
export(libs.compose.multiplatform.export)
linkerOpts("-lz")
val backend = rootProject.findProperty("rendererBackend")?.toString() ?: "skia"
if (backend == "fusion-renderer") {
linkerOpts(
"-lnative_drawing", "-limage_source", "-lpixelmap", "-lpixelmap_ndk.z",
"-lnative_window", "-lace_napi.z", "-lhilog_ndk.z", "-lhitrace_ndk.z",
"-luv", "-lunwind", "-licu"
)
}
}
target.compilations.getByName("main") {
val resource by cinterops.creating {
defFile(file("src/ohosMain/cinterop/resource.def"))
includeDirs(file("src/ohosMain/cinterop/include"))
}
}
}
sourceSets {
commonMain.dependencies {
implementation(project(":immersive-overlays"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.atomicFu)
}
val ohosMain by creating {
dependsOn(commonMain.get())
dependencies {
api(libs.compose.multiplatform.export)
}
}
getByName("ohosArm64Main").dependsOn(ohosMain)
getByName("ohosX64Main").dependsOn(ohosMain)
}
}
// 成套复制动态库与头文件,鸿蒙宿主不依赖 Android 或 iOS SDK。
arrayOf("debug", "release").forEach { type ->
val capitalized = type.replaceFirstChar { it.uppercase() }
tasks.register<Copy>("publish${capitalized}BinariesToHarmonyApp") {
group = "harmony"
dependsOn("link${capitalized}SharedOhosArm64", "link${capitalized}SharedOhosX64")
duplicatesStrategy = DuplicatesStrategy.INCLUDE
into(rootProject.file("example/ohos"))
from("build/bin/ohosArm64/${type}Shared/libkn_api.h") {
into("entry/src/main/cpp/include/arm64-v8a/")
}
from("build/bin/ohosArm64/${type}Shared/libkn.so") {
into("entry/libs/arm64-v8a/")
}
from("build/bin/ohosX64/${type}Shared/libkn_api.h") {
into("entry/src/main/cpp/include/x86_64/")
}
from("build/bin/ohosX64/${type}Shared/libkn.so") {
into("entry/libs/x86_64/")
}
val resourcePackage = "${rootProject.name}.${project.name.lowercase()}.generated.resources"
from("src/commonMain/composeResources") {
into("entry/src/main/resources/rawfile/composeResources/$resourcePackage/")
}
}
}
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
#Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
kotlin.native.cacheKind=none
org.gradle.internal.http.connectionTimeout=100000
org.gradle.internal.http.socketTimeout=100000
# 渲染模式
# rendererBackend=fusion-renderer
rendererBackend=skia
ohosSkikoVersion=0.9.22.2-1.2.0
共享模块发布完成后,在 example/ohos 安装 OHPM 依赖。宿主的主工程配置明确区分目标版本和最低兼容版本;新材质要求看的是目标配置与运行系统的共同条件,不是把最低版本也一律抬到相同数值。最低兼容配置只表示工程保留相应保护路径,不能据此宣传新接口在所有旧系统都有效。
entry 的原生选项同时包含两种 ABI。平板安装 ARM64 调试包,模拟器使用 X64。准备真机包时,调试签名要与应用包名匹配,安装后再核对设备 API 和实际浮层。
{
"app": {
"signingConfigs": [],
"products": [
{
"name": "default",
"compatibleSdkVersion": "6.0.0(20)",
"targetSdkVersion": "26.0.0",
"compileSdkVersion": "26.0.0",
"runtimeOS": "HarmonyOS",
"buildOption": {
"nativeCompiler": "BiSheng",
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
],
"buildModeSet": [
{
"name": "debug",
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
},
{
"name": "immersive_overlays",
"srcPath": "./immersive_overlays"
}
]
}
{
"apiType": "stageMode",
"buildOption": {
"resOptions": {
"copyCodeResource": {
"enable": false
}
},
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
//"arguments": "-DOHOS_ENABLE_HWASAN=ON",
//"arguments": "-DOH0S_ENABLE_ASAN=ON",
"cppFlags": "",
"abiFilters": ["arm64-v8a", "x86_64"]
},
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": false,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
"nativeLib": {
"debugSymbol": {
"strip": true,
"exclude": []
}
}
},
],
"targets": [
{
"name": "default"
},
{
"name": "ohosTest",
}
]
}
{
"name": "entry",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "",
"author": "",
"license": "",
"dependencies": {
"@aasd23/immersive-overlays": "file:../packages/immersive-overlays.har",
"libentry.so": "file:./src/main/cpp/types/libentry",
"@cpf-kmp-cmp/compose": "1.9.2-1.2.0"
}
}

{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"phone", "tablet", "2in1"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"orientation": "auto_rotation",
"skills": [
{
"entities": [
"entity.system.home"
],
"actions": [
"ohos.want.action.home"
]
}
]
}
],
"extensionAbilities": [
{
"name": "EntryBackupAbility",
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
"type": "backup",
"exported": false,
"metadata": [
{
"name": "ohos.extension.backup",
"resource": "$profile:backup_config"
}
],
}
]
}
}
相册示例不访问设备照片,不请求相册权限,也不需要为了显示菜单申请额外的业务权限。导入真实图片、读取文件和执行持久化删除属于后续业务扩展,应按各自接口另行处理授权与失败情况,不能因为这个演示包能运行,就认为相册数据访问权限也已经具备。
命令从 AtomGit 总仓库根目录开始。准备脚本取固定版本的 CPF-KMP-CMP 脚手架,在 .work 目录中生成可构建工程。第一次运行需要下载工具链和依赖。下方先安装项目依赖、构建 HAR,再切换到已打包的文件依赖。选择 API 26 模拟器时,先用 HDC 核对它的 API,再安装未签名的示例包。真机应使用对应应用包名的调试签名。
python examples/01-immersive-album/prepare.py
cd .work/01-immersive-album
.\gradlew.bat :immersive-overlays:publishAllPublicationsToBuildLocalRepository
.\gradlew.bat :composeApp:publishDebugBinariesToHarmonyApp
cd example/ohos
ohpm install
cd entry
ohpm install
cd ..
hvigorw.bat --mode module -p product=default -p module=immersive_overlays@default assembleHar
mkdir packages
copy immersive_overlays\build\default\outputs\default\immersive_overlays.har packages\immersive-overlays.har
把 entry/oh-package.json5 中 @aasd23/immersive-overlays 的值改为 file:../packages/immersive-overlays.har,保存后继续下面的命令。手动验证依赖时,可以在 entry/oh-package-lock.json5 中确认解析路径指向该 HAR。
cd entry
ohpm install
cd ..
hvigorw.bat --mode module -p product=default -p module=entry@default assembleHap
hdc list targets
hdc -t EMULATOR_TARGET shell param get const.ohos.apiversion
hdc -t EMULATOR_TARGET install entry/build/default/outputs/default/entry-default-unsigned.hap
hdc -t EMULATOR_TARGET shell aa start -a EntryAbility -b com.aasd23.immersivealbum
8. 问题处理
菜单出现但材质不明显时,先核对实际运行版本、目标版本和能力日志,再观察组件范围及应用是否禁用材质。不要先叠加一层自绘模糊去“补效果”。自绘模糊可以是设计方案,但它不能证明系统材质接口已经接通,也会让后面的排障失去依据。
共享界面没有出现时,应先检查控制器、库名称、生成的共享库和资源路径。菜单属于原生宿主,它能弹出并不能证明共享页面已经正确加载。反过来,Compose 页面显示正常,也不能证明原生菜单回传成功。这两个现象要分别检查,再用请求编号把它们串起来。
取消以后数量变化,通常不是颜色或动画的问题,而是业务更新时机出了错。例子把数量修改集中在 confirmDelete 分支,菜单删除只发起确认。取消按钮和系统取消都返回取消语义,返回键关闭菜单则返回 dismiss。它们在界面上都表现为“什么也不删除”,日志中的动作仍然保留区别,便于定位不同关闭来源。
从菜单进入确认时,可以在日志里找两条记录。选中删除的菜单动作是 #3,确认窗回传的是 #4。宿主创建菜单选项时,把 #3 捕获到关闭回调。即使关闭通知比 #4 来得晚,回调仍会回传 #3,宿主和库可以分别判断它是否过期。确认窗的两个按钮和系统取消入口也共用同一个完成回调,重复触发不会再次执行确认结果。
日志也不能随便照搬系统组件的域值。应用日志有自己的取值范围,原型中曾因为域值越界而看不到预期记录,现已使用范围内的值。排查时要同时确认日志参数是否合法、标签是否一致、筛选范围是否正确。下面从真机 HiLog 对照材质能力、确认回传和更新后的计数。
11-06 18:36:33.938 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: host_ready api=26 library=immersive-overlays/0.1.0
11-06 18:36:33.940 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: material_probe api=26 supported=true enabled=true level=0 state=0
11-06 18:36:39.652 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: reply {"requestId":1,"action":"favorite"}
11-06 18:36:43.768 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: reply {"requestId":2,"action":"cancel"}
11-06 18:36:48.091 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: reply {"requestId":3,"action":"delete"}
11-06 18:36:50.171 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: reply {"requestId":4,"action":"confirmDelete"}
11-06 18:36:50.174 28932 28932 I A0A126/com.aasd23.immersivealbum/ImmersiveProbe: event {"kind":"state","api":26,"materialSupported":true,"favorite":false,"photoCount":2,"nativeResults":4,"rejectedResults":0,"pendingId":0}

真机和模拟器各完成 8 项检查,覆盖首屏、菜单显示、收藏返回、直接打开确认窗、取消、从菜单再次打开确认窗和确认删除。页面上最后显示 2 张照片和 4 次有效回传。回传次数对应收藏、取消、从菜单选择删除和最终确认,可以和日志里的请求编号逐一对上。
接入真实业务时,删除还应增加数据层成功与失败的处理。原生确认仅表示用户同意,不代表文件已经删除成功。共享层可以先进入处理中状态,等待仓库操作完成后再更新列表;失败时保留条目并显示原因。如果菜单打开期间列表发生变化,也应以请求中的照片标识重新查找对象,不能只按旧下标操作。
材质等级则是另一项检查。日志可以确认系统支持和应用开关,具体颜色还取决于浮层背后的内容。把示例移到背景接近纯白的页面时,可以先找一块带颜色的区域对照,再决定弹窗放在哪里。
9. 总结
这次交付有两份库产物和一个消费示例。Kotlin 库把操作变成带编号的请求和类型化回调,鸿蒙 HAR 管理原生菜单、弹窗和材质。相册只决定菜单有哪些选项、确认后做什么。把这套做法换到其他页面时,可以沿用两个库,修改文案、锚点和业务回调即可。
这张相册页让按钮跟随共享界面绘制,用按压时的高光交代触摸反馈;菜单和弹窗则用系统材质跟随下方画面。交互可以核对两个结果:取消后保留 3 张,确认后变为 2 张。菜单关闭和新弹窗各用自己的请求编号,关掉旧菜单时也能保留正在等待的确认请求。
如果把这一结构迁入现有项目,建议先保留普通原生组件路径,让请求与回传走通,再启用系统材质并核对支持状态。随后替换示例数据仓库、补齐序列化和业务失败处理。这样新增的系统能力会落在明确的平台边界内,后续维护不必重新拆开整张业务页面。
更多推荐



所有评论(0)