本文记录 JetLime 接入 OpenHarmony 的完整过程,覆盖 CMP 时间线库盘点、ohosArm64/ohosX64 双目标、Kotlin/Native 动态库、drawBehind 几何复刻、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建、签名和真机验收。

JetLime 是 Compose Multiplatform 生态的时间线(Timeline)库,对外 API 是 JetLimeColumn / JetLimeRow 两个列表容器加 JetLimeEvent / JetLimeExtendedEvent 事件节点:EventPosition.dynamic() 决定 Start / Middle / End 位置链,PointPlacement 三档锚点配合 pointStartFactor = 1.1 的几何公式把节点和连接线画在 drawBehind 里,分页版本还有保持线条连续的 dynamicPaginated() 规则。本次适配走的是 cmp_qrose / haze / miuix 已过审的「参数模型内核复刻」路线:上游 commonMain 十三个源文件全部锚定 androidx.compose,与社区 HarmonyOS Kotlin 2.2.21-1.0.0 工具链无法同构建,于是把取值语义与布局几何以纯 Kotlin 复刻进 example/nativeApp,同一份源码在 JVM 上跑 36 项钉值测试、在 ohos 双 ABI 上跑真机渲染,ArkUI 页面作为验收宿主——鸿蒙侧解析出的位置序列、锚点坐标、描边半径与 Android/iOS/Desktop 完全同源。

项目地址: AtomGit/oh-tpc/JetLime

上游仓库: pushpalroy/JetLime

开发工具: 华为云码道

环境安装: 环境安装指南

一、背景

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

JetLime 为 Compose 应用提供垂直 / 水平时间线:开发者用 JetLimeColumn(itemsList, style = JetLimeDefaults.columnStyle()) 搭起容器,每个条目用 JetLimeEvent(style = JetLimeEventDefaults.eventStyle(position = position)) 渲染节点与连接线,即可获得点填充 / 空心 / 自定义图标三种节点、START / CENTER / END 三档锚点、纯色 / 渐变 / 虚线线条、RTL 镜像与内置分页。KMP 项目把这些参数语义与几何公式放在 commonMain,由 Android、iOS、Desktop、Web 宿主共享渲染。OpenHarmony 应用同样需要这套能力,但默认构建不会生成 OpenHarmony 的 Kotlin/Native 变体。

如果只在 ArkTS 里手写一条竖线加几个圆,页面能看,却无法证明公共 Kotlin 实现已经进入 OpenHarmony ARM64 设备完成编译、链接和执行,也无法保证跨端几何一致。适配需要同时验证 KMP 模型内核、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。

障碍具体问题
目标缺失必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB 与动态库。
工具链不一致上游 Kotlin 2.4.0 / Compose 1.10.3 / AGP 8.13.1;社区 HarmonyOS Kotlin 2.2.21-1.0.0 配 CMP 1.9.2。两条插件线不能共存于同一个 Gradle 构建。
源码面 100% Compose通读 jetlime/src/commonMain 的 13 个源文件:EventPosition / EventPointType 这些「模型」文件也挂着 @Immutable / @Stable / Color / Painter,没有可整体搬走的纯 Kotlin 文件。
几何原语不同上游节点与连接线画在 Modifier.drawBehind 里(drawCircle / drawLine / Stroke);Kotlin/Native 共享库里没有 Skia,也没有 DrawScope。
语言边界不同ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。
交付链路复杂.so、CMake、N-API、HAP、签名和 hdc 都要单独验收。

因此,本项目把边界放在 KMP 目标配置、参数与几何模型内核和 C ABI/N-API 桥接层。取值语义与布局公式仍由公共 Kotlin 实现负责,ArkTS 只负责调用 Native、解析 JSON 和展示结果。

1.2 库提供的能力

真机 Demo 提供四个可观察场景(Tab):

  • 垂直时间线:VerticalAlignment 左 / 右换边、PointPlacement 三档锚点、EventPointType 四种节点(Filled / Empty / ✓ 图标 / ⇄ 图标)、点半径与描边滑条、填充百分比滑条;
  • 水平时间线:HorizontalAlignment 上 / 下换边、RTL 镜像开关(时间线方向整体翻转)、内容卡片宽度滑条;
  • 样式实验室:lineSolidBrush / lineGradientBrush 切换、线宽、dashPathEffect 虚线 on / off 双参、contentDistance / itemSpacing 间距、脉冲缩放采样(1.0–1.2)、明暗主题、事件数量;
  • 位置规则:dynamicPaginated 分页演示——已加载条目 1–12 滑条 + isLastPage 开关,实时回显 EventPosition 位置链。

Native 侧返回的 RenderOk 包含:

