Kotlin Multiplatform 三方库 miuix 的 OpenHarmony 适配实战(五模块源码收编 147 文件 + skiko actual 复用 + Compose 1.12→1.9 降级 + AGSL shader 路线回退)

示例工程:cmp-miuix-demo(本仓库 miuix-lib/ 源码库 + composeApp/ CMP 模块 + harmonyApp/ DevEco 工程)

库版本:miuix(源码收编,上游 Compose Multiplatform 1.12.2 / Kotlin 2.4.21 主干)|验证环境:Kotlin 2.2.21-0.4.0(ovCompose OpenHarmony 定制工具链)|Compose Multiplatform 1.9.2-0.4.0|JDK 21.0.12|DevEco Studio 26.0.0|DevEco 模拟器 x86_64|OpenHarmony API 26

miuix 是 Compose Multiplatform 生态里最受欢迎的第三方 UI 组件库之一(1.5k stars,至今每周仍有提交),提供一套 MIUI / HyperOS 视觉风格的完整组件:Button / Card / Switch / Slider / TextField / SuperDialog / PullToRefresh / Scaffold / NavigationBar,外加 squircle 平滑圆角、Monet 动态取色、颜色规格 2025 等基础设施。官方支持 Android / iOS / Desktop / Web(wasmJs),没有发布 OpenHarmony 切片。
在这里插入图片描述

这个库的适配和之前所有库都不一样,难点不在「补平台代码」(ohosMain 只写了 7 个文件),而在两个库之间的版本鸿沟:上游源码基于 Compose Multiplatform 1.12.2 / Kotlin 2.4.21 编写,而 OpenHarmony 定制工具链(ovCompose)是 1.9.2-0.4.0 / 2.2.21-0.4.0——中间隔了三个 minor 版本,WindowInfo、DialogProperties、PointerEventType 等基础 API 的形态都变了。本文记录把五个源码模块整体收编、逐项抚平版本差异、再在模拟器上跑通四个 Tab 组件展示的全过程。

CMP Miuix 首屏 *先睹为快:DevEco Studio 26.0.0 + 模拟器实测。左侧工程结构,右侧应用安装完成(用时 2 s 339 ms),基础组件 Tab 已上屏*

一、先看清楚:miuix 的源码结构决定适配策略

先拆 miuix 的仓库。它是多模块工程,各模块对平台的依赖面差异很大:

上游模块内容对平台的依赖
miuix-ui核心 UI 组件(33 个 basic 组件 + 4 overlay + 9 theme 等)Compose + 少量 expect/actual
miuix-coreUtils(platform() 枚举、dp 扩展)expect/actual:Platform 枚举
miuix-squircle平滑圆角(SDF 距离场 + 贝塞尔 Path 双路线)AGSL/SkSL RuntimeShader(native)
miuix-shaderAGSL RuntimeShader 抽象层org.jetbrains.skia.RuntimeEffect
miuix-preference偏好设置组件(依赖 miuix-ui)纯 Compose
androidx.navigationevent预测性返回(Google 库,非 miuix 自有)Compose 1.12 系统库

关键观察有三个:

  1. skiko 源集的 actual 全是纯 Compose 代码。miuix 有几个 expect 声明(DynamicColors、ProvideDisplayCutoutFix、WindowNavigationEventScope),actual 放在 skikoMain(iOS/Desktop 共用),实现里只调 Compose 公开 API、不碰 Skia C 互操作。OpenHarmony 的 CMP 同样基于 Skiko 渲染——这些文件原封不动搬进 ohosMain 就能用。
  2. 唯一真正的 Skia 依赖在 shader 路线:squircle 的高性能路线用 org.jetbrains.skia.RuntimeEffect 编译 SkSL 片段着色器,而 OpenHarmony 定制版 Compose 没有暴露这个 API。
  3. androidx.navigationevent 在 OpenHarmony 上不存在,而 miuix 的 Dialog / BottomSheet 靠它做「预测性返回」逐帧动画。

策略由此确定:五个模块源码全部收编进一个 miuix-lib,skikoMain 的 actual 直接复用,shader 路线降级到 Path,navigationevent 自写 API 等价兼容层。

主题调色 ## 二、工程结构
cmp-miuix-demo/
├── miuix-lib/                     # 独立 KMP 库模块,收编五个上游模块 + 兼容层
│   ├── build.gradle.kts           # ohosArm64/ohosX64 + androidTarget
│   └── src/
│       ├── commonMain/kotlin/     # 140 个 .kt
│       │   ├── top/yukonga/miuix/ # 94 个(miuix-ui + core + squircle + shader)
│       │   ├── com/materialkolor/ # 42 个(Monet 动态取色 MCU 实现)
│       │   ├── androidx/navigationevent/  # 3 个(预测性返回兼容层)
│       │   └── ohos/cmp/miuix/compat/     # 1 个(WindowInfo 兼容函数)
│       └── ohosMain/kotlin/       # 7 个 actual(见第三节)
├── composeApp/                    # CMP 应用模块,四 Tab 组件展示 Demo
│   └── src/commonMain/kotlin/com/example/cmp_pure/App.kt
└── harmonyApp/                    # DevEco 工程
    └── entry/                     # ArkTS 入口,NAPI 加载 libkn.so

miuix-lib 与 composeApp 的关系沿用 markdown 渲染库适配时验证过的结论:源码放独立模块、以 klib 依赖引用,避免 147 个 public 声明进入 composeApp 的 C API 导出面(源码并入 composeApp 会在 K/N 链接期触发 CAdapterCodegen NPE,这个坑在上一篇文章里展开过,此处不重复)。

三、适配过程:五个关键点

3.1 五模块收编,ohosMain 只写 7 个文件

把 miuix-ui、miuix-core、miuix-squircle、miuix-shader、materialkolor 的 MCU 实现整体拷进 commonMain,然后补 ohosMain。整个适配真正手写的平台实现只有 7 个文件:4 个是从上游 skikoMain 直接复用(改包名即可编译),3 个是本次新写的降级实现:

miuix-lib/src/ohosMain/kotlin/top/yukonga/miuix/kmp/
├── shader/RuntimeShader.ohos.kt              # 【新写】shader 路线降级(见 3.3)
├── squircle/RenderEffect.skiko.kt            # 【复用】no-op,squircle 走 Path 路线
├── color/DynamicColors.skiko.kt              # 【复用】纯 Compose 实现
├── theme/ProvideDisplayCutoutFix.skiko.kt    # 【复用】纯 Compose 实现
├── window/WindowNavigationEventScope.skiko.kt# 【复用】纯 Compose 实现
├── utils/Utils.ohos.kt                       # 【新写】Platform 枚举补 Ohos 分支
├── window/AlphaImageBitmap.ohos.kt           # 【新写】位图 alpha 通道读取
└── squircle/…(BakedSquircleSdf 上游漏提交,源码补齐)

