本文记录 KMPDevicePreview 接入 OpenHarmony 的完整过程,覆盖设备预览库盘点、Compose 源码集隔离、鸿蒙工具链钉版、ohosArm64 / ohosX64 目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。

KMPDevicePreview 是基于 Compose Multiplatform 的设备预览库:把你的应用包进一台模拟设备框(Pixel 6 / iPhone 15 Pro / Generic Device),支持设备切换、横竖屏、暗色模式、预览开关,核心机制是用模拟设备的像素密度(Density(deviceDensity × scaleFactor))覆盖 LocalDensity,让被包裹的内容按目标设备的密度自行缩放——内容本身不需要感知自己正在被模拟。本次适配与社区早期路线(qrose / haze / PreCompose / voyager 的"零依赖内核提取")同源但形状不同:这个库的不可替代物不是算法,而是一套布局模型 + 密度语义,两者都是纯 Kotlin。因此适配把 core(Device、DeviceConfiguration、ScreenSize、SimulatedDevice)与 devices(三台内置设备 + 新增公共 DeviceCatalog)原样编译成 OpenHarmony KLIB,上游 Compose UI(preview 包三个文件)整体隔离进未注册的 composeMain 源码目录——不删一行、也不在 ohos 上硬编一行——设备框与内容由 ArkUI 原生绘制,密度覆盖语义用 vpPerDp = simulatedDensity / 屏幕 densityPixels 在 ArkTS 侧恒等复现。这与社区已落地的 Vico / Reorderable 完全同构:数据模型下沉 Kotlin/Native,UI 交给宿主。

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

上游仓库: KAppMaker/KMPDevicePreview

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

KMPDevicePreview 为 Compose Multiplatform 应用提供"在任何设备上预览我的应用"这一件事的完整答案:DeviceWithConfigurationView { App() } 一行包裹根组件,即可在侧边(宽屏)或底部弹层(窄屏)里切换设备、旋转方向、切暗色模式、关预览;SimulatedDevicePreview 单独使用时只出设备框。它的实现出人意料地轻——设备"外壳"只是一层 RoundedCornerShape(8.dp) + BorderStroke(2.dp, Color.Black) 的圆角描边 Box,真正的魔法在密度:BoxWithConstraints 拿到容器像素尺寸后算出 scaleFactor = min(containerWidth / screenWidth, containerHeight / screenHeight),再以 Density(deviceDensity * scaleFactor, fontScale = 1f) 覆盖 LocalDensity,容器内所有 dp 值立刻按模拟设备的像素密度折算。上游支持 Android、iOS、Desktop(JVM)、JS、WasmJS。OpenHarmony 应用同样需要这套预览能力,但上游构建(Kotlin 2.0.21 / Compose Multiplatform 1.7.0)不会生成 OpenHarmony 变体。

如果只把示例页面改成 ArkTS,页面可以画一个圆角矩形,却无法证明设备模型、布局数学和密度语义在 OpenHarmony ARM64 设备上完成编译、链接和执行。适配需要同时验证设备模型、配置状态机、缩放数学、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
Compose UI 编不进 ohospreview 包依赖 CMP 1.7.0 的 LocalDensity / ModalBottomSheet / material-icons,社区工具链没有 compose.ui 的 ohos 制品,HarmonyOS Kotlin 2.2.21-1.0.0 无法编译这些文件。
工具链不一致上游 Kotlin 2.0.21 / Gradle 8.9 / CMP 1.7.0 与鸿蒙工具链无法共存于一个 Gradle 构建;ohosArm64() 只存在于 eazytec 的 HarmonyOS Kotlin 插件。
internal 边界设备清单 allDevices 在上游是 internal,桥接层拿不到;Compose 配置面板与鸿蒙桥需要同一个数据源。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
密度语义跨不了桥LocalDensity / CompositionLocal 是 Compose 运行时概念,C ABI 只能同步一来一回。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在:设备模型与布局数学的 ohos 编译、Compose 源码集隔离(cmp_qrose 模式)和 C ABI/N-API 桥接层。设备框绘制、内容渲染与密度换算的"最后一厘米"由 ArkUI 承担,preview 包的 Compose 实现原样保留在源码树里,等社区 compose.ui ohos 制品与工具链对齐后可以原路接回。

1.2 库提供的能力

Demo 提供一个真实的设备预览会话,对应桥接的全部入口:

  • 设备清单(catalog):DeviceCatalog.all 返回三台内置设备——Pixel 6(1080×2400 @2.625)、iPhone 15 Pro(1179×2556 @3.0)、Generic Device(1080×2400 @2.625),顺序即 DeviceType 枚举序(Android → iOS → Other);
  • 布局计算(layout):给定容器物理像素尺寸与一台设备,桥返回上游 SimulatedDevicePreview.kt 的同款数学——screenWidth / screenHeight 已按横竖屏旋转、scaleFactor、frameWidth / frameHeight、simulatedDensity;
  • 四项配置:设备、isPortrait(横屏自动旋转 ScreenSize)、isDarkMode、isPreviewModeEnabled,全部随 layout 请求下发并回显;
  • 密度模拟:ArkTS 侧用 vpPerDp = simulatedDensity / densityPixels 把内容的所有 dp 尺寸换算成 vp——这正是上游 Density 覆盖的恒等变形,换设备(2.625 → 3.0)时内容肉眼可见地缩放;
  • 预览关闭:isPreviewModeEnabled=false 时设备框消失、内容全屏直渲染,对应上游"直接渲染 content()"的分支。

