本文记录 compose-plus 接入 OpenHarmony 的完整过程,覆盖 Compose 依赖盘点、composeMain 源集拆分、零依赖纯 Kotlin 内核重写、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 五页签验收页面、HAP 构建和真机/真机验收。

compose-plus 是 ehsannarmani 的 Compose Multiplatform 扩展库,三个模块 functions / extensions / components 分别提供状态与动画原语、Modifier 扩展、以及 Row / Column / Box 的语义化预置组合(CenterVerticallyRow、SpaceBetweenRow、CenteredColumn……)。本次适配与社区「真编译整库」路线(KStore / kstore-file 那类)有本质区别:compose-plus 全库 31 个 commonMain 文件全部建立在 androidx.compose.* 之上(compose.ui、compose.foundation、compose.animation、compose.runtime.saveable),而社区 Maven 上没有任何 Compose 的 ohos 构件,因此只能走「零依赖内核提取」:把 @Composable 的语义翻译成纯 Kotlin 可计算的几何算术、缓动求值、类型化编解码三件事,再由 C ABI/N-API 交给 ArkTS,最后用 ArkUI 原生控件把计算结果画出来。这条路线不追求「让 Compose 跑在鸿蒙上」,而是追求「让 compose-plus 的行为在鸿蒙上可验证、可复用」。

项目地址: AtomGit/oh-tpc/compose-plus.git

上游仓库: ehsannarmani/compose-plus

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配

compose-plus 解决的是 Compose 里最琐碎的一类重复劳动:想「垂直居中 + 两端对齐」的 Row,常规写法要同时记 Arrangement.SpaceBetween 和 Alignment.CenterVertically 两个常量,库把它收敛成一个 CenterVerticallySpaceBetweenRow;想要 2/4/8/16/20/32dp 六档圆角,不必每次写 RoundedCornerShape(8.dp);想要 rememberSaveable 的类型安全包装、animate*AsState 的统一缓动、cutVisibleIf 的切割显现,模块里都有现成的。KMP 项目把这些放在 commonMain,由 Android、iOS、桌面共享。

OpenHarmony 应用同样需要这套能力,但上游构建(Kotlin 2.4.0 / Gradle 9.6.1 / AGP 9.2.0)不会生成 OpenHarmony 变体。更关键的是:它依赖的东西本身不存在鸿蒙版本。这不是「加两个 target 就能编」的问题,compose.ui 在社区 Maven 上没有 ohosArm64 / ohosX64 变体,@Composable、Modifier、Arrangement、Shape、State<T> 一个都解析不到。

如果只把示例页面改成 ArkTS,页面可以摆出几个按钮,却无法证明 compose-plus 的任何一行逻辑在鸿蒙设备上完成过编译与执行。适配需要同时验证内核、Kotlin/Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。
依赖不可达31 个上游文件全部 import androidx.compose.*,而 compose.ui / compose.foundation / compose.animation / compose.runtime.saveable 没有任何 ohos 变体,加仓库、换版本都无解。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,@Composable 函数更不可能跨语言调用;需要 C ABI、C++ N-API 和 JSON。
语义跨不了桥Modifier 是链式不可变对象、State<T> 是 composition 内的可变容器、Arrangement 是布局期回调——三者都无法用一次同步 C 调用表达。
工具链不一致上游 Kotlin 2.4.0 + AGP 9.2.0 + com.android.kotlin.multiplatform.library 与鸿蒙 Kotlin 发行版无法共存于一个 Gradle 构建。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:保留上游源码作为「语义说明书」(src/composeMain,不是源集、不编译),在 src/commonMain 重写一层零第三方依赖的纯 Kotlin 内核承担可移植语义,再由 C ABI/N-API 把内核暴露给 ArkTS。ArkTS 只负责调用 Native、解析 JSON 和用 ArkUI 原生控件渲染。

1.2 库提供的能力

Demo 把三个模块的内核入口全部接到页面上,共 7 个 C ABI、5 个页签:

  • 概览:ComposePlusVersion() 版本握手 + ComposePlusCatalog() 全量索引(31 个上游文件 / 143 个符号,逐条附带「在鸿蒙上发生了什么」的说明);
  • 布局求解:ComposePlusLayout() —— 7 种主轴编排(Start / Center / End / SpaceBetween / SpaceAround / SpaceEvenly / SpacedBy)× 3 种交叉轴对齐 × Row/Column/Box × RTL,一次返回所有子元素的 main/cross/x/y;
  • 形状与圆角:ComposePlusShape() —— 6 档圆角预设、CircleShape 半径规则、四角独立半径与钳制、CutShape 的 SVG path 输出;
  • 状态保存:ComposePlusState() —— rememberSaveable 的可移植那一半:8 种类型标签的编解码 + StateRegistry 快照/恢复;
  • 动画与权限:ComposePlusAnimation() 的 5 条缓动曲线、tween(400)、无限循环,以及 ComposePlusPermissions() 的 Android → OpenHarmony 权限映射与结果归一。

响应统一带 ok 字段,失败时 error 带原文:

字段作用
ok操作是否成功,失败时 error 带原文。
op / kind / orientation回显本次请求的判别字段。
业务字段布局是 placements[],形状是 radii + cut.commands,状态是 json/snapshot,动画是 fraction/value,权限是 pairs[]。

内核侧完整能力(9 种 AnimateKind、VisibilitySpec 的 alpha/scale/cut 三通道、Remember 的身份键缓存、ContentPadding、SpacerScale、ScrollSpec)全部随编译进入 .so,JVM 侧保留 90 项交叉验证用例,证明 ohos .so 里执行的逻辑没有漂移。

1.3 实现适配

维度要求
代码复用上游 31 个文件一个字节不改,git mv 进 src/composeMain 作为语义基线;commonMain 的 27 个内核文件承接它们的行为。
平台目标根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);app/(Android/Desktop/iOS/Wasm 示例)、iosApp/、document/、.github/ 留盘不进构建。
桥接稳定使用 7 个 C ABI 和 UTF-8 JSON,不传 Kotlin 对象地址;异常永远不跨 FFI。
依赖纪律内核零第三方依赖(连 kotlinx 都不用):libs.versions.toml 里只有 kotlin 一个版本号,JSON 也是自研的。
UI 完整五个页签覆盖全部桥接入口:版本握手、索引、布局、形状、状态、动画、权限。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现,也不是 Compose 的渲染后端。Compose 的布局/绘制/组合三件事在鸿蒙上由 ArkUI 承担;compose-plus 提供的决策(该摆在哪、半径多少、进度多少、快照长什么样)由内核算,ArkTS 照单执行。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点 31 个上游文件的 compose 依赖面、确认制品可达性、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.4.20-0.1.0-07 钉版
第 3 阶段:源集拆分       ── 全部上游源码 git mv 到 src/composeMain(非源集)、commonMain 从零写内核
第 4 阶段:桥协议建模     ── 7 个 C ABI、UTF-8 JSON 契约、guard 错误通道
第 5 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和 DisposeString 纪律
第 6 阶段:ArkUI 宿主     ── 五页签、首帧不碰 KN、权限声明与申请、ArkTS 编译期 API 适配
第 7 阶段:示例与验证     ── 90 项交叉验证、klib、双 ABI so、符号检查、HAP、安装运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
compose-plus/                         上游三个库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
compose-plus/<m>/src/composeMain/     上游源码原地搬迁(**不是源集、不编译**,语义基线 + 审计留痕)
compose-plus/<m>/src/commonMain/      新增:零依赖纯 Kotlin 内核(27 个文件)
compose-plus/<m>/src/jvmTest/         新增:JVM 交叉验证(90 个用例)
example/nativeApp/                    Kotlin/Native 动态库(Bridge.kt:7 个 @CName)
example/harmonyApp/                   DevEco Stage、CMake N-API 和 ArkTS 五页签页面
README.OpenHarmony.md / _CN.md        鸿蒙适配说明(中英双份)
README.upstream.md                    上游原文保存
doc/                                  本文与效果图
app/ iosApp/ document/ .github/       上游示例、文档站与 CI,留盘不进构建

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include 三个库模块与 :example:nativeApp,rootProject.name = "ohos_compose_plus"。上游的 kover / dokka / vanniktech 发布插件、AGP classpath、GitHub Packages 发布段全部随根构建替换剥离——本 fork 不发 Maven Central。