字段作用
ok / error调用结果;Kotlin 异常永不跨语言边界,统一转 JSON 错误。
width / height / bytes / pngBase64画布尺寸、PNG 字节数与完整渲染帧,供状态行对账与 PixelMap 刷新。
positionsEventPosition 解析序列(如 Start,Middle,Middle,End),分页规则直接可见。
events[i]每个事件的 pointX / pointY / radius / fillRadius / strokeRadius / segments,与上游 drawBehind 公式逐位对账。
resolved解析后的生效参数(16 / 8 / 4dp 默认值、填充 0.5、animFactor 等)。

共享层还提供三类交叉验证:模型钉值(默认值表、动画参数、pointStartFactor)、几何钉值(锚点公式、分段区间、RTL 镜像、虚线相位、渐变插值)、协议回显(空请求回显全部默认值、非法色值转 ok:false、density 换算)。

1.3 实现适配

维度要求
代码复用JetLime 4.3.0 参数与几何模型以纯 Kotlin 复刻进 ohosArm64/ohosX64,每个值注明上游出处并由 JVM 测试钉住。
平台目标example/nativeApp 独立消费工程启用 ohosArm64 + ohosX64,生成双 ABI 动态库。
桥接稳定两个 C ABI 符号 + 扁平 UTF-8 JSON,不传递 Kotlin 对象地址,返回字符串由 N-API 侧即刻释放。
CMP 兼容上游 jetlime 模块与 sample 源码零改动;社区 CMP 工具链对齐后可直接原生编 ohos,本内核退役为对照实现。
UI 完整四 Tab 覆盖桥全部入口字段,每个字段至少一个可见控件与状态行回显。
仓库规范项目说明、文章、效果图和源码链接统一使用 AtomGit。

本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。JetLime 的 JetLimeColumn / JetLimeEvent 仍在仓库 commonMain,继续由 Android/iOS/Desktop/Web 消费;鸿蒙 Demo 验证的是同一份参数与几何模型内核的 OpenHarmony Native 链路。

二、实现路线图

第 1 阶段:项目初始化     ── 盘点源码集、时间线框架边界和 OpenHarmony 交付物
第 2 阶段:目标与依赖打通 ── example/ 独立 Gradle 工程、ohosArm64/ohosX64、双 ABI so
第 3 阶段:参数模型建模   ── JetLimeModel 复刻 Defaults/EventPosition/Placement + 钉值测试
第 4 阶段:几何引擎       ── TimelineEngine 平移 drawBehind 公式 + 软件光栅化管线
第 5 阶段:原生桥接       ── C ABI、C++ N-API、参数校验和内存释放
第 6 阶段:ArkUI 宿主     ── 四 Tab 时间线工作台、状态行回显、HAP、签名和真机验收

三、逐步实现过程

第 1 阶段:项目初始化

1.1 工程边界
jetlime/                                        上游库模块,源码零改动
sample/                                         上游 Compose 示例,零改动
example/settings.gradle.kts                     独立 OHOS 构建 rootProject "ohos_jetlime"
example/gradle/ + gradlew                       Gradle 8.14.1 独立 wrapper
example/nativeApp/src/commonMain/               JetLimeModel / TimelineEngine / Rasterizer / PngEncoder / MiniJson
example/nativeApp/src/ohosMain/                 Bridge.kt(@CName C ABI)
example/nativeApp/src/jvmTest/                  模型钉值 + 几何 + 协议共 36 项测试
example/harmonyApp/                             DevEco Stage:CMake NAPI + ArkTS 四 Tab

example 是独立 Gradle 工程:上游根构建(spotless 全仓格式化、Vanniktech 发布流、Dokka、cocoapods)一字不动,Gradle 与 Hvigor 分开执行,互不影响。这是 haze / miuix 先例确立、本仓沿用的边界——上游构建复杂时,替换式改写会砸坏整条流水线。

1.2 工具链矩阵
项目配置
Kotlin MultiplatformOpenHarmony 构建使用 HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
Gradle8.14.1(example/ 独立 wrapper;上游根构建不动)
JDK21(org.gradle.java.home 写入 example/gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64
DevEco product5.1.0(18),runtimeOS: HarmonyOS
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.terminator.ohos.jetlime
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 页面边界
标题区          JetLime · OpenHarmony / 版本握手行(桥 4.3.0-ohos.1 · 上游 4.3.0 · Kotlin 2.2.21-1.0.0)
Tab 区          垂直时间线 / 水平时间线 / 样式实验室 / 位置规则
参数区          对齐 chips、锚点 chips、节点类型 chips、半径/描边/填充/间距/虚线滑条、RTL 与主题开关
结果区          Image(PixelMap) 全宽展示 + 状态行(尺寸 / png 字节)+ 位置链 chips(位置规则 Tab)

页面启动时先做 jetLimeVersion() 版本握手并展示,再自动渲染默认帧:5 事件垂直时间线 + 线在左 + START 锚点 + Filled 0.5。

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

2.1 加入 ohosArm64 / ohosX64

example/settings.gradle.kts 的 pluginManagement 与 dependencyResolutionManagement 都把两个 eazytec Maven URL 排在最前,否则 ohosArm64() 在官方 Kotlin 插件里不存在:

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()
    }
}