响应包含:

字段作用
ok操作是否成功,失败时 error 带原文。
kind / name / type回显请求类型、设备名与 DeviceType.displayName。
screenWidth / screenHeight / density / aspectRatio模拟设备的屏幕参数(横屏时已交换宽高)。
scaleFactormin(containerWidth/screenWidth, containerHeight/screenHeight)。
frameWidth / frameHeight设备框尺寸:fillMaxHeight 语义下 frameHeight = containerHeight,frameWidth = frameHeight × aspectRatio。
simulatedDensitydensity × scaleFactor,即上游 Density 覆盖值,ArkTS 密度换算的唯一数据源。

模型侧完整能力(DeviceConfiguration 状态机、SimulatedDevice.currentScreenSize 旋转语义、ScreenSize.rotated())全部在上游 commonMain 里原样随编译进入 .so,JVM 目标保留作交叉验证,证明 ohos .so 里执行的布局数学没有漂移。

1.3 实现适配

维度要求
代码复用core 与 devices 的全部 commonMain 文件原样编译,不提取、不重写;Compose preview 包整体隔离进 composeMain,一个字符不改。
平台目标库模块声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。
构建隔离根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 composeApp(CMP 1.7.0 示例)与 iosApp 留盘不进 settings.gradle.kts。
桥接稳定使用 1 个 C ABI 和扁平 JSON,不传递 Kotlin 对象地址;异常永远不跨 FFI。
公共入口新增 DeviceCatalog(all / byName)把上游 internal allDevices 开出正门——对齐 cmp_qrose 为 internal QRCode 新增 Qrose.encodeQr 的已审先例。
UI 完整三 Tab 覆盖全部桥接入口:预览(设备框 + 密度缩放内容)、配置(四行配置项)、设备清单(分组选择)。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。KMPDevicePreview 的 UI 价值在"设备框 + 密度模拟"这套交互模型而不在具体控件——设备框是圆角描边矩形,内容是宿主自己的 UI,ArkUI 原生绘制反而比像素级搬 Compose 更贴近真实使用形态。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点模块与依赖边界、确认零第三方依赖、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:源码集与桥建模 ── preview 隔离 composeMain、DeviceCatalog 正门、catalog/layout JSON 契约
第 4 阶段:原生桥接       ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主     ── vpPerDp 密度换算、设备框、三 Tab、状态行回显
第 6 阶段:示例与验证     ── HAP、签名、设备安装、真机运行和效果图

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
kmpdevicepreview/                         上游库模块(进 ohos 构建,jvm + ohosArm64 + ohosX64)
kmpdevicepreview/src/commonMain/          core 5 文件 + devices 4 文件(纯 Kotlin,原样编译)
kmpdevicepreview/src/composeMain/         上游 preview 包 3 文件整体迁入(未注册源集,任何目标都不编译)
kmpdevicepreview/src/ohosMain/            新增:Platform actual(9 行)
example/nativeApp/                        Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/                       DevEco Stage、CMake N-API 和 ArkTS 三 Tab 页面
composeApp/ iosApp/                       上游示例,留盘不进 settings.gradle.kts
README.OpenHarmony.md / _CN.md            鸿蒙适配说明(中英双份)
README.upstream.md                        上游原文保存

构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :kmpdevicepreview 与 :example:nativeApp,rootProject.name = "ohos_kmpdevicepreview"。上游的 dokka / vanniktech 发布插件、binary-compatibility-validator、Android Gradle Plugin classpath 全部随根构建替换剥离——本 fork 不发 Maven Central。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin,org.gradle.java.home 写进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm() 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.9 升级对齐 qrose 线;2.2.21 线的标准钉法)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.kmpdevicepreview
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
模块文件依赖形态处置
core5纯 Kotlin(Device 接口、DeviceConfiguration / DeviceType / ScreenSize / SimulatedDevice 数据类),零 UI、零第三方依赖整体编译(jvm + 双 ohos)
devices4纯 Kotlin 硬编码设备数据(Pixel6 / IPhone15Pro / GenericDevice + allDevices)整体编译(零平台源码集)
preview3Compose UI(BoxWithConstraints、LocalDensity、material3 ModalBottomSheet、material-icons)整体隔离 composeMain,不参与任何目标编译

expect 声明全库只有 1 处:internal expect fun getSourceSetName(): String。上游给了 5 个平台 actual——android / ios 返回各自名字供无参 DeviceWithConfigurationView 选择 Compact 布局,其余走 Expanded。ohos 补第 6 个,加上 DeviceCatalog,这就是整个 fork 仅有的两个新增 Kotlin 源文件:

// kmpdevicepreview/src/ohosMain/kotlin/com/kappmaker/devicepreview/Platform.ohos.kt
internal actual fun getSourceSetName(): String {
    return "ohosMain"
}
// kmpdevicepreview/src/commonMain/kotlin/com/kappmaker/devicepreview/devices/DeviceCatalog.kt
public object DeviceCatalog {