skikoMain 的 actual 能直接复用的原因值得展开:这些文件的实现只用了 LocalDensity、LocalView、CompositionLocalProvider 等纯 Compose 公开 API,没有任何 org.jetbrains.skia.* 的 C 互操作。OpenHarmony 的 CMP 与 iOS/Desktop 共享同一套 Skiko 渲染栈,所以这层「平台差异」在 OpenHarmony 上根本不存在——文件拷进 ohosMain,包名对齐,编译通过。

先看那 4 个复用文件到底写了什么——短到几乎不需要解释:

// ① theme/DynamicColors.skiko.kt —— 动态取色:直接复用 miuix 自己的 Monet 实现
@Composable
actual fun platformDynamicColors(dark: Boolean): Colors = monetSystemColors(dark)

// ② theme/ProvideDisplayCutoutFix.skiko.kt —— 挖孔屏适配:
//    OpenHarmony 的安全区由 ArkUI 侧管理,Compose 根容器无需再补 padding
@Composable
actual fun ProvideDisplayCutoutFix() {}

// ③ utils/WindowNavigationEventScope.skiko.kt —— 窗口级返回事件作用域:
//    miuix 的弹层都挂在宿主窗口内部,不存在跨窗口重绑,直接透传
@Composable
actual fun WindowNavigationEventScope(content: @Composable () -> Unit) {
    content()
}

// ④ shader/RenderEffect.skiko.kt —— 模糊 / 滤镜类 RenderEffect:OpenHarmony 侧可用
actual fun isRenderEffectSupported(): Boolean = true

四个文件加起来不到 15 行有效代码,全篇没有任何 org.jetbrains.skia.*。这就是「skiko 源集能复用」的实证。

模块本身的 Gradle 配置和上一篇文章的 markdown-lib 是同一套思路——独立模块 + klib 依赖,把 147 个 public 声明挡在 composeApp 的 C API 导出面之外:

// miuix-lib/build.gradle.kts
kotlin {
    androidTarget { /* jvmTarget 11 */ }

    ohosArm64()   // 真机 arm64
    ohosX64()     // 模拟器 / 开发机 x64

    // miuix 内部用了 @OptIn / 实验性 Compose API;native 侧显式开启 expect/actual 类支持
    targets.withType<KotlinNativeTarget>().configureEach {
        compilations.configureEach {
            compilerOptions.options.freeCompilerArgs.add("-Xexpect-actual-classes")
        }
    }

    sourceSets {
        commonMain.dependencies {
            // 对外暴露的 Compose API 面(MiuixTheme / Button / Slider 出现在 public 签名中)
            api(compose.runtime); api(compose.foundation); api(compose.animation)
            api(compose.ui); api(compose.material3)
            implementation(libs.atomicFu)
        }
        // OHOS 共享源码集(对应目录 src/ohosMain/)
        val ohosMain = create("ohosMain").apply { dependsOn(commonMain.get()) }
        val ohosArm64Main by getting { dependsOn(ohosMain) }
        val ohosX64Main by getting { dependsOn(ohosMain) }
    }
}

唯一要动的是 miuix-core 的 Platform 枚举:上游只有 Android / IOS / Desktop / Web,没有 Ohos 值。OpenHarmony 行为最接近 Desktop(鼠标+触摸混合输入),复用 Desktop 分支即可,不需要新增枚举值(新增会破坏所有 when(platform) 分支的完备性检查)。这个判断落在 Utils.ohos.kt 里,它也是三个新写文件中最「有内容」的一个:

// miuix-lib/src/ohosMain/kotlin/top/yukonga/miuix/kmp/utils/Utils.ohos.kt
package top.yukonga.miuix.kmp.utils

// skiko 是中间源集,platform() 由 desktop/ios/macos/js/wasmJs 各自给出;
// Platform 枚举里没有 Ohos 值 —— 直接复用最接近的 Desktop:
// OpenHarmony 的 CMP 同样跑在 Skiko(Skia)后端,输入模型是触摸为主 + 可接鼠标。
actual fun platform(): Platform = Platform.Desktop

actual val hasFocusReassignBug: Boolean = false

@Composable
actual fun getRoundedCorner(): Dp = 0.dp

@Composable
actual fun RemovePlatformDialogDefaultEffects() {
    // No-op:OpenHarmony 的 Dialog 由 Compose 自己管理,无平台默认窗口动画/遮罩需要清除。
}

// OpenHarmony 适配:定制版 Compose 1.9.2 的 DialogProperties 没有 animateTransition 参数
// (该参数在 Compose Multiplatform 1.12 才加入),这里用旧签名等价实现:
// 禁用平台默认宽度/insets、透明遮罩、不响应返回键(由 miuix 的 NavigationBackHandler 接管)。
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun platformDialogProperties(): DialogProperties = DialogProperties(
    dismissOnBackPress = false,
    usePlatformDefaultWidth = false,
    usePlatformInsets = false,
    scrimColor = Color.Transparent,
)

platformDialogProperties() 里藏着 3.2 要讲的另一个版本差异(先记一笔,下文展开):返回键交给 miuix 自己的 NavigationBackHandler,所以这里的 dismissOnBackPress = false。

3.2 版本鸿沟之一:WindowInfo 与 DialogProperties

上游基于 CMP 1.12.2 写,OpenHarmony 工具链是 1.9.2——这不是简单降版本号能解决的,两版之间有实质 API 差异。第一处:

// 上游(CMP 1.12):WindowInfo 直接提供 dp 尺寸
val height = windowInfo.containerDpSize.height

// OpenHarmony(CMP 1.9.2):只有像素单位的 containerSize
// → 自写兼容函数(ohos/cmp/miuix/compat/WindowInfoCompat.kt)
fun windowDpSize(windowInfo: WindowInfo, density: Density): DpSize =
    with(density) {
        // 只有 Size.toDpSize(),没有 IntSize.toDpSize(),手动构造
        DpSize(windowInfo.containerSize.width.toDp(), windowInfo.containerSize.height.toDp())
    }

细节在注释里:1.9.2 的 Size 有 toDpSize() 而 IntSize 没有,不能直接套用同名扩展,要手动逐分量转换。

改动不是「改一处」,而是把源码里所有 containerDpSize 的读点替换成这个顶层函数。真实调用点有四处,覆盖下拉刷新、弹层布局与大屏判定:

// ① utils/Overscroll.kt —— 下拉刷新的滚动行程上限(纵向 / 横向)
scrollRange = with(currentDensity) {
    if (isVertical) {
        windowDpSize(currentWindowInfo, currentDensity).height.toPx()
    } else {
        windowDpSize(currentWindowInfo, currentDensity).width.toPx()
    }
}

// ② utils/OverscrollFactory.kt —— 另一条 overscroll 分支,同样按 density 换算
with(density) {
    effect.scrollRangeV = windowDpSize(windowInfo, density).height.toPx()
    effect.scrollRangeH = windowDpSize(windowInfo, density).width.toPx()
}

// ③ utils/MiuixPopupUtils.kt —— 大屏判定(决定弹层居中还是贴底)
@Composable
internal fun isLargeScreen(): Boolean {
    val windowInfo = LocalWindowInfo.current
    val density = LocalDensity.current
    val windowWidth = windowDpSize(windowInfo, density).width
    val windowHeight = windowDpSize(windowInfo, density).height
    return windowHeight >= 480.dp && windowWidth >= 840.dp
}