// settings.gradle.kts
pluginManagement {
    repositories {
        // HarmonyOS Kotlin distribution (ohosArm64 / ohosX64 targets). Must come first,
        // otherwise configuration fails with "Unresolved reference: ohosArm64".
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_compose_plus"

include(":compose-plus:functions")
include(":compose-plus:extensions")
include(":compose-plus:components")
include(":example:nativeApp")
1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.4.20-0.1.0-07(eazytec Maven)
JDK21(org.gradle.java.home 指向 Temurin 21)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle9.6.1(沿用上游 wrapper)
第三方依赖无(libs.versions.toml 只有 kotlin 一个版本号)
DevEco product5.1.0(18) 兼容
DevEco Studio26.0.0 Release
ABIarm64-v8a + x86_64
Bundlecom.terminator.ohos.composeplus
版本0.0.2-ohos.1
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块commonMain 文件依赖形态处置
functions5 上游文件(state/State.kt、state/SaveableState.kt、Animation.kt、Remember.kt、ActivityContract.kt)compose.runtime + compose.runtime.saveable + compose.animation.core,含 1 处 expect(LaunchPermissionRequest)拆:只留 Saver 半边与动画求值
extensions18 上游文件(圆角 / 切割 / 可见性 / 手势 / 滚动 / 对齐 / 尺寸 / 背景 / Then / Remember / Spacer / Animation / IfThenAnimate…)纯 Modifier 扩展 + graphicsLayer + animateFloatAsState,零 Android API拆:保留「0…1 进度驱动哪个视觉通道」的映射
components8 上游文件(Row.kt / Column.kt / Box.kt / List.kt / Direction.kt / Scrollable.kt / Spacer.kt / Animated.kt)每个文件都是若干 @Composable 包装 + Arrangement / Alignment 常量拆:保留 Arrangement.arrange() 的算术

三模块合计 31 个 commonMain 文件、143 个对外符号,ComposePlusCatalog() 就是这张表的运行时投影——它存在的意义是让「一个符号都没漏」这件事可被验收,而不是写在文档里自证。

components 值得单独说明:上游 8 个文件里绝大多数函数只是「固定一对 Arrangement + Alignment 常量再调用 Row」。例如 CenterVerticallySpaceBetweenRow 就是 Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically)。跨不过桥的不是这些函数,而是它们背后那两个常量的摆放算法,所以内核把 Arrangement 的 7 种分配策略与 Alignment 的 3 种偏置写成 LayoutResolver,一次求解给出全部子元素坐标。

1.4 页面边界
标题区        Compose Plus · OpenHarmony / Kotlin/Native 内核 · C ABI · N-API · ArkTS 宿主
Tab 切换      概览 / 布局求解 / 形状与圆角 / 状态保存 / 动画与权限
概览页        版本握手行、Catalog 三模块卡片(文件 → 符号胶囊 → 移植说明)、7 个 ABI 名单、原始 JSON
布局求解页    轴向切换、7 种主轴、3 种交叉轴、容器宽高、间距、RTL、子元素尺寸增删、placement 列表
形状与圆角页  6 档预设、circle、四角半径滑杆、CutShape 方向/进度/反向、SVG path 与包围盒
状态保存页    seed / encode / decode / registry 四种 op、8 种 kind、快照恢复、keys 列表
动画与权限页  sample / color / repeat、5 条缓动、400ms 时间轴、权限 8 条映射与真实申请
底部          内核版本与 so 链路(libohoscomposeplus.so → libcomposeplus_napi.so)

页面启动时先做版本握手,再依次拉 Catalog、跑一次默认布局。所有 Native 调用都套 try/catch,异常只走页面状态行,不跨 FFI。

第 2 阶段:目标与依赖打通

2.1 制品线验证(本适配的成败手)

先查社区 Maven(https://maven.eazytec-cloud.com)上有没有 Compose 的 ohos 制品,结论是没有:

org.jetbrains.compose.ui:ui                  → 无 ohosArm64 / ohosX64 变体
org.jetbrains.compose.foundation:foundation  → 同上
org.jetbrains.compose.animation:animation    → 同上
org.jetbrains.compose.runtime:runtime-saveable → 同上
org.jetbrains.kotlin:kotlin-stdlib           → 有(2.4.20-0.1.0-07,随鸿蒙 Kotlin 发行版)

这决定了路线:KStore 那类库可以「换线不换版」真编译,compose-plus 不行。Compose 的 UI/布局/绘制在鸿蒙上没有 ABI 层,任何「加个 target」的尝试都会停在 Unresolved reference: Modifier。

三个关键结论:其一,@Composable 不是语言特性而是编译器插件 + 运行时协议,鸿蒙 Kotlin 发行版不带 Compose 编译器插件,没有折中方案;其二,compose-plus 里真正有价值的部分(布局算术、缓动曲线、圆角几何、Saver 编解码、权限契约)不以 Compose 类型出现在签名上,因此可以被无损翻译;其三,凡是签名里带 Modifier / @Composable / State<T> 的部分,一律留在 Kotlin 侧或改由 ArkUI 承担,不试图跨桥。

2.2 工具链钉版(为什么是 2.4.20-0.1.0-07)

上游是 Kotlin 2.4.0 + Gradle 9.6.1 + AGP 9.2.0。鸿蒙线只有 2.4.20-0.1.0-07 与 2.4.0 同代,元数据版本(2.4.x)与上游一致,源码级零降级;Gradle 9.6.1 上游 wrapper 原样保留(2.4.20 线支持 Gradle 9.x,不像 2.2.21 旧线必须降 8.14.1)。

# gradle/libs.versions.toml —— 整个文件只有这一个版本号
[versions]
# HarmonyOS Kotlin distribution: the only Kotlin line that knows about
# ohosArm64 / ohosX64. Published by the community Maven (maven.eazytec-cloud.com).
kotlin = "2.4.20-0.1.0-07"

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

「零依赖」不是口号,是文件里长出来的事实:没有 kotlinx-coroutines、没有 kotlinx-serialization、没有 compose.*。少的每一个依赖,都是从「鸿蒙上有没有变体」这个问题里减掉的一次风险。

2.3 源集拆分:composeMain 不是源集

上游源码不是删除,而是 git mv 进 src/composeMain/,并且故意不注册为 source set:

// compose-plus/components/build.gradle.kts
kotlin {
    applyDefaultHierarchyTemplate()
    jvmToolchain(21)

    jvm()
    ohosArm64()
    ohosX64()

    sourceSets {
        commonMain.dependencies {
            api(projects.composePlus.extensions)   // 模块间依赖保持上游拓扑
        }
        jvmTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

// src/composeMain is intentionally NOT registered as a source set — compose.foundation /
// compose.animation have no ohos variants yet.

为什么留在树里:第一,它是审计基线——Catalog.kt 里每条 note 都对应 composeMain 里的一个具体函数,评审时可以逐条对照「上游写了什么、内核保留了哪一半」;第二,日后 Compose 若发布 ohos 变体,只需把 composeMain 改回 commonMain 并删掉内核即可平滑回退;第三,git mv 保留了 git log --follow 的历史,不制造「新增一堆文件又删掉一堆文件」的噪声提交。

2.4 内核重写:27 个文件承接 31 个上游文件
模块内核文件内容
functions6Layout 无关:Json(自研解析/写入)、Easing(5 曲线 + 2 种 spec)、Animation(animateFloat/animateColor)、Saveable(编解码 + registry + seeds)、Permissions(契约 + 映射)、Catalog
extensions15modifier/{Shape,Visibility,Align,Size,Scroll,Background,Gesture,Then}、utils/{ContentPadding,CutShape}、Animation、IfThenAnimate、Remember、Spacer、Catalog
components6Layout(LayoutResolver 核心)、Direction、Scrollable、Spacer、Animated、Catalog

27 = 22 个承接文件 + 5 个 fork 新增(Json.kt、Easing.kt、3 个 Catalog.kt)。映射是多对一的,例如 state/State.kt + state/SaveableState.kt 收敛成 Saveable.kt,utilities 与 modifier 里重复的圆角算术收敛成一个 CornerGeometry。

内核能被 JVM 直接跑,是这条路线成立的前提:

./gradlew :compose-plus:functions:compileKotlinOhosArm64 \
          :compose-plus:extensions:compileKotlinOhosArm64 \
          :compose-plus:components:compileKotlinOhosArm64     # BUILD SUCCESSFUL

ohos 编译器全程不认识 androidx,因为它压根没被 import 过。

2.5 测试与 ohos 测试任务

jvmTest 用 kotlin("test"),不引 turbine / coroutines-test 一类的额外运行时。ohos Kotlin 插件会自动生成 ohosTest / ohosSimulatorTest 任务,本构建没有设备侧测试运行器,按 cmp_qrose 的处置全局关掉:

// 根 build.gradle.kts
subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}

这样「测试只在 JVM 跑」是构建层面的强制约束,而不是口头约定;ohos 侧只承担编译与链接。

第 3 阶段:内核实现(技术核心)

这一阶段是整个适配里唯一「有智力含量」的部分:把 Compose 的四个语义域翻译成可计算的纯 Kotlin。原则只有一条——只保留能算出答案的那一半,且答案必须能被断言。

3.1 布局求解:Arrangement.arrange() 的算术

Row.kt / Column.kt / Box.kt 里所有函数,最终都归约到「给定容器尺寸、子元素尺寸、编排策略,算出每个子元素的位置」。Compose 用 Arrangement 接口做这件事,内核把它写成三个公式:

// 每个间隙的值(Arrangement 真正分配的东西)
fun gap(request: LayoutRequest): Double {
    val n = request.sizes.size
    if (n < 2) return 0.0
    val occupied = request.totalChildrenMain
    val free = (request.containerMain - occupied).coerceAtLeast(0.0)
    return when (request.mainAxis) {
        MainAxisAlignment.SpacedBy -> request.space
        MainAxisAlignment.SpaceBetween -> free / (n - 1)
        MainAxisAlignment.SpaceAround -> free / n
        MainAxisAlignment.SpaceEvenly -> free / (n + 1)
        else -> request.space
    }
}

// 首个偏移
fun leadingOffset(request: LayoutRequest): Double {
    val n = request.sizes.size
    if (n == 0) return 0.0
    val gap = gap(request)
    val occupied = request.totalChildrenMain + gap * (n - 1)
    val free = (request.containerMain - occupied).coerceAtLeast(0.0)
    return when (request.mainAxis) {
        MainAxisAlignment.Start -> 0.0
        MainAxisAlignment.Center -> free / 2.0
        MainAxisAlignment.End -> free
        MainAxisAlignment.SpaceBetween -> 0.0
        MainAxisAlignment.SpaceAround -> gap / 2.0
        MainAxisAlignment.SpaceEvenly -> gap
        MainAxisAlignment.SpacedBy -> 0.0
    }
}

三个容易写错的地方:

  1. SpaceAround / SpaceEvenly 的 leadingOffset 不经过 rest。它们的 gap 自身就含首尾配额:SpaceAround 给每个子元素发一个 free/n 的槽,首个子元素只吃掉半槽,所以是 gap/2;SpaceEvenly 首尾各有一整个间隙,所以是 gap。
  2. SpacedBy 不重新分配自由空间。它用调用方给的 space 排队,剩下多少 free 由 alignment 决定;这也是为什么它单独存在,而不是 SpaceBetween 的一个特例。
  3. 溢出不能产生负偏移。free 与 rest 全部 coerceAtLeast(0.0),子元素总宽超过容器时从 0 开始堆叠、溢出到容器外——这与 Compose 一致(Compose 也不会给你负偏移)。

逐个拜访 + RTL 镜像:

private fun resolveLinear(request: LayoutRequest): List<Placement> {
    val gap = gap(request)
    var cursor = leadingOffset(request)
    val crossSize = request.containerCross
    return request.sizes.mapIndexed { index, child ->
        val childMain = child.mainAxis(request.orientation)
        val childCross = child.crossAxis(request.orientation)
        val main = if (request.rtl) request.containerMain - cursor - childMain else cursor
        val cross = ((crossSize - childCross) * request.crossAxis.bias).coerceAtLeast(0.0)
        cursor += childMain + gap
        Placement.of(request.orientation, index, main, cross, child.width, child.height)
    }
}

rtl 只镜像 main,不动 gap 分配——所以 RTL 下仍是「从容器远端开始排,间隙沿同一方向累积」,只是整串摆放被翻到另一侧。宿主不要再翻一次,这是页面上最容易重复实现的一条。

Placement 同时给两套坐标:main/cross 是容器自身轴(「第 3 个子元素离队首多远」),x/y 是屏幕坐标(ArkUI .position({x, y}) 直接可用)。Row 是 x == main,Column 是 y == main。

手算一个 SpaceBetween:容器 300 宽、两个 50 宽子元素 → free = 200,gap = 200/(2-1) = 200,leading = 0,落位 main = 0, 250。换成 SpaceAround → gap = 100,leading = 50,落位 50, 200。换成 SpaceEvenly → gap = 66.667,leading = 66.667,落位 66.667, 183.333。这三个数在页面点一下就能对上(见 §6.5)。

Box 没有 arrangement,每个子元素按 boxAlignment 独立摆放,九宫格偏置 0 / 0.5 / 1:200×100 容器里放 20×20 与 60×40 两个子元素、Center → (90,40) 与 (70,30),各自居中、互不影响。

3.2 形状与圆角:半径算术 + SVG path

Shape.kt 的六个预设是六个数:2 / 4 / 8 / 16 / 20 / 32 dp。真正需要保留的是三条规则:

enum class CornerPreset(val dp: Double) { Tiny(2.0), Small(4.0), Medium(8.0), Large(16.0), ExtraLarge(20.0), Huge(32.0) }

// CircleShape —— 短边的一半
val circleRadius: Double get() = minOf(width, height) / 2.0

// RoundedCornerShape 会把每个角钳制在短边的一半
fun clampTo(width: Double, height: Double): CornerRadii {
    val limit = minOf(width, height) / 2.0
    return copy(
        topStart = topStart.coerceIn(0.0, limit), topEnd = topEnd.coerceIn(0.0, limit),
        bottomEnd = bottomEnd.coerceIn(0.0, limit), bottomStart = bottomStart.coerceIn(0.0, limit),
    )
}

优先级按上游重载的语义收敛成一条链:circle > preset > 四角独立值 > all,最后统一走 clampTo。ArkUI 的 borderRadius 吃 vp,而鸿蒙上 vp 与 dp 是 1:1,所以这些数字可以原样喂给宿主。

CutShape 是唯一必须输出几何而不是标量的地方。上游是 Shape.createOutline() 里 Path().moveTo/lineTo/close().let { Outline.Generic(it) };compose.ui.graphics.Path 在鸿蒙上不存在,但 ArkUI 的 Path().commands(...) 吃 SVG path 字符串,于是内核直接产出:

// M0,0 L120,0 L120,80 L0,80 Z  —— 直接喂 ArkUI Path().commands()
fun commands(): String {
    val pts = points()
    val head = pts.first()
    val tail = pts.drop(1).joinToString(" ") { (x, y) -> "L${trim(x)},${trim(y)}" }
    return buildString {
        append("M").append(trim(head.first)).append(',').append(trim(head.second))
        if (tail.isNotEmpty()) append(' ').append(tail)
        append(" Z")
    }
}

reverseCut 的处理与上游同构:上游把内容转 180°、裁剪、再转回来,等价于「显现从远端长出来」,内核直接改顶点顺序(w*(1-p) 而不是 w*p),省掉两次矩阵往返。progress 钳制在 [0,1],trim() 保留两位小数并去掉 .0,保证路径串稳定、可比对。

3.3 状态保存:只保留 Saver 那半边

上游 rememberSaveable 有两半:composition 里的持有者(MutableState)和值 ↔ Bundle 的编码器(Saver)。前者是 Compose 运行时概念,鸿蒙上由 ArkUI 的 @State 承担;后者是纯数据变换,可以完整移植。

类型标签是这层的核心——它保证 encode → decode 类型稳定,就像 autoSaver() 拒绝从 Bundle 里猜类型:

private const val TAG_BOOL = "b";  private const val TAG_STR = "s"
private const val TAG_INT = "i";   private const val TAG_LONG = "l"
private const val TAG_FLOAT = "f"; private const val TAG_DOUBLE = "d"
private const val TAG_LIST = "a";  private const val TAG_MAP = "m"

{"t":"i","v":7} 解码回来仍是 int(不是 double),这是 90 个用例里专门钉住的一条。

StateRegistry 复刻 SaveableStateRegistry 的两条语义:snapshot() 把整个注册表写成一层 JSON 对象;restore() 不做类型强转,且快照里没有的 key 一律丢弃——这正是 rememberSaveable 遗忘「composition 里已不存在」的状态的方式。StateSeeds 则一一对应上游的 8 个 remember*State 默认值(bool false、string ""、int 0、float 0f……),页面上「seed」按钮点一遍就能看出默认值有没有漏。

3.4 缓动与动画:48 次二分换 1e-5 精度

上游统一用 tween(durationMillis = 400, easing = FastOutSlowInEasing)。内核把五条曲线写成 CubicBezierEasing 的控制点,用二分解 x(t) = fraction:

enum class Easing(val x1: Double, val y1: Double, val x2: Double, val y2: Double) {
    Linear(0.0, 0.0, 1.0, 1.0),
    FastOutSlowIn(0.4, 0.0, 0.2, 1.0),
    FastOutLinearIn(0.4, 0.0, 1.0, 1.0),
    LinearOutSlowIn(0.0, 0.0, 0.2, 1.0),
    EaseInOut(0.42, 0.0, 0.58, 1.0),
    ;
    private fun solveT(x: Double): Double {
        var low = 0.0; var high = 1.0; var mid = x
        repeat(48) {
            mid = (low + high) / 2.0
            if (bezier(mid, x1, x2) < x) low = mid else high = mid
        }
        return (low + high) / 2.0
    }
}

Compose 用 Newton-Raphson + 二分兜底,这里纯二分 48 次,两者误差远小于 1e-5——远超 60/120Hz 帧预算能分辨的精度。可验证的锚点:FastOutSlowIn.transform(0.5) = 0.77556131…,经 Json 的四位小数格式化后落盘就是 0.7756,页面上「400ms 时间轴拖到一半」时看到的就是这个数。这就是「快出慢入」的字面含义(中点已经走完 77.56%)。

两个 spec 直接对应上游两种用法:

内核上游语义
TweenSpec(400, delay, easing)tween(400)fractionAt(elapsed)、isFinished(elapsed);elapsed < delay 先返回 0,duration == 0 在 delay 到点后立即返回 1
RepeatableSpec(1000, Linear, INFINITE = -1)infiniteRepeatable(tween(1000, LinearEasing))fractionAt 按 elapsed % duration 取模回绕,isFinished 对无限循环恒为 false

Animations.animateFloat/animateColor 只做一件事:给定 condition 与 spec,返回目标值(以及颜色动画的端点 ARGB/hex)。真正的逐帧插值交给 ArkUI ——内核不持有时间轴,也不该持有:C ABI 是同步的,逐帧推 60 次 Native 调用既慢又没必要。

extensions/Animation.kt 的 9 个 animate*AsState 重载被收敛成一个 AnimatedValue + AnimateKind(分量数 1/2/4:标量 1 个、offset/size 2 个、rect 4 个),宿主按 components 决定要插几个数。modifier/Visibility.kt 的五个函数(visibleIf/scaleVisibleIf/cutVisibleIf/fadeIn/scaleIn)收敛成 VisibilitySpec → LayerState:同一个 0…1 进度映射到 alpha、scaleX/scaleY、或 CutShape 的裁剪几何,fadeIn/scaleIn 的 delay = 100 也保留。

3.5 权限契约:映射而非替换

上游 LaunchPermissionRequest 是 expect 函数,Android 用 rememberLauncherForActivityResult,桌面/native/wasmJs 是空实现。鸿蒙有运行时权限模型,但它不是 Composable launcher:权限要先在 module.json5 静态声明,再通过 abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context, permissions) 动态申请。

所以内核保留契约(问谁、答案什么形状、单/多请求怎么归一),把 launcher 留给 ArkTS:

private val harmonyMapping = mapOf(
    "android.permission.CAMERA" to "ohos.permission.CAMERA",
    "android.permission.RECORD_AUDIO" to "ohos.permission.MICROPHONE",
    "android.permission.ACCESS_FINE_LOCATION" to "ohos.permission.LOCATION",
    "android.permission.ACCESS_COARSE_LOCATION" to "ohos.permission.APPROXIMATELY_LOCATION",
    "android.permission.READ_CONTACTS" to "ohos.permission.READ_CONTACTS",
    "android.permission.WRITE_CONTACTS" to "ohos.permission.WRITE_CONTACTS",
    "android.permission.READ_MEDIA_IMAGES" to "ohos.permission.READ_IMAGEVIDEO",
    "android.permission.INTERNET" to "ohos.permission.INTERNET",
)

只列语义无歧义的 8 对,其余原样透出(调用方本来也得手工处理)。PermissionResult 提供 allGranted / anyDenied / denied / asSingleResult(),正好覆盖上游两个重载的两种回调形状;requestPermissionsFromUser 返回的 authResults 与请求同序,这与上游交给 onResult 的 Map<String, Boolean> 是一一对应的。

3.6 自研 JSON:为什么不用 kotlinx-serialization

桥的进出都是 JSON,看起来引 kotlinx-serialization-json 最省事。没有引的理由有三个:

  1. 零依赖是这条路线最大的资产。每引一个库,就要重新回答一次「它在鸿蒙上有没有变体」。内核现在连 kotlinx 都不需要,libs.versions.toml 只有一个 kotlin 版本号,构建期没有第二处可能失败的地方。
  2. 输出格式要可断言。Json.formatNumber 固定四位小数、四舍五入、永不用科学计数法——0.77556131… 落盘必须是 0.7756,1e-7 不能写成 1E-7。序列化库的默认数字策略是给机器看的,桥的输出是给人对着截图核对的。
  3. 键序稳定。LinkedHashMap 写入,响应字段顺序固定(ok 永远第一个),页面上「原始 JSON」区块因此可以逐字比对,diff 不会因为键序抖动而噪声化。

代价是 314 行 Json.kt(解析 + 写入 + 取值扩展 str/num/bool/objects/strings),但这个文件同时被 Saveable 复用,净成本低于引一个序列化运行时。

第 4 阶段:桥协议建模

4.1 七个 C ABI
ir.ehsannarmani.compose_plus.{functions,extensions,components}/**(内核,纯 Kotlin)
        ↓ LayoutResolver / Easing / SaveableCodec / CornerGeometry / PermissionResults
Bridge.kt(@CName × 7,UTF-8 JSON 进出)
        ↓ const char*
N-API libcomposeplus_napi.so(7 个 JS 方法)
        ↓
ArkUI Index.ets(五页签)
C ABI 符号ArkTS 方法内核来源覆盖
ComposePlusVersioncomposePlusVersion()—fork 版本握手(0.0.2-ohos.1)
ComposePlusCatalogcomposePlusCatalog('')三模块 Catalog31 文件 / 143 符号索引 + 移植说明
ComposePlusLayoutcomposePlusLayout(req)components/Layout.ktRow/Column/Box 全部摆放算术
ComposePlusShapecomposePlusShape(req)modifier/Shape.kt + utils/CutShape.kt预设、圆、四角、SVG path
ComposePlusStatecomposePlusState(req)functions/Saveable.ktseed/encode/decode/registry
ComposePlusAnimationcomposePlusAnimation(req)Easing.kt + Animation.kt + extensions/Animation.ktsample/color/repeat/value
ComposePlusPermissionscomposePlusPermissions(req)functions/Permissions.ktmap/result
4.2 JSON 契约
请求(布局): {"orientation":"row","width":300,"height":100,"mainAxis":"spaceBetween",
              "crossAxis":"center","space":0,"rtl":false,
              "sizes":[{"w":50,"h":20},{"w":50,"h":20}]}
响应(布局): {"ok":true,"orientation":"row","mainAxis":"SpaceBetween","crossAxis":"Center",
              "rtl":false,"count":2,"gap":200,"leadingOffset":0,
              "placements":[{"index":0,"main":0,"cross":40,"x":0,"y":40,"w":50,"h":20},
                            {"index":1,"main":250,"cross":40,"x":250,"y":40,"w":50,"h":20}]}

请求(形状): {"width":120,"height":80,"preset":"medium","circle":false,
              "cutDirection":"horizontal","cutProgress":0.6}
响应(形状): {"ok":true,"preset":"Medium","presetDp":8,"circle":false,"radius":0,
              "radii":{"topStart":8,"topEnd":8,"bottomEnd":8,"bottomStart":8},"uniform":true,
              "cut":{"direction":"horizontal","progress":0.6,"reverse":false,
                     "commands":"M0,0 L72,0 L72,80 L0,80 Z","left":0,"top":0,"right":72,"bottom":80},
              "presets":[{"name":"Tiny","dp":2},…]}

请求(状态): {"op":"encode","value":{"t":"i","v":7}}
响应(状态): {"ok":true,"op":"encode","kind":"int","size":1,"json":"{\"t\":\"i\",\"v\":7}"}

请求(动画): {"op":"sample","elapsed":200,"duration":400,"easing":"fastOutSlowIn",
              "condition":true,"first":1,"second":0}
响应(动画): {"ok":true,"op":"sample","target":1,"fraction":0.7756,"value":0.7756,
              "finished":false,"duration":400,"delay":0,"easing":"FastOutSlowIn"}

三条设计上的不对称,都是有意的:

  • 布局一次给全量:不让宿主「问第 i 个在哪」,因为 gap 是全局分配,逐个子元素问会把 O(n) 变成 O(n²),且宿主必须自己保存 cursor 状态——那就等于把内核算法抄一遍。
  • 形状给「参数化的结果」而非「参数」:radii 是钳制后的值(不是用户输入值),commands 是可直接绘制的路径串,cut.bounds 是等价的矩形。宿主不需要知道 clampTo 和 reverse 怎么算。
  • 动画只给目标与采样点:target 是终值,fraction/value 是给定 elapsed 的瞬时值。时间轴在 ArkUI 侧(this.getUIContext().animateTo(...)),内核不做 requestAnimationFrame 的替身。
4.3 错误通道

每个导出统一走 guard {},Kotlin 异常永远不跨 FFI:

private fun guard(block: () -> JsonValue): String = try {
    val body = block()
    Json.write(
        when (body) {
            is JsonValue.Obj -> {
                val fields = LinkedHashMap(body.fields)
                if (!fields.containsKey("ok")) fields["ok"] = jsonBool(true)
                JsonValue.Obj(fields)
            }
            else -> body
        },
    )
} catch (t: Throwable) {
    """{"ok":false,"error":${Json.write(jsonString(t.message ?: t.toString()))}}"""
}

它顺手做了两件事:给所有成功响应补齐 ok:true(调用方只需判断一处),以及把 error.message 做一次 JSON 转义(错误文本里有引号也不会破坏响应结构)。非法 JSON、未知 op、尺寸为负(require 抛 IllegalArgumentException)、presets 名字拼错,全部落到同一个通道,ArkTS 侧统一渲染。

4.4 哪些东西刻意不跨桥
跨不过去的原因归宿
@Composable 函数编译器插件 + 组合运行时协议删掉,语义转成内核类型
Modifier 链不可变对象图,无稳定 ABI转成「参数 → 结果」的纯函数
State<T> / MutableStatecomposition 内的可变容器,生命周期绑定ArkUI @State
Arrangement / Alignment 常量布局期回调,依赖 measure 结果LayoutResolver 算术
Shape.createOutline依赖 Size/Density/Path 运行时SVG path 字符串
updates 式订阅C ABI 是同步一来一回调用后读回 + 页面状态行

这张表就是「适配边界」的定义:凡是要跨过去的,必须先变成可序列化的数据;变不成的,就换成宿主的原生机制。

第 5 阶段:原生桥接(技术难点)

5.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libcomposeplus_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohoscomposeplus.so)
  │ 7 个 @CName 符号
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 与生命周期问题(谁 free、什么时候 free、跨线程能不能碰);只导出整数又会让页面复制内核语义。C ABI + JSON 是唯一同时满足「稳定」「够用」「可调试」的形态——hdc shell hilog 里能直接看到 JSON 原文,问题定位不需要符号表。

5.2 Kotlin/Native 导出
private const val VERSION = "0.0.2-ohos.1"

@CName("ComposePlusVersion")
fun composePlusVersion(): String = VERSION

@CName("ComposePlusLayout")
fun composePlusLayout(request: String): String = guard { /* … */ }

// …Catalog / Shape / State / Animation / Permissions 同构

binaries.sharedLib { baseName = "ohoscomposeplus" } 双 ABI 产出 libohoscomposeplus.so,copyKnSo* 把 so 与匹配的 libc++_shared.so 回填到 entry/libs/<abi>/:

fun cxxShared(triple: String): File {
    val deps = file("${System.getProperty("user.home")}/.konan/dependencies")
    val hits = deps.listFiles().orEmpty().map { File(it, "lib/$triple/libc++_shared.so") }.filter { it.isFile }
    check(hits.isNotEmpty()) { "libc++_shared.so for $triple not found under $deps" }
    return hits.first()
}

tasks.matching { it.name == "linkDebugSharedOhosArm64" }.configureEach { finalizedBy("copyKnSoArm64") }
tasks.matching { it.name == "linkDebugSharedOhosX64" }.configureEach { finalizedBy("copyKnSoX64") }

triple 分别是 aarch64-linux-ohos 与 x86_64-linux-ohos。回填是强制的:entry/libs/<abi>/ 少一个 so,安装期就会报 9568347。

5.3 N-API 方法分发

7 个回调共用一套模板,避免七份重复的参数校验:

using RequestFn = const char *(*)(const char *request);

napi_value InvokeWithRequest(napi_env env, napi_callback_info info, const char *abiName, RequestFn fn) {
    size_t argc = 1;
    napi_value args[1] = {nullptr};
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    std::string request;
    if (argc >= 1) {
        napi_valuetype type = napi_undefined;
        napi_typeof(env, args[0], &type);
        if (type == napi_string) {
            request = JsToUtf8(env, args[0]);
        } else if (type != napi_undefined && type != napi_null) {
            napi_throw_type_error(env, nullptr, (std::string(abiName) + " argument must be a string").c_str());
            return nullptr;
        }
    }
    OH_LOG_DEBUG(LOG_APP, "%{public}s request bytes=%{public}zu", abiName, request.size());
    return MakeJsString(env, fn(request.c_str()), abiName);
}

ComposePlusVersion 不吃参数,单独一个函数(顺带当 dlopen 探针):

napi_value NapiVersion(napi_env env, napi_callback_info info) {
    (void)info;
    const char *raw = ComposePlusVersion();
    // 这条能打出来,说明 libcomposeplus_napi.so 已 dlopen、KN 运行时已初始化。
    OH_LOG_INFO(LOG_APP, "ComposePlusVersion -> %{public}s", raw == nullptr ? "(null)" : raw);
    return MakeJsString(env, raw, "ComposePlusVersion");
}

JsToUtf8 里有个 API 26 的坑:napi_get_value_string_utf8 的缓冲区参数不是 const,而 C++11 的 std::string::data() 返回 const char*。处置是先量长度、开 std::vector<char>、拷完再构造 std::string——所以这段代码不能用 const_cast 抄近路。

5.4 内存纪律:DisposeString,绝不 free

KN 返回的字符串属于 Kotlin 运行时(有自己的分配器与 GC),必须归还:

void DisposeKnString(const char *raw) {
    if (raw == nullptr) return;
    libohoscomposeplus_ExportedSymbols *symbols = libohoscomposeplus_symbols();
    if (symbols != nullptr && symbols->DisposeString != nullptr) {
        symbols->DisposeString(raw);
    }
}

napi_value MakeJsString(napi_env env, const char *raw, const char *abiName) {
    if (raw == nullptr) { /* 抛错 */ }
    napi_value out = nullptr;
    napi_status st = napi_create_string_utf8(env, raw, NAPI_AUTO_LENGTH, &out);
    DisposeKnString(raw);             // 复制进 JS 字符串之后立刻归还
    if (st != napi_ok || out == nullptr) { /* 抛错 */ }
    return out;
}

libohoscomposeplus_api.h 按社区已审仓库同款手写,只声明 ExportedSymbols 结构与 7 个业务符号,不拖整份生成头——生成头里几十个 createNullable* 对本项目毫无用处。

5.5 CMake:$ORIGIN 与短名 NEEDED
set(KN_SO_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${OHOS_ARCH})
if(NOT EXISTS ${KN_SO})   # KN_SO = ${KN_SO_DIR}/libohoscomposeplus.so
    message(FATAL_ERROR "Missing ${KN_SO}. Run :example:nativeApp:linkDebugSharedOhosArm64 / OhosX64 first.")
endif()

add_library(ohoscomposeplus SHARED IMPORTED)
set_target_properties(ohoscomposeplus PROPERTIES
    IMPORTED_LOCATION ${KN_SO}
    IMPORTED_NO_SONAME TRUE)      # 否则绝对路径会被写进 NEEDED

target_link_options(composeplus_napi PRIVATE "-Wl,--no-undefined" "-Wl,-rpath,\$ORIGIN")
set_target_properties(composeplus_napi PROPERTIES
    BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN" BUILD_WITH_INSTALL_RPATH TRUE SKIP_BUILD_RPATH FALSE)

add_custom_command(TARGET composeplus_napi POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different ${KN_SO} $<TARGET_FILE_DIR:composeplus_napi>
    COMMAND ${CMAKE_COMMAND} -E copy_if_different ${KN_SO_DIR}/libc++_shared.so $<TARGET_FILE_DIR:composeplus_napi>)

三件事缺一不可:OHOS_ARCH 为空或 KN so 缺失直接 FATAL_ERROR(否则会在链接期报一堆莫名其妙的不定点错误);IMPORTED_NO_SONAME + $ORIGIN 保证 NEEDED 里只有短名,不会出现 Windows 盘符;POST_BUILD 把 KN so 与 libc++ 拷到与 NAPI so 同目录,dlopen 时互相可见。

// types/libcomposeplus_napi/index.d.ts
export const composePlusVersion: () => string;
export const composePlusCatalog: (request: string) => string;
export const composePlusLayout: (request: string) => string;
export const composePlusShape: (request: string) => string;
export const composePlusState: (request: string) => string;
export const composePlusAnimation: (request: string) => string;
export const composePlusPermissions: (request: string) => string;

第 6 阶段:ArkUI 宿主

6.1 五页签结构
标题区   Compose Plus · OpenHarmony / Kotlin/Native 内核 · C ABI · N-API · ArkTS 宿主
Tabs     概览 | 布局求解 | 形状与圆角 | 状态保存 | 动画与权限
底部     Kotlin/Native 内核 0.0.2-ohos.1 · libohoscomposeplus.so → libcomposeplus_napi.so

页面用五个 Scroll + Tabs(barMode: BarMode.Scrollable、scrollable(false),避免横滑误触),每个页签是一组 SectionCard:标题 + 内容卡片 + JsonBlock(原始响应)+ ErrorText(错误原文)。所有交互控件都收敛成三个可复用小组件:SliderRow、ToggleRow、ChipGroup。

6.2 首个真坑:@BuilderParam 的 this 绑定(启动即崩)

第一版页面把内容块写成 SectionCard({ title: '…', content: this.CatalogModulesContent }),编译期零警告,启动即崩:

Reason:TypeError
Error message:Cannot read property length of undefined
Stacktrace:
  at anonymous entry (entry/src/main/ets/pages/Index.ets:545:31)
  at CatalogModulesContent entry (entry/src/main/ets/pages/Index.ets:544:25)
  at initialRender entry (entry/src/main/ets/pages/Index.ets:169:7)

545 行是 if (this.catalogModules.length === 0),169 行是 SectionCard.build() 里的 this.content()。根因不在数据,而在 this:@Builder 以引用形式传给 @BuilderParam 时,子组件调用 this.content() 的 this 指向子组件 SectionCard,它只有 title 和 content 两个字段,catalogModules 是 undefined。

ArkTS 的规矩是「要绑父组件就显式包一层」,12 处调用点全部改成箭头函数:

SectionCard({ title: `模块清单 (${this.catalogModuleCount})`, content: () => { this.CatalogModulesContent() } })
SectionCard({ title: 'C ABI 导出函数', content: () => { this.CatalogAbiContent() } })
// …共 12 处

同一条规则的另一半:TabContent() { this.OverviewPanel() } 这种就地调用不受影响,this 仍是页面组件。所以排查时的判据很简单——看它是「传引用」还是「就地调用」。

6.3 首帧不碰 Kotlin/Native

libohoscomposeplus.so 的 dlopen + KN 运行时首次初始化(GC、内存管理器、tiered 编译)在真机上要几十到上百毫秒。放在 aboutToAppear 里同步跑,等于把首帧渲染堵在 UI 线程上:

aboutToAppear(): void {
  const seed: SizeInput[] = [];
  seed.push({ w: 60, h: 40 }); seed.push({ w: 80, h: 60 }); seed.push({ w: 50, h: 50 });
  this.sizes = seed;
  // 首帧不同步触碰 Kotlin/Native:dlopen + KN 首次初始化较重,推到下一帧执行。
  this.bootTimer = setTimeout(() => {
    this.bootTimer = -1;
    this.reloadOverview();     // ComposePlusVersion + ComposePlusCatalog
    this.solveLayout();        // ComposePlusLayout
    this.solveShape();         // ComposePlusShape
    this.solveState();         // ComposePlusState
    this.solveAnimation();     // ComposePlusAnimation
    this.mapPermissions();     // ComposePlusPermissions
  }, 0);
}

aboutToDisappear(): void {
  this.stopTimer();
  if (this.bootTimer !== -1) { clearTimeout(this.bootTimer); this.bootTimer = -1; }
}

页面先渲染出来(底部版本行显示「未连接」),下一帧内核就绪后回填。视觉上是一次极短的白→满,体验上换掉了首帧卡顿与「无响应被杀」的风险。

6.4 权限:静态声明 + 动态申请,两边必须对齐

鸿蒙要求权限先在 module.json5 静态声明,再动态申请;而且 requestPermissionsFromUser 里出现未声明的权限会被服务端直接拒绝。所以三个地方必须一一对应:

// module.json5 —— 只声明这三个
"requestPermissions": [
  { "name": "ohos.permission.INTERNET",    "reason": "$string:reason_internet",    "usedScene": { "abilities": ["EntryAbility"], "when": "always" } },
  { "name": "ohos.permission.CAMERA",      "reason": "$string:reason_camera",      "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } },
  { "name": "ohos.permission.MICROPHONE",  "reason": "$string:reason_microphone",  "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } },
]
// 与 module.json5、内核映射表三方对齐
private readonly androidPerms: string[] = [
  'android.permission.CAMERA',
  'android.permission.RECORD_AUDIO',
  'android.permission.INTERNET'
];

申请走 abilityAccessCtrl,并把结果回灌给内核的 op=result 做归一(allGranted / anyDenied / denied):

private requestPermissions(): void {
  const wanted: Permissions[] = [];
  for (let i = 0; i < this.permHarmony.length; i++) {
    wanted.push(this.permHarmony[i] as Permissions);
  }
  const hostContext: common.Context | undefined = this.getUIContext().getHostContext();
  if (hostContext === undefined) { this.permError = '获取 UIAbility 上下文失败'; return; }
  const context: common.UIAbilityContext = hostContext as common.UIAbilityContext;

  const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  atManager.requestPermissionsFromUser(context, wanted).then((data: PermissionRequestResult) => {
    const granted: string[] = [];
    const perms: string[] = data.permissions;
    const results: number[] = data.authResults;   // 与 perms 同序,0 表示已授权
    for (let i = 0; i < perms.length; i++) {
      if (results[i] === 0) granted.push(perms[i]);
    }
    this.submitPermissionResult(granted);         // 回灌内核 op=result 做归一
  }).catch((err: BusinessError) => {
    this.permError = `requestPermissionsFromUser 失败: ${err.code} ${err.message}`;
  });
}

authResults 与 permissions 同序、0 表示已授权——这正是内核 PermissionResults.from(request, granted) 需要的输入形状,所以「系统返回」到「内核归一」之间不需要任何中间转换。

降级教训:早期版本声明了 7 个权限(含 READ_MEDIA / WRITE_MEDIA 两个 API 12 起废弃的项,以及定位两件套),在真机上属于「多声明但用不上」的负债——不仅是审核负担,还容易和 androidPerms 列表错位。裁到 3 个之后,「声明 = 映射 = 申请」三者严格相等,这条链路才可验证。

6.5 ArkTS 编译期 API 适配

首次 CompileArkTS 报了 4 个错、4 个警告,全部是 SDK 版本面的事,与内核无关:

现象真正原因处置
CopyOptions.Device 不存在枚举名是 LocalDevice改 CopyOptions.LocalDevice
arkts-no-untyped-obj-literalsreturn {}; 返回无类型空对象字面量删掉 parseInput(),改 jsonText() 直接内联 JSON 文本(顺带避免 parse/stringify 的键序重排)
string 不能赋给 Permissions权限枚举类型this.permHarmony[i] as Permissions
Circle().fill() 报错该 API 在 SDK 26 才有,当前 compat 是 5.1.0(18)用 Row().width(6).height(6).borderRadius(3).backgroundColor(PRIMARY) 画圆点
animateTo 已废弃全局函数迁到 UIContextthis.getUIContext().animateTo(...)
getContext(this) 已废弃同上this.getUIContext().getHostContext(),并判 undefined

这些错误有个共同点:都是「页面 API 随 SDK 演进」的问题,而不是适配逻辑的问题。所以处置原则是「改调用点,不改架构」——内核、C ABI、N-API 一行没动。

第 7 阶段:示例与验证

7.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                     双 ABI sharedLib(baseName=ohoscomposeplus)+ copyKnSo*
│   └── src/ohosMain/kotlin/org/terminator/ohos/composeplus/
│       └── Bridge.kt                        7 个 @CName + guard + JSON helpers
└── harmonyApp/
    ├── build-profile.json5                  signingConfigs 留空(签名由 DevEco 自动生成回填)
    ├── AppScope/app.json5                    bundleName com.terminator.ohos.composeplus、0.0.2-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/           linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt        IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp         7 个 N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohoscomposeplus_api.h
        ├── src/main/cpp/types/libcomposeplus_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets       ArkUI 五页签验收页面
7.2 构建、签名和安装
./gradlew :compose-plus:functions:jvmTest :compose-plus:extensions:jvmTest :compose-plus:components:jvmTest
./gradlew compileKotlinOhosArm64 compileKotlinOhosX64
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon

签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,勾选自动签名后由 IDE 回填带 material 的完整配置)。

hdc list targets -v
hdc -t <设备序列号> install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start -a EntryAbility -b com.terminator.ohos.composeplus -m entry
7.3 日志探针

N-API 层在 Init 与 NapiVersion 各留一条 OH_LOG_INFO(tag ComposePlusNapi)。这两行是「层间二分」的锚点:

hdc shell hilog | grep -iE "ComposePlusNapi|composeplus|dlopen"
# 期望看到:
#   composeplus_napi Init: 7 properties
#   ComposePlusVersion -> 0.0.2-ohos.1

打得出来 ⇒ so 已 dlopen 且 KN 运行时初始化完成,问题在 ArkTS 侧;打不出来 ⇒ 问题在打包/依赖/ABI 侧。这类探针比「猜」便宜得多。

四、完整代码对照

4.1 整体架构

compose-plus 上游源码(31 文件,src/composeMain,作为语义基线)
    │ @Composable / Modifier / Arrangement / Shape / State<T>
    ▼(人工翻译,一对一或一对多)
纯 Kotlin 内核(27 文件,src/commonMain,零第三方依赖)
    │ LayoutResolver / Easing / SaveableCodec / CornerGeometry / PermissionResults
Bridge.kt
    │ 7 个 @CName,UTF-8 JSON 进出,guard 兜底
libohoscomposeplus.so(arm64-v8a / x86_64)
    │ C ABI
libcomposeplus_napi.so(CMake,$ORIGIN,DisposeString)
    │ 7 个 JS 方法
Index.ets
    │ JSON.parse + @State
    ▼
ArkUI 五页签(概览 / 布局求解 / 形状与圆角 / 状态保存 / 动画与权限)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 两处置前、ohos_compose_plus、只 include 三库 + nativeApp
gradle/libs.versions.toml全仓唯一版本号:Kotlin 2.4.20-0.1.0-07
build.gradle.kts(根)版本 0.0.2-ohos.1、全局禁用 Ohos*Test* 任务
compose-plus/*/build.gradle.kts纯 KMP:jvm() + ohosArm64() + ohosX64(),composeMain 不注册为源集
functions/.../Json.kt自研 JSON 解析/写入,四位小数、非科学计数、键序稳定
functions/.../Easing.kt5 条 cubic-bezier + TweenSpec + RepeatableSpec
functions/.../Saveable.kt8 类型标签编解码 + StateRegistry + StateSeeds
functions/.../Animation.ktanimateFloat / animateColor(只给目标 + 采样)
functions/.../Permissions.kt权限契约 + 8 条映射 + 结果归一
extensions/.../modifier/Shape.kt6 档预设、圆、四角、clampTo
extensions/.../utils/CutShape.kt切割几何 + SVG path 输出
extensions/.../modifier/Visibility.ktalpha / scale / cut 三通道 + fadeIn delay
components/.../Layout.ktgap / leadingOffset / Placement / RTL / Box
example/nativeApp/.../Bridge.kt7 个 C ABI、guard、JSON 取值助手
harmonyApp/.../cpp/napi_init.cpp7 个 N-API 方法、类型检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED KN so、$ORIGIN、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 五页签验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档(19 节)

4.3 关键 API 对照

上游(Compose)内核(纯 Kotlin)作用
Arrangement.SpaceBetween 等 7 种MainAxisAlignment + LayoutResolver.gap/leadingOffset主轴分配
Alignment.CenterVertically 等 3 种CrossAxisAlignment(bias)交叉轴偏置
Box(contentAlignment = …) 9 种BoxAlignment(xBias, yBias)独立摆放
Row / Column / Box / ListLayoutOrientation + Placement(main,cross,x,y)坐标输出
RoundedCornerShape(dp) / CircleShapeCornerPreset / CornerRadii / CornerGeometry半径算术
Shape.createOutline() + PathCutGeometry.commands() → SVG path切割几何
Modifier.visibleIf/scaleVisibleIf/cutVisibleIfVisibilitySpec → LayerState0…1 进度映射
rememberSaveable 的 SaverSaveableCodec + StateRegistry编解码与快照
remember*State 8 个StateSeeds 8 个工厂默认值
animate*AsState(tween(400))TweenSpec + Easing缓动求值
infiniteRepeatable(tween(1000, Linear))RepeatableSpec(1000, Linear, -1)无限循环
9 个 animate*AsState 重载AnimatedValue + AnimateKind分量数 1/2/4
LaunchPermissionRequest(expect)PermissionRequest / PermissionResults契约 + 映射

4.4 ArkTS 与 Kotlin 的边界

// ArkTS:只做「拼请求 → 解析 → 刷状态」
const res: PlacementResponse = JSON.parse(composePlusLayout(JSON.stringify({
  orientation: 'row', width: 300, height: 100,
  mainAxis: 'spaceBetween', crossAxis: 'center', space: 0, rtl: false,
  sizes: [{ w: 50, h: 20 }, { w: 50, h: 20 }]
}))) as PlacementResponse;
// res.placements[1].x === 250
// Kotlin:只做「算」,一次算完所有子元素
val req = LayoutRequest(
    orientation = LayoutOrientation.Row, width = 300.0, height = 100.0,
    mainAxis = MainAxisAlignment.parse("spaceBetween"),
    crossAxis = CrossAxisAlignment.parse("center"),
    space = 0.0, rtl = false,
    sizes = listOf(ChildSize(50.0, 20.0), ChildSize(50.0, 20.0)),
)
val placements = LayoutResolver.resolve(req)   // [(main=0,cross=40), (main=250,cross=40)]

两者之间只传输扁平 JSON 字符串,不传 Kotlin 对象、不传函数、不传未校验的动态结构。

五、关键决策说明

决策 1:抽内核而不是真编译整库

KStore 那类库能「真编译」,是因为它依赖的 kotlinx 制品线在鸿蒙上齐了;compose-plus 依赖的 Compose 一个变体都没有,加 target、换仓库、降版本都无解。所以路线不是选择题,而是约束下的唯一解:把可移植语义抽成纯 Kotlin 内核。代价是「内核是重写的,不是原件的」;对冲手段是90 项 JVM 用例 + 235 个静态断言,以及 Catalog.kt 里 143 条符号级的移植说明。

决策 2:上游源码留在 composeMain,而不是删掉或塞进构建

删掉会失去审计基线(无法回答「这个内核类型对应上游哪个函数、保留了哪一半」);塞进构建会直接把 androidx unresolved 打回来。留在树里、不注册源集,是唯一同时满足「可追溯」「可回退」「不参与编译」的形态。git mv 还保住了 git log --follow。

决策 3:内核零第三方依赖,JSON 自己写

每引一个库就要重新回答一次「鸿蒙上有没有变体」。内核现在只依赖 stdlib,libs.versions.toml 只有一个版本号,构建期没有第二处可能失败的地方。自研 JSON 的额外收益是输出可断言:四位小数、非科学计数、键序稳定——0.7756 这种数字要能对着截图核对,序列化库的默认数字策略做不到。

决策 4:C ABI + JSON,而不是导出对象或反射

导出 Kotlin 对象会暴露 ABI 与生命周期(谁 free、跨线程能否碰、异常怎么传);导出整数会让页面复制内核语义。JSON 是「稳定、够用、可调试」的交点:hilog 里能看到原文,guard 保证异常不跨 FFI,DisposeString 把内存归还运行时。

决策 5:布局一次返回全部 placement

逐个子元素查询会把 O(n) 变 O(n²),而且要求宿主自己维护 cursor 状态——那等于把 Arrangement 算法抄一遍,抄错就没人能发现。一次返回全量,宿主的职责退化成「按坐标摆」,正确性完全落在内核与 JVM 用例上。

决策 6:jvm() 不是历史包袱,是验收闸门

三个模块都保留 jvm() 目标,不是为了发布 JVM 产物,而是为了让同一份 commonMain 能跑在普通 JVM 上做断言。只有 ohos 编译通过是不够的——编译通过只证明语法合法,90 个用例才证明算术没漂。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Temurin 21.0.12)、Gradle 9.6.1、DevEco Studio 26.0、OpenHarmony 命令行工具(hvigor / hdc)与 HarmonyOS 真机(HarmonyOS-7.0.0/phone_all_x86,API 26,x86_64)。

6.2 JVM 交叉验证

./gradlew :compose-plus:functions:jvmTest :compose-plus:extensions:jvmTest :compose-plus:components:jvmTest

90 项用例全部通过,静态断言调用点 235 个(78 / 96 / 61,不含循环展开):

测试类用例数静态断言钉住什么
FunctionsKernelTest2878JSON 往返与数字格式、5 条缓动的端点/单调/中点、TweenSpec 的 delay 与零时长、SaveableCodec 的类型标签、StateRegistry 快照与丢弃、权限映射 8 对
ExtensionsKernelTest36966 档预设、CircleShape、四角钳制、CutShape 顶点/路径/RTL 反向、VisibilitySpec 三通道、ContentPadding、IfThenAnimate 条件、Remember 身份键缓存
ComponentsKernelTest26617 种主轴 × 3 种交叉轴、RTL 镜像、Box 九宫格、溢出非负、columnSwapsMainAndCross、SpacerScale、ScrollSpec

几条有代表性的断言:

  • fastOutSlowInMatchesComposeControlPoints:transform(0.5) = 0.77556… → 落盘 0.7756;
  • columnSwapsMainAndCross:Row 与 Column 互换容器宽高后,main 序列完全一致,x/y 按 §3.1 的规则旋转;
  • overflowNeverProducesNegativeOffsets:50 宽容器放两个 40 宽子元素,SpaceBetween 下所有 main >= 0;
  • boxPlacesEveryChildIndependently:200×100 + Center → (90,40) 与 (70,30);
  • 解码 {"t":"i","v":7} 后 kind == "int"(不是 double)。

6.3 分层验收 L1–L6

层命令 / 证据结果
L1 验证层三个 jvmTestBUILD SUCCESSFUL,28 + 36 + 26 = 90 用例全绿
L2 编译层compileKotlinOhosArm64 / OhosX64三份 klib,ohos 编译器全程不认识 androidx
L3 链接层linkDebugSharedOhosArm64 / OhosX64 + copyKnSo*entry/libs/arm64-v8a/ 与 x86_64/ 各有 libohoscomposeplus.so + 匹配的 libc++_shared.so
L4 符号层llvm-nm -D7 个符号在双 ABI 上都是全局 T
L5 打包层hvigorw assembleHapentry-default-unsigned.hap 产出(签名由 DevEco 完成)
L6 设备层安装 + aa start + 五页签交互见 §6.5
llvm-nm -D example/harmonyApp/entry/libs/arm64-v8a/libohoscomposeplus.so \
  | grep -E "ComposePlus(Version|Catalog|Layout|Shape|State|Animation|Permissions)"
# 期望:7 行 T 符号

llvm-readelf -d example/harmonyApp/entry/libs/arm64-v8a/libcomposeplus_napi.so
# 期望:NEEDED 只出现短名(libohoscomposeplus.so / libace_napi.z.so / libhilog_ndk.z.so / libc++_shared.so)
#       不出现任何盘符

6.4 HAP 与真机

HAP 内含双 ABI 各三件套(libcomposeplus_napi.so / libohoscomposeplus.so / libc++_shared.so)。三个 so 缺一不可:缺 NAPI so 会 import 失败,缺 KN so 会在 CMake 配置期报 Missing …/libohoscomposeplus.so,缺 libc++_shared.so 安装期就会 9568347。

签名走 DevEco 自动签名(证书落本机 ~/.ohos/config,不入库),安装与启动:

hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.terminator.ohos.composeplus -m entry

6.5 功能用例

以下为逐项验收口径,数字均可与内核用例对上:

  1. 版本握手:底部与概览顶部显示 0.0.2-ohos.1(来自 ComposePlusVersion,非静态文案);
  2. 索引完整性:概览页显示 3 模块 / 31 文件 / 143 符号,每条带移植说明(截图即此页);
  3. 布局求解:300 宽容器两个 50 宽子元素——Start → main = 0, 50;SpaceBetween → 0, 250,gap = 200;SpaceAround → 50, 200;SpaceEvenly → 66.667, 183.333;切 column 后 main 序列不变、x/y 旋转;
  4. RTL:勾上后单个 50 宽子元素从 main = 0 变 250;
  5. 形状:六个预设逐个点,presetDp 依次 2 / 4 / 8 / 16 / 20 / 32;勾 circle 后 radius = min(w,h)/2、uniform = true;拖 cutProgress,commands 线性收缩并在 0/1 处钳制;
  6. 状态保存:8 种 kind 各自 seed;编码 {"t":"i","v":7} 再解码仍是 int;两个 key 的快照用单 key 快照恢复后 keys 只剩一个;
  7. 动画:sample 在 400ms 处 fraction = 1、finished = true;200ms 处 0.7756;repeat 在 1000ms 处 cycle 回绕到 0;
  8. 权限:映射页列出 8 条(CAMERA→CAMERA 未变、RECORD_AUDIO→MICROPHONE 变了),真实申请返回 allGranted / denied 并回灌内核归一。

6.6 验证结论

内核、Kotlin/Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证:应用在鸿蒙真机上安装并进入前台,概览页取到 Native 返回的 Catalog 全量索引(见下节效果图),底部版本行显示真实握手结果。

七、运行效果

7.1 运行截图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

页面顶部是标题与副标题,页签为「概览 / 布局求解 / 形状与圆角 / 状态保存 / 动画与权限」;正文是 functions 模块的三张卡片:state/SaveableState.kt 的 9 个 rememberSaveable* 符号、Animation.kt 的 rememberAnimatable / animateFloat / animateColor、Remember.kt 的 rememberFilter / rememberMap,每条下方是该符号「在鸿蒙上变成了什么」的说明。底部一行是 Kotlin/Native 内核 0.0.2-ohos.1 · libohoscomposeplus.so → libcomposeplus_napi.so——这三段文字全部来自 Native 调用,不是硬编码。

7.2 命令速查

git clone https://atomgit.com/oh-tpc/compose-plus.git
cd compose-plus
export JAVA_HOME="/path/to/jdk-21"

./gradlew :compose-plus:functions:jvmTest :compose-plus:extensions:jvmTest :compose-plus:components:jvmTest  # 90 项
./gradlew compileKotlinOhosArm64 compileKotlinOhosX64                                                        # klib
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64               # 双 ABI so

cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.terminator.ohos.composeplus -m entry
hdc shell hilog | grep -E "ComposePlus|composeplus"

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 CMP 适配,必须让三个库模块真正编译成 ohosArm64 / ohosX64 KLIB 并链接成 .so;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 和 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 拆源集时最容易漏文件:只要有一个 androidx.compose.* 的 import 留在 commonMain,JVM 编译立刻 unresolved。判据只有一个——commonMain 里搜不到 androidx;
  4. composeMain 千万别注册成 source set,否则 ohosArm64 编译期一定会去解析 Compose 依赖,报错位置还会漂到依赖块;
  5. 根构建禁用 Ohos*Test* 任务要写成 contains("Ohos") && contains("Test"),只匹配 OhosTest 会漏掉 ohosSimulatorTest 一类变体名;
  6. libc++_shared.so 必须按 triple(aarch64-linux-ohos / x86_64-linux-ohos)去 ~/.konan/dependencies/*/lib/<triple>/ 找,路径写错会得到「找不到文件」而不是「ABI 不匹配」这种更贵的错误;
  7. IMPORTED_NO_SONAME 不加,CMake 会把 F:\... 之类的绝对路径写进 NEEDED,设备上必然 dlopen 失败;
  8. 插桩发现:napi_get_value_string_utf8 在 API 26 上缓冲区非 const,C++11 的 std::string::data() 编不过,必须 std::vector<char> 两步走;
  9. @BuilderParam 的 this 绑定:SectionCard({ content: this.XxxContent }) 传引用,子组件调用时 this 指向子组件,undefined.length 直接 TypeError 崩在首帧。正确写法是 content: () => { this.XxxContent() }。本项目 12 处调用点全部踩过,TabContent() { this.XxxPanel() } 这种就地调用则不受影响;
  10. 首帧不要同步 dlopen + 初始化 KN 运行时:真机上足以把 UI 线程堵住,用 setTimeout(…, 0) 推到下一帧并在 aboutToDisappear 里 clearTimeout;
  11. 权限三方对齐(module.json5 静态声明 / ArkTS androidPerms 列表 / 内核映射表)任意一处不一致,requestPermissionsFromUser 都会被拒——声明多少就申请多少,映射不过去就别声明;
  12. 根 .gitignore 的 /build/、.idea 是根锚定模式,盖不住嵌套目录。DevEco 打开工程后生成的 example/harmonyApp/.idea/、.hvigor/、entry/build/、entry/libs/ 要单独追加,*.so 全局排除;
  13. build-profile.json5 的 signingConfigs 条目缺 material 字段会被 hvigor schema 拒绝(00303038 must have required property 'material')——仓库内留空数组,勾选自动签名后由 IDE 回填;
  14. KN 返回的字符串必须 libohoscomposeplus_symbols()->DisposeString 归还运行时,不能 free()——那不是同一套分配器。

8.2 已知问题

  • Compose UI 不跨平台:本项目不提供 Compose 渲染后端,@Composable 语义由 ArkUI 原生控件承担;app/ 里的 Android/Desktop 示例仍依赖上游 Compose;
  • 颜色动画只给端点:animateColor 返回 fromArgb / toArgb 与 fraction,中间帧由宿主插值;若要与 Android 逐帧一致,需要在 ArkUI 侧按同一色彩空间(Oklab)插值,内核不做这件事;
  • Modifier 链不可重建:上游 Modifier.a().b().c() 的链式语义(顺序、覆盖、裁剪层级)不跨桥,内核只保留每条扩展的结果(几何/进度/参数),链式组合由宿主自行决定;
  • updates 式订阅没有:C ABI 是同步一来一回,状态变化靠「操作后读回 + 页面状态行」观察;
  • 手势与滚动只有参数:Gesture.kt / Scroll.kt / Scrollable.kt 保留阈值、方向、边界模式等参数,事件流仍归 ArkUI;
  • IfThenAnimate 的过渡曲线是静态映射:上游 AnimatedContent 的 transitionSpec 只保留三个 spec 的数值,实际过渡由 getUIContext().animateTo 执行;
  • 只验证了 phone 形态:deviceTypes 仅 phone,平板/折叠屏的多栏布局未纳入验收矩阵。

8.3 未来优化方向

  • 把 LayoutResolver 的输入直接对接 ArkUI onMeasureSize,让「内核算、宿主摆」变成一次真实的自定义布局;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 增加 Gesture / Scroll 的事件侧桥接(当前只有参数),补齐 Modifier 链的常见组合;
  • 在 CI 中加入 ohos 编译、llvm-nm 符号检查与 HAP 构建,把「L2–L5」自动化;
  • 补充平板/折叠屏形态与 arm64 真机的验收矩阵、自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 31 个 commonMain 文件(全部 androidx.compose.*)
    → 验证制品线:Compose 无 ohos 变体(路线定为「抽内核」)
    → 工具链对齐 Kotlin 2.4.20-0.1.0-07(Gradle 9.6.1 沿用)
    → 源集拆分:git mv 到 composeMain(不注册为源集)
    → 27 个零依赖内核文件(布局算术 / 缓动 / 编解码 / 几何)
    → 90 项 JVM 交叉验证(235 个静态断言)
    → ohosArm64 / ohosX64 KLIB
    → C ABI(@CName × 7)+ UTF-8 JSON + guard
    → C++ N-API(DisposeString、$ORIGIN、短名 NEEDED)
    → ArkUI 五页签(@BuilderParam 绑定、首帧不碰 KN、权限三方对齐)
    → HAP + 签名 + 真机运行(效果图)

9.2 封装层次

  • 内核层:LayoutResolver、Easing、SaveableCodec、CornerGeometry、CutGeometry、PermissionResults——纯 Kotlin,零依赖,JVM 可跑;
  • 桥接层:7 个 @CName,UTF-8 JSON 进出,guard 吞异常;
  • Native 层:双 ABI libohoscomposeplus.so,copyKnSo* 回填三件套;
  • N-API 层:7 个 JS 方法、类型检查、DisposeString 归还、$ORIGIN rpath;
  • ArkTS 层:五页签、@State 状态、JSON 解析、错误文本、SVG path / 坐标直接消费;
  • DevEco 层:CMake、HAP、签名、安装与运行。

9.3 三条经验

  1. 先验证制品可达性,再决定适配路线。KStore 的正确答案是真编译(因为它依赖的 kotlinx 有 ohos 变体),compose-plus 的正确答案是抽内核(因为 Compose 一个变体都没有)。路线选择必须基于当天的制品事实,而不是上一个库的成功经验;
  2. 跨不过桥的东西,先变成数据。Modifier 变参数、State<T> 变 @State、Shape 变 SVG path、Arrangement 变坐标。能不能变成数据,就是「适配边界」的唯一判据;
  3. 交叉验证是「内核没漂」的唯一可信证明。编译通过只证明语法合法;90 项用例、235 个断言才证明 SpaceAround 的 gap/2、FastOutSlowIn 的 0.7756、int 不会被解码成 double。而其中一个用例(columnSwapsMainAndCross)恰好就是「宿主该不该自己旋转坐标」这个问题的答案。

9.4 适配成果

当前适配已完成:

  • compose-plus 0.0.2-ohos.1 的零依赖纯 Kotlin 内核(27 个文件),承接上游 31 个文件 / 143 个符号的全部可移植语义;
  • 三模块 ohosArm64 + ohosX64 KLIB 编译与双 ABI libohoscomposeplus.so 链接(llvm-nm 验证 7 个全局 T 符号);
  • 7 个 C ABI 与 C++ N-API 包装层($ORIGIN rpath、短名 NEEDED、DisposeString 内存纪律);
  • DevEco Stage ArkUI 五页签验收页面(概览 / 布局求解 / 形状与圆角 / 状态保存 / 动画与权限);
  • 90 项 JVM 交叉验证全绿(235 个静态断言)+ HAP 构建、签名、真机安装运行与效果图;
  • AtomGit 项目文档、OpenHarmony README(中英双份)与本地效果图。

适配过程 FAQ

以下问题来自 ohosArm64 目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。

Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?

  • 确认 DevEco Studio 版本与工程 build-profile.json5 的 compatibleSdkVersion 一致;
  • 在 File > Settings > Build, Execution, Deployment > Ohpm 配置可用的 ohpm 仓库镜像或代理;
  • 删除 oh_modules、oh-package-lock.json5 与 .hvigor 缓存后重新 Sync;
  • 公司网络需要代理时,同时给 ohpm 与 hvigor 配置 http.proxy。

Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?

  • 先在 example 目录执行 ./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用 linkDebugSharedOhosX64);
  • 确认 libxxx.so 与 libc++_shared.so 已复制到 ohos/entry/libs/<abi>/;
  • 检查 entry/build-profile.json5 的 abiFilters 与设备 ABI 是否一致(真机 arm64-v8a,模拟器 x86_64);
  • 清理 entry/.cxx 与 entry/build 后重试。

Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?

  • 确认 CMake 的 add_library(<target> SHARED ...) 名称与 import ... from 'libxxx_napi.so' 完全一致;
  • 解包 HAP,确认 libs/<abi>/libxxx_napi.so 已打包;
  • 删除 entry/build、entry/.cxx 与 oh_modules 后重新构建;
  • 检查 module.json5 的 srcEntry 与页面路径是否正确。

Q4:真机安装报 signature verification failed 或 install failed?

  • 在 DevEco Studio 的 Signing Configs 配置证书、Profile 与密码;
  • bundleName 必须与签名 profile 中的 bundle name 一致;
  • 设备需加入 profile 的 deviceIds,调试证书有效期需覆盖当前时间;
  • 用 hdc list targets 确认设备在线,再执行 hdc install -r xxx-signed.hap。

Q5:应用启动白屏、闪退或 NAPI 调用崩溃?

  • 用 hdc hilog | grep -i "napi\|kotlin" 查看原生日志;
  • 确认 libc++_shared.so 与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致;
  • 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用 XxxFree 释放;
  • 在 DevEco 的 Native 调试配置中附加 lldb,定位空指针与越界访问。

Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?

  • 使用鸿蒙发行版 Kotlin(如 2.2.21-1.0.0),并在 settings.gradle.kts 配置 eazytec 仓库;
  • Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
  • 确认 kotlin { ohosArm64() } 已应用,且 local.properties 的 SDK 路径有效;
  • 清理 ~/.gradle/caches 中对应版本的 Kotlin/Native 缓存后重试。

Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?

  • 对齐 DevEco Studio、HarmonyOS SDK 与 build-profile.json5 中的 compatibleSdkVersion;
  • 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免 modelVersion 与 IDE 不匹配;
  • 命令行构建时设置 DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录。

Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?

  • 使用 DevEco 自带的 node 与 hvigor:
    "$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...;
  • 设置 JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME;
  • 加上 --no-daemon 排除守护进程缓存影响,并清理 entry/build 与 .hvigor。

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