开源鸿蒙平台 CMP 三方库「PhotoCalendar」适配全流程
本文记录
PhotoCalendar接入 OpenHarmony 的完整过程,覆盖 UI 型 CMP 库的源码集拆分、kotlinx-datetime 制品线选型、真实日期引擎编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 双视图页面、HAP 构建和真机验收。
PhotoCalendar是基于 Compose Multiplatform 的照片日历组件库:网格月历Calendar与横向滚动HorizontalCalendar两个@Composable,CalendarEvent给特定日期挂背景图(圆形 / 圆角裁剪 + 白色遮罩),EN / KO 双语标题与星期标签,选中日蓝色半透明圆形指示器。本次适配与存储类库(KStore 的"真编译整库 + 补一个 actual")有本质区别:PhotoCalendar 的全部交付面都在 Compose UI 上,而鸿蒙工具链编不了@Composable,因此按社区已审核的 cmp_qrose 模式走**“日期算法真编译 + Compose UI 隔离 + ArkUI 复刻”**路线——DateYearMonth/TimeExt/FormatYearMonth纯 Kotlin 算法原样编译成 OpenHarmony KLIB,10 个 Compose 文件隔离到composeMain留源不编,新增PhotoCalendar门面对外输出网格与横滚数据,ArkUI 页面作为验收宿主。同一年月的网格布局必须和 Android / iOS 上的上游 1.1.1 逐格一致,这是本次适配的验收底线。
项目地址: AtomGit/oh-tpc/PhotoCalendar.git
上游仓库: DongChyeon/PhotoCalendar
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
PhotoCalendar 为 Compose Multiplatform 应用提供"日历 UI"这一件事的完整答案:Calendar(selectedDate, events) { } 一行得到网格月历,HorizontalCalendar(...) 一行得到横向滚动日历,两者共享选中状态;CalendarEvent(date, imgUrl, imgShape) 把任意网络图片挂到某一天;CalendarLanguage.EN / KO 切换 October 2026 / 2026년 10월 与两套星期标签;CalendarHeaderConfig / CalendarDayConfig / CalendarIndicatorConfig 覆盖字体、颜色与指示器形状。KMP 项目把这些能力放在 commonMain,由 Android、iOS、桌面共享。OpenHarmony 应用同样需要这套日历能力,但上游构建(Kotlin 2.2.20 / Compose Multiplatform 1.9.0 / AGP 8.11.2)不会生成 OpenHarmony 变体。
如果只把示例页面改成 ArkTS,页面可以画出格子,却无法证明公共日期算法(周对齐、行数计算、月翻页、双语格式化)在 OpenHarmony ARM64 设备上完成编译、链接和执行——而那正是日历库最容易在跨月、跨年、闰年上出错的 部分。适配需要同时验证日期引擎、kotlinx-datetime 运行时、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 必须增加 ohosArm64() / ohosX64() 才能生成 OpenHarmony KLIB。 |
| 工具链不一致 | 上游 Kotlin 2.2.20 / CMP 1.9.0 / AGP 8.11.2 与鸿蒙工具链无法共存于一个 Gradle 构建;ohosArm64() 只存在于 HarmonyOS Kotlin 插件。 |
| 依赖矩阵 | 库模块 commonMain 依赖 compose.*、coil3 与 kotlinx-datetime——前两者没有 ohos 制品且只被 UI 文件使用;datetime 必须挑出带 ohos klib 的制品线。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| UI 语义跨不了桥 | @Composable、LazyRow、remember、MaterialTheme 是 Kotlin 侧概念,C ABI 只能同步一来一回。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:日期算法的 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。日期语义完全由上游原版实现负责,ArkTS 只负责调用 Native、解析 JSON 和渲染视图。
1.2 库提供的能力
Demo 提供一个真实的日历会话,对应桥接的全部入口:
- 今天(today):
LocalDate.now()走Clock.System+ 系统时区,返回 iso / year / month / day; - 网格月历(month):
weeks[N][7]每格带 day / iso / inMonth——行由atDay(1).plus(weekNumber * 7 天)再previousOrSame(SUNDAY)对齐(上游Calendar.kt的Week算法),行数由getNumberWeeks(SUNDAY)决定,跨月补位格inMonth=false; - 横向日历(horizontal):
1..lengthOfMonth()逐日,weekday 按isoDayNumber从 Monday-first 数组取(上游HorizontalCalendar的Day算法); - 翻月(shiftMonth):
plusMonths整数运算,跨年正确; - 双语(labels / formatYearMonth):EN
Sun…Sat/ KO일…토,October 2026/2026년 10월。
响应字段:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
title | 双语年月标题,来自 FormatYearMonth,不在 ArkTS 手写。 |
weeks / numWeeks / weekLabels | 网格数据、行数与 Sunday-first 周标签。 |
days | 横向日历逐日数据(day / iso / weekday)。 |
iso / year / month / day | today 与 shiftMonth 的返回字段。 |
事件背景图(CalendarEvent 的 imgUrl / imgShape)不进桥——图片加载与裁剪是视图职责,ArkTS Image 直接消费 URL;桥只管日期。选中态、指示器、双视图联动是页面状态,同样不过桥。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 日期算法 4 个 commonMain 文件(DateYearMonth / TimeExt / FormatYearMonth / CalendarLanguage)原样编译,不提取、不重写;仅新增 PhotoCalendar 门面(全仓唯一新增库源文件)。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);composeApp / iosApp(上游 Android/iOS Demo)留盘不进构建。 |
| 桥接稳定 | 使用 1 个 C ABI 和 UTF-8 JSON(6 个 op),不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | kotlinx-datetime 换到 ohos 制品线 0.7.1-0.5.0-01(版本主体 0.7.1,对上游 0.6.1 源码级兼容),compose / coil3 随 UI 隔离移出编译面。 |
| UI 完整 | 单页覆盖全部桥接入口:网格月历、横向日历、双视图选中联动、事件背景图、双语切换和状态行。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。PhotoCalendar 本身只碰 UI,适配面天然最大;ArkTS Demo 的职责是把上游两个 Composable 的行为(网格行数、横滚定位、事件图、双语)逐条复刻出来,日期数据一律来自桥。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、按"是否碰 Compose"给 16 个源文件分类、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、datetime 制品线选型
第 3 阶段:桥协议建模 ── 6 op JSON 契约、PhotoCalendar 门面(internal 算法公共化)、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 双视图布局、事件样本、双语切换、选中联动和状态行
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
calendar/ 上游日历库(日期算法进 ohos 构建,jvm + ohosArm64 + ohosX64)
calendar/src/commonMain/ DateYearMonth + TimeExt + FormatYearMonth + CalendarLanguage
+ PhotoCalendar 门面(新增,全仓唯一新增库源文件)
calendar/src/composeMain/ 上游 Compose UI(10 文件 + 箭头图标,留源不编)
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
composeApp/ iosApp/ 上游 Android/iOS Demo,留盘不进 settings
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :calendar 与 :example:nativeApp,rootProject.name = "ohos_PhotoCalendar"。上游的 AGP / Compose Compiler / 热重载 / ktlint / vanniktech 发布插件全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin 21.0.12,org.gradle.java.home 钉进 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 8.14.3 降级;2.2.21 线按 qrose 先例钉 8.14.1) |
| kotlinx | kotlinx-datetime 0.7.1-0.5.0-01(KBA ohos 制品线) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.photocalendar |
export JAVA_HOME="/path/to/jdk-21"
java -version
1.3 源码依赖度盘点
上游 calendar 模块 commonMain 共 12 个 Kotlin 文件,按"是否 import androidx.compose / coil3"一刀切成两类:
| 文件 | 依赖形态 | 处置 |
|---|---|---|
model/DateYearMonth.kt | 纯 Kotlin + datetime,零 UI | 留在 commonMain 编译 |
util/TimeExt.kt | 纯 Kotlin + datetime(now / previousOrSame / weekOfMonth / plusMonths / getNumberWeeks) | 留在 commonMain 编译 |
util/FormatYearMonth.kt | 纯 Kotlin + datetime(双语标题) | 留在 commonMain 编译 |
CalendarLanguage.kt | 零依赖枚举(EN / KO) | 留在 commonMain 编译 |
CalendarConfig.kt、theme/(Color、Theme、Type)、ui/(Calendar、HorizontalCalendar、DayBackgroundImage)、util/(CalendarLayoutUtil、ModifierExt) | androidx.compose.*(TextStyle / Shape / Composable / LazyRow / MaterialTheme) | git mv 到 src/composeMain/,不进 ohos 编译 |
CalendarEvent.kt | 混合体:date / imgUrl 是纯数据,imgShape: Shape 与 @Immutable 是 Compose | 整文件归 composeMain;鸿蒙侧事件模型由 Demo 的 iso -> {url, circle} 承载 |
这一步是 UI 型 CMP 库适配的核心动作:composeMain 不是 Kotlin 源集,ohos 编译器看不到这些文件,androidx.compose 与 coil3 的 import 不会再挡编译;源码原样保留,等社区 CMP 工具链与上游版本对齐后可以整体接回。上游的 composeResources(左右箭头矢量图)一并迁移。
1.4 页面边界
标题区 PhotoCalendar / 语言切换胶囊(EN · KO)
网格月历 ‹ October 2026 › 翻月 / Sunday-first 周表头 / N×7 网格
(跨月补位格空白不可点,事件格铺图+遮罩,选中格蓝 40% 圆形蒙层白字)
横向日历 标题左+箭头右 / 逐日横滚(星期标签+56vp 日格)/ 选中自动滚到 day-4
状态行 grid weeks=N · horizontal days=L · today=yyyy-MM-dd
页面启动时先调 op=today 定位当月,再并行调 op=month / op=horizontal 渲染双视图。
第 2 阶段:目标与依赖打通
2.1 制品线验证(本适配的成败手)
日期算法站在 kotlinx-datetime 上,上游钉 0.6.1。先查社区 Maven(https://maven.eazytec-cloud.com)上哪个版本带 ohos klib——用 .module 元数据逐个验证,不能只看版本号后缀:
org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat
→ module 只有 jvm 变体,ohos 解析失败(名字带 compat 也不行)
org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-OH-001
→ 仓库里只有 jar / pom,没有 klib,不可用
org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.5.0-01
→ module 内含 ohosarm64 / ohosx64 变体 ← 就用这个
两个关键结论:其一,0.7.1-0.5.0-01(KBA 线)的 API 对 0.6.1 源码级兼容——LocalDate / DatePeriod / DayOfWeek / Clock / plus / minus 都在,只是 monthNumber / dayOfMonth / Clock 被标了弃用(为了不改上游源码,接受这些编译警告);其二,官方 Maven Central 上任何版本都没有 ohos 变体,版本必须带 KBA 后缀才解析得到。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 / Gradle 8.14.1)
datetime 0.7.1-0.5.0-01 的元数据在 2.2.21 可读范围内,不需要像 KStore 那样升到 2.4.20 线。社区此前批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / haze 用的),本次直接沿用;上游 Gradle wrapper 8.14.3 降到 8.14.1——2.2.21 线的既定经验(qrose 先例),两条工具链线的经验不能互相套用(2.4.20 线可留 Gradle 9.x,2.2.21 线必须 8.14.1)。
settings.gradle.kts 把 eazytec 仓库写在最前:
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_PhotoCalendar"
include(":calendar")
include(":example:nativeApp")
2.3 编译真实日期引擎
calendar/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.datetime) // 0.7.1-0.5.0-01
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
compose / coil3 / AGP 依赖随源码集拆分全部移出编译面。实测:
./gradlew :calendar:compileKotlinJvm :calendar:compileKotlinOhosArm64 :calendar:compileKotlinOhosX64
# BUILD SUCCESSFUL
日期算法的 4 个上游文件一个没动,只有 PhotoCalendar.kt 门面是新的。
2.4 测试基线的处置:上游没有真测试
上游 androidHostTest / androidDeviceTest 只有 AGP 模板生成的 2+2=4 示例断言,没有任何日历逻辑用例——"测试零改动全绿"在这类库上无从谈起。处置是补建交叉验证:在 commonTest 新增 PhotoCalendarTest(6 用例,见 6.2),把上游算法的关键行为钉成断言;同时沿用社区纪律,根构建全局禁用 ohos 测试任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
ohos 侧只承担编译与链接,测试统一在 JVM 跑。
第 3 阶段:桥协议建模
3.1 JSON 契约
com.dongchyeon.calendar/**(上游日期算法,原样编译)
↓ DateYearMonth / TimeExt / FormatYearMonth
PhotoCalendar 门面(public:month / horizontalMonth / today / shiftMonth / labels)
↓ 结构化数据
Bridge.kt(@CName × 1,扁平 JSON 进出)
↓ UTF-8 JSON
N-API photoCalendarRender()
↓
ArkUI Index.ets(单页滚动)
请求与响应:
请求 : {"op":"today"}
{"op":"month","year":2026,"month":10,"language":"EN"}
{"op":"horizontal","year":2026,"month":10,"language":"KO"}
{"op":"shiftMonth","year":2026,"month":1,"delta":-1}
{"op":"labels","language":"KO"}
{"op":"formatYearMonth","year":2026,"month":10,"language":"EN"}
响应 : {"ok":true,"iso":"2026-10-16","year":2026,"month":10,"day":16}
{"ok":true,"title":"October 2026","numWeeks":6,
"weekLabels":["Sun","Mon",...],
"weeks":[[{"day":27,"iso":"2026-09-27","inMonth":false}, ...], ...]}
{"ok":false,"error":"..."}
一个入口 6 个 op:日历的所有日期语义(行数、对齐、标签、翻页)都从这走,ArkTS 不做任何日历算术。
3.2 门面为什么是 PhotoCalendar(而不是改 internal 可见性)
atDay / previousOrSame / getNumberWeeks / plusMonths 都是上游 internal 扩展,example/nativeApp 摸不到。处理办法不是改可见性(那会动上游 API 面),而是在库模块内新增 PhotoCalendar 门面对象——对外 public、对内复用 internal 扩展,对齐 cmp_qrose 的 Qrose.encodeQr 做法:
// calendar/src/commonMain/kotlin/com/dongchyeon/calendar/PhotoCalendar.kt
object PhotoCalendar {
fun month(year: Int, month: Int, language: CalendarLanguage = CalendarLanguage.EN): CalendarMonthData {
val yearMonth = DateYearMonth(year, month)
val numWeeks = yearMonth.getNumberWeeks(startDayOfWeek = DayOfWeek.SUNDAY)
val weeks = (0 until numWeeks).map { weekNumber ->
val beginningWeek = yearMonth.atDay(1).plus(DatePeriod(days = weekNumber * 7))
var currentDay = beginningWeek.previousOrSame(DayOfWeek.SUNDAY)
(0 until 7).map {
val cell = CalendarDayCell(currentDay.dayOfMonth, currentDay.toString(),
currentDay.monthNumber == yearMonth.month)
currentDay = currentDay.plus(DatePeriod(days = 1))
cell
}
}
/* ... */
}
fun horizontalMonth(...) / today() / shiftMonth() / weekLabels() / formatYearMonth()
}
算法体逐行来自上游 Calendar.kt 的 Week 与 TimeExt.kt——网格行由 atDay(1).plus(weekNumber * 7 天) 再 previousOrSame(SUNDAY) 对齐,行数由 getNumberWeeks(SUNDAY) 决定。门面只是把 Composable 内部的日期逻辑抽成纯函数,没有引入任何新算法。
3.3 错误通道
导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("PhotoCalendarRender")
fun photoCalendarRender(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、缺 op / year / month、不支持的 op,全部走同一个 ok:false 通道,ArkTS 侧统一渲染到状态行。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libpcal_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohospcal.so)
│ PhotoCalendarRender
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;让 ArkTS 自算日历又会让算法漂移,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("PhotoCalendarRender")
fun photoCalendarRender(request: String): String // today/month/horizontal/shiftMonth/labels/formatYearMonth 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖算法 |
|---|---|---|
PhotoCalendarRender | photoCalendarRender(req) | 门面全部入口:网格对齐、行数、双语标题、星期标签、翻月、today |
桥内 JSON 解析沿用 cmp_qrose 审核模式的 MiniJson:按逗号切顶层字段、剥引号、认布尔整数,字符串里的逗号靠引号状态机跳过。不引 kotlinx-serialization——多一个 KN 依赖、多一个版本对齐面,收益为零。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"photoCalendarRender", nullptr, NapiPhotoCalendarRender, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohospcal_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohospcal_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。API 26 的 N-API 头把 napi_get_value_string_utf8 缓冲区标成非 const,先量长度、开 std::vector<char>、拷完再构造 std::string。
4.4 CMake 和类型声明
add_library(ohospcal SHARED IMPORTED)
set_target_properties(ohospcal PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(pcal_napi SHARED napi_init.cpp)
target_link_libraries(pcal_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohospcal)
OHOS_ARCH 为空或 libohospcal.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const photoCalendarRender: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 启动注入和触发刷新
aboutToAppear(): void {
const today = JSON.parse(photoCalendarRender('{"op":"today"}')) as TodayData;
this.todayIso = today.iso;
this.selectedIso = today.iso; // 双视图共享选中态(对齐上游 App.kt)
this.displayYear = today.year;
this.displayMonth = today.month;
this.refresh();
}
private refresh(): void {
const month = JSON.parse(photoCalendarRender(
`{"op":"month","year":${this.displayYear},"month":${this.displayMonth},"language":"${this.language}"}`)) as MonthData;
const horizontal = JSON.parse(photoCalendarRender(
`{"op":"horizontal","year":${this.displayYear},"month":${this.displayMonth},"language":"${this.language}"}`)) as HorizontalData;
/* @State 回填 + 事件样本重建 + scrollToSelection */
}
翻月走 op=shiftMonth 而不是本地 month ± 1——让跨年边界(2026-01 往前是 2025-12)也经过库算法。
5.2 页面交互
单页自上而下:标题行(PhotoCalendar + EN·KO 胶囊)、网格月历(居中 ‹ October 2026 ›,周表头,N×7 网格)、横向日历(标题左箭头右,List 横滚,每项星期标签 + 56vp 日格)、底部状态行。事件样本对齐上游 composeApp:当月 today+1(圆形)、today+2(圆角 8);另加每月 12 日(圆角)/ 24 日(圆形)保证翻月也有图——事件契约仍是 iso -> {url, circle},只是 Demo 样本数据。选中日自动滚动到 day - 4(上游 LaunchedEffect(selectedDate) 同款定位),初始滚动延迟 50ms 等 List 挂载。
5.3 对比度和错误边界
选中格蓝色 40% 圆形蒙层(#662196F3)配白字,未选中黑字——对齐上游 CalendarIndicatorConfig.default() 与 selectedDayTextColor;事件图铺满格 + 白色 30% 遮罩 + 圆形/圆角裁剪——对齐上游 DayBackgroundImage。ok:false 时错误原文进状态行,Native 调用本身抛错(理论不该发生)同样被 try/catch 收进状态行,页面不崩。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohospcal)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/example/nativeapp/
│ └── Bridge.kt @CName × 1 + MiniJson(扁平 JSON,继承自 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.photocalendar、versionName 1.1.1-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/libohospcal_api.h
├── src/main/cpp/types/libpcal_napi/{index.d.ts, oh-package.json5}
├── src/main/module.json5 含 ohos.permission.INTERNET(事件图走网络)
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
./gradlew :calendar:jvmTest # 6 项交叉验证
./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 中勾选自动生成,证书写入本机,不进入仓库(仓库内 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 org.terminator.ohos.photocalendar -m entry
四、完整代码对照
4.1 整体架构
com.dongchyeon.calendar/**(上游日期算法原样编译)
│ DateYearMonth / TimeExt / FormatYearMonth / CalendarLanguage
PhotoCalendar 门面(新增,public)
│ month / horizontalMonth / today / shiftMonth / labels / formatYearMonth
Bridge.kt + MiniJson
│ PhotoCalendarRender
libpcal_napi.so
│ photoCalendarRender
Index.ets
│ JSON parse + @State 更新
▼
ArkUI 真机页面(网格月历 + 横向日历)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_PhotoCalendar、composeApp/iosApp 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + datetime 0.7.1-0.5.0-01 钉版 |
build.gradle.kts(根) | 版本 1.1.1-ohos.1、全局禁用 Ohos*Test* 任务 |
calendar/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,依赖收窄为 stdlib + datetime |
calendar/src/commonMain → composeMain | 10 个 Compose 文件 + composeResources git mv(保留历史) |
calendar/.../PhotoCalendar.kt | 唯一新增库源文件(门面:5 个数据类 + 6 个函数) |
calendar/src/commonTest/.../PhotoCalendarTest.kt | 新增:6 项 JVM 交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | 1 个 C ABI、6 op 分发、MiniJson、错误通道 |
harmonyApp/.../cpp/napi_init.cpp | N-API 导出、参数检查、DisposeString |
harmonyApp/.../cpp/CMakeLists.txt | IMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套 |
harmonyApp/.../ets/pages/Index.ets | ArkUI 验收页面 |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(19 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | DateYearMonth.atDay / atEndOfMonth / lengthOfMonth | 年月 → 具体日期(上游 internal,门面内复用) |
| 引擎 | LocalDate.previousOrSame(SUNDAY) | 周行对齐(上游 Week 的第一刀) |
| 引擎 | DateYearMonth.getNumberWeeks(SUNDAY) | 网格行数(周索引计数) |
| 引擎 | plusMonths / minusMonths | 翻月整数运算,跨年正确 |
| 引擎 | formatYearMonthInEnglish / Korean | 双语标题 |
| 门面 | PhotoCalendar.month / horizontalMonth | 输出完整视图数据(唯一新增 API 面) |
| Native | PhotoCalendarRender | C ABI JSON 导出 |
| N-API | photoCalendarRender(req) | 向 ArkTS 暴露能力 |
| ArkTS | refresh() / shiftMonth(delta) | 触发刷新与翻页 |
4.4 ArkTS 与 Kotlin 的边界
const raw = photoCalendarRender(JSON.stringify({
op: 'month', year: 2026, month: 10, language: 'EN'
}));
const res = JSON.parse(raw);
// res.title === "October 2026"
// res.numWeeks === 6
// res.weeks[0][4] === { day: 1, iso: "2026-10-01", inMonth: true }
val data = PhotoCalendar.month(2026, 10, CalendarLanguage.EN)
when (op) {
"month" -> monthJson(json.int("year", 0), json.int("month", 1), language)
"horizontal" -> horizontalJson(...)
"shiftMonth" -> PhotoCalendar.shiftMonth(year, month, delta)
/* ... */
}
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构;日期算术只在 Kotlin 侧发生。
五、关键决策说明
决策 1:真编译日期算法而不是在 ArkTS 重写日历
周对齐、行数、翻月、双语格式化是日历库的全部"引擎"部分,上游有现成实现。在 ArkTS 重写一遍必然在跨月补位、跨年、闰年二月上漂移;真编译让 previousOrSame / getNumberWeeks / plusMonths 全部是上游原件,JVM 测试钉住行为,设备端只需对账。
决策 2:工具链钉 2.2.21-1.0.0、Gradle 降 8.14.1
datetime 0.7.1-0.5.0-01 的元数据 2.2.21 可读,没有 KStore 那种"coroutines 逼着升 2.4.20"的约束。沿用社区批准的 2.2.21 线(qrose / haze 先例)是最稳妥选择;Gradle 从上游 8.14.3 降到 8.14.1 同样是该线的既定经验——2.4.20 线可留 Gradle 9.x、2.2.21 线必须 8.14.1,两条线的经验不能互相套用。
决策 3:根构建替换(cmp_qrose 模式),composeApp / iosApp 留盘
上游根构建(AGP / Compose / 热重载 / ktlint / vanniktech 发布)对鸿蒙没有保留价值,整个替换为极简 ohos 构建。composeApp 与 iosApp 是上游 Android/iOS Demo,带 AGP 与 iOS 插件,进鸿蒙构建配置期就死——留盘不进 settings.gradle.kts,它们的存在方式只有这一种。
决策 4:Compose UI 隔离 composeMain + 门面不透出 internal
UI 型 CMP 库适配的标准刀法:把 10 个 Compose 文件 git mv 到 composeMain(不是删除——留源对照上游 API,工具链对齐后可接回),CalendarEvent 这类"数据 + Shape"混合体整体归 UI 侧,鸿蒙侧事件模型由 JSON 承载。库侧只新增 PhotoCalendar 门面:internal 扩展在模块内可复用、对外 public 化的只有门面本身——上游 API 面零改动。
决策 5:补建 jvmTest 交叉验证 + 把上游行为怪癖钉成预期
上游没有真测试,"测试零改动"无从谈起;补 6 用例把算法行为钉死,包括一个反直觉的上游怪癖——getNumberWeeks 按周索引计数,月末日落在完整行时网格仍多出一行整空行(2026-10 就是 6 行)。测试把它断言为预期行为,防止有人"顺手修掉"造成与 Android/iOS 不一致。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x86_64)、JDK 21(Temurin 21.0.12)、DevEco Studio 5.1.0 Release、OpenHarmony 命令行工具(hvigor / hdc)与鸿蒙设备(arm64)。
6.2 JVM 交叉验证
./gradlew :calendar:jvmTest
6 项用例全部通过(本次适配新增):
| 用例 | 钉住什么 |
|---|---|
october2026GridMatchesUpstreamLayout | 2026-10(10 月 1 日周四):6 行、首格 2026-09-27 inMonth=false、weeks[0][4]=2026-10-01、weeks[4][6]=2026-10-31、最后一行整行空(上游怪癖) |
weekLabelsFollowUpstreamOrder | EN Sun…Sat / KO 일…토,Sunday-first |
titlesMatchUpstreamFormatters | October 2026 / 2026년 10월 |
horizontalMonthHasOneCellPerDayWithIsoWeekday | 31 天、首日 Thu、末日 Sat(Monday-first ISO 标签) |
shiftMonthRollsOverYearBoundary | 2026-01 -1 → 2025-12、2026-12 +1 → 2027-01 |
todayHasConsistentFields | iso 与 year/month/day 字段自洽 |
datetime 从 0.6.1 换到 0.7.1-0.5.0-01 线后测试照跑全绿——"换线不换语义"的直接证据。
6.3 HAP 验证
:calendar:compileKotlinJvm / OhosArm64 / OhosX64: BUILD SUCCESSFUL
:calendar:jvmTest: 6/6 PASSED
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm -D: PhotoCalendarRender 为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 三件套回填 entry/libs/<abi>/
BuildNativeWithCmake: finished(双 ABI 编出 libpcal_napi.so)
CompileArkTS: finished
PackageHap: finished
install bundle successfully
start ability successfully
debugShared 未 strip 的 libohospcal.so arm64 约 8.3MB、x86_64 约 7.8MB(含 Kotlin/Native 运行时与 kotlinx-datetime),配对 libc++_shared.so 约 1.8 / 1.7MB。
6.4 功能用例
- 启动后状态行出现
grid weeks=6 · horizontal days=31 · today=2026-10-16(三段数据全部来自真实 Native 调用); - 网格标题
October 2026、周表头Sun Mon Tue Wed Thu Fri Sat,与 JVM 测试值一致; - 事件背景图落在 17(today+1,圆形)、18(today+2,圆角)与 12(圆角)、24(圆形),图片铺满格 + 白色遮罩,日期文字压顶层;
- 点
‹›翻月:标题随op=shiftMonth变化,跨年正确(2026-01 往前是 2025-12),行数随月份在 5/6 行间变化,跨月补位格空白不可点; - 横向日历初始滚动到 today-4 附近,点击任一天后网格与横向选中态联动(蓝 40% 圆形蒙层 + 白字);
- 点 EN·KO 切换:标题
October 2026↔2026년 10월、周表头Sun…↔일…、横向日标签Thu↔목,全部重新过桥; - 状态行任何时刻不出现
failed;断网时事件图不显示但网格布局不塌。
6.5 验证结论
日期引擎、kotlinx-datetime 运行时、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙设备上成功安装并进入前台,页面展示了真实的日历会话:网格行数、双视图选中联动、事件背景图与双语切换(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/PhotoCalendar.git
cd PhotoCalendar
export JAVA_HOME="/path/to/jdk-21"
./gradlew :calendar:compileKotlinJvm :calendar:compileKotlinOhosArm64 # klib
./gradlew :calendar:jvmTest # 6 项交叉验证
./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.photocalendar -m entry
hdc shell hilog | grep Pcal
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算 KMP 适配,必须让
:calendar的日期算法真正编译成ohosArm64/ohosX64动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- kotlinx-datetime 选型不能只看版本号后缀:
0.8.0-0.6.x-compat只有 jvm、0.7.1-OH-001只有 jar,必须翻.module元数据确认ohosarm64/ohosx64变体,最终钉0.7.1-0.5.0-01; - Compose 文件留在
commonMain会让 ohos 编译报一片unresolved reference androidx——源码集拆分(git mv 到composeMain)必须在加 ohos 目标之前完成,compose / coil3 依赖随之移出; CalendarEvent是"数据 + Shape"混合体:date/imgUrl纯数据、imgShape是 Compose 类型,硬拆成纯模型会动上游 API——整文件归composeMain,鸿蒙侧用 JSON 承载;atDay/getNumberWeeks等是internal,桥跨模块摸不到:不改可见性,在库模块内新增PhotoCalendar门面(对齐Qrose.encodeQr先例);- 事件图走网络,
module.json5忘了ohos.permission.INTERNET图片永远不出来,且没有任何报错提示; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - CAdapter 链接期有一条
api.cpp:448clang 警告(生成的 napi 胶水),与 qrose 相同,可忽略——.so仍然链出来; - datetime 0.7.1 线把
monthNumber/dayOfMonth/Clock标了弃用:改掉就要动上游源码、破坏"算法 100% 复用",接受警告是正确取舍; getNumberWeeks按周索引计数,月末日落在完整行时网格多出一行整空行(2026-10 即 6 行)——这是上游行为不是 bug,用测试钉成预期,防止被"顺手修复"。
8.2 已知问题
Calendar/HorizontalCalendar/CalendarTheme/CalendarConfig系列仍在composeMain,鸿蒙侧视图是 ArkUI 复刻(非 Compose 渲染);- 样式 DSL 未透出:桥侧样式是上游默认值等价物(22/16/14fp、黑字、蓝 40% 圆形指示器),需要自定义时在桥 JSON 加字段即可,C 签名不用动;
imgShape只映射"圆 / 圆角 8"两种:上游是任意 ComposeShape,通用 Shape 序列化属于未来工作;- 事件图加载在 ArkTS
Image:行为与 coil3 等价(铺满 + 遮罩 + 裁剪),但没有 coil 的内存缓存策略; - android / iOS / desktop 目标剥离(源文件留在树里),发布插件一并剥离,本 fork 不发 Maven Central;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.photocalendar匹配的签名。
8.3 未来优化方向
- 桥 JSON 透出样式字段(字号、指示器颜色 / 形状、
isTablet平板档),覆盖上游CalendarConfig全部配置面; - 事件模型支持任意圆角半径与更多形状枚举;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵和自动化截图脚本;
- 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 社区 CMP 工具链与上游版本对齐后,把
composeMain接回commonMain,ArkUI 复刻降级为 fallback。
九、总结
9.1 核心难点回顾
上游 calendar(12 文件 commonMain)
→ 按"是否碰 Compose"拆分:4 纯算法留下、10 UI 隔离 composeMain
→ 验证 datetime ohos 制品线(0.7.1-0.5.0-01,翻 .module 确认)
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 降 8.14.1)
→ 根构建替换(cmp_qrose 模式,composeApp/iosApp 留盘)
→ ohosArm64 / ohosX64 KLIB + PhotoCalendar 门面(唯一新增库源文件)
→ C ABI(@CName × 1)+ 6 op JSON 契约
→ C++ N-API(DisposeString 纪律)
→ ArkTS JSON 解析
→ ArkUI 真机单页验收(网格 / 横滚 / 事件图 / 双语 / 选中联动)
9.2 封装层次
- 引擎层:上游原版
DateYearMonth、previousOrSame/weekOfMonth/getNumberWeeks/plusMonths、FormatYearMonth双语格式化; - 门面层:新增
PhotoCalendar对象,把 internal 算法组织成 6 个 public 入口,输出完整视图数据; - Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:管理页面状态、事件样本、选中联动、双语切换与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- UI 型 CMP 库的适配边界画在"算法 / 视图"之间:能编进 ohos 的是日期算法,编不进的是 Compose——用
composeMain隔离留源、用门面透出算法、用 ArkUI 复刻行为,三件事各归其位;把边界画错(在 ArkTS 重写算法,或硬编 Compose)都会失败; - 依赖选型必须查元数据而不是猜后缀:datetime 三个候选版本后缀都"像"鸿蒙版,只有翻
.module才知道谁真带ohosarm64klib;工具链经验按线区分(2.2.21 线降 Gradle 8.14.1,2.4.20 线可留 9.x),不能互相套用; - 没有上游测试就补建交叉验证:6 个 JVM 用例把网格布局、星期标签、双语标题、翻年边界钉死,连"2026-10 最后一行整空"这种反直觉怪癖也断言为预期——交叉验证是"算法没漂"的唯一可信证明,也是 UI 型库适配能被验收的底线。
9.4 适配成果
当前适配已完成:
- PhotoCalendar
1.1.1日期算法的双 ABI Kotlin/Native 编译(上游commonMain算法文件零改动,仅新增PhotoCalendar门面与 6 项测试); - kotlinx-datetime
0.7.1-0.5.0-01ohos 制品线在日历库上的完整落地(源码级兼容 0.6.1 API,弃用警告接受并记录); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 单页验收页面(网格月历、横向日历、事件背景图、双语切换、选中联动、状态行);
- 6 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so)、签名、设备安装和真机验证;
- 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。
参考文档
更多推荐



所有评论(0)