// ④ layout/BottomSheetContentLayout.kt —— BottomSheet 退出时滑出屏幕的位移
val windowHeightPx = with(density) { windowDpSize(windowInfo, density).height.toPx() }
animateDismissOffScreen(
    dragOffsetY = dragOffsetY,
    sheetHeightPx = sheetHeightPx.intValue,
    windowHeightPx = windowHeightPx,
)

注意 ④ 里那句上游注释——不要把 windowHeight 当作 remember 的 key:

val windowHeight = windowDpSize(windowInfo, density).height
// Do NOT use windowHeight as a remember key — IME resize would recreate handlers and trigger dismiss.
val currentWindowHeight by rememberUpdatedState(windowHeight)

软键盘弹出会改变窗口高度,如果拿高度做 key,DisposableEffect 里的返回处理器会在每次 IME 变化时重建,直接触发一次误关闭。用 rememberUpdatedState 只读最新值、不重建副作用。

第二处是 DialogProperties:上游用了 animateTransition 参数控制对话框过渡动画,这个参数是 CMP 1.12 才加入的。OpenHarmony 的 DialogProperties 没有它:

// 上游(CMP 1.12)
DialogProperties(
    dismissOnBackPress = false,
    usePlatformDefaultWidth = false,
    usePlatformInsets = false,
    scrimColor = Color.Transparent,
    animateTransition = false,      // ← 1.12 才有
)

// OpenHarmony(CMP 1.9.2):移除该参数
DialogProperties(
    dismissOnBackPress = false,
    usePlatformDefaultWidth = false,
    usePlatformInsets = false,
    scrimColor = Color.Transparent,
)

对话框仍有默认的进入/退出动画(走 1.9.2 的既有动画路径),只是少了一个显式开关。而 dismissOnBackPress = false 是刻意的选择:返回键要交给 miuix 自己的 NavigationBackHandler,否则会出现「按返回键同时触发系统关闭和 miuix 关闭动画」的双关闭。

3.3 版本鸿沟之二:AGSL shader 路线整体回退到 Path

这是视觉风险最大的一处。miuix 的招牌特性 squircle(平滑圆角)有两条渲染路线:

  1. shader 路线:SquircleBackground 用 RuntimeShader 编译一段 SkSL,按 SDF 距离场逐像素画圆角,性能最好;
  2. Path 路线:SquirclePath 用贝塞尔曲线构造轮廓,走普通绘制。

shader 路线依赖 org.jetbrains.skia.RuntimeEffect(编译 SkSL)和 Shader.skiaShader / asComposeShader(skiko 桥接扩展)——OpenHarmony 定制版 Compose 三者都没暴露。于是写一个降级 actual:

// miuix-lib/src/ohosMain/kotlin/top/yukonga/miuix/kmp/shader/RuntimeShader.ohos.kt
package top.yukonga.miuix.kmp.shader

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush

/**
 * 降级策略:
 * - isRuntimeShaderSupported() 返回 false → SquircleBackground 的
 *   `if (!isRuntimeShaderSupported()) return null` 直接短路,全量走 Path 路线;
 * - 其余 actual 提供 no-op / 显式报错:编译通过,且万一被调用能立刻暴露。
 */
actual fun isRuntimeShaderSupported(): Boolean = false

actual fun RuntimeShader(shaderString: String): RuntimeShader = UnsupportedRuntimeShader

actual fun RuntimeShader.asComposeShader(): Shader =
    error("RuntimeShader is not supported on OpenHarmony; squircle falls back to the Path renderer.")

actual fun RuntimeShader.asBrush(): ShaderBrush =
    error("RuntimeShader is not supported on OpenHarmony; squircle falls back to the Path renderer.")

/** 占位实现:所有 uniform 设置为 no-op(正常不会被执行)。 */
private object UnsupportedRuntimeShader : RuntimeShader {
    override fun setFloatUniform(name: String, value: Float) = Unit
    override fun setFloatUniform(name: String, value1: Float, value2: Float) = Unit
    override fun setFloatUniform(name: String, value1: Float, value2: Float, value3: Float) = Unit
    override fun setFloatUniform(name: String, value1: Float, value2: Float, value3: Float, value4: Float) = Unit
    override fun setFloatUniform(name: String, values: FloatArray) = Unit
    override fun setIntUniform(name: String, value: Int) = Unit
    override fun setIntUniform(name: String, value1: Int, value2: Int) = Unit
    override fun setIntUniform(name: String, value1: Int, value2: Int, value3: Int) = Unit
    override fun setIntUniform(name: String, value1: Int, value2: Int, value3: Int, value4: Int) = Unit
    override fun setIntUniform(name: String, values: IntArray) = Unit
    override fun setColorUniform(name: String, color: Color) = Unit
    override fun setInputShader(name: String, shader: Shader) = Unit
}

expect 声明了 12 个 uniform setter,占位对象必须全部实现——这就是「降级要显式」的代价:不能图省事返回一个 TODO(),因为类型系统要求把接口填满;但每个方法体都是 no-op,运行期不会有任何实际开销。

同一条路线上还有第二个 actual:miuix-shader 的 alpha 位图(makeAlphaImageBitmap / makeLinearShader),它服务于「把预烘焙 SDF 位图作为 child shader 喂给 RuntimeShader」。skiko 版用 org.jetbrains.skia 的 Bitmap / Image / ImageInfo 直接构造 ALPHA_8 位图,OpenHarmony 同样没有这些桥接 API:

// miuix-lib/src/ohosMain/kotlin/top/yukonga/miuix/kmp/squircle/internal/AlphaImageBitmap.ohos.kt
package top.yukonga.miuix.kmp.squircle.internal

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Shader

/**
 * 这两个函数只服务于 squircle 的 AGSL shader 渲染路线:
 * SquircleBackground 用 makeLinearShader(makeAlphaImageBitmap(...)) 把预烘焙的
 * SDF 位图作为 child shader 喂给 RuntimeShader。而 OpenHarmony 侧
 * isRuntimeShaderSupported() 恒为 false,该路线整体不启用(走 Path 渲染)。
 */
internal actual fun makeAlphaImageBitmap(size: Int, alphaBytes: ByteArray): ImageBitmap =
    ImageBitmap(size, size)

internal actual fun makeLinearShader(image: ImageBitmap): Shader =
    error("Squircle AGSL shader path is disabled on OpenHarmony; the Path renderer is used instead.")

makeAlphaImageBitmap 返回一个空位图(形状对、内容空),makeLinearShader 直接 error——两者都不会被执行:

// squircle/SquircleBackground.kt —— 短路点就在这里
internal fun rememberSquircleBrush(
    topStart: Dp, topEnd: Dp, bottomEnd: Dp, bottomStart: Dp, extension: Float,
): SquircleShaderBrush? {
    if (!LocalSquircleEnabled.current || !isRuntimeShaderSupported()) return null   // ← 恒返回 null
    val density = LocalDensity.current
    val extClamped = extension.coerceIn(SquircleDefaults.ExtensionMin, SquircleDefaults.ExtensionMax)
    // ... shader 路线的全部代码都在这行之后
}