rootProject.name = "ohos_jetlime"
include(":nativeApp")

nativeApp 只声明三个目标,sharedLib 只放这里,避免每个模块各吐一份带运行时的 so:

kotlin {
    applyDefaultHierarchyTemplate()
    jvm()                       // JVM 交叉验证目标
    ohosArm64 { binaries.sharedLib { baseName = "ohosjetlime" } }
    ohosX64   { binaries.sharedLib { baseName = "ohosjetlime" } }
}
2.2 构建核心库和 Native 产物
cd example
./gradlew :nativeApp:jvmTest \
  :nativeApp:linkDebugSharedOhosArm64 \
  :nativeApp:linkDebugSharedOhosX64

copyKnSoArm64 / copyKnSoX64 用 finalizedBy 挂在链接任务上,准备:

example/harmonyApp/entry/libs/arm64-v8a/libohosjetlime.so    ≈8.5 MB
example/harmonyApp/entry/libs/arm64-v8a/libc++_shared.so     ≈1.9 MB(aarch64-linux-ohos triple)
example/harmonyApp/entry/libs/x86_64/libohosjetlime.so       ≈7.7 MB
example/harmonyApp/entry/libs/x86_64/libc++_shared.so        ≈1.7 MB(x86_64-linux-ohos triple)

nm -gU libohosjetlime.so 可见 JetLimeRender / JetLimeVersion / libohosjetlime_symbols 均为全局 T 符号。

2.3 独立消费工程

example 不依赖上游任何 Gradle 模块,内核(模型 + 引擎 + 光栅化)就在 nativeApp/src/commonMain 里,同时被 jvm() 与两个 ohos 目标编译——同一份源码,JVM 上跑测试钉值,ohos 上跑真机。

第 3 阶段:参数模型建模(技术难点)

3.1 JSON 契约
Index.ets generate()
        ↓ TimelineRequest(扁平 JSON)
N-API jetLimeRender(string)
        ↓ const char*
@CName("JetLimeRender")
        ↓ TimelineEngine.render
布局(drawBehind 公式复刻)→ 光栅化(线/点/卡片)→ PNG Base64
        ↓ UTF-8 JSON
ArkTS Image(PixelMap) + 位置链/几何回显

页面不复制任何位置判定或几何公式,每个回显字段都由公共 Kotlin 代码生成。

3.2 复刻的数据模型

JetLimeModel.kt 把上游五个源文件的取值语义 1:1 镜像成纯 Kotlin,文件头与每个常量都注明出处:

object JetLimeModel {
  const val VERSION = "4.3.0-ohos.1"        // <上游>-ohos.1 约定
  const val UPSTREAM_VERSION = "4.3.0"

  object TimelineDefaults {                  // JetLimeDefaults.kt
    const val CONTENT_DISTANCE_DP = 16f      // ContentDistance = 16.dp
    const val ITEM_SPACING_DP = 8f           // ItemSpacing = 8.dp
    const val LINE_THICKNESS_DP = 4f         // LineThickness = 4.dp
    const val LOAD_MORE_THRESHOLD = 3        // 分页预取阈值
  }

  object EventDefaults {                     // JetLimeEventDefaults.kt
    const val POINT_RADIUS_DP = 12f          // PointRadius = 12.dp
    const val POINT_STROKE_WIDTH_DP = 2f     // PointStrokeWidth = 2.dp
    const val POINT_FILL_PERCENT = 0.5f      // PointType = filled(0.5f)
    const val POINT_ANIM_INITIAL = 1.0f      // pointAnimation() 初值
    const val POINT_ANIM_TARGET = 1.2f       // 终值
    const val POINT_ANIM_DURATION_MS = 800   // tween(800, FastOutLinearInEasing)
    const val ADDITIONAL_CONTENT_MAX_WIDTH_DP = 72f
  }

  const val POINT_START_FACTOR = 1.1f        // JetLimeStyle.pointStartFactor
}

