本文记录 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 / daytoday 与 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 MultiplatformHarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven)
JDK21(Temurin 21.0.12,org.gradle.java.home 钉进 gradle.properties)
OpenHarmony 目标ohosArm64 + ohosX64(jvm 保留作交叉验证)
Gradle8.14.1(上游 wrapper 8.14.3 降级;2.2.21 线按 qrose 先例钉 8.14.1)
kotlinxkotlinx-datetime 0.7.1-0.5.0-01(KBA ohos 制品线)
DevEco product5.1.0(18) 兼容
DevEco Studio5.1.0 Release
ABIarm64-v8a + x86_64
Bundleorg.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 方法覆盖算法
PhotoCalendarRenderphotoCalendarRender(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.tomlKotlin 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 → composeMain10 个 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.kt1 个 C ABI、6 op 分发、MiniJson、错误通道
harmonyApp/.../cpp/napi_init.cppN-API 导出、参数检查、DisposeString
harmonyApp/.../cpp/CMakeLists.txtIMPORTED 引入 KN so、$ORIGIN rpath、POST_BUILD 三件套
harmonyApp/.../ets/pages/Index.etsArkUI 验收页面
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 面)
NativePhotoCalendarRenderC ABI JSON 导出
N-APIphotoCalendarRender(req)向 ArkTS 暴露能力
ArkTSrefresh() / 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 项用例全部通过(本次适配新增):

用例钉住什么
october2026GridMatchesUpstreamLayout2026-10(10 月 1 日周四):6 行、首格 2026-09-27 inMonth=false、weeks[0][4]=2026-10-01、weeks[4][6]=2026-10-31、最后一行整行空(上游怪癖)
weekLabelsFollowUpstreamOrderEN Sun…Sat / KO 일…토,Sunday-first
titlesMatchUpstreamFormattersOctober 2026 / 2026년 10월
horizontalMonthHasOneCellPerDayWithIsoWeekday31 天、首日 Thu、末日 Sat(Monday-first ISO 标签)
shiftMonthRollsOverYearBoundary2026-01 -1 → 2025-12、2026-12 +1 → 2027-01
todayHasConsistentFieldsiso 与 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 功能用例

  1. 启动后状态行出现 grid weeks=6 · horizontal days=31 · today=2026-10-16(三段数据全部来自真实 Native 调用);
  2. 网格标题 October 2026、周表头 Sun Mon Tue Wed Thu Fri Sat,与 JVM 测试值一致;
  3. 事件背景图落在 17(today+1,圆形)、18(today+2,圆角)与 12(圆角)、24(圆形),图片铺满格 + 白色遮罩,日期文字压顶层;
  4. 点 ‹ › 翻月:标题随 op=shiftMonth 变化,跨年正确(2026-01 往前是 2025-12),行数随月份在 5/6 行间变化,跨月补位格空白不可点;
  5. 横向日历初始滚动到 today-4 附近,点击任一天后网格与横向选中态联动(蓝 40% 圆形蒙层 + 白字);
  6. 点 EN·KO 切换:标题 October 2026 ↔ 2026년 10월、周表头 Sun… ↔ 일…、横向日标签 Thu ↔ 목,全部重新过桥;
  7. 状态行任何时刻不出现 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 踩坑复盘

  1. 只改 ArkTS 页面不算 KMP 适配,必须让 :calendar 的日期算法真正编译成 ohosArm64 / ohosX64 动态库;
  2. settings.gradle.kts 里 eazytec 双仓必须排在 pluginManagement 与 dependencyResolutionManagement 最前,否则配置期直接 Unresolved reference: ohosArm64;
  3. 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;
  4. Compose 文件留在 commonMain 会让 ohos 编译报一片 unresolved reference androidx——源码集拆分(git mv 到 composeMain)必须在加 ohos 目标之前完成,compose / coil3 依赖随之移出;
  5. CalendarEvent 是"数据 + Shape"混合体:date / imgUrl 纯数据、imgShape 是 Compose 类型,硬拆成纯模型会动上游 API——整文件归 composeMain,鸿蒙侧用 JSON 承载;
  6. atDay / getNumberWeeks 等是 internal,桥跨模块摸不到:不改可见性,在库模块内新增 PhotoCalendar 门面(对齐 Qrose.encodeQr 先例);
  7. 事件图走网络,module.json5 忘了 ohos.permission.INTERNET 图片永远不出来,且没有任何报错提示;
  8. HAP 缺当前 ABI 的 so 会报安装失败 9568347,abiFilters 必须双 ABI,每个 ABI 三件套(NAPI so / KN so / libc++)缺一不可;
  9. KN 返回字符串必须 DisposeString 归还运行时,N-API 层不能 free();
  10. CAdapter 链接期有一条 api.cpp:448 clang 警告(生成的 napi 胶水),与 qrose 相同,可忽略——.so 仍然链出来;
  11. datetime 0.7.1 线把 monthNumber / dayOfMonth / Clock 标了弃用:改掉就要动上游源码、破坏"算法 100% 复用",接受警告是正确取舍;
  12. getNumberWeeks 按周索引计数,月末日落在完整行时网格多出一行整空行(2026-10 即 6 行)——这是上游行为不是 bug,用测试钉成预期,防止被"顺手修复"。

8.2 已知问题

  • Calendar / HorizontalCalendar / CalendarTheme / CalendarConfig 系列仍在 composeMain,鸿蒙侧视图是 ArkUI 复刻(非 Compose 渲染);
  • 样式 DSL 未透出:桥侧样式是上游默认值等价物(22/16/14fp、黑字、蓝 40% 圆形指示器),需要自定义时在桥 JSON 加字段即可,C 签名不用动;
  • imgShape 只映射"圆 / 圆角 8"两种:上游是任意 Compose Shape,通用 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 三条经验

  1. UI 型 CMP 库的适配边界画在"算法 / 视图"之间:能编进 ohos 的是日期算法,编不进的是 Compose——用 composeMain 隔离留源、用门面透出算法、用 ArkUI 复刻行为,三件事各归其位;把边界画错(在 ArkTS 重写算法,或硬编 Compose)都会失败;
  2. 依赖选型必须查元数据而不是猜后缀:datetime 三个候选版本后缀都"像"鸿蒙版,只有翻 .module 才知道谁真带 ohosarm64 klib;工具链经验按线区分(2.2.21 线降 Gradle 8.14.1,2.4.20 线可留 9.x),不能互相套用;
  3. 没有上游测试就补建交叉验证: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-01 ohos 制品线在日历库上的完整落地(源码级兼容 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。

参考文档

Logo

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

更多推荐