这个 if 是上游本来就有的(用于全局开关 LocalSquircleEnabled),我们只是让 isRuntimeShaderSupported() 在 OpenHarmony 上恒定返回 false,一行上游代码都没改就把整条 shader 路线短路掉了。

那视觉为什么不受影响?因为 Path 路线本来就是几何正确的实现——用三次贝塞尔拟合连续曲率:

// squircle/SquirclePath.kt —— Path 路线:贝塞尔手柄比例与预烘焙 SDF 保持一致
internal const val SQUIRCLE_CONTROL = 0.643f

fun Path.addSquircleRect(
    width: Float, height: Float, cornerRadius: Float,
    extension: Float = SquircleDefaults.Extension,
    squircleEnabled: Boolean = true,
) {
    if (width <= 0f || height <= 0f) return
    if (!squircleEnabled) {
        // 退化路径:退化为普通圆角矩形
        val radius = max(0f, cornerRadius).coerceAtMost(min(width, height) * 0.5f)
        if (radius <= 0f) addRect(Rect(0f, 0f, width, height))
        else addRoundRect(RoundRect(0f, 0f, width, height, CornerRadius(radius, radius)))
        return
    }
    val extClamped = extension.coerceIn(SquircleDefaults.ExtensionMin, SquircleDefaults.ExtensionMax)
    val halfMin = min(width, height) * 0.5f
    val tile = max(0f, cornerRadius * extClamped).coerceAtMost(halfMin)
    if (tile <= 0f) { addRect(Rect(0f, 0f, width, height)); return }

    val handle = tile * (1f - SQUIRCLE_CONTROL)
    moveTo(tile, 0f)
    lineTo(width - tile, 0f)
    cubicTo(width - handle, 0f, width, handle, width, tile)
    lineTo(width, height - tile)
    cubicTo(width, height - handle, width - handle, height, width - tile, height)
    lineTo(tile, height)
    cubicTo(handle, height, 0f, height - handle, 0f, height - tile)
    lineTo(0f, tile)
    cubicTo(0f, handle, handle, 0f, tile, 0f)
    close()
}

四条边 + 四个角的 cubicTo 拼出连续曲率轮廓,SQUIRCLE_CONTROL = 0.643f 这个常数必须与预烘焙 SDF 保持一致(源码注释里也写了 must stay in lock-step with the value the pre-baked SDF was generated for),否则 Path 与 shader 两条路线的圆角会错开。所以「shader 不支持」只损失逐像素的 GPU 优化,轮廓一模一样。

顺带修了一个上游问题:BakedSquircleSdf.kt(SDF 缓存表)在上游仓库里漏提交了,SquircleBackground 引用它的两处编译不过——从发布产物的 class 反推签名补齐了这份源码:

// miuix-lib/src/commonMain/kotlin/top/yukonga/miuix/kmp/squircle/internal/BakedSquircleSdf.kt
package top.yukonga.miuix.kmp.squircle.internal

/**
 * 预烘焙的 squircle 圆角 SDF(有符号距离场)数据。
 *
 * 背景:`miuix-squircle` 上游仓库的 SquircleBackground.kt 引用了本类型,
 * 但仓库里并没有对应的源文件——上游漏提交了。这里补一个等价占位实现让工程可以编译。
 *
 * 运行期不会被执行:rememberSquircleBrush 首行就是
 * `if (!LocalSquircleEnabled.current || !isRuntimeShaderSupported()) return null`,
 * 而 OpenHarmony 侧 isRuntimeShaderSupported() 恒为 false,
 * 所以 squircle 走 Path 渲染路线,本对象的三个常量不会被读取。
 */
object BakedSquircleSdf {
    /** SDF 位图边长(正方形,单位 px)。 */
    const val SIZE: Int = 64

    /** 单个圆角在 SDF 中覆盖的半宽(归一化到 [0, 1] 的半个 range)。 */
    const val HALF_RANGE: Float = 0.5f

    /** SDF 位图的原始字节(RGBA_8888,SIZE * SIZE * 4)。未启用时保持全 0。 */
    val bytes: ByteArray = ByteArray(SIZE * SIZE * 4)
}

三个常量的取值是从发布产物里反推的:SIZE = 64 对应位图边长,HALF_RANGE = 0.5f 对应 SDF 的归一化范围,bytes 是 64 × 64 × 4 的全零数组(RGBA_8888)。敢用全零数组,是因为这条路线在 OpenHarmony 上永不被执行——如果是会被读取的数据,占位实现就会变成静默画错,那就违背「降级要显式失败」的原则了。

3.4 版本鸿沟之三:PointerEventType 与 navigationevent 兼容层

PointerEventType:上游 PullToRefresh / Overscroll 用了 PointerEventType.PanStart / PanMove / PanEnd 区分触控板滚动与触摸拖动,这三个枚举值在 CMP 1.12 加入,OpenHarmony 的 1.9.2 没有。降级策略:删除这三个分支,落到 pressed 状态判断(触摸场景下语义等价——按下即拖动);Unknown 分支用于触控板滚动,恒不匹配即安全禁用。

// utils/Overscroll.kt
// OpenHarmony 适配:无 PanStart/PanMove/PanEnd 事件类型,删除后由 else 按 pressed 判断
val event = awaitPointerEvent()
val active = when (event.type) {
    PointerEventType.Scroll -> gestureActive
    else -> event.changes.fastAny { it.pressed }
}
if (gestureActive && !active && animationJob?.isActive != true && abs(offset) > offsetThreshold) {
    // 手势结束 → 回弹
}

一个 when 从四分支缩到两分支,语义没丢:Scroll 保留触控板/滚轮场景,其余(触摸按下、抬起、Unknown)统一用 changes.any { it.pressed } 判定「是否还在拖」。Slider 里那些 PointerEventType.Exit / Release 属于 1.9.2 已有的枚举值,不受影响。

androidx.navigationevent:miuix 的 Dialog / BottomSheet 用它做「预测性返回」——系统返回手势进行时按 progress 逐帧驱动关闭动画。这个库在 OpenHarmony 上不存在,且 OpenHarmony 的返回事件由 ArkUI 层经 NAPI 下发(onBackPressed → napi → Compose),拿不到逐帧的 gesture progress。所以自写了一套 API 等价、行为降级的兼容层(androidx/navigationevent/ 3 个文件):

  • NavigationEventState.transitionState 接口保留,但恒为 Idle(无逐帧进度),调用方编译期与运行期类型安全不变;
  • NavigationBackHandler 保留 enabled 语义与 onBackCompleted / onBackCancelled 回调,由宿主在返回时触发完成路径。

效果:Dialog / BottomSheet / Popup 的显示、动画、关闭逻辑全部可用,仅「返回手势拖动进度」这一项降级为「返回即关闭」。