    /** 每一台内置设备,按 DeviceType 排序(Android → iOS → Other)。 */
    public val all: List<Device> = allDevices

    /** 按显示名查设备,忽略大小写。 */
    public fun byName(name: String): Device? {
        return allDevices.firstOrNull { it.name.equals(other = name, ignoreCase = true) }
    }
}

上游 allDevices 是 internal,只服务 Compose 配置面板——桥直接反射或在 ArkTS 里抄一份设备表都是漂移隐患,开正门是唯一正解(cmp_qrose 为 internal QRCode 加 Qrose.encodeQr 的同一先例)。preview 包则值得单独说明:它的三个文件是上游 API 的本体,隔离不等于删除——源文件一行不动地放进 composeMain,这个目录不是注册源集(默认层级模板不含它),编译器静默跳过,源码保留只为对照上游 API 与日后工具链对齐时接回。

1.4 页面边界
标题区          KMPDevicePreview / 状态行(设备 · 屏幕参数 · scale · simDensity · frame,实时回显)
Tab 切换        预览 / 配置 / 设备清单
预览 Tab        预览容器(onAreaChange 记录 vp 尺寸触发桥调用)
                启用时:圆角描边设备框 + 按密度缩放的 50 项内容列表
                关闭时:内容无框全屏直渲染(对应上游 isPreviewModeEnabled=false)
配置 Tab        Device / Orientation / Theme / Preview Mode 四行(深灰面板白字,镜像上游 ConfigurationView)
                「更换」弹出按 DeviceType 分组的设备选择层(镜像上游 AllDevicesView)
设备清单 Tab    catalog 全量设备行(名称 + 分辨率 · density + 类型标签),点击选中

页面启动时先在 aboutToAppear 拉取 catalog 并读取 display.getDefaultDisplaySync().densityPixels,预览区首次 onAreaChange 拿到容器尺寸后发起第一次 layout 调用。

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

2.1 工具链钉版(为什么是 2.2.21-1.0.0 + 8.14.1)

本库 commonMain 零第三方依赖——设备模型与布局数学只需要 kotlin-stdlib 与 kotlin.math.min。没有 KStore 那类"制品线版本反推工具链"的约束,所以直接采用社区已审的 qrose 线钉法:HarmonyOS Kotlin 2.2.21-1.0.0 + Gradle 8.14.1 + JDK 21。上游的 Kotlin 2.0.21 / CMP 1.7.0 不需要迁就——依赖它们的 composeApp 示例本来就不进本 fork 构建,两条工具链线互不干扰。

gradle.properties 里三个关键项:

#JDK 21 (Temurin) is required by the HarmonyOS Kotlin toolchain
org.gradle.java.home=C:\\Program Files\\Eclipse Adoptium\\jdk-21.0.12.101-hotspot

version=1.0.0-alpha02-ohos.1

kotlin.native.ignoreDisabledTargets=true
kotlin.native.distribution.downloadFromMaven=true
2.2 根构建替换

settings.gradle.kts 把 eazytec 仓库写在最前——仓库顺序错了,配置期第一行 ohosArm64() 就 Unresolved:

pluginManagement {
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
    repositories {
        maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
        maven("https://maven.eazytec-cloud.com/nexus/content/groups/harmonyos")
        google()
        mavenCentral()
    }
}

rootProject.name = "ohos_kmpdevicepreview"

include(":kmpdevicepreview")
include(":example:nativeApp")

版本目录收敛到只剩一个插件(本库无第三方库依赖):

[versions]
kotlin = "2.2.21-1.0.0"

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

根 build.gradle.kts 全局禁用 ohos 测试任务——HarmonyOS Kotlin 工具链跑不了 KN 测试,check 不能被它卡死:

subprojects {
    tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
        .configureEach { enabled = false }
}
2.3 库模块收敛与 Compose 隔离

kmpdevicepreview/build.gradle.kts 收敛为纯 KMP 模块,并保留上游的 explicitApi() API 纪律:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    explicitApi()
    applyDefaultHierarchyTemplate()

    // JVM stays as the cross-validation target.
    jvm {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }

    ohosArm64()
    ohosX64()
}

composeMain 不需要任何 Gradle 声明——它不在默认层级模板里,Kotlin 编译器看不见它。三个 Compose 文件的迁移用 git mv 完成,历史可追溯:

kmpdevicepreview/src/commonMain/.../preview/SimulatedDevicePreview.kt      → src/composeMain/.../preview/
kmpdevicepreview/src/commonMain/.../preview/DeviceWithConfigurationView.kt → src/composeMain/.../preview/
kmpdevicepreview/src/commonMain/.../preview/ConfigurationView.kt           → src/composeMain/.../preview/

迁移后 commonMain 只剩纯 Kotlin,实测:

./gradlew :kmpdevicepreview:compileKotlinJvm                                    # BUILD SUCCESSFUL
./gradlew :kmpdevicepreview:compileKotlinOhosArm64 :kmpdevicepreview:compileKotlinOhosX64  # BUILD SUCCESSFUL

core 与 devices 的全部 commonMain 文件一个没动,新增的只有 Platform.ohos.kt(9 行)和 DeviceCatalog.kt(18 行)。

第 3 阶段:桥协议建模

