开源鸿蒙平台KMP/CMP 服务卡片三方库适配从 0 到 1 实战
本文记录 CMP Service Card 接入 OpenHarmony 的完整过程,覆盖工程盘点、
ohosArm64示例架构、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkTS Form Kit 服务卡片、HAP 签名和真机验收。与“在应用页面画一个卡片”不同,本次适配实现的是可以由鸿蒙系统管理并添加到桌面的真实服务卡片。卡片由
formrenderservice独立进程渲染,应用进程只负责提供数据、处理生命周期和接收卡片事件。项目的核心是 KMP 服务卡片模型和不可变动作归约,示例 UI 使用 ArkTS Stage 页面和 Form Kit。应用内页面用于预览三种模板,桌面卡片用于验证
FormExtensionAbility、动态绑定数据、尺寸变化和postCardAction回调。
项目地址: AtomGit/oh-tpc/cmp-service-card
开发工具: DevEco Studio
一、背景
1.1 为什么 KMP/CMP 开源鸿蒙平台 项目需要 OpenHarmony 服务卡片适配
CMP Service Card 将卡片状态、指标、进度、动作和刷新规则放在 Kotlin Multiplatform 公共模块中。这个模型可以在 JVM 测试中验证,也可以编译为 OpenHarmony ARM64 Kotlin/Native 动态库。
OpenHarmony 应用不能直接把 JVM 或 Android Compose 产物放到手机桌面。如果只把应用页面重新写成 ArkTS,虽然能够展示几个按钮,却无法验证共享 Kotlin 模型是否真的运行在鸿蒙设备上;如果只做一个应用内卡片,又无法覆盖系统 Form Kit 的新增、更新、移除和事件回调。
这次适配需要同时解决下面几个问题:
| 障碍 | 具体问题 |
|---|---|
| 目标缺失 | 示例工程默认没有 ohosArm64(),无法生成 OpenHarmony KLIB 和 ARM64 动态库。 |
| 工具链不一致 | Kotlin/Native、OpenHarmony LLVM、Native SDK 和 Gradle 插件必须使用匹配版本。 |
| 进程边界不同 | 桌面卡片运行在系统 formrenderservice 进程,不能直接复用应用页面的内存状态。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,必须经过 C ABI、C++ N-API 和 JSON。 |
| 渲染模型不同 | Compose 或普通 ArkUI 页面不能直接作为 Form Kit 卡片,需要声明 form_config.json 和 FormExtensionAbility。 |
| 系统权限约束 | 三方应用不能静默把卡片钉到桌面,必须通过系统卡片管理页由用户确认。 |
| 交付链路复杂 | 原生库、CMake、HAP、签名、设备安装和卡片生命周期都需要分别检查。 |
因此,本次实现把边界放在三个地方:Kotlin/Native 目标和公共模型、C ABI/N-API 原生桥接、ArkTS Form Kit 生命周期与卡片页面。Kotlin 负责数据和动作规则,ArkTS 负责页面与系统服务卡片承载。
1.2 库提供的能力
CMP Service Card 的公共 API 不是一个固定的 UI 组件,而是一组平台无关的模型和状态规则:
ServiceCardModel:标题、副标题、正文、更新时间、进度、强调色、指标和动作;ServiceCardEngine.catalog():返回全部卡片模板;ServiceCardEngine.card(index, revision):根据模板索引和刷新版本生成确定性数据;ServiceCardEngine.reduce(model, actionId, revision):对complete、snooze等动作做不可变归约;ServiceCardModel.toJson():生成跨 Kotlin/Native、C++ 和 ArkTS 的 JSON 契约;ServiceCardEngine.runChecks():验证模板、动作、进度、唯一 ID、刷新和归约规则。
本次示例选择三个能直接体现服务卡片场景的模板:
- 每日专注:显示完成度、连续天数和“完成”动作;
- 配送进度:显示备货、运输、送达状态和“稍后提醒”动作;
- 设备概览:显示模拟电量和网络状态,验证只有刷新动作的卡片;
- 桌面尺寸:每个 Form 声明
2*2、2*4、4*4三种尺寸; - 系统管理:应用页面调用
formProvider.openFormManager进入系统加桌流程; - 卡片事件:卡片内的刷新、完成和稍后提醒通过
postCardAction回到 Ability; - 独立持久化:每个 form ID 保存自己的刷新版本、动作状态和尺寸。
1.3 实现目标
| 维度 | 要求 |
|---|---|
| 代码复用 | 卡片模型、动作归约、刷新规则和 JSON 序列化由 Kotlin 共享。 |
| 平台目标 | 为示例模块增加 ohosArm64,生成 libservice_card.so。 |
| 桥接稳定 | 使用少量 C ABI 函数和 JSON,避免把 Kotlin 对象直接暴露给 ArkTS。 |
| 卡片完整 | 页面要能预览模板、打开系统卡片管理页,卡片要能动态更新并响应动作。 |
| 生命周期完整 | 覆盖 onAddForm、onUpdateForm、onFormEvent、onRemoveForm、onSizeChanged 和可见性通知。 |
| 可测试 | JVM 测试、Native 链接、ArkUI/HAP 构建和真机安装分别验收。 |
| 签名安全 | 仓库只保留无个人凭据的工程,证书、profile 和密码由开发者手动配置。 |
| 仓库规范 | README、文章、效果图和项目地址统一使用 AtomGit。 |
说明: 本次交付提供源码适配和独立 OpenHarmony 示例,不新增一个替代 KMP 公共模型的 ArkTS 卡片业务库。这样可以保持共享业务规则和鸿蒙渲染层的边界清晰。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点公共模型、示例边界和 Form Kit 目标
第 2 阶段:目标与依赖打通 ── 加入 ohosArm64、仓库和独立消费工程
第 3 阶段:模型与序列化 ── 建立卡片模型、动作归约和 JSON 契约
第 4 阶段:原生桥接 ── Kotlin/Native C ABI、C++ N-API 和内存释放
第 5 阶段:能力封装 ── ArkUI 预览、Form Kit 生命周期和卡片事件
第 6 阶段:示例与验证 ── HAP 构建、签名、设备安装和桌面卡片验收
每个阶段都使用真实产物作为下一阶段输入:service-card 先验证模型,nativeApp 再把模型链接为 ARM64 动态库,ohosApp 通过 CMake 和 N-API 加载动态库,最后由 DevEco 负责 HAP 打包和签名。
三、逐步实现过程
第 1 阶段:项目初始化
1.1 盘点原库源码和公共 API
根工程保留 KMP/CMP 参考布局,并把 OpenHarmony 消费示例放在 example/ 下,避免把 DevEco 工具链、ArkTS 文件和签名配置混入公共库模块:
service-card/ 公共 KMP 模型、动作归约和 JSON 边界
sample/shared/ 参考布局中的公共消费示例
vico/ 与参考工程一致的聚合层占位
example/shared/ OpenHarmony 示例门面和 JVM 测试
example/nativeApp/ ohosArm64 Kotlin/Native 动态库
example/ohosApp/ DevEco Stage 应用和 Form Kit 工程
scripts/ 构建、签名工程和 HAP 辅助脚本
docs/openharmony/ 验收记录和运行效果图
example 是独立的 Gradle 工程。它不会把 DevEco 工程当成 Kotlin 子模块,因此可以分别执行 Gradle 和 Hvigor,也可以将 ohosApp 复制到另一个目录完成手动签名。
1.2 固定工具链和版本矩阵
本次示例使用 Kotlin Multiplatform 2.2.21-1.0.0、Gradle Wrapper、JDK 21、HarmonyOS/OpenHarmony API 20 ARM64 Native SDK 和 arm64-v8a HAP ABI。根工程版本集中在 gradle/libs.versions.toml,示例工程在 example/build.gradle.kts 中使用同一 Kotlin 插件版本。
example/ohosApp
↓ CMake + N-API
example/nativeApp/libservice_card.so
↓ project dependency
example/shared
↓ shared Kotlin service-card model
ServiceCardEngine + immutable reducer
这里的 shared 不是 ArkUI 页面的临时缓存,而是明确的跨平台模型层。它通过 ServiceCardExamples 暴露目录、单卡片、动作归约和自检;nativeApp 只负责把模型转换成 C ABI 可返回的 JSON,ohosApp 负责读取 JSON、展示预览和实现 Form Kit 生命周期。
1.3 创建适配示例目录
服务卡片的核心不是在应用内摆放几个大卡片,而是让系统能够在桌面上创建、调整大小、更新和移除 Form。因此页面采用“应用预览 + 添加到桌面入口”的布局:顶部切换三个模板,中间展示当前模型,底部进入系统卡片管理页。
页面不会把桌面服务卡片误认为应用内页面。Index.ets 的职责是预览和发起系统操作;ServiceCard.ets 才是由 form_config.json 声明、由 EntryFormAbility 提供数据的桌面 Form 页面。
第 2 阶段:目标与依赖打通
2.1 加入 ohosArm64 目标
公共 service-card 模块保持 JVM 测试,同时由示例 Native 模块增加 OpenHarmony ARM64 目标:
plugins { kotlin("multiplatform") }
kotlin {
jvmToolchain(21)
ohosArm64 {
binaries.sharedLib {
baseName = "service_card"
linkerOpts("--entry=0", "--version-script=${project.file("src/ohosArm64Main/linker/shared-library.map")}")
linkerOpts("-lace_napi.z", "-luv", "-lhilog_ndk.z")
}
}
sourceSets {
commonMain.dependencies { implementation(project(":shared")) }
}
}
JVM 目标让卡片规则可以在不连接设备的情况下运行测试,ohosArm64 则让同一份 commonMain 代码进入 Kotlin/Native 动态库。
2.2 focused build 的作用
Native 模块使用 linker map 限制导出符号:
global:
ServiceCardCatalog;
ServiceCardGet;
ServiceCardRunChecks;
ServiceCardFree;
local: *;
OpenHarmony 动态库不是直接由 ArkTS 加载的 JavaScript 包,而是通过 CMake 链接到 libentry.so。因此构建时需要同时处理 Kotlin/Native 链接器参数、OpenHarmony NDK 库和导出符号。
2.3 配置插件仓库和依赖仓库
example/settings.gradle.kts 配置 OpenHarmony 社区 Maven、Maven Central 和 Gradle Plugin Portal:
pluginManagement {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
mavenLocal()
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
mavenCentral()
}
}
OpenHarmony 示例使用 JDK 21。执行脚本前先确认:
export JAVA_HOME="/path/to/jdk-21"
java -version
2.4 通过构建产物消费共享库
example/nativeApp 依赖 :shared,并通过 prepareOhos 将 Kotlin/Native 产物复制到 DevEco 工程:
val prepareOhos by tasks.registering(Copy::class) {
dependsOn("linkDebugSharedOhosArm64")
from(layout.buildDirectory.dir("bin/ohosArm64/debugShared")) {
include("libservice_card.so")
into("libs/arm64-v8a")
}
from(layout.buildDirectory.dir("bin/ohosArm64/debugShared")) {
include("libservice_card_api.h")
into("src/main/cpp/include")
}
into(rootProject.layout.projectDirectory.dir("ohosApp/entry"))
}
动态库和生成头文件属于构建产物,.gitignore 会排除它们。仓库保留复制任务,这样每台开发机都可以从源码重新生成与本机 SDK 匹配的文件。
第 3 阶段:模型与序列化
3.1 为什么需要统一 JSON 契约
ArkTS、C++ 和 Kotlin/Native 之间不能直接共享 Kotlin 对象。为了让边界稳定,示例使用一个 JSON 响应描述当前卡片:
ServiceCardEngine
↓ ServiceCardModel
Kotlin/Native JSON encoder
↓ UTF-8 buffer
C++ N-API string
↓
ArkTS JSON.parse
↓
App preview or FormBindingData
这样标题、正文、进度、指标和动作始终在 Kotlin 模型中生成,ArkTS 不需要了解 Kotlin data class 的内部结构,也不会重新计算一套动作规则。JSON 字段可以扩展,而 C ABI 入口保持稳定。
3.2 ServiceCardModel 设计
页面和桌面卡片需要的不是一个标题,而是一组可以跨进程传递的稳定字段:
public data class ServiceCardModel(
val id: String,
val title: String,
val subtitle: String,
val body: String,
val updatedAt: String,
val progress: Int,
val accent: String,
val dimension: ServiceCardDimension,
val metrics: List<ServiceCardMetric>,
val actions: List<ServiceCardAction>,
)
模型构造时会检查 ID 非空、进度在 0..100、强调色符合 #RRGGBB,以及动作 ID 唯一。错误尽早在共享层暴露,避免无效数据进入卡片渲染进程。
3.3 三种模板和刷新语义
ServiceCardEngine.card(index, refresh) 根据模板索引生成确定性数据:
public fun card(index: Int, refresh: Int = 0): ServiceCardModel {
require(index in 0..2) { "Unknown card index" }
require(refresh >= 0) { "Revision must be non-negative" }
return when (index.mod(3)) {
0 -> focusCard(refresh)
1 -> deliveryCard(refresh)
else -> deviceCard(refresh)
}
}
每日专注的进度随刷新版本递增,配送进度在备货、运输和送达之间变化,设备概览的电量随刷新版本下降但保持在合法范围。数据是本地确定性的,不依赖网络,所以可以在设备端确认调用确实经过 Kotlin/Native。
3.4 动作归约和错误响应
动作由 ServiceCardEngine.reduce 统一处理:
public fun reduce(model: ServiceCardModel, actionId: String, refresh: Int): ServiceCardModel {
if (actionId.isEmpty()) return model
require(model.actions.any { it.id == actionId }) { "Action not supported by card" }
return when (actionId) {
"complete" -> model.copy(progress = 100, body = "今日计划已完成")
"snooze" -> model.copy(body = "已暂缓提醒(演示)")
else -> model
}
}
Native 层把动作状态编码为 0(无动作)、1(完成)和 2(稍后提醒),刷新本身通过 revision 表示。异常会转换成 {"error":"..."} JSON,ArkTS 可以在边界处统一捕获。
第 4 阶段:原生桥接(技术难点)
4.1 问题:Kotlin/Native 对象不能直接交给 ArkTS
Kotlin/Native 的 ServiceCardModel、List 和异常对象属于 Kotlin 运行时对象。ArkTS 通过 N-API 接收到的是 JavaScript 值,C++ 不能把 Kotlin 对象地址直接当成 JavaScript 对象使用。
最终采用三层桥接:
ArkTS
│ JSON string
▼
C++ N-API entry
│ const char*
▼
Kotlin/Native C ABI
│ shared service-card model
▼
UTF-8 JSON + explicit free
4.2 方案对比
| 方案 | 优点 | 缺点 | 选用 |
|---|---|---|---|
| 直接导出 Kotlin 对象 | 代码少 | ABI、生命周期和类型不可控 | ❌ |
| 导出基础字段数组 | 不需要 JSON | 字段扩展和错误处理困难 | ❌ |
| C ABI + JSON | 边界清晰、易扩展、易调试 | 有一次序列化开销 | ✅ |
| 在 ArkTS 重写卡片模型 | 页面调用简单 | 逻辑重复,可能与 Kotlin 结果不一致 | ❌ |
4.3 Kotlin/Native 导出函数
example/nativeApp/src/ohosArm64Main/kotlin/.../NativeBridge.kt 导出四个函数:
@CName("ServiceCardCatalog")
public fun catalogNative(): CPointer<ByteVar> = response { ... }
@CName("ServiceCardGet")
public fun cardNative(index: Int, revision: Int, action: Int): CPointer<ByteVar> = response { ... }
@CName("ServiceCardRunChecks")
public fun checksNative(): CPointer<ByteVar> = response { ... }
@CName("ServiceCardFree")
public fun freeNative(pointer: CPointer<ByteVar>?) { ... }
返回值使用 nativeHeap.allocArray<ByteVar> 分配,并以 0 结尾,满足 C 字符串约定。所有返回字符串都必须由 ServiceCardFree 释放。
4.4 C++ N-API 方法分发
napi_init.cpp 注册三个 ArkTS 方法:
napi_property_descriptor methods[] = {
{"getCatalog", nullptr, Catalog, nullptr, nullptr, nullptr,
napi_default, nullptr},
{"getCard", nullptr, Card, nullptr, nullptr, nullptr,
napi_default, nullptr},
{"runChecks", nullptr, RunChecks, nullptr, nullptr, nullptr,
napi_default, nullptr},
};
getCard 检查索引、刷新版本和动作是否为非负 int32,然后调用 Kotlin/Native 的 ServiceCardGet。所有返回字符串都遵循“调用 Native → 创建 ArkTS 字符串 → 释放 Native 缓冲区”的顺序。
4.5 CMake imported library
DevEco 工程将 Kotlin/Native 动态库作为 imported library 链接到 libentry.so:
add_library(service_card SHARED IMPORTED)
set_target_properties(service_card PROPERTIES
IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/arm64-v8a/libservice_card.so")
add_library(entry SHARED napi_init.cpp)
target_include_directories(entry PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(entry PRIVATE service_card libace_napi.z.so)
因此 prepareOhos 必须先把 .so 和头文件复制到固定目录,否则 Ninja 会在链接阶段报告 imported library 不存在。
4.6 N-API 生命周期
ArkTS getCard(index, revision, action)
│
▼
ReadNumber + argument check
│
▼
ServiceCardGet(...)
│
▼
napi_create_string_utf8(...)
│
▼
ServiceCardFree(nativeBuffer)
│
▼
return JS string
C++ 负责把 Native 缓冲区转换成 ArkTS 字符串并释放缓冲区。页面调用者不需要知道 Kotlin/Native 的堆实现,也不会因为反复刷新卡片而积累 Native 内存。
第 5 阶段:能力封装
5.1 应用内预览和桌面卡片分工
Index.ets 是应用内预览页面,负责:
- 选择三个卡片模板;
- 展示标题、正文、进度、指标和动作;
- 调用 Native 自检;
- 处理应用内刷新、完成和稍后提醒;
- 调用
openServiceCardManager打开系统 Form 管理流程。
ServiceCard.ets 是真正的桌面卡片页面,负责:
- 使用
@LocalStorageProp接收 Form Binding Data; - 使用
FormLink返回应用页面; - 使用
postCardAction发送卡片动作; - 根据尺寸决定是否显示指标行。
两者共享 Kotlin 数据模型,但不共享应用进程中的临时状态。
5.2 Form Kit 配置
form_config.json 声明三个动态 Form:
{
"name": "ServiceCard",
"description": "每日专注",
"src": "./ets/widget/pages/ServiceCard.ets",
"uiSyntax": "arkts",
"defaultDimension": "2*2",
"supportDimensions": ["2*2", "2*4", "4*4"],
"updateEnabled": true,
"updateDuration": 1,
"isDynamic": true,
"formVisibleNotify": true
}
DeliveryCard 和 DeviceCard 使用相同的页面和尺寸声明,通过 Form 名称在 onAddForm 中选择不同的 Kotlin 模板。
5.3 系统卡片管理入口
应用页面不会尝试静默把卡片放到桌面,而是先查询 Form 元数据,再构造系统 Want:
const forms: formInfo.FormInfo[] = await formProvider.getFormsInfo();
const form = forms.find((candidate) => candidate.name === formName);
const want: Want = {
bundleName: form.bundleName,
abilityName: form.abilityName,
parameters: {
[formInfo.FormParam.DIMENSION_KEY]: dimension,
[formInfo.FormParam.NAME_KEY]: form.name,
[formInfo.FormParam.MODULE_NAME_KEY]: form.moduleName,
},
};
formProvider.openFormManager(want);
鸿蒙系统会显示卡片管理页面,用户选择尺寸并确认后,系统才会调用 onAddForm。这是三方应用能够使用的标准流程。
5.4 EntryFormAbility 生命周期
EntryFormAbility 以字符串保存 form ID,并为每个实例保存 index、revision、action 和 dimension:
onAddForm(want: Want): formBindingData.FormBindingData
onUpdateForm(formId: string): void
onFormEvent(formId: string, message: string): void
onRemoveForm(formId: string): void
onChangeFormVisibility(newStatus: Record<string, number>): void
onSizeChanged(formId: string, newDimension: formInfo.FormDimension, rect: formInfo.Rect): void
onAddForm 生成初始绑定数据,onUpdateForm 增加刷新版本,onFormEvent 校验卡片动作,onRemoveForm 清理 Preferences,onSizeChanged 重新发布布局字段。所有异步写入通过 Promise 队列串行执行,避免旧回调覆盖新状态。
5.5 数据绑定和卡片事件
数据更新链路如下:
Kotlin model -> JSON -> N-API -> CardBindingData
-> formBindingData.createFormBindingData
-> @LocalStorageProp fields in ServiceCard.ets
卡片页面使用 postCardAction 发送 message:
const action: CardAction = {
action: 'message',
params: { message: 'refresh', params: '' },
};
postCardAction(component, action);
Form Kit 将消息转交给 onFormEvent,Ability 更新模型、写入快照,再调用 formProvider.updateForm。复杂的指标和动作在边界处扁平化为卡片渲染进程需要的基础字段。
第 6 阶段:示例与验证
6.1 example 工程结构
example/
├── build.gradle.kts
├── settings.gradle.kts
├── shared/
│ ├── build.gradle.kts
│ └── src/commonMain + src/commonTest/
├── nativeApp/
│ ├── build.gradle.kts
│ └── src/ohosArm64Main/
│ ├── kotlin/.../NativeBridge.kt
│ └── linker/shared-library.map
└── ohosApp/
└── entry/
├── src/main/ets/pages/Index.ets
├── src/main/ets/formability/EntryFormAbility.ets
├── src/main/ets/widget/pages/ServiceCard.ets
└── src/main/cpp/napi_init.cpp
三个 Kotlin/Native、共享模型和 DevEco 入口分别承担公共规则、原生动态库和 ArkUI/Form Kit 职责。
6.2 原生模块注册
napi_init.cpp 注册名为 entry 的 N-API 模块:
static napi_module serviceCardModule = {1, 0, nullptr, Init, "entry", nullptr, {0}};
extern "C" __attribute__((constructor))
void RegisterServiceCardModule() {
napi_module_register(&serviceCardModule);
}
注册的方法与 ArkTS 类型声明一致:
export const getCatalog: () => string;
export const getCard: (index: number, revision: number, action: number) => string;
export const runChecks: () => string;
6.3 Native 动态库准备
先在仓库根目录执行:
export JAVA_HOME="/path/to/jdk-21"
./scripts/build-openharmony.sh
或者只执行示例任务:
(cd example && ./gradlew :shared:jvmTest :nativeApp:prepareOhos)
成功后应存在:
example/ohosApp/entry/libs/arm64-v8a/libservice_card.so
example/ohosApp/entry/src/main/cpp/include/libservice_card_api.h
6.4 构建与安装
仓库提供的工程不应写入个人签名材料。可以复制一份不含构建缓存和证书的 DevEco 工程:
python3 scripts/prepare-signing-project.py "$HOME/service_card_ohos_signing"
在 DevEco Studio 中手动配置 API 20 ARM64 签名后构建 HAP:
./scripts/build-hap.sh "$HOME/service_card_ohos_signing"
安装并启动:
hdc list targets -v
hdc -t <target-id> install -r \
entry/build/default/outputs/default/entry-default-signed.hap
hdc -t <target-id> shell aa start \
-a EntryAbility -b com.ohos.servicecard.sample
最后按三个模板、系统卡片管理页、尺寸切换、卡片刷新、主动作和 6/6 自检验收页面。
四、完整代码对照
4.1 整体架构
ServiceCardEngine
├─ ServiceCardModel / ServiceCardMetric / ServiceCardAction
├─ catalog()
├─ card(index, revision)
├─ reduce(model, actionId, revision)
└─ runChecks()
↓ JSON
NativeBridge.kt
├─ ServiceCardCatalog
├─ ServiceCardGet
├─ ServiceCardRunChecks
└─ ServiceCardFree
↓ C ABI
napi_init.cpp
├─ getCatalog()
├─ getCard()
└─ runChecks()
↓ ArkTS
Index.ets / EntryFormAbility.ets
├─ JSON.parse
├─ app preview
├─ FormBindingData
├─ formProvider.updateForm
└─ postCardAction -> onFormEvent
↓ Form Kit
ServiceCard.ets
├─ @LocalStorageProp
├─ FormLink
└─ desktop card actions
4.2 文件清单
| 文件 | 作用 |
|---|---|
service-card/src/commonMain/.../ServiceCard.kt | 公共卡片模型、指标、动作和尺寸枚举。 |
service-card/src/commonMain/.../ServiceCardEngine.kt | 三种模板、刷新版本、动作归约和六项自检。 |
service-card/src/commonMain/.../ServiceCardJson.kt | JSON 编码和字符串转义。 |
service-card/src/commonTest/.../ServiceCardEngineTest.kt | JVM 侧模型、边界和动作测试。 |
example/shared/src/commonMain/.../ServiceCardExamples.kt | 示例门面,连接共享模型和 Native 模块。 |
example/nativeApp/.../NativeBridge.kt | Kotlin/Native C ABI、JSON 和内存释放。 |
example/nativeApp/.../shared-library.map | 限制导出的 Native 符号。 |
example/ohosApp/entry/src/main/cpp/napi_init.cpp | C++ N-API 方法和模块注册。 |
example/ohosApp/entry/src/main/cpp/CMakeLists.txt | imported library 和 N-API 链接。 |
example/ohosApp/entry/src/main/ets/pages/Index.ets | 应用预览、模板切换和系统卡片入口。 |
example/ohosApp/entry/src/main/ets/formability/EntryFormAbility.ets | Form Kit 生命周期、持久化和更新。 |
example/ohosApp/entry/src/main/ets/servicecard/ServiceCardFormManager.ets | 查询 Form 元数据并打开系统卡片管理页。 |
example/ohosApp/entry/src/main/ets/widget/pages/ServiceCard.ets | 桌面服务卡片 UI 和 postCardAction。 |
example/ohosApp/entry/src/main/resources/base/profile/form_config.json | Form 名称、尺寸、动态更新和可见性声明。 |
scripts/build-openharmony.sh | 根测试、示例测试和 Native 准备。 |
scripts/prepare-signing-project.py | 复制无个人证书的 DevEco 工程。 |
scripts/build-hap.sh | 调用 Hvigor 构建 HAP。 |
docs/openharmony/images/cmp-service-card-openharmony.jpg | 运行效果图。 |
4.3 关键 API 对照
ServiceCardCatalog → 返回三个卡片模板 JSON
ServiceCardGet → 返回指定模板、刷新版本和动作状态
ServiceCardRunChecks → 返回六项共享验收检查
ServiceCardFree → 释放 Native 字符串
openFormManager → 进入系统卡片添加流程
onAddForm → 创建桌面卡片实例并生成绑定数据
onUpdateForm → 推进版本并更新卡片
onFormEvent → 处理 refresh / complete / snooze
onRemoveForm → 清理卡片实例状态
ArkTS 页面只依赖字符串和基础数字,Native 指针、Kotlin 对象和内存管理细节都被留在桥接层内部。
4.4 ArkTS 与 Kotlin 的语法和运行边界
| 能力 | Kotlin/Kotlin/Native | ArkUI/Form Kit |
|---|---|---|
| 模板目录 | 生成 List<ServiceCardModel> | 使用 ForEach 或 Form 元数据渲染。 |
| 刷新状态 | card(index, revision) | @State revision 或 Preferences 快照。 |
| 动作归约 | reduce(model, actionId, revision) | postCardAction 发送字符串事件。 |
| 数据边界 | toJson() 生成 UTF-8 JSON | JSON.parse 后生成 Binding Data。 |
| 卡片更新 | 返回新的不可变模型 | formProvider.updateForm 发布绑定数据。 |
| 桌面入口 | 不持有系统 Want | getFormsInfo + openFormManager。 |
| 状态持久化 | 负责业务规则 | Ability 使用 Preferences 按 form ID 保存。 |
五、关键决策说明
决策 1:把 ohosArm64 加入示例的公共构建约定
示例模块加入 ohosArm64,让同一份共享卡片模型进入 Kotlin/Native 动态库。这样可以验证真实的 KMP/CMP 数据链路,而不是在 ArkTS 中重新写一套演示数据。
决策 2:独立消费者必须通过构建产物消费
example/shared、example/nativeApp 和 example/ohosApp 按独立工程组织,先验证共享模型,再准备动态库,最后由 DevEco 打包。这样可以同时覆盖 Gradle 变体、KLIB、CMake、N-API 和 HAP 链路,避免根工程通过但下游无法消费。
决策 3:JSON 作为跨语言数据契约
如果在 ArkTS 中重新生成标题、进度和动作,Native 层只剩一个空壳,无法证明 Kotlin/Native 代码被设备真正调用。现在由 ServiceCardEngine 生成完整卡片模型,ArkTS 只做 JSON 解析和渲染。
决策 4:桥接层只开放四个 C ABI 入口
页面字段会随模板增加而变化,JSON 可以向后兼容新增字段。C ABI 只保留目录、单卡片、自检和释放四个入口,不需要为每一个标题或指标扩展 N-API 方法签名。
决策 5:桌面卡片使用 Form Kit,而不是应用内模拟
用户需要的是可以放在桌面上的鸿蒙服务卡片。form_config.json、FormExtensionAbility、formBindingData、formProvider.updateForm 和 postCardAction 都属于系统 Form Kit 链路,应用页面只作为预览和进入系统管理页的入口。
决策 6:系统确认是产品约束,不是代码缺陷
三方应用不能绕过系统和用户确认静默添加桌面卡片。openFormManager 能够打开系统管理流程,但最终添加需要用户在系统界面确认;部分桌面版本还需要通过长按应用图标进入服务卡片列表。
决策 7:把库验证和设备验证分开
模型和动作由 JVM 测试验证,Kotlin/Native 编译验证目标,CMake 验证链接,Hvigor 验证 HAP,真机验证 N-API、Form Kit 和桌面交互。证书、profile 和密码属于开发机材料,不应放入 AtomGit。
六、测试与验证
6.1 测试环境
本次真机验证使用:
| 项目 | 值 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| ABI | ARM64 |
| DevEco | DevEco Studio 6 系列 |
| API | HarmonyOS/OpenHarmony API 20 |
| HAP 包名 | com.ohos.servicecard.sample |
| 入口 | EntryAbility |
| 示例版本 | 1.0.0-ohos.1 |
6.2 静态检查与单元测试
公共测试覆盖模板目录、动作存在性、进度边界、唯一 ID、刷新变化和不可变动作归约:
JAVA_HOME=/path/to/jdk-21 \
./gradlew :service-card:jvmTest :sample:shared:jvmTest
示例共享层和 Native 动态库准备任务为:
(cd example && ./gradlew :shared:jvmTest :nativeApp:prepareOhos)
Native 构建成功后检查:
entry/libs/arm64-v8a/libservice_card.so
entry/src/main/cpp/include/libservice_card_api.h
6.3 原生桥接和 HAP 验证
DevEco 构建阶段需要确认:
- CMake 能找到
libservice_card.so; - Ninja 能生成
libentry.so; - ArkTS 编译可以解析
libentry.so的类型声明; - HAP 包含
libs/arm64-v8a下的动态库; - 签名由当前开发机的 DevEco 配置完成。
命令行构建使用:
DEVECO=/Applications/DevEco-Studio.app/Contents
export PATH="$DEVECO/tools/node/bin:$DEVECO/tools/ohpm/bin:$PATH"
export DEVECO_SDK_HOME="$DEVECO/sdk"
cd example/ohosApp
"$DEVECO/tools/ohpm/bin/ohpm" install --all
"$DEVECO/tools/hvigor/bin/hvigorw" \
--mode module \
-p module=entry@default \
-p product=default \
-p buildMode=debug \
clean assembleHap --no-daemon
输出通常为:
entry/build/default/outputs/default/entry-default-unsigned.hap
entry/build/default/outputs/default/entry-default-signed.hap
6.4 功能验证用例
用例 1:默认页面和自检
启动应用后,页面显示“服务卡片”、三个模板按钮、当前预览卡片和添加到桌面入口。页面底部显示 6/6 自检通过,包含三种模板、动作、进度、唯一 ID、刷新和归约六项结果。
用例 2:三个模板切换
按顺序点击每日专注、配送进度、设备概览:
| 按钮 | 页面内容 | 主要验证点 |
|---|---|---|
| 每日专注 | 完成度和连续天数 | 进度、完成动作、刷新版本。 |
| 配送进度 | 订单阶段和站点 | 状态变化、稍后提醒动作。 |
| 设备概览 | 电量和网络指标 | 模拟设备数据和刷新动作。 |
每次切换都会更新标题、副标题、正文、进度条、指标和动作按钮。
用例 3:应用内动作
点击预览页的刷新、完成或稍后提醒,ArkTS 将动作转换为 readCard 的参数并重新解析 Native JSON。刷新会改变确定性数据,完成会将每日专注进度设为 100,稍后提醒会更新配送正文。
用例 4:进入系统卡片管理页
选择一个模板后点击“添加当前卡片到桌面”。应用调用 getFormsInfo 和 openFormManager,系统显示卡片添加界面。确认后由 onAddForm 创建实例并显示绑定数据。
用例 5:桌面卡片动作
在桌面卡片上点击“刷新”或模板主动作,确认 postCardAction 进入 onFormEvent,Preferences 中的实例状态发生变化,并通过 formProvider.updateForm 更新桌面卡片。系统版本或桌面实现不同的时候,卡片管理页可能需要用户再次确认或手动进入。
用例 6:尺寸和生命周期
调整卡片尺寸,确认 onSizeChanged 保存新的 dimension;从桌面移除卡片,确认 onRemoveForm 清理 form ID;系统触发更新时,确认 onUpdateForm 推进 revision;可见性变化进入 onChangeFormVisibility。
6.5 验证结论
本次验收结果:
service-cardJVM 测试通过;sample:sharedJVM 测试通过;nativeAppARM64 动态库生成成功;- ArkTS 类型解析和 HAP 构建成功;
- 签名 HAP 安装成功并启动
EntryAbility; - 应用首页三个模板切换成功,显示
6/6 自检通过; - 点击添加按钮可以进入鸿蒙系统卡片管理流程;
- 卡片添加需要系统确认,应用不能静默完成桌面钉选;
- 卡片生命周期、动态数据绑定和事件回调已按代码链路实现。
七、运行效果
7.1 获取运行截图
下面是 CMP Service Card 应用和系统服务卡片管理页的运行效果图。图中包含三个模板切换按钮、应用内预览卡片、每日专注桌面卡片预览以及“添加至桌面”入口。

7.2 界面文本快照
| 截图区域 | 对应实现 |
|---|---|
服务卡片 / 在桌面,随时看见重要的事 | Index.ets 页面标题和服务卡片说明。 |
| 每日专注 / 配送进度 / 设备概览 | choose(index) 切换三个共享 Kotlin 模板。 |
CMP Service Card 系统卡片页 | 鸿蒙系统 Form Kit 卡片管理界面。 |
| 每日专注卡片 | ServiceCard.ets 的 @LocalStorageProp 数据绑定。 |
刷新 / 完成 | postCardAction 发送到 onFormEvent。 |
第 0 次刷新 | Ability 持久化的 revision 状态。 |
| 添加至桌面 | 系统要求用户确认的卡片管理流程。 |
| 添加当前卡片到桌面 | openServiceCardManager() 调用入口。 |
其他两个模板
- 配送进度:卡片正文在备货、运输途中、今日送达之间变化,较大尺寸显示预计时间和站点指标。
- 设备概览:卡片显示模拟电量和网络状态,刷新版本变化时电量按规则下降。
7.3 验证命令速查
# 根工程测试和 OpenHarmony 原生库准备
./scripts/build-openharmony.sh
# 单独运行共享测试和 Native 复制任务
(cd example && ./gradlew :shared:jvmTest :nativeApp:prepareOhos)
# 准备不带个人证书的 DevEco 工程
python3 scripts/prepare-signing-project.py "$HOME/service_card_ohos_signing"
# 在已配置签名的工程中构建 HAP
./scripts/build-hap.sh "$HOME/service_card_ohos_signing"
# 安装和启动
hdc -t <target-id> install -r entry-default-signed.hap
hdc -t <target-id> shell aa start \
-a EntryAbility -b com.ohos.servicecard.sample
八、遗留问题与改进方向
8.1 踩坑复盘
- Native 文件必须先复制:如果没有执行
prepareOhos,CMake 的 imported library 路径存在但文件不存在,Ninja 会直接失败。 - JDK 版本必须匹配:OpenHarmony 示例使用 JDK 21,不能用不兼容的 JDK 版本绕过 Kotlin DSL 或 Native 编译器检查。
- 签名配置不能进源码:DevEco 生成的 profile 可能带有绝对路径和密码,必须使用独立签名工程;公开仓库只保留无凭据的
build-profile.json5。 - 多设备 hdc 需要指定目标:同时连接真机和电脑设备时,安装命令要加
-t,否则 hdc 会要求确认设备。 - Form ID 不能转成 JavaScript number:Form Kit ID 可能超过安全整数范围,Ability 必须使用字符串 key 保存实例状态。
- 桌面卡片不能静默添加:调用
openFormManager只是打开系统管理流程,最终添加动作由用户确认,不能把“调用成功”误判为“已钉到桌面”。 - 卡片进程不能依赖应用内存:应用页面的
@State与 Form 渲染进程隔离,桌面实例必须通过 Binding Data 和 Preferences 恢复状态。 - 卡片事件需要校验:
onFormEvent既可能收到原始消息,也可能收到 JSON 包装消息,需要先解析再确认动作属于当前模型。
8.2 已知问题
- 当前示例使用本地确定性数据,没有接入网络请求和真实设备数据源;
- 系统卡片管理页和最终加桌交互受设备桌面、HarmonyOS 版本和系统策略影响;
- 目前卡片页面采用单一 ArkTS
ServiceCard.ets,更多业务卡片可以拆分为独立的 Form 页面; - JSON 作为跨语言契约足够直观,但大规模指标和高频更新场景需要评估序列化开销;
- HAP 需要开发者在 DevEco Studio 中完成签名,仓库不会提供可直接发布的证书。
8.3 未来优化方向
- 将不同卡片模板拆成可复用的 ArkTS Form 组件,同时保持统一的 Binding Data 契约。
- 为服务卡片增加真实网络数据、离线缓存、失败占位和增量更新策略。
- 引入定时任务或后台数据源时,补充权限、调度、功耗和数据合规说明。
- 为 API 20 及后续 HarmonyOS SDK 建立独立 CI,自动执行 JVM、Native、HAP 和 ELF 依赖审计。
- 为卡片数据模型提供稳定的版本迁移和 Preferences schema 迁移策略。
- 如果后续发布 OpenHarmony 变体,再为消费者提供明确的 AtomGit 坐标、版本策略和二进制交付说明。
九、总结
9.1 核心难点回顾
CMP Service Card 的 OpenHarmony 适配难点不是把一个普通页面画出来,而是让同一份 Kotlin 卡片模型真正经过 Kotlin/Native、C ABI、C++ N-API 和 Form Kit,到达设备桌面上的系统服务卡片:
共享模型 → Kotlin/Native → C ABI → C++ N-API → ArkTS JSON
→ FormBindingData → formrenderservice → 桌面服务卡片
每一层都有清晰的输入和输出,出现问题时可以分别检查模型、动态库、符号、HAP、签名或系统卡片生命周期。
9.2 封装层次
KMP/CMP root modules
└── example
├── shared 卡片模板、指标、动作和 JVM 检查
├── nativeApp ohosArm64 动态库、C ABI、JSON 和内存释放
└── ohosApp Stage、N-API、Form Kit、ArkTS 卡片和 HAP
9.3 三条经验
- 先固定数据和动作语义,再写页面。 卡片标题、进度和动作由
ServiceCardEngine统一定义,ArkTS 不需要猜测 Kotlin 返回值。 - 把工具链问题和系统卡片问题分开。 JVM、Native、CMake、Hvigor、签名、HDC 和 Form Kit 分层验证,能迅速定位是依赖、符号、签名还是系统确认流程问题。
- 签名工程和源码工程隔离。 开源仓库提交源码和脚本,开发者在 DevEco 中手动完成签名,既满足真机运行,也避免泄露本机密钥。
9.4 适配成果
- OpenHarmony 示例具备
ohosArm64Kotlin/Native 构建链路; - Kotlin 共享层提供三个服务卡片模板、刷新规则和不可变动作归约;
- Native 层通过四个 C ABI 函数向 ArkTS 提供 JSON 和自检;
- C++ N-API 层完成参数校验、字符串转换和 Native 内存释放;
- ArkUI 应用页面提供模板预览和系统卡片管理入口;
- ArkTS
FormExtensionAbility覆盖新增、更新、事件、移除、尺寸和可见性回调; - 真机 HAP 安装和
EntryAbility启动验证通过; - 系统卡片管理页可以由应用调用打开,最终加桌遵循鸿蒙用户确认流程;
- 中文、英文 README、本文、效果图和仓库地址统一使用 AtomGit;
- 签名材料、HAP 和原生构建产物不进入源码仓库。
参考文档
更多推荐


所有评论(0)