兼容层第一块是状态定义(androidx/navigationevent/NavigationEvent.kt)——把 androidx 的密封接口原样复刻,只让 InProgress 永远不被构造:

package androidx.navigationevent

/** 与 androidx.navigationevent.NavigationEventInfo 等价。 */
class NavigationEventInfo {
    companion object {
        val None: NavigationEventInfo = NavigationEventInfo()
    }
}

/** 单次返回事件的进度信息。 */
class NavigationEvent(val progress: Float)

/** 返回过渡状态,对应 androidx.navigationevent.NavigationEventTransitionState。 */
sealed interface NavigationEventTransitionState {
    val direction: Int
    val latestEvent: NavigationEvent

    /** 无过渡。 */
    class Idle : NavigationEventTransitionState {
        override val direction: Int = TRANSITIONING_NONE
        override val latestEvent: NavigationEvent = NavigationEvent(0f)
    }

    /** 过渡进行中。兼容层不会走到这里。 */
    class InProgress(
        override val direction: Int,
        override val latestEvent: NavigationEvent,
    ) : NavigationEventTransitionState

    companion object {
        const val TRANSITIONING_NONE: Int = 0
        const val TRANSITIONING_BACK: Int = 1
        const val TRANSITIONING_FORWARD: Int = 2
    }
}

/** 返回事件状态,对应 androidx.navigationevent.compose.NavigationEventState。 */
class NavigationEventState {
    /** 当前过渡状态。兼容层恒为 Idle。 */
    val transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle()
}

第二块是处理器 + 注册表(androidx/navigationevent/compose/NavigationBackHandler.kt)。这是兼容层里唯一有真实行为的部分——它把「返回」从「系统回调」改成「宿主主动派发」:

package androidx.navigationevent.compose

@Composable
fun rememberNavigationEventState(
    currentInfo: NavigationEventInfo = NavigationEventInfo.None,
): NavigationEventState = remember(currentInfo) { NavigationEventState() }

@Composable
fun NavigationBackHandler(
    state: NavigationEventState,
    isBackEnabled: Boolean = true,
    onBackCompleted: () -> Unit,
    onBackCancelled: () -> Unit = {},
) {
    var currentEnabled by remember { mutableStateOf(isBackEnabled) }
    currentEnabled = isBackEnabled
    var currentCompleted by remember { mutableStateOf(onBackCompleted) }
    currentCompleted = onBackCompleted
    var currentCancelled by remember { mutableStateOf(onBackCancelled) }
    currentCancelled = onBackCancelled
    val key = currentCompositeKeyHash

    // 向宿主注册/反注册一个可调用的返回句柄。
    // Compose 的组合与 effect 均在主线程执行,无需额外加锁。
    DisposableEffect(key, currentEnabled) {
        val handle = object : NavigationBackHandle {
            override val enabled: Boolean get() = currentEnabled
            override fun onBack() = currentCompleted()
            override fun onCancel() = currentCancelled()
        }
        if (currentEnabled) {
            NavigationBackRegistry.register(key, handle)
        }
        onDispose { NavigationBackRegistry.unregister(key, handle) }
    }
}

/** 宿主持有的返回句柄。 */
interface NavigationBackHandle {
    val enabled: Boolean
    fun onBack()
    fun onCancel()
}

/** 返回句柄注册表。宿主(Compose 根 / napi 桥)在收到系统返回时调用 dispatchBack()。 */
object NavigationBackRegistry {
    // 用插入有序的 Map 保证「后注册者优先」的语义(Dialog 压在 BottomSheet 上时,先关 Dialog)。
    private val handles = LinkedHashMap<Int, NavigationBackHandle>()

    fun register(key: Int, handle: NavigationBackHandle) { handles[key] = handle }

    fun unregister(key: Int, handle: NavigationBackHandle) {
        if (handles[key] === handle) handles.remove(key)
    }

    /**
     * 派发一次系统返回。从最后注册的句柄往前找第一个启用的,
     * 命中则走完成路径并返回 true;否则返回 false 让宿主自行处理。
     */
    fun dispatchBack(): Boolean {
        val keys = handles.keys.toList()
        for (i in keys.indices.reversed()) {
            val handle = handles[keys[i]] ?: continue
            if (handle.enabled) {
                handle.onBack()
                return true
            }
        }
        return false
    }
}

两个设计点值得单独说:

  1. LinkedHashMap + 倒序遍历 = 后注册者优先。弹层是层层叠加的,后打开的 Dialog 后注册,返回时应该先关它——这个语义和 Android 的 OnBackPressedDispatcher 完全一致。
  2. dispatchBack() 返回 Boolean。返回 false 表示「没有弹层接管」,宿主可以据此走自己的退出逻辑(比如退出页面)。这让兼容层与 ArkTS 侧的 onBackPressed 能干净地接在一起:Index.ets 里的 onBackPressed() 先问 Compose,Compose 说没接管再交给系统。

调用方代码一行都没改,仍然长这样(layout/BottomSheetContentLayout.kt):

NavigationBackHandler(
    state = rememberNavigationEventState(),
    isBackEnabled = allowDismiss,
    onBackCancelled = { coroutineScope.launch { resetGesture() } },
    onBackCompleted = {
        if (allowDismiss) {
            coroutineScope.launch {
                val windowHeightPx = with(density) { windowDpSize(windowInfo, density).height.toPx() }
                animateDismissOffScreen(...)
            }
        }
    },
)

3.5 materialkolor 的 SPEC_2026 缺失与 ThemeController 只读

miuix 的 Monet 动态取色直接内嵌了 materialkolor 的实现(42 个文件),但上游用的是 materialkolor 6.0.0,其中 SchemeCmf 依赖 Material Color 的 SPEC_2026 调色规格——本地 MCU 实现不含该 spec。处理:从 ThemePaletteStyle 枚举中移除 Cmf 风格(9 种剩 8 种),其余风格在 SPEC_2025 不受支持时优雅降级到 SPEC_2021:

// theme/ThemeController.kt

// 枚举:Cmf 已移除(9 → 8)
@Stable
enum class ThemePaletteStyle {
    TonalSpot, Neutral, Vibrant, Expressive,
    Rainbow, FruitSalad, Monochrome, Fidelity, Content,
}