颜色默认值取 Material 3 基线 colorScheme(亮色 primary #6750A4 / onPrimary #FFFFFF / secondary #625B71 / tertiary #7D5260,暗色 #D0BCFF 一族)。上游在组合期读 MaterialTheme,桥无法跨语言取主题,因此以基线值兜底、全部字段可覆写——Demo 观感与上游默认一致,且不锁死任何颜色。

3.3 EventPosition 两条解析规则

这是 JetLime 最有「复刻价值」的语义,两条全部复刻并钉值:

  • dynamic(index, listSize):上游先判 listSize - 1 → END 再判 0 → START,所以单元素列表解析为 End 而不是 Start——照「直觉」复刻必错;
  • dynamicPaginated(index, listSize, isLastPage):isLastPage=false 时末条目保持 Middle(外向连接线继续伸向下一页);单条目待分页保持 Start(上游 commit fa8e9e2「fix: preserve START for single-item pending pages」专门修过这个);isLastPage=true 时末条目正常解析为 End。
fun eventPositionDynamicPaginated(index: Int, listSize: Int, isLastPage: Boolean): String =
    if (!isLastPage && index == listSize - 1) {
      if (index == 0) POSITION_START else POSITION_MIDDLE
    } else {
      eventPositionDynamic(index, listSize)
    }

真机「位置规则」Tab 把这两条规则做成了可操作的验收:关闭 isLastPage 时位置链末尾显示 Middle、单条显示 Start;打开开关末条目跳成 End,连接线终止。

第 4 阶段:几何引擎(技术难点)

4.1 drawBehind 公式逐条对照

TimelineEngine.layout() 是上游 VerticalEvent / HorizontalEvent 两个 drawBehind 块的纯 Kotlin 平移(垂直方向):

上游公式复刻
xOffset = LEFT ? radius : size.width - radiuspointCross = left ? r : width - r
START:yOffset = radius * 1.1anchor = r * POINT_START_FACTOR
CENTER:(height - (notEnd ? spacing : 0)) / 2effectiveLength / 2
END:effectiveHeight - radius * 1.1effectiveLength - r * 1.1
START 连接线:(yOffset) → (height + yOffset * 0.1),仅 notEnd同,分段区间逐位一致
CENTER/END:0 → yOffset(notStart)+ yOffset → height(notEnd)同
内容留白:absolutePadding(radius * 2 + contentDistance)contentStart = r * 2 + contentDistance
描边圆:radius - strokeWidth / 2(Stroke 语义)strokeRadius = r - sw / 2
填充圆:radius - radius * (1 - fillPercent) = radius * fillPercentfillRadius = r * fillPercent
自定义图标框:radius * 2.4 * animFactor 居中s = r * 2.4 * animFactor

水平方向镜像对称(yOffset 由 HorizontalAlignment 决定),并完整复刻 RTL 分支:xOffset = size.width - logicalXOffset,START 分段的越界量从右缘 width + xShift 翻转为左缘 -xShift——这条分支在几何测试里有专门用例(锚点镜像为 boxLength - 13.2,分段翻转为 [-5.88, 58.8])。

4.2 渐变线逐事件盒重扫(上游语义细节)

上游 JetLimeDefaults.lineGradientBrush() 默认 start = Offset.Zero, end = Offset.Infinite, TileMode.Clamp。在 drawBehind 作用域里 Offset.Infinite 映射到每个事件盒子的尺寸,所以渐变是逐事件盒重扫的——每个条目都从 primary 重新渐到 tertiary,不是整条时间线一次渐变。引擎按投影公式取色:

// 事件盒坐标系内的投影:t = (x·w + y·h) / (w² + h²),w/h 为该事件盒的跨轴尺寸与盒长
private fun gradientT(event: EventLayout, cfg: Config, atMainCanvas: Float): Float
4.3 虚线相位语义

PathEffect.dashPathEffect([on, off], phase):距离 d 处 (d + phase) mod (on + off) < on 才绘制。phase 会把开头一段截短(留下 on - phase 存根),有专门测试用例钉住(on=8, off=4, phase=2 时首段为 [0, 6],第二段 [10, 18])。

4.4 软件光栅化管线

共享库里没有 Skia / DrawScope,Rasterizer.kt 提供带 1px 平滑抗锯齿的画布原语,与上游调用一一对应:

原语对应上游
fillCircle(cx, cy, r, color)drawCircle(color, radius, center) 填充
strokeCircle(cx, cy, r, sw, color)drawCircle(style = Stroke(sw))(居中于半径)
axisLine(fixed, from, to, thickness, color)drawLine(上游连接线全部轴向对齐)
segment(x0, y0, x1, y1, t, color)自定义图标笔划(任意角线段)
roundedRect / hBar内容卡片与文字占位条

渲染顺序与 drawBehind 一致:先连接线,再内容卡片(卡片 + 标题条 / 正文条占位——上游 itemContent 是任意 Composable,Demo 以抽象卡片表示),最后节点(背景圆 → 填充圆 / 图标 → 描边环)。PNG 编码器沿用 cmp_qrose / haze 血统的零依赖实现(stored zlib 块),Demo 图像在几十 KB 量级。

动画是静态采样:上游 EventPointAnimation 是 infiniteRepeatable(tween(800, FastOutLinearInEasing), Reverse) 的 1.0→1.2 半径脉冲,PNG 是单帧,桥以 animFactor 参数(默认 1.0,Demo 滑条 1.0–1.3)采样脉冲过程中的一个半径系数,动画参数本身钉在模型测试里。

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

5.1 三层桥接
ArkTS
  │ 扁平 JSON string
  ▼
C++ N-API entry(libjetlime_napi.so)
  │ const char*
  ▼
Kotlin/Native C ABI(libohosjetlime.so)
  │ TimelineEngine
  ▼
UTF-8 JSON + 显式 DisposeString

HarmonyOS Kotlin 的 CAdapter 不会把 @CName 业务函数挂到 ArkTS 模块,所以 ArkTS 永远只 import { jetLimeRender, jetLimeVersion } from 'libjetlime_napi.so',不 import KN so——与 cmp_qrose / haze / miuix 完全同一条规则。

5.2 Kotlin/Native 导出函数
@CName("JetLimeVersion")
fun jetLimeVersion(): String = TimelineEngine.versionJson()

@CName("JetLimeRender")
fun jetLimeRender(request: String): String = try {
  TimelineEngine.render(request)
} catch (t: Throwable) {
  """{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}

Kotlin 异常永不跨语言边界——跨过去是无信息崩溃,统一转 ok:false。引擎的 render() 自身也 catch-all 返回 ok:false,与 Bridge 双保险,这样该契约在 jvmTest 里可以直接验证。

5.3 C++ N-API 方法分发与内存释放
napi_property_descriptor desc[] = {
    {"jetLimeRender", nullptr, NapiJetLimeRender, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
    {"jetLimeVersion", nullptr, NapiJetLimeVersion, nullptr, nullptr, nullptr,
     static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};

C++ 层只做四件事:类型检查、字符串转换、调 C ABI、把返回字符串拷进 JS 后立刻 libohosjetlime_symbols()->DisposeString(raw) 释放——不 free,不让 ArkTS 管。

5.4 CMake 和类型声明
add_library(ohosjetlime SHARED IMPORTED)
set_target_properties(ohosjetlime PROPERTIES
    IMPORTED_LOCATION ${KN_SO_DIR}/libohosjetlime.so
    IMPORTED_NO_SONAME TRUE)

add_library(jetlime_napi SHARED napi_init.cpp)
target_link_options(jetlime_napi PRIVATE "-Wl,--no-undefined" "-Wl,-rpath,\$ORIGIN")
target_link_libraries(jetlime_napi PUBLIC libace_napi.z.so libhilog_ndk.z.so ohosjetlime)

add_custom_command(TARGET jetlime_napi POST_BUILD  # KN so + libc++ 保持在 NAPI so 旁边
    COMMAND ${CMAKE_COMMAND} -E copy_if_different ${KN_SO} $<TARGET_FILE_DIR:jetlime_napi>
    ...)
export const jetLimeRender: (request: string) => string;
export const jetLimeVersion: () => string;

第 6 阶段:ArkUI 宿主与交付

6.1 工程结构
example/
├── settings.gradle.kts / build.gradle.kts / gradle.properties / gradlew
├── nativeApp/
│   ├── build.gradle.kts                       # jvm + ohos 双 ABI + copyKnSo*
│   └── src/
│       ├── commonMain/kotlin/example/nativeapp/  # JetLimeModel / TimelineEngine / Rasterizer / PngEncoder / MiniJson
│       ├── ohosMain/kotlin/example/nativeapp/Bridge.kt
│       └── jvmTest/kotlin/example/nativeapp/     # 36 项交叉验证(13/14/9)
└── harmonyApp/
    ├── AppScope/app.json5                     # bundle org.terminator.ohos.jetlime
    └── entry/src/main/
        ├── cpp/{CMakeLists.txt, napi_init.cpp, include/, types/libjetlime_napi/}
        ├── ets/{entryability/EntryAbility.ets, pages/Index.ets}
        └── module.json5 / resources/
6.2 构建、签名和安装
export JAVA_HOME="/path/to/jdk-21"
cd example
./gradlew :nativeApp:jvmTest \
  :nativeApp:linkDebugSharedOhosArm64 \
  :nativeApp:linkDebugSharedOhosX64

# DevEco Studio 打开 example/harmonyApp(不是仓库根目录!)
# File → Project Structure → Signing Configs 手动自动签名(证书不入库)
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc list targets -v
hdc -t <设备序列号> install -r \
  example/harmonyApp/entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.jetlime -m entry

四、完整代码对照

4.1 整体架构

JetLimeModel.kt
    │ JetLime 4.3.0 默认值表 + EventPosition 解析规则(出处注释 + 钉值)
    ▼
TimelineEngine.kt
    │ drawBehind 公式平移 → 布局回显 + 渲染编排 → PNG Base64 JSON
    ▼
Rasterizer.kt / PngEncoder.kt
    │ AA 画布原语(圆/环/轴线/圆角矩形/线段)+ stored-zlib PNG
    ▼
Bridge.kt
    │ JetLimeRender / JetLimeVersion(@CName C ABI)
    ▼
libjetlime_napi.so
    │ jetLimeRender / jetLimeVersion
    ▼
Index.ets
    │ JSON parse + PixelMap 刷新 + 位置链 chips
    ▼
ArkUI 真机页面

4.2 文件清单

文件职责
example/settings.gradle.kts独立 OHOS 构建、eazytec 插件仓库、ohos_jetlime
example/gradle.propertiesJDK 21、native 三开关、version=4.3.0-ohos.1
example/nativeApp/build.gradle.ktsjvm + ohosArm64/ohosX64 sharedLib + copyKnSo*
nativeApp/.../JetLimeModel.ktJetLime 4.3.0 参数模型复刻(出处注释 + 钉值)
nativeApp/.../TimelineEngine.ktdrawBehind 几何平移 + JSON 协议 + 渲染编排
nativeApp/.../Rasterizer.ktAA 画布原语(圆 / 环 / 轴线 / 圆角矩形 / 线段 / 图标笔划)
nativeApp/.../PngEncoder.kt / MiniJson.kt零依赖 PNG(stored zlib)/ 扁平 JSON 解析
nativeApp/.../Bridge.kt@CName C ABI,catch-all JSON 错误
nativeApp/src/jvmTest/36 项:钉值 / 几何 / 协议
harmonyApp/.../napi_init.cppN-API 导出、字符串转换与释放
harmonyApp/.../Index.etsArkUI 四 Tab 时间线工作台
README.OpenHarmony.md / _CN.md适配说明(中文版为主文档)

4.3 关键 API 对照

层次API作用
KMPJetLimeModel.TimelineDefaultsJetLimeDefaults:16dp / 8dp / 4dp / 阈值 3
KMPJetLimeModel.EventDefaultsJetLimeEventDefaults:12dp 半径 / 2dp 描边 / 填充 0.5 / 动画参数
KMPJetLimeModel.eventPositionDynamicEventPosition.dynamic(先 END 后 START)
KMPJetLimeModel.eventPositionDynamicPaginated分页位置规则(含 fa8e9e2 修复)
KMPTimelineEngine.layout平移 VerticalEvent / HorizontalEvent 几何
NativeJetLimeRenderJSON 请求 → JSON(含 pngBase64 与几何回显)响应
NativeJetLimeVersionfork / 上游 / Kotlin / 引擎握手
N-APIjetLimeRender / jetLimeVersion向 ArkTS 暴露的两个函数
ArkTSgenerate组装请求、调桥、刷新 PixelMap 与状态行

4.4 ArkTS 与 Kotlin 的边界

const body: string = JSON.stringify({
  orientation: this.currentOrientation(),
  alignment: this.currentAlignment(),
  items: this.tab === 'position' ? this.loadedItems : this.items,
  pointPlacement: this.placement,
  pointType: this.pointType,
  density: 2,
  width: horizontal ? 640 : 360,
  height: horizontal ? 320 : 560,
});
const raw: string = jetLimeRender(body);
const parsed: RenderOk = JSON.parse(raw) as RenderOk;
val strokeRadius = radius - cfg.strokeWidthPx / 2f   // Compose Stroke 语义
val fillRadius = radius * cfg.fillPercent             // 上游填充圆公式

两者之间只传输扁平 JSON,不传输 Kotlin 对象或未校验的动态结构;位置判定、锚点公式、分段规则全部在 Kotlin 侧,ArkTS 不重写任何几何。

五、关键决策说明

决策 1:独立 example/ 构建,而不是整仓切换鸿蒙 Kotlin

上游根构建包含 spotless 全仓格式化、Vanniktech 发布流、Dokka 与 cocoapods;且上游 Kotlin 2.4.0 / Compose 1.10.3 与 HarmonyOS Kotlin 2.2.21-1.0.0 两条插件线不能共存于一个 Gradle 构建。独立构建零侵入、与上游可并行维护(haze / miuix 先例)。

决策 2:复刻参数与几何模型,而不是硬接 Compose 修饰符

JetLime commonMain 十三个文件全部锚定 androidx.compose,连 EventPosition / EventPointType 这些「模型」文件都挂着 @Immutable / Color / Painter,没有可整体搬走的纯 Kotlin 文件;有价值的正是取值语义与 drawBehind 公式。1:1 复刻 + 上游出处注释 + JVM 钉值,是对「代码复用」的诚实实现;硬接 drawBehind / Painter 等于再打一套 Compose 发行版。

决策 3:软件光栅化 + 动画静态采样,而不是等 Skia 或逐帧回放

共享库里没有 Skia / DrawScope。轴向对齐的连接线用一维覆盖率精确光栅化,圆 / 环 / 圆角矩形走 SDF 距离平滑,语义可对齐、可测试;infiniteRepeatable 脉冲以 animFactor 单帧采样(1.0–1.2 对应上游初值到终值区间),动画参数钉在模型测试里而非逐帧回放。

决策 4:JSON 作为跨语言数据契约

扁平键值对 + 手写 MiniJson,不引 kotlinx.serialization / coroutines:少一个需要与 HarmonyOS Kotlin 对齐的 KN 依赖,C 签名 const char* → const char* 永不改变,协议加字段不动 ABI。

决策 5:ArkUI 作为验收宿主,参数同源是底线

回显字段(位置链、锚点、半径、分段)必须与上游公式逐位一致;「设备上看起来对」不构成验收,jvmTest 钉值 + 状态行对账才构成。

六、测试与验证

6.1 测试环境

本次验证使用 macOS(Apple Silicon)、JDK 21、HarmonyOS Kotlin 2.2.21-1.0.0、Gradle 8.14.1、DevEco Studio 5.1.0(18) 工具链与鸿蒙真机(arm64-v8a,签名安装)。

6.2 共享样例测试

cd example && ./gradlew :nativeApp:jvmTest

36 项测试分三类全部通过:

  • 模型钉值(13 项):16 / 8 / 4dp 默认、LoadMoreThreshold=3、点半径 12dp、描边 2dp、填充 0.5、动画 1.0→1.2 @800ms Reverse、AdditionalContentMaxWidth=72dp、pointStartFactor=1.1、版本约定、dynamic 三段解析、单元素→End、空列表拒绝、dynamicPaginated 三条规则(含 fa8e9e2 单条 START 修复);
  • 几何钉值(14 项):START 锚点 = 13.2px、越界分段 13.2→73.32、盒子堆叠 72+72+64、CENTER 有效高度减间距后取半、CENTER 分段穿锚连续、END 锚点 = 50.8px、RIGHT 镜像 xOffset、内容留白 = 2r + contentDistance、水平 TOP / BOTTOM、RTL 锚点镜像与分段翻转、animFactor 缩放回显(radius 12 / fillRadius 6 / strokeRadius 11)、虚线相位语义(phase=2 时首段截为 6)、渐变三色等分插值;
  • 协议钉值(9 项):版本握手四字段、空请求回显全部默认值、分页位置链两种形态、PNG 签名与 IHDR 尺寸、bytes 回显与解码字节数一致、非法色值转 ok:false、坏数字回退默认、几何坐标出现在 events 回显、density=2 时 dp 翻倍。

6.3 HAP 验证

CompileArkTS: finished
BuildNativeWithCmake / BuildNativeWithNinja: finished
PackageHap: finished
SignHap: skipped(签名配置不入库,DevEco 内自动签名)
BUILD SUCCESSFUL

未签名 HAP(≈7.9 MB)内含双 ABI 各三个原生库:

libs/arm64-v8a/libohosjetlime.so   libs/arm64-v8a/libjetlime_napi.so   libs/arm64-v8a/libc++_shared.so
libs/x86_64/libohosjetlime.so      libs/x86_64/libjetlime_napi.so      libs/x86_64/libc++_shared.so

6.4 功能用例(真机实测)

  1. 启动后版本握手行显示 桥 4.3.0-ohos.1 · 上游 4.3.0 · Kotlin 2.2.21-1.0.0 · jetlime-kn-rasterizer,并自动渲染默认帧(5 事件垂直时间线);
  2. 垂直时间线 Tab:默认参数下节点为「白底 + 紫描边 + 50% 紫内圆」,连接线沿左缘贯穿;切换「线在右」线与卡片整体换边;CENTER 锚点连接线改为穿过节点中心连续;✓ / ⇄ 图标节点按 2.4×radius 图标框渲染;
  3. 水平时间线 Tab:线换到卡片上方 / 下方;打开 RTL 镜像后时间线方向整体翻转、START 越界段翻向左缘;
  4. 样式实验室 Tab:渐变模式每个条目从紫重新渐变到 tertiary 色(逐事件盒重扫);虚线开关配合 dashOn / dashOff 滑条实时改变线型;深色主题整帧切换;
  5. 位置规则 Tab:关闭 isLastPage 时位置链末尾显示 Middle、单条显示 Start;打开开关末条目跳成 End,连接线终止——dynamicPaginated 两条规则直接可见;
  6. 任意参数组合下状态行不出现 失败:,Native 异常显示在错误区域,HAP 不崩溃。

6.5 验证结论

Native 动态库(双 ABI + libc++)、C ABI、N-API、ArkUI 页面和签名 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实 Native 渲染的垂直 / 水平时间线、渐变与虚线线条、四类节点样式与分页位置规则,状态行回显与上游参数表逐位一致。

七、运行效果

7.1 真机截图

在这里插入图片描述

7.2 命令速查

git clone https://atomgit.com/oh-tpc/JetLime.git
cd JetLime
export JAVA_HOME="/path/to/jdk-21"
cd example
./gradlew :nativeApp:jvmTest \
  :nativeApp:linkDebugSharedOhosArm64 \
  :nativeApp:linkDebugSharedOhosX64

# DevEco Studio 打开 example/harmonyApp,自动签名后:
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
hdc -t <设备序列号> install -r \
  example/harmonyApp/entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <设备序列号> shell aa start \
  -a EntryAbility -b org.terminator.ohos.jetlime -m entry

八、遗留问题与改进方向

8.1 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须真正编译 ohosArm64/ohosX64 动态库并在设备上 dlopen 成功;
  2. EventPosition.dynamic 先判 END 再判 START,单元素列表是 End——照「直觉」复刻必错;
  3. dynamicPaginated 的单条目 START 保留是上游专门修过的(fa8e9e2),复刻老版本语义会与 4.3.0 不一致;
  4. 渐变 Brush.linearGradient(Offset.Zero → Offset.Infinite) 在 drawBehind 里按事件盒作用域取尺寸,逐条目重扫,不是整线一次渐变;
  5. Kotlin/Native 无 String.format,小数回显要手写(本仓 fmt() 去尾零,输出 13.2 而非 13.20),否则协议测试对不上账;
  6. 签名工程必须与源码分离,证书不能进入 AtomGit;模拟器是 x86_64,双 ABI 缺一不可(缺 x64 报安装错误 9568347)。

8.2 已知问题

  • 动画是静态采样:infiniteRepeatable 脉冲以 animFactor 单帧表示,动画参数钉在测试里而非逐帧回放;
  • 内容卡片是抽象占位(标题条 + 正文条),上游 itemContent 是任意 Composable,PNG 侧无法复刻任意 UI;事件文案在 ArkTS 层展示;
  • 自定义图标以程序化笔划复刻(✓ 与 ⇄ 两个 glyph,尺寸 2.4×radius 遵循上游),非上游的矢量资源本体;
  • 软件光栅化无 GPU 加速,整帧 360×560 渲染毫秒级,满足演示与验收、非实时帧率场景;
  • JetLimeExtendedEvent 的 additionalContent 双列布局未在桥中透出(模型常量 AdditionalContentMaxWidth = 72dp 已钉值);
  • HAP 安装必须使用与 bundle name org.terminator.ohos.jetlime 匹配的签名。

8.3 未来优化方向

  • 接入相册 PixelMap 作为时间线内容背景(协议加 pixelData 字段,不动 C 签名);
  • 透出 JetLimeExtendedEvent 双列布局几何;
  • 社区已出现 Compose 1.12 鸿蒙分支(-0.1.0 线,compottie 已验证可直编),评估 JetLime 源码直接切线编译 ohos 目标,届时本内核退役为对照实现;
  • 在 CI 中加入 Native 链接、ELF 符号检查和 HAP 构建;
  • 补充不同设备 ABI、API 版本与性能模式的验收矩阵。

九、总结

9.1 核心难点回顾

JetLime 4.3.0 参数与几何模型(commonMain 语义)
    → 纯 Kotlin 复刻 + JVM 钉值(13 项)
    → drawBehind 公式平移 + 几何钉值(14 项)
    → ohosArm64/ohosX64 Kotlin/Native
    → C ABI(JetLimeRender/JetLimeVersion)
    → C++ N-API(libjetlime_napi.so)
    → ArkTS JSON 解析 + PixelMap + 位置链回显
    → ArkUI 真机四 Tab 时间线工作台

9.2 封装层次

  • KMP 层:提供 JetLime 4.3.0 默认值表、EventPosition 双解析规则、Placement 锚点与分段公式的同源语义;
  • Native 层:生成双 ABI 动态库并通过两个 C ABI 符号输出 JSON + PNG + 几何回显;
  • N-API 层:完成参数检查、字符串转换和 Native 内存释放;
  • ArkTS 层:管理四 Tab 状态、请求组装、结果展示和错误文本;
  • DevEco 层:完成 CMake、HAP、签名、安装和真机运行。

9.3 三条经验

  1. 先让参数模型在 JVM 与 ohos 双侧编译通过并钉值,再接 ArkUI;回显字段与上游公式逐位一致是验收底线;
  2. Compose 重度库的可复用资产往往是「取值语义 + 绘制公式」而不是某个文件——复刻 + 出处注释 + 测试钉值,好过硬接工具链;
  3. 用扁平 JSON 和少量 C ABI 代替跨语言对象传递,并明确字符串释放责任;分别记录自动测试、Native 链接、HAP 构建和真机运行,避免把编译成功误认为设备链路可用。

9.4 适配成果

当前适配已完成:

  • example/ 独立 OpenHarmony Gradle 构建(上游根构建零改动);
  • JetLime 4.3.0 参数与几何模型内核的双 ABI Kotlin/Native 编译(libohosjetlime.so arm64/x64 + 匹配 libc++);
  • 两个 C ABI 符号和 C++ N-API 包装(libjetlime_napi.so);
  • 36 项 JVM 交叉验证测试全部通过(模型 13 / 几何 14 / 协议 9);
  • DevEco Stage ArkUI 四 Tab 验收页面;
  • HAP 构建、签名、真机安装与运行验证;
  • AtomGit 项目文档(README.OpenHarmony.md / README.OpenHarmony_CN.md)与真机效果图。

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

更多推荐