Kotlin Multiplatform 三方库 miuix 的 OpenHarmony 适配实战
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 组件展示的全过程。
*先睹为快: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-core | Utils(platform() 枚举、dp 扩展) | expect/actual:Platform 枚举 |
miuix-squircle | 平滑圆角(SDF 距离场 + 贝塞尔 Path 双路线) | AGSL/SkSL RuntimeShader(native) |
miuix-shader | AGSL RuntimeShader 抽象层 | org.jetbrains.skia.RuntimeEffect |
miuix-preference | 偏好设置组件(依赖 miuix-ui) | 纯 Compose |
androidx.navigationevent | 预测性返回(Google 库,非 miuix 自有) | Compose 1.12 系统库 |
关键观察有三个:
- skiko 源集的 actual 全是纯 Compose 代码。miuix 有几个
expect声明(DynamicColors、ProvideDisplayCutoutFix、WindowNavigationEventScope),actual 放在skikoMain(iOS/Desktop 共用),实现里只调 Compose 公开 API、不碰 Skia C 互操作。OpenHarmony 的 CMP 同样基于 Skiko 渲染——这些文件原封不动搬进ohosMain就能用。 - 唯一真正的 Skia 依赖在 shader 路线:squircle 的高性能路线用
org.jetbrains.skia.RuntimeEffect编译 SkSL 片段着色器,而 OpenHarmony 定制版 Compose 没有暴露这个 API。 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(平滑圆角)有两条渲染路线:
- shader 路线:
SquircleBackground用RuntimeShader编译一段 SkSL,按 SDF 距离场逐像素画圆角,性能最好; - 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
}
}
两个设计点值得单独说:
LinkedHashMap+ 倒序遍历 = 后注册者优先。弹层是层层叠加的,后打开的 Dialog 后注册,返回时应该先关它——这个语义和 Android 的OnBackPressedDispatcher完全一致。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 拼图。
更多推荐




所有评论(0)