@Stable
internal fun colorsFromSeed(
    seed: Color,
    colorSpec: ThemeColorSpec,
    paletteStyle: ThemePaletteStyle,
    dark: Boolean,
): Colors {
    // Check if the selected style supports SPEC_2025
    val isSpec2025Supported = when (paletteStyle) {
        ThemePaletteStyle.TonalSpot,
        ThemePaletteStyle.Neutral,
        ThemePaletteStyle.Vibrant,
        ThemePaletteStyle.Expressive,
        -> true

        else -> false
    }

    // OpenHarmony 适配:materialkolor 6.0.0 的 SchemeCmf 依赖 SPEC_2026,
    // 本地 MCU 实现不含该 spec,故移除 Cmf 风格(枚举值也已删)。
    // 其余风格在 SPEC_2025 不受支持时优雅降级到 SPEC_2021。
    val internalSpec = when {
        colorSpec == ThemeColorSpec.Spec2025 && isSpec2025Supported -> ColorSpec.SpecVersion.SPEC_2025
        else -> ColorSpec.SpecVersion.SPEC_2021
    }

    val hctColor = Hct.fromInt(seed.toArgb())
    val scheme: DynamicScheme = when (paletteStyle) {
        ThemePaletteStyle.TonalSpot -> SchemeTonalSpot(
            sourceColorHct = hctColor,
            isDark = dark,
            contrastLevel = 0.0,
            specVersion = internalSpec,
            platform = DynamicScheme.Platform.PHONE,
        )
        ThemePaletteStyle.Neutral -> SchemeNeutral(/* 同上四个参数 */)
        ThemePaletteStyle.Vibrant -> SchemeVibrant(/* 同上四个参数 */)
        ThemePaletteStyle.Expressive -> SchemeExpressive(/* 同上四个参数 */)
        ThemePaletteStyle.Rainbow -> SchemeRainbow(/* 同上四个参数 */)
        ThemePaletteStyle.FruitSalad -> SchemeFruitSalad(/* 同上四个参数 */)
        ThemePaletteStyle.Monochrome -> SchemeMonochrome(/* 同上四个参数 */)
        ThemePaletteStyle.Fidelity -> SchemeFidelity(/* 同上四个参数 */)
        ThemePaletteStyle.Content -> SchemeContent(/* 同上四个参数 */)
    }
    // 把 Monet 的 26 个 role 映射到 miuix 的 Colors
    val roles = MonetRoles(
        primary = Color(scheme.primary),
        onPrimary = Color(scheme.onPrimary),
        // ... surfaceContainerHighest / outline / outlineVariant / onSurfaceVariant
    )
    return mapMd3RolesToMiuixColorsCommon(roles, dark)
}

注意这里的降级是按风格判定而不是全局开关:TonalSpot / Neutral / Vibrant / Expressive 四种风格有 SPEC_2025 实现,其余四种走 SPEC_2021。所以「移除 Cmf」只影响一个枚举值,其余 8 种风格的取色结果与上游一致。

另一个坑:ThemeController 的所有属性都是 val ... by mutableStateOf(...)——只读:

class ThemeController(
    colorSchemeMode: ColorSchemeMode = ColorSchemeMode.System,
    lightColors: Colors = lightColorScheme(),
    darkColors: Colors = darkColorScheme(),
    keyColor: Color? = null,
    colorSpec: ThemeColorSpec = ThemeColorSpec.Spec2021,
    paletteStyle: ThemePaletteStyle = ThemePaletteStyle.TonalSpot,
    isDark: Boolean? = null,
) {
    val colorSchemeMode: ColorSchemeMode by mutableStateOf(colorSchemeMode)
    val lightColors: Colors by mutableStateOf(lightColors)
    val darkColors: Colors by mutableStateOf(darkColors)
    val keyColor: Color? by mutableStateOf(keyColor)
    val colorSpec: ThemeColorSpec by mutableStateOf(colorSpec)
    val paletteStyle: ThemePaletteStyle by mutableStateOf(paletteStyle)
    val isDark: Boolean? by mutableStateOf(isDark)

    @Composable
    fun currentColors(): Colors = when (colorSchemeMode) {
        ColorSchemeMode.System -> { /* isSystemInDarkTheme() 决定明暗 */ }
        ColorSchemeMode.Light -> lightColors
        ColorSchemeMode.Dark -> darkColors
        ColorSchemeMode.MonetSystem -> { /* keyColor ?: platformDynamicColors(dark) */ }
        ColorSchemeMode.MonetLight -> {
            keyColor?.let { remember(keyColor, colorSpec, paletteStyle) { colorsFromSeed(it, colorSpec, paletteStyle, false) } }
                ?: platformDynamicColors(dark = false)
        }
        ColorSchemeMode.MonetDark -> { /* 同上,dark = true */ }
    }
}

构造参数是 val,currentColors() 又是 @Composable——这意味着:一旦 controller 建好,模式与调色板就固定了;想切主题,唯一的路子是重建 controller。所以官方用法是把构造参数提为 composable state,用 remember(key) 驱动重建:

// composeApp/src/commonMain/kotlin/com/example/cmp_pure/App.kt
@Composable
fun App() {
    // ThemeController 的构造参数是只读的(内部 val + mutableStateOf),
    // 官方用法是把模式/调色板提为 state,参数变化时重建 controller。
    var colorMode by remember { mutableStateOf(ColorSchemeMode.Light) }
    var paletteStyle by remember { mutableStateOf(ThemePaletteStyle.TonalSpot) }
    val controller = remember(colorMode, paletteStyle) {
        ThemeController(
            colorSchemeMode = colorMode,
            paletteStyle = paletteStyle,
        )
    }

    MiuixTheme(controller = controller) {
        MaterialTheme {
            var selectedTab by remember { mutableIntStateOf(0) }
            val tabs = listOf("基础组件", "表单控件", "弹层反馈", "主题调色")
            // ... PrimaryTabRow + when(selectedTab) 分发四个 Tab
        }
    }
}

remember(colorMode, paletteStyle) 里的 key 很关键:它保证只有模式/调色板变化时才重建 controller,不会因为其他状态变化而丢掉主题状态。Demo 的「主题调色」Tab 就是把这两个 state 和 RadioButton 绑在一起:

@Composable
private fun ThemePaletteTab(
    colorMode: ColorSchemeMode,
    onColorModeChange: (ColorSchemeMode) -> Unit,
    paletteStyle: ThemePaletteStyle,
    onPaletteStyleChange: (ThemePaletteStyle) -> Unit,
) {
    SmallTitle(text = "ColorSchemeMode")
    val modes = listOf(
        "Light" to ColorSchemeMode.Light,
        "Dark" to ColorSchemeMode.Dark,
        "MonetLight" to ColorSchemeMode.MonetLight,
        "MonetDark" to ColorSchemeMode.MonetDark,
    )
    modes.forEach { (label, mode) ->
        BasicComponent(
            title = label,
            endActions = {
                RadioButton(selected = colorMode == mode, onClick = { onColorModeChange(mode) })
            },
        )
    }

    HorizontalDivider()
    SmallTitle(text = "PaletteStyle(Monet 模式生效)")
    val styles = listOf(
        ThemePaletteStyle.TonalSpot, ThemePaletteStyle.Neutral, ThemePaletteStyle.Vibrant,
        ThemePaletteStyle.Expressive, ThemePaletteStyle.Content,
    )
    styles.forEach { style ->
        BasicComponent(
            title = style.name,
            endActions = {
                RadioButton(
                    selected = paletteStyle == style,
                    onClick = {
                        onPaletteStyleChange(style)
                        // paletteStyle 只在 Monet 模式下生效;从普通模式切过来时自动转到 MonetLight,
                        // 让用户立刻看到调色板变化。
                        if (colorMode == ColorSchemeMode.Light || colorMode == ColorSchemeMode.Dark) {
                            onColorModeChange(ColorSchemeMode.MonetLight)
                        }
                    },
                )
            },
        )
    }
}