3.1 JSON 契约
com.kappmaker.devicepreview/**(上游 core + devices,原样编译)
        ↓ Device / DeviceConfiguration / SimulatedDevice / DeviceCatalog
Bridge.kt(@CName × 1,扁平 JSON 进出)
        ↓ UTF-8 JSON
N-API devicePreviewRender()
        ↓
ArkUI Index.ets(三 Tab 单页)

请求与响应:

请求 : {"kind":"catalog"}
       {"kind":"layout","device":"Pixel 6","containerWidth":720,"containerHeight":1560,
        "isPortrait":true,"isDarkMode":false,"isPreviewModeEnabled":true}
响应 : {"ok":true,"kind":"catalog","count":3,"devices":[{"name","type","width","height","density","aspectRatio"}...]}
       {"ok":true,"kind":"layout","name","type","screenWidth","screenHeight","density","aspectRatio",
        "isPortrait","isDarkMode","isPreviewModeEnabled","containerWidth","containerHeight",
        "scaleFactor","frameWidth","frameHeight","simulatedDensity"}
       {"ok":false,"error":"..."}

一个入口、两种 kind——沿袭 qrose 的分发模式而不是开多个 C 符号,index.d.ts 与 NAPI 层永远只有一个名字要对。container 传物理像素,返回的 frameWidth / frameHeight 同单位,ArkTS 侧自行做 vp 换算(见 5.1)。

3.2 布局数学为什么锁死在 Kotlin 侧

桥内的计算严格逐行镜像上游 SimulatedDevicePreview.kt:

val simulated = SimulatedDevice(device = device, configuration = configuration)
val screen = simulated.currentScreenSize        // 横屏时 rotated()
val aspectRatio = screen.width / screen.height
val scaleFactor = if (containerWidth > 0f && containerHeight > 0f) {
    min(containerWidth / screen.width, containerHeight / screen.height)
} else {
    1f
}
val frameHeight = if (containerHeight > 0f) containerHeight else screen.height
val frameWidth = frameHeight * aspectRatio      // fillMaxHeight().aspectRatio(w/h) 的语义
val simulatedDensity = screen.density * scaleFactor   // Density(deviceDensity * scaleFactor) 的语义

这是本次适配的"验收底线":scaleFactor / frameWidth / simulatedDensity 的每一个都对应上游一行 Compose 代码,桥不是"重新实现一遍缩放",而是"把上游的缩放原样跑在 ohos 上"。ArkTS 侧只做单位换算,不做第二次数学。

3.3 错误通道

导出函数整体 try/catch,Kotlin 异常永远不跨 FFI:

@CName("DevicePreviewRender")
fun devicePreviewRender(request: String): String = try {
    val json = MiniJson.parse(request)
    when (json.str("kind").ifBlank { "layout" }) {
        "catalog" -> catalog()
        else -> layout(json)
    }
} catch (t: Throwable) {
    """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

非法 JSON、未知设备名(回落列表第一台)、缺字段(全部带默认值)、不支持的 kind,要么走默认值要么走同一个 ok:false 通道,ArkTS 侧统一渲染。

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

4.1 三层桥接
ArkTS
  │ JSON string
  ▼
C++ N-API entry(libdevicepreview_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosdevicepreview.so)
  │ DevicePreviewRender
  ▼
UTF-8 JSON + DisposeString

直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制布局数学,所以选择 C ABI + JSON。

4.2 Kotlin/Native 导出函数
@CName("DevicePreviewRender")
fun devicePreviewRender(request: String): String   // catalog / layout 一口进
C ABI 符号ArkTS 方法覆盖内核
DevicePreviewRenderdevicePreviewRender(req)DeviceCatalog + SimulatedDevice 状态机 + 上游缩放数学
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
    {"devicePreviewRender", nullptr, NapiDevicePreviewRender, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosdevicepreview_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosdevicepreview_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。

4.4 CMake 和类型声明
add_library(ohosdevicepreview SHARED IMPORTED)
set_target_properties(ohosdevicepreview PROPERTIES
  IMPORTED_LOCATION ${KN_SO}
  IMPORTED_NO_SONAME TRUE)

add_library(devicepreview_napi SHARED napi_init.cpp)
target_link_libraries(devicepreview_napi PUBLIC
  libace_napi.z.so
  libhilog_ndk.z.so
  ohosdevicepreview)

OHOS_ARCH 为空或 libohosdevicepreview.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。

export const devicePreviewRender: (request: string) => string;

第 5 阶段:ArkUI 宿主

5.1 密度换算(本库核心语义的 ArkTS 复现)
aboutToAppear(): void {
  this.densityPixels = display.getDefaultDisplaySync().densityPixels;
  this.fetchCatalog();
}

private generate(): void {
  // 预览区 onAreaChange 给出 vp;桥需要物理 px
  const req: LayoutRequest = {
    kind: 'layout', device: this.deviceName,
    containerWidth: this.areaWidth * this.densityPixels,
    containerHeight: this.areaHeight * this.densityPixels,
    isPortrait: this.isPortrait, isDarkMode: this.isDarkMode,
    isPreviewModeEnabled: this.isPreviewModeEnabled
  };
  const res = JSON.parse(devicePreviewRender(JSON.stringify(req))) as LayoutResult;
  this.layout = res;   // 状态行回显 scale / simDensity / frame
}

private vpPerDp(): number {
  const l = this.layout;
  if (l && l.ok && !this.isPreviewModeEnabled) {
    return 1;          // 关预览:内容回到真实密度全屏渲染
  }
  if (l && l.ok && l.simulatedDensity !== undefined) {
    return l.simulatedDensity / this.densityPixels;   // Density 覆盖的恒等变形
  }
  return 1;
}

设备框照抄上游的绘制参数换算成 ArkUI:fillMaxHeight().aspectRatio(w/h) 就是 height(frameHeight/densityPixels) + width(frameWidth/densityPixels),RoundedCornerShape(8.dp) + BorderStroke(2.dp, Black) 就是 borderRadius(12) + border({ width: 2, color: Color.Black })(8dp→12vp 为演示近似值,可调)。框内 SampleContent 的每一个 dp——50 项列表的 padding(16dp)、fontSize(14dp)、Divider(0.5dp)——全部乘以 vpPerDp(),换设备时内容密度随之改变,这正是上游 LocalDensity 覆盖在 ArkUI 里的等价物。

5.2 页面交互

预览 Tab 自上而下:状态行(Pixel 6 · 1080x2400 @2.625 · scale=0.868 · simDensity=2.277 · frame=268x595vp,每次布局实时刷新)、预览容器(居中设备框,暗色模式切换内容 50 项列表的 #FFFFFF/#1F2937 ↔ #121212/#E5E7EB 配色)。配置 Tab 深灰面板(#3C3C3C)白字,四行配置——Device 行「更换」弹出按 Android / iOS / Other 分组的设备选择层,Orientation 行「⟳」翻转横竖屏,Theme 行「☀/☾」切暗色,Preview Mode 行 Toggle 控制预览开关;每次改动同步触发 generate()。设备清单 Tab 渲染 catalog 全量设备(名称 + WxH · density=d 副文字 + 类型标签),当前选中行高亮并带 ✓,点击即选中并提示切到预览 Tab 查看。三个 Tab 共享同一组 @State,配置改动切回预览立即生效。

5.3 对比度和错误边界

配置面板深灰底白字与上游 Color.DarkGray 语义一致;ok:false 时错误原文进状态行,页面不崩溃;Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行。暗色模式下内容列表背景 #121212、文字 #E5E7EB、分隔线 #374151,对比度按 Material 暗色规范取值。

第 6 阶段:示例与验证

6.1 工程结构
example/
├── nativeApp/
│   ├── build.gradle.kts                      双 ABI sharedLib(baseName=ohosdevicepreview)+ cxxShared + copyKnSo*
│   └── src/ohosMain/kotlin/example/nativeapp/
│       └── Bridge.kt                         @CName + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
    ├── build-profile.json5                   compatibleSdkVersion 5.1.0(18)、runtimeOS HarmonyOS
    ├── AppScope/app.json5                    bundleName org.terminator.ohos.kmpdevicepreview、versionName 1.0.0-alpha02-ohos.1
    └── entry/
        ├── build-profile.json5               externalNativeOptions + abiFilters [arm64-v8a, x86_64]
        ├── libs/{arm64-v8a,x86_64}/          linkDebugShared* 经 copyKnSo* 回填的三件套(gitignore)
        ├── src/main/cpp/CMakeLists.txt       IMPORTED 引入 KN so、$ORIGIN rpath
        ├── src/main/cpp/napi_init.cpp        N-API 导出、参数检查、DisposeString
        ├── src/main/cpp/include/libohosdevicepreview_api.h
        ├── src/main/cpp/types/libdevicepreview_napi/{index.d.ts, oh-package.json5}
        └── src/main/ets/pages/Index.ets      ArkUI 三 Tab 验收页面
6.2 构建、签名和安装
./gradlew :kmpdevicepreview:compileKotlinJvm                                     # JVM 交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64

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

copyKnSo* 挂在 link 任务的 finalizedBy 上,把 libohosdevicepreview.so 和同 triple 的 libc++_shared.so(取自 ~/.konan/dependencies/,保证与 KN 运行时 ABI 匹配)自动拷进 entry/libs/<abi>/。签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组,product 引用 "signingConfig": "default",勾选自动签名后由 IDE 回填完整配置)。

hdc list targets -v
hdc -t <设备序列号> install -r \
  entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.kmpdevicepreview -m entry

四、完整代码对照

4.1 整体架构

com.kappmaker.devicepreview.core/**(上游原样编译)
    │ Device / DeviceConfiguration / DeviceType / ScreenSize / SimulatedDevice
com.kappmaker.devicepreview.devices/**(上游原样编译 + DeviceCatalog 正门)
    │ Pixel6 / IPhone15Pro / GenericDevice / allDevices
Bridge.kt + MiniJson
    │ DevicePreviewRender(catalog / layout)
libdevicepreview_napi.so
    │ devicePreviewRender(req)
Index.ets
    │ JSON parse + vpPerDp 换算 + @State 更新
    ▼
ArkUI 真机页面(三 Tab:预览 / 配置 / 设备清单)

4.2 文件清单

文件职责
settings.gradle.kts极简 ohos 构建、eazytec Maven 置前、ohos_kmpdevicepreview、composeApp/iosApp 移出
gradle/libs.versions.tomlKotlin 2.2.21-1.0.0 单插件钉版
gradle.propertiesJDK 21、fork 版本 1.0.0-alpha02-ohos.1、kotlin.native.* 两属性
build.gradle.kts(根)全局禁用 Ohos*Test* 任务
kmpdevicepreview/build.gradle.kts纯 KMP:explicitApi + jvm + ohosArm64 + ohosX64
kmpdevicepreview/src/composeMain/**上游 preview 包整体隔离(未注册源集,不编译)
kmpdevicepreview/src/ohosMain/.../Platform.ohos.kt新增 actual(9 行)
kmpdevicepreview/src/commonMain/.../DeviceCatalog.kt新增公共入口(18 行)
example/nativeApp/build.gradle.kts双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填
nativeApp/.../Bridge.kt1 个 C ABI、catalog/layout 分发、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 三 Tab 验收页面
README.OpenHarmony_CN.md鸿蒙适配主文档

4.3 关键 API 对照

层次上游 Compose API鸿蒙对应
预览SimulatedDevicePreview(simulatedDevice) { content }kind=layout + 预览 Tab 设备框
密度Density(deviceDensity × scaleFactor) 覆盖 LocalDensitysimulatedDensity 字段 + vpPerDp = simulatedDensity / densityPixels
布局fillMaxHeight().aspectRatio(w/h)frameHeight = containerHeight、frameWidth = frameHeight × aspectRatio
设备框RoundedCornerShape(8.dp) + BorderStroke(2.dp, Black)borderRadius(12) + border(2vp, Black)
屏幕旋转SimulatedDevice.currentScreenSizescreenWidth/screenHeight 已在桥内旋转
主题SimulatedDeviceThemeIsDark CompositionLocalisDarkMode 字段 + 内容列表配色切换
配置面板ConfigurationView 四行配置 Tab 四行(Device/Orientation/Theme/Preview Mode)
设备选择AllDevicesView(internal allDevices 分组)设备清单 Tab + kind=catalog(经 DeviceCatalog 正门)
布局选择DeviceWithConfigurationViewExpanded / Compact预览+配置上下堆叠(ModalBottomSheet 未复刻,见 8.2)
预览关闭isPreviewModeEnabled.not() 直接渲染 content()无框全屏渲染 SampleContent,vpPerDp = 1

4.4 ArkTS 与 Kotlin 的边界

const res = JSON.parse(devicePreviewRender(JSON.stringify({
  kind: 'layout', device: 'iPhone 15 Pro',
  containerWidth: 720, containerHeight: 1560,
  isPortrait: true, isDarkMode: false, isPreviewModeEnabled: true
})));
// res.scaleFactor / res.frameWidth / res.simulatedDensity 驱动设备框与内容密度
val device = DeviceCatalog.byName("iPhone 15 Pro") ?: DeviceCatalog.all.first()
val screen = SimulatedDevice(device, configuration).currentScreenSize
val scaleFactor = min(containerWidth / screen.width, containerHeight / screen.height)

两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;数学只存在于 Kotlin 侧,ArkTS 只做单位换算。

五、关键决策说明

决策 1:Compose UI 整体隔离而不是逐文件改写

preview 包的三个文件是上游 API 的本体,逐文件"翻译成 ArkTS"等于把库重写一遍,还会引入漂移。隔离进未注册的 composeMain 目录是 cmp_qrose 已审路径:源码零改动保留,ohos 编译器看不见它们,等社区 compose.ui ohos 制品与工具链对齐后原路接回。真正值得下沉的是布局模型与密度语义——它们是纯 Kotlin,编进 ohos 的每一个字段都能和上游逐行对账。

决策 2:桥只出布局 JSON,不引入光栅化 / PNG

qrose 的产物是图片,所以需要 KN 光栅化 + PNG 编码器;KMPDevicePreview 的产物是布局模型——设备框是圆角描边矩形,内容是宿主 UI,两者都该由 ArkUI 原生绘制。像素化一张设备框再让 ArkTS 解码显示,是把"矢量可缩放"退化成"位图固定分辨率",还平白多出 PNG 编码器与 Base64 通道。这与 Vico(图表数据交 ArkTS 画 Canvas)完全同构:模型下沉,绘制留在宿主。

决策 3:DeviceCatalog 开正门,internal 数据不抄不反射

上游 allDevices 是 internal,只服务 Compose 配置面板。桥的三条歪路——反射(KN 上不可靠)、ArkTS 里抄设备表(漂移起点)、把 allDevices 改 public 破坏上游 API 面(explicitApi() 下是可见的 API 变更)——都不如新增一个 DeviceCatalog 正门:all 复用同一份 allDevices 列表,byName 提供名字查取,上游 API 面零破坏。

决策 4:vpPerDp 恒等换算复现 Density 覆盖

上游的密度模拟是 Density(deviceDensity × scaleFactor):内容里 1.dp 折算为 deviceDensity × scaleFactor 个物理像素。ArkUI 的长度单位 vp 与物理像素的关系是 px = vp × densityPixels,所以模拟设备上 1.dp 应渲染为 simulatedDensity / densityPixels 个 vp——一次除法,恒等变形,没有任何近似。这条换算是整个 Demo 的"aha 时刻":切换 Pixel 6(2.625)与 iPhone 15 Pro(3.0),同样的 50 项列表肉眼可见地变大。

决策 5:Gradle 8.14.1 + Kotlin 2.2.21-1.0.0 钉法(qrose 线)

本库 commonMain 零第三方依赖,不存在 KStore 那种"制品线版本反推工具链"的约束;上游 Kotlin 2.0.21 / CMP 1.7.0 的世界(composeApp 示例)整体留盘不进构建。工具链直接钉社区已审的 qrose 线——eazytec 双仓置前、Gradle 8.14.1、JDK 21、kotlin.native.ignoreDisabledTargets + kotlin.native.distribution.downloadFromMaven。两条工具链线的经验不能互相套用:2.2.21 线降 8.14.1,2.4.20 线留 9.x。

六、测试与验证

6.1 测试环境

本次验证使用 Windows(x64)、JDK 21(Temurin,org.gradle.java.home 钉入)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙真机(arm64)。

6.2 编译与符号验证

./gradlew :kmpdevicepreview:compileKotlinJvm      # BUILD SUCCESSFUL(explicitApi + DeviceCatalog + ohos actual)
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
                                                  # BUILD SUCCESSFUL
llvm-nm -D example/harmonyApp/entry/libs/arm64-v8a/libohosdevicepreview.so | grep DevicePreviewRender
# 0000000000189f00 T DevicePreviewRender

libohosdevicepreview.so 双 ABI 就位:arm64 约 3.6MB、x86_64 约 3.3MB(含 Kotlin/Native 运行时),旁边各随一份同 triple 的 libc++_shared.so。composeMain 的三个 Compose 文件确认不在任何 ohos 源集里。

6.3 HAP 验证

:kmpdevicepreview:compileKotlinJvm: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
copyKnSoArm64 / copyKnSoX64: 三件套自动回填 entry/libs/<abi>/
llvm-nm -D: DevicePreviewRender 为全局 T 符号
BuildNativeWithCmake: finished(双 ABI 编出 libdevicepreview_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully

6.4 功能用例

  1. 启动进入预览 Tab,状态行实时回显布局参数。真机实测(Pixel 6,预览区约 268×595vp):
    Pixel 6 · 1080x2400 @2.625 · scale=0.868 · simDensity=2.277 · frame=268x595vp;
  2. 数学自洽核对:268 / 595 ≈ 0.4504 与设备宽高比 1080 / 2400 = 0.45 吻合(aspectRatio 语义成立);2.625 × 0.868 ≈ 2.278 与 simDensity=2.277 吻合(Density 覆盖语义成立)——桥不是"画了个框",而是把上游数学原样跑在真机上;
  3. 配置 Tab 切换 Orientation / Theme / Preview Mode,预览 Tab 立即生效:横屏时状态行宽高互换、暗色时内容列表切 #121212 配色、关预览后设备框消失内容全屏;
  4. 设备清单 Tab 三台设备可选,选中后回预览 Tab,状态行设备名与密度随切换更新(Pixel 6 @2.625 ↔ iPhone 15 Pro @3.0,内容密度肉眼可见变化);
  5. 「更换」弹层按 Android / iOS / Other 分组渲染,分组顺序与 DeviceType 枚举序一致;
  6. 全部操作走同一 C ABI 入口,异常通道 ok:false 原文回显,页面不崩溃。

6.5 验证结论

设备模型、配置状态机、缩放数学、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的设备预览会话:设备框按容器自适应、内容按模拟设备密度缩放、四项配置即点即生效(见下节效果图)。

七、运行效果

7.1 真机截图

7.2 命令速查

git clone https://atomgit.com/oh-tpc/KMPDevicePreview.git
cd KMPDevicePreview
export JAVA_HOME="/path/to/jdk-21"
./gradlew :kmpdevicepreview:compileKotlinJvm                                  # JVM 交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.kmpdevicepreview -m entry
hdc shell hilog | grep DevicePreview

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :kmpdevicepreview 真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 机器上残留旧版本 Kotlin daemon(同时维护 2.4.20-eazytec 线项目时)会让新构建复用类路径不兼容的守护进程,compileKotlinOhosArm64 三秒即报 java.lang.ClassNotFoundException: org.jetbrains.kotlin.cli.utilities.MainKt——与工程配置无关,gradlew --stop 后重跑即可;本仓适配时真实踩到,用"同配置的 qrose 工程可编过"定位到 daemon 残留;
  4. ArkTS 没有 FontWeight.SemiBold——那是 Compose 的枚举名,ArkTS 只有 Lighter / Normal / Regular / Medium / Bold / Bolder,照抄 Compose 写法直接编译失败(本仓改 Bold / Medium);
  5. ArkUI 的 List 直接子组件只能是 ListItem / ListItemGroup 等,ForEach 里直接放 Column 编译报 10505...(本仓包一层 ListItem() 解决);
  6. ArkTS import KN so 拿不到 DevicePreviewRender:HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务符号挂到 ArkTS,必须 CMake NAPI 包装 libdevicepreview_napi.so;
  7. HAP 缺当前 ABI 的 so 报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;libc++ 必须取自 ~/.konan/dependencies/ 的同 triple 目录,与 KN 运行时 ABI 匹配;
  8. CMake 把 IMPORTED 绝对路径写进 RUNPATH 会让 so 的 NEEDED 出现 Windows 盘符——IMPORTED_NO_SONAME + $ORIGIN rpath 解决;
  9. hvigor assembleHap 半路报不相干的错,先查打包 JDK 是不是 DevEco 自带 JBR;
  10. 三个名字必须一致:nm_modname = "devicepreview_napi"、ArkTS import 'libdevicepreview_napi.so'、index.d.ts 导出 devicePreviewRender,有一个写错都是"能安装、一调用就崩";
  11. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  12. entry/libs/ 与 *.so 进 gitignore——so 由各人的 link 任务本地回填,证书与签名材料绝不进入 AtomGit。

8.2 已知问题

  • 鸿蒙侧没有 Compose UI:preview 包在 composeMain 不编译,LocalDensity 覆盖机制以 vpPerDp 换算等价复现,CompositionLocal / ModalBottomSheet 等 Compose 运行时能力没有对应物;
  • 上游 Compact 布局的 ModalBottomSheet 未复刻:Demo 用「预览 + 配置上下堆叠 + Tab」替代,ArkUI bindSheet 可做但未引入;
  • 上游 WindowSizeClass 重载在鸿蒙桥中无对应物:以显式 isPreviewModeEnabled + Tab 布局替代;
  • 设备框圆角 12vp / 描边 2vp 是上游 8dp / 2dp 的演示近似换算,可调,不影响桥协议;
  • 上游 composeApp 示例与 iosApp 留盘不进构建(CMP 1.7.0 与鸿蒙工具链无法共存于一个 Gradle 构建),.github/workflows 为上游遗留未改动;
  • HAP 安装必须使用与 bundle name org.terminator.ohos.kmpdevicepreview 匹配的签名。

8.3 未来优化方向

  • 用 ArkUI bindSheet 复刻 Compact 布局的底部配置弹层,补齐上游 DeviceWithConfigurationViewCompact 的交互形态;
  • 评估 .har 形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套;
  • 增加 onAreaChange 防抖与横竖屏切换的过渡动画;
  • 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
  • 补充真机(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本。

九、总结

9.1 核心难点回顾

上游 core + devices(全部 commonMain,纯 Kotlin)
    → 确认零第三方依赖,钉 qrose 线工具链(Kotlin 2.2.21-1.0.0 / Gradle 8.14.1)
    → 根构建替换(cmp_qrose 模式,composeApp/iosApp 留盘)
    → Compose preview 包整体隔离 composeMain(未注册源集,零改动保留)
    → ohosArm64 / ohosX64 KLIB + DeviceCatalog 正门 + 9 行 actual
    → C ABI(@CName × 1)+ catalog/layout JSON 契约
    → C++ N-API(DisposeString 纪律)
    → ArkTS vpPerDp 密度换算(Density 覆盖的恒等变形)
    → ArkUI 真机三 Tab 验收(设备框 / 密度缩放 / 四项配置 / 设备清单)

9.2 封装层次

  • 模型层:上游原版 Device / DeviceConfiguration / ScreenSize / SimulatedDevice 状态机与旋转语义;
  • 设备层:上游原版三台内置设备 + DeviceCatalog 公共正门;
  • Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出布局 JSON;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理页面状态、vpPerDp 换算、设备框绘制、配置分发与错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 路线跟着"库的不可替代物"走:qrose 的不可替代物是编码器(纯算法),KMPDevicePreview 的不可替代物是布局模型 + 密度语义(纯数学)——两者都不需要 Compose 就能表达,能表达的编 ohos,不能表达的隔离 composeMain。判断"哪些必须下沉"之前先回答"这个库去掉 UI 后还剩什么";
  2. 上游 internal 的数据要开正门:DeviceCatalog 复用同一份 allDevices,上游 API 面零破坏;反射与 ArkTS 抄表都是漂移起点,对齐 cmp_qrose Qrose.encodeQr 的已审先例即可;
  3. 状态行回显是布局类库最好的验收面:把 scale / simDensity / frame 实时打在页面上,真机上一次截图就能完成数学自洽核对(宽高比 0.4504 ↔ 0.45、密度 2.278 ↔ 2.277)——"数学没漂"不需要仪器,需要的是把中间量亮出来。

9.4 适配成果

当前适配已完成:

  • KMPDevicePreview 1.0.0-alpha02(fork 版本 1.0.0-alpha02-ohos.1)设备模型的双 ABI Kotlin/Native 编译(上游 commonMain 文件零重写,仅 Platform.ohos.kt 9 行 + DeviceCatalog.kt 18 行新增);
  • Compose preview 包整体隔离 composeMain,源码零改动保留,上游 API 对照与日后接回路径清晰;
  • 1 个 C ABI 符号(DevicePreviewRender)和 C++ NAPI 包装层(llvm-nm 验证全局 T 符号);
  • DevEco Stage ArkUI 三 Tab 验收页面(设备框预览、密度缩放内容、四项配置、分组设备清单、状态行实时回显);
  • JVM 交叉验证 + 双 ABI so(arm64 约 3.6MB / x86_64 约 3.3MB,各随同 triple libc++)+ HAP 构建、签名、设备安装和真机验证;
  • AtomGit 项目文档、OpenHarmony README(中英双份)和本地效果图(doc/img/)。

适配过程 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