注意 styles 列表里没有 Cmf——这就是 3.5 开头移除该枚举值的直接体现。实测点 MonetLight + Vibrant,整个应用的主色立刻跟着变,说明 Monet 取色链路(colorsFromSeed → DynamicScheme → mapMd3RolesToMiuixColorsCommon → MiuixTheme)在 OpenHarmony 上是完整跑通的。

3.6 构建配置:把 147 个文件编成 klib,再编进 libkn.so

最后看一眼工程侧——三处配置决定了「源码能不能编成 OpenHarmony 上的 .so」。

① 模块声明与仓库:miuix-lib 必须显式 include,仓库列表要有 OpenHarmony 定制 Maven:

// settings.gradle.kts
rootProject.name = "cmp_miuix"

pluginManagement {
    repositories {
        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(":miuix-lib")     // ← 收编的五模块全在这里

② 版本:工具链必须是定制版,否则 ohosArm64() / ohosX64() 不存在:

# gradle/libs.versions.toml
[versions]
kotlin              = "2.2.21-0.4.0"   # OpenHarmony 定制工具链
composeMultiplatform = "1.9.2-0.4.0"   # ovCompose
kotlinx-coroutines  = "1.10.2-0.4.0"
atomicFu            = "0.31.0-0.4.0"

③ 依赖方式:composeApp 用 implementation(project(":miuix-lib")) 走 klib 依赖,而不是把源码并进来:

// composeApp/build.gradle.kts
kotlin {
    listOf(
        ohosArm64(),   // 真机 arm64
        ohosX64(),     // 模拟器/开发机 x64
    ).forEach { ohosTarget ->
        ohosTarget.binaries.sharedLib {
            baseName = "kn"                     // → libkn.so / libkn_api.h
            // Release 链接阶段的 DevirtualizationAnalysis 内存占用过高,容易 OOM
            if (buildType == NativeBuildType.RELEASE) {
                optimized = false
            }
            export(libs.compose.multiplatform.export)
            linkerOpts("-lz")
            // rendererBackend == fusion-renderer 时追加 native_drawing / pixelmap / uv 等系统库
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.atomicFu)
            // miuix UI 库(miuix-ui/core/squircle + materialkolor MCU + navigationevent 兼容层)
            // 以 klib 依赖方式引入,源码收拢在 :miuix-lib 模块
            implementation(project(":miuix-lib"))
        }

        val ohosMain = sourceSets.create("ohosMain").apply { dependsOn(commonMain.get()) }
        ohosMain.dependencies { api(libs.compose.multiplatform.export) }
        val ohosArm64Main by getting { dependsOn(ohosMain) }
        val ohosX64Main by getting { dependsOn(ohosMain) }
    }
}

一行 implementation(project(":miuix-lib")) 就是上一篇文章那条结论的复用:147 个 public 声明留在 miuix-lib 的 klib 里,composeApp 的 C API 导出面只剩 MainArkUIViewController,CAdapterCodegen 不会碰到它们。本节所有代码改动都建立在这个前提上——如果当初把源码并进 composeApp,第 3.1~3.5 节做得再漂亮也会卡在链接期。

四、运行效果(DevEco 模拟器实测)

以下截图均为模拟器实拍。模拟器是 OpenHarmony API 26(x86_64),HAP 47.9MB(仅 x86_64 ABI),DevEco 显示安装用时 2 s 339 ms。

基础组件:Button(注意两种签名——Button 接收 RowScope content,TextButton 接收 text: String 参数,适配时容易踩)、Card(squircle 平滑圆角,Path 路线渲染)、Surface(默认取 colorScheme.surface)、BasicComponent(miuix 经典设置列表项)。

基础组件
@Composable
private fun BasicComponentsTab() {
    SmallTitle(text = "Button")
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        // 注意两种签名:Button 的 content 是 RowScope(要自己放 Text),
        // TextButton 直接收 text: String
        Button(onClick = { }) { Text(text = "主要按钮") }
        TextButton(text = "文字按钮", onClick = { })
    }

    HorizontalDivider()
    SmallTitle(text = "Card")
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.defaultColors(),   // squircle 平滑圆角,本次走 Path 路线渲染
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(text = "Card 标题", fontWeight = FontWeight.SemiBold)
            Spacer(Modifier.height(4.dp))
            Text(text = "miuix 的 Card 使用 squircle 平滑圆角,比传统圆角更接近 HyperOS 视觉。")
        }
    }

    HorizontalDivider()
    SmallTitle(text = "Surface")
    Surface(modifier = Modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier.padding(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text(
                text = "Surface 默认取 colorScheme.surface",
                color = MiuixTheme.colorScheme.onSurface,
            )
        }
    }

    SmallTitle(text = "BasicComponent(设置项样式)")
    BasicComponent(
        title = "这是一个设置项",
        summary = "左侧标题 + 右侧摘要的经典 miuix 列表项",
        onClick = { },
    )
}

表单控件:TextField(TextFieldValue 驱动,不是 String)、Switch / Checkbox / RadioButton(注意 miuix 的 Checkbox 用 ToggleableState 而非 Boolean,RadioButton 参数名是 selected 而非 checked)、Slider。

表单控件
@Composable
private fun FormControlsTab() {
    var textValue by remember { mutableStateOf(TextFieldValue("")) }
    var switchOn by remember { mutableStateOf(true) }
    var checkOn by remember { mutableStateOf(false) }
    var radioIndex by remember { mutableIntStateOf(0) }
    var sliderValue by remember { mutableStateOf(0.5f) }

    SmallTitle(text = "TextField")
    TextField(
        value = textValue,                       // 注意:TextFieldValue,不是 String
        onValueChange = { textValue = it },
        label = "请输入内容",
        modifier = Modifier.fillMaxWidth(),
    )

    HorizontalDivider()
    SmallTitle(text = "Switch / Checkbox / RadioButton")
    BasicComponent(
        title = "开关",
        summary = if (switchOn) "已开启" else "已关闭",
        endActions = { Switch(checked = switchOn, onCheckedChange = { switchOn = it }) },
    )
    BasicComponent(
        title = "复选框",
        summary = if (checkOn) "已勾选" else "未勾选",
        endActions = {
            // miuix 的 Checkbox 用 ToggleableState(三态)+ onClick,不是 Boolean + onCheckedChange
            Checkbox(
                state = if (checkOn) ToggleableState.On else ToggleableState.Off,
                onClick = { checkOn = !checkOn },
            )
        },
    )
    listOf("选项一", "选项二", "选项三").forEachIndexed { index, label ->
        BasicComponent(
            title = label,
            endActions = {
                // RadioButton 的参数名是 selected,不是 checked
                RadioButton(selected = radioIndex == index, onClick = { radioIndex = index })
            },
        )
    }

    HorizontalDivider()
    SmallTitle(text = "Slider")
    Column {
        Text(text = "当前值:${(sliderValue * 100).toInt()}")
        Spacer(Modifier.height(8.dp))
        Slider(value = sliderValue, onValueChange = { sliderValue = it }, modifier = Modifier.fillMaxWidth())
    }
}

三个容易踩的签名差异:TextField 收 TextFieldValue、Checkbox 收 ToggleableState + onClick、RadioButton 的参数名是 selected 而非 checked。这些都是 miuix 自己的 API 设计,与 Material 3 不同——跨库迁移时逐个核对,不要凭肌肉记忆写。

弹层反馈:OverlayDialog / OverlayBottomSheet 的入口。弹层走 navigationevent 兼容层——显示、动画、关闭全部可用,返回手势进度降级为返回即关闭。

弹层反馈
@Composable
private fun OverlayFeedbackTab() {
    var showDialog by remember { mutableStateOf(false) }
    var showBottomSheet by remember { mutableStateOf(false) }
    var resultText by remember { mutableStateOf("") }

    SmallTitle(text = "Dialog / BottomSheet")
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(onClick = { showDialog = true }) { Text(text = "打开对话框") }
        Button(onClick = { showBottomSheet = true }) { Text(text = "打开底部弹窗") }
    }

    if (resultText.isNotEmpty()) {
        Spacer(Modifier.height(8.dp))
        Text(text = resultText, color = MiuixTheme.colorScheme.primary)
    }

    OverlayDialog(
        show = showDialog,
        title = "操作确认",
        summary = "这是一个 miuix 风格的对话框,使用 squircle 圆角与自定义动画。",
        onDismissRequest = { showDialog = false },     // 点击遮罩 / 系统返回
        onDismissFinished = { showDialog = false },    // 关闭动画播完
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            TextButton(text = "取消", onClick = { showDialog = false }, modifier = Modifier.weight(1f))
            Button(
                onClick = { showDialog = false; resultText = "已确认对话框操作" },
                modifier = Modifier.weight(1f),
            ) { Text(text = "确定") }
        }
    }

    OverlayBottomSheet(
        show = showBottomSheet,
        title = "底部弹窗",
        onDismissRequest = { showBottomSheet = false },
        onDismissFinished = { showBottomSheet = false },
    ) {
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            listOf("分享", "收藏", "复制链接").forEach { label ->
                BasicComponent(
                    title = label,
                    summary = "点击选择「$label」",
                    onClick = { showBottomSheet = false; resultText = "选择了:$label" },
                )
            }
        }
    }
}

弹层内部那条链路正好把 3.4 的结论串起来:OverlayDialog → DialogContentLayout → NavigationBackHandler(state, isBackEnabled, onBackCompleted, onBackCancelled) → NavigationBackRegistry。按返回键时,transitionState 恒为 Idle 意味着没有逐帧的 progress 可以驱动位移动画,于是直接走 animateDismissOffScreen(...) 的完整关闭动画——看起来是「一下关掉」,而不是「跟着手指拖走」,其余行为一点没少。

主题调色:ColorSchemeMode(Light/Dark/Monet 系)与 PaletteStyle(TonalSpot/Neutral/Vibrant/Expressive/Content,Cmf 已移除)实时切换,验证 Monet 动态取色链路在 OpenHarmony 上完整可用。

主题调色

五、FAQ

Q1:Unresolved reference: yukonga?
miuix 源码全部在 miuix-lib 模块,composeApp 必须 implementation(project(":miuix-lib")),且 settings.gradle.kts 有 include(":miuix-lib")。

Q2:为什么 squircle 圆角看起来和官方 Demo 一样,shader 不是不支持吗?
Path 路线本来就是视觉正确的实现,shader 路线只是性能优化(逐像素 GPU 绘制)。isRuntimeShaderSupported() 返回 false 后全量走 Path,形状不变。

Q3:containerDpSize 找不到?
CMP 1.9.2 的 WindowInfo 只有像素 containerSize。用 windowDpSize(windowInfo, density)(ohos.cmp.miuix.compat 包)替代,注意 IntSize 没有 toDpSize() 扩展,要逐分量 toDp()。

Q4:PointerEventType.PanStart 编译不过?
CMP 1.12 的枚举值。删除相关分支,落到 pressed 状态判断;触控板滚动走 Unknown(恒不匹配,安全禁用)。

Q5:ThemeController 属性赋值报 val cannot be reassigned?
属性是 val by mutableStateOf,只读。把模式/调色板提为 composable state,作为构造参数传入并用 remember(colorMode, paletteStyle) 驱动重建。

Q6:Checkbox 没有 checked 参数?
miuix 的 Checkbox 用 ToggleableState(三态)+ onClick,不是 Boolean + onCheckedChange。RadioButton 同理,参数名是 selected。

Q7:Dialog 能弹但返回手势没有逐帧动画?
预期行为。navigationevent 兼容层的 transitionState 恒为 Idle,返回手势进度降级为「返回即关闭」,其余能力不变。

Q8:app 装上就闪退,faultlog 报 cannot find record '&../../oh_modules/@cpf-kmp-cmp/compose/Index&...'?
@cpf-kmp-cmp/compose 的 .abc 字节码没进 HAP。在工程目录内跑 ohpm install --all(带 --prefix 会报 00618008 Cross Driver Error),再 hvigorw clean 后重编。本次适配实测复现并按此解决。

Q9:uitest 点击不了 Compose 里的按钮?
XComponent 限制:uitest uiInput click 能命中 TabRow 这类有语义节点的组件,但 Compose 内部 clickable 元素收不到注入事件。自动化验证 UI 状态用 snapshot_display 截图 + 人工核验;交互验证在 DevEco 或真机上手动做。

六、总结与参考

把 miuix 搬上 OpenHarmony,工作量分布和之前所有适配都不同:平台代码只写了 7 个文件(其中 5 个还是复用上游 skiko actual),真正的工程量在跨越 Compose 1.12 → 1.9 的版本鸿沟。WindowInfo 换 dp 尺寸接口、DialogProperties 删动画开关、PointerEventType 砍触控板分支、navigationevent 自写兼容层、materialkolor 降 SPEC——每一处都是「上游以为天经地义、这里必须重新找路」的 API 形态差异。

两条经验值得沉淀:

其一,判断 skiko actual 能否复用的标准是「是否触碰 Skia C 互操作」。只用 Compose 公开 API 的 actual,OpenHarmony 直接拿去用;一旦引用 org.jetbrains.skia.*(如 RuntimeEffect),就要准备降级路线。这个判断方法适用于所有含 skikoMain 源集的 CMP 库。

其二,降级要显式失败,不要静默错画。shader 路线的 asComposeShader() 没有做成返回空对象,而是直接 error(...)——万一未来某处调用路径变了,会在第一时间暴露,而不是画出错误的圆角。兼容层的价值不在「让它能跑」,在「让行为可预期」。

miuix 组件库打通之后,OpenHarmony 上的 CMP 开发者就有了 HyperOS 视觉风格的完整组件体系——从按钮、开关、列表到弹层、导航、动态取色,开箱即用。配合之前适配的 markdown 渲染、分页、KV 存储、图片加载,CMP 的 OpenHarmony 生产链路又补上了一块最大的 UI 拼图。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