开源鸿蒙平台 CMP 三方库「Matisse」适配全流程
本文记录
Matisse接入 OpenHarmony 的完整过程,覆盖 Android-only 上游的能力盘点、可移植引擎提取、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 相册选择器页面、HAP 构建和真机验收。
Matisse是基于 Jetpack Compose 实现的 Android 图片/视频选择框架:宿主通过ActivityResultContract传入Matisse配置(媒体类型、最多可选数量、网格列数、returnOnTap、混选开关、拍照策略),框架内部完成 MediaStore 查询、分页、相册聚合、多选状态机、预览与系统相机拍照,最后以MediaResource列表返回。本次适配与 KStore 那类"制品线齐备后真编译整库"的路线有本质区别:上游是纯 Android 工程(Activity、ActivityResultContract、Parcelable、MediaStore、Paging3、Coil/Glide 一个都少不了),鸿蒙上不存在它们的对应物,因此适配单位不是"整库"而是业务规则引擎——选择状态机、相册聚合、分页窗口、拍照会话状态机被提取为零依赖纯 Kotlin 编译到 OpenHarmony,UI 与媒体库访问由 ArkTS Demo 承担。引擎的每一条语义(四条配置校验的报错原文、maxSelectable == 1的单选替换、混选拦截、序号重排、OFFSET 死循环保护、拍照会话进程重建恢复表)都由 70 项 JVM 交叉验证用例逐条钉住。
项目地址: AtomGit/oh-tpc/Matisse.git
上游仓库: leavesCZY/Matisse
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 KMP/CMP 适配
Matisse 为 Android 应用提供"图片视频选择"这一件事的完整答案:MatisseContract 一行拉起选择器,maxSelectable / mediaType / allowMixedMedia / returnOnTap 覆盖全部选择策略,CaptureStrategy(FileProvider / MediaStore / Smart 三策略)处理拍照输出与清理,ImageEngine 把 Coil 与 Glide 做成宿主可插拔。上游把这些能力实现在 AppCompatActivity + MediaStore + Paging3 之上,Kotlin 与 Compose 只是最外层的表达方式。OpenHarmony 应用同样需要媒体选择,但上游构建(Kotlin 2.4.20 / Gradle 9.7.1 / AGP 9.4.1 / compileSdk 37)不会生成 OpenHarmony 变体,Android 应用框架 API 在鸿蒙上也没有直接对应物。
如果只把示例页面改成 ArkTS,页面可以画出一个网格,却无法证明上游的选择规则、分页语义、拍照时序在 OpenHarmony ARM64 设备上原样成立。适配需要同时验证业务引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 交付链路。
| 障碍 | 具体问题 |
|---|---|
| 平台 API 缺失 | AppCompatActivity、ActivityResultContract、Parcelable、FileProvider、Toast 在鸿蒙上无对应物,上游 27 个 Kotlin 文件全部直接 import Android 框架。 |
| 媒体库接口不同 | 上游查询走 ContentResolver + MediaStore(SQL 谓词、GROUP BY 聚合、ContentUris),鸿蒙媒体库是 photoAccessHelper(ArkTS 侧 API)。 |
| 工具链不一致 | 上游 Kotlin 2.4.20 / Gradle 9.7.1 / AGP 9.4.1 与鸿蒙 Kotlin 2.2.21-1.0.0 / Gradle 8.14.1 线无法共存于一个 Gradle 构建。 |
| 依赖矩阵 | Paging3、Coil3、Glide、androidx.paging-compose 均为 Android 制品,ohos 上没有变体。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 时序型语义易丢失 | 拍照流程的权限→建 Uri→相机→回读→清理时序、进程重建恢复表,是最容易在"换平台重写"中悄悄漂移的部分。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:业务规则引擎的提取与 ohos 编译、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。选择/聚合/分页/拍照时序完全由提取出的引擎负责并与上游逐条对齐,ArkTS 只负责媒体查询、渲染和调用 Native。
1.2 库提供的能力
Demo 提供一个真实的媒体选择会话,对应桥接的全部入口:
- 配置校验(config):上游
Matisse构造参数的 4 条IllegalArgumentException规则与报错原文全部保留——maxSelectable < 1、maxSelectable > 1 && returnOnTap、gridColumns < 1、不含图片却启用拍照; - 选择状态机(toggle / state / selection / deselectAll):已选→取消;
maxSelectable == 1→ 清空旧选中再选;达到上限 → 拦截并按媒体类型给出matisse_error_max_images / max_videos / max_media文案键;!allowMixedMedia且将混选 → 拦截给出matisse_error_mixed_media;选中项按选择顺序编号 1、2、3…,取消后自动重排; - 相册聚合(buckets):「全部」桶固定第一、数量 = 各相册之和、封面 = 最新一条媒体;其余相册按名称升序,空名/零计数剔除;
- 分页窗口(pageBegin / pageBatch):默认桶内剔除已拍照条目、跨批次去重、短批即到底、OFFSET 不再推进时触发死循环保护;
- 拍照会话状态机(capture 七个入口)*:
Idle → RequestingPermission → AwaitingCamera → Finalizing → Idle全相位迁移,含进程重建恢复表; - 版本握手(info / capturedList):
info返回 fork 版本与全部 action 清单,来自真实 Native 调用而非静态文字。
响应统一携带:
| 字段 | 作用 |
|---|---|
ok | 操作是否成功,失败时 error 带原文。 |
blocked / messageKey | toggle 被拦截的原因与文案键,ArkTS 侧映射为与上游 strings.xml 逐字一致的 toast。 |
isSelected / positionIndex / positionFormatted | 选择态与序号,网格复选框的唯一权威来源。 |
acceptedIndices / needMore / reachedEnd | 分页窗口对一批查询结果的裁决。 |
decision / phase | 拍照状态机的下一步指令与当前相位。 |
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 引擎语义逐条对齐上游(每条 jvmTest 用例注释引用上游文件);上游 Android 实现 40 个文件 git mv 到 src/androidMain 留盘,一行不改、不参与构建。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);:app、:build-logic 留盘不进 settings。 |
| 桥接稳定 | 单一 @CName("MatisseCall") C ABI + UTF-8 扁平 JSON(17 个 action),不传递 Kotlin 对象地址;异常永远不跨 FFI。 |
| 依赖纪律 | 引擎零第三方依赖(不需要 kotlinx 制品线),工具链钉社区标准线:Kotlin 2.2.21-1.0.0(eazytec Maven)+ Gradle 8.14.1 + JDK 21。 |
| UI 完整 | 两页覆盖全部桥接入口:配置页(实时校验、引擎握手、测试图插入)、选择器(权限、分页网格、相册切换、多选规则、预览、拍照)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。上游 UI 层(Compose 网格、
MatisseCheckbox、预览 Swiper、VideoView)在鸿蒙上由 ArkTS 等价复刻;引擎里没有任何渲染代码,适配面天然收敛在"规则"上。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点 Android API 依赖面、划分"可移植规则 / 必须重做"边界、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、Gradle 8.14.1
第 3 阶段:引擎提取与桥协议 ── 四大状态机提取为纯 Kotlin、17 action 扁平 JSON 契约、拍照 FSM 恢复表
第 4 阶段:原生桥接 ── @CName MatisseCall、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── 配置页 + 选择器两页、READ_IMAGEVIDEO、photoAccessHelper 分页、CameraPicker 拍照
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
matisse/src/commonMain/ 提取的业务引擎(jvm + ohosArm64 + ohosX64,零依赖纯 Kotlin)
matisse/src/jvmTest/ 70 项交叉验证(每条用例注释引用上游文件)
matisse/src/androidMain/ 上游 Android 实现整体 git mv 而来,一行未改、不参与构建
example/nativeApp/ Kotlin/Native 动态库(Bridge + MiniJson)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 两页 Demo
app/ build-logic/ jitpack.yml key.jks 上游残留,留盘不进 settings
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :matisse 与 :example:nativeApp,rootProject.name = "ohos_Matisse"。上游的 AGP、compose、parcelize、vanniktech 发布插件与 build-logic 组合构建全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin jdk-21.0.12.101-hotspot,org.gradle.java.home 钉死) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(wrapper 重生成;2.2.21 线要求 8.x,沿用 qrose / swipe 先例) |
| 引擎依赖 | 无(纯 Kotlin + kotlin-stdlib,不需要 kotlinx ohos 制品线) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 26.0.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.matisse |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 依赖度盘点:什么能移植,什么必须重做
| 上游能力 | 依赖形态 | 处置 |
|---|---|---|
配置校验、MediaType 语义 | 纯 Kotlin(Matisse.kt init 块 + sealed interface) | 提取进 commonMain,报错原文逐字保留 |
| 选择状态机 | LinkedHashMap + 纯逻辑(MatisseViewModel.onMediaCheckChanged) | 提取(Compose mutableStateMapOf 换普通 Map) |
| 分页窗口 | OFFSET 循环 + 去重/排除/死循环保护(MediaPagingSource.load) | 提取为纯函数式窗口(Paging3 依赖剔除) |
| 相册聚合 | GROUP BY 聚合 + 默认桶构造(MediaProvider + loadMediaBucketsAsync) | 提取聚合规则;SQL 查询由 ArkTS photoAccessHelper 承担 |
| 拍照会话状态机 | 四相位 sealed class + 恢复表(BaseCaptureActivity) | 提取(Activity/launcher 换事件驱动) |
MediaStore 查询 / ContentUris | Android 框架 | ArkTS 侧重做(getAssets / uri 反解) |
Activity、ActivityResultContract、Parcelable | Android 框架 | ArkTS 侧重做(页面路由 + 引擎会话) |
Coil / Glide ImageEngine | Android 制品 | ArkUI Image / getThumbnail 等价承担 |
| Compose UI 十件套 | androidx.compose | ArkTS 复刻(网格 / 复选框 / 预览 / 视频播放) |
这份盘点决定了"引擎提取"的边界:凡是规则就提取,凡是系统访问就重做。引擎因此做到零第三方依赖——连 kotlinx-coroutines 都不需要,这也是后续工具链敢直接钉社区标准线的原因。
1.4 页面边界
配置页 Index.ets
状态行 Kotlin 引擎已连接 · matisse 2.3.2-ohos.1 · Kotlin 2.2.21-1.0.0(info 握手)
网格列数 2 / 3 / 4 药丸
最多可选 1..9 滑杆
媒体类型 仅图片 / 仅视频 / 图片和视频 / MIME 示例(image/png) 药丸
单击即返回 returnOnTap 开关(maxSelectable == 1 时才可用)
允许混选 allowMixedMedia 开关
选择器内拍照 captureEnabled 开关(媒体类型含图片时才可用)
操作区 插入测试图片 / 启动选择器
结果区 选择结果列表(Picker 页带回)
选择器页 Picker.ets
顶栏 相册下拉(引擎 buckets 排序)+ 返回
网格 gridColumns 列方格 + 复选框序号 + 视频角标 + 首格拍照入口
底栏 预览(n) / 确定(n/max)
预览层 全屏 Swiper + 视频 + 勾选同步
页面启动时先调 info 做握手(效果图顶部状态行即其真机输出),进入选择器后再走权限→分页→聚合链路。
第 2 阶段:目标与依赖打通
2.1 为什么是 Kotlin 2.2.21-1.0.0 + Gradle 8.14.1
社区批准的钉法是 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / swipe / Flippable 用的标准线),该线要求 Gradle 8.x——wrapper 重生成为 8.14.1。上游是 Kotlin 2.4.20 / Gradle 9.7.1 / AGP 9.4.1,与鸿蒙工具链无法共存;但本 fork 的引擎是新提取的零依赖纯 Kotlin,不存在"读不了 2.4 元数据"的问题,上游 Android 实现整体留盘不编译,也就没有降级成本。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_Matisse"
include(":matisse")
include(":example:nativeApp")
dependencyResolutionManagement 同样双仓置前。少了这两段置前,配置期直接 Unresolved reference: ohosArm64。
2.2 编译提取出的引擎
matisse/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
// 引擎在 commonMain 为每个目标编译;上游 Android 实现(Activity、
// MediaStore、Coil/Glide)留在 src/androidMain,不参与任何目标编译。
jvm()
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
gradle/libs.versions.toml 只剩一条 Kotlin 版本;根构建保留上游 group,版本从 gradle.properties 读 2.3.2-ohos.1,并全局禁用 ohos 测试任务(测试统一 JVM 跑):
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
实测:
./gradlew :matisse:jvmTest # 70/70 通过
./gradlew :matisse:compileKotlinOhosArm64 :matisse:compileKotlinOhosX64 # BUILD SUCCESSFUL
2.3 上游 Android 实现的处置:git mv 到 androidMain
上游 matisse/src/main 的 40 个文件(27 个 Kotlin + manifest + 资源)整体 git mv 到 matisse/src/androidMain:src/main 是 AGP 的源集目录,src/androidMain 在无 android 目标的 KMP 构建里只是一个普通目录——目录名本身就说明了"这是 Android 实现、不在本构建里",同时 git 历史零断链(提交里全部是 R 重命名、零内容改动)。:app、:build-logic、jitpack.yml、key.jks 作为上游残留同样留盘不进 settings。
第 3 阶段:引擎提取与桥协议建模
3.1 四大状态机
github.leavesczy.matisse/**(上游 Android 实现,留盘不编译)
│ 语义提取(每条 jvmTest 用例注释引用上游文件)
MatisseEngineTypes.kt / MatisseEngine.kt / CaptureStateMachine.kt(commonMain,零依赖)
│ @CName("MatisseCall") 扁平 JSON(17 action)
N-API libmatisse_napi.so
│
ArkUI Index.ets + Picker.ets
选择状态机完整保留上游 onMediaCheckChanged 的判定顺序:
fun toggle(item: MediaItem): ToggleResult {
if (selected.containsKey(item.mediaId)) { // 已选 → 取消
selected.remove(item.mediaId)
} else {
if (config.maxSelectable == 1) { // 单选:清空再选
selected.clear()
} else if (selected.size >= config.maxSelectable) {
return blocked("max") // 上限拦截 + 文案键
} else if (!config.allowMixedMedia && selected.values.any {
it.isImage != item.isImage }) {
return blocked("mixed") // 混选拦截
}
selected[item.mediaId] = item
}
/* 序号按插入顺序重排,limitReached = size >= maxSelectable */
}
分页窗口保留 MediaPagingSource.load 的全部护栏:排除集只在默认桶生效(对齐上游 excludedMediaIds)、跨批次去重、批大小小于页大小即到底、以及"连续两批零接受且首批 id 相同 → 到底"的 OFFSET 死循环保护。拍照会话状态机保留 BaseCaptureActivity 的四相位与恢复表:
| 恢复相位 | 上游行为 | 引擎行为 |
|---|---|---|
AwaitingCamera && !launcherStarted | 丢弃孤儿 Uri(deleteImageUri) | 返回 deleteImageUri 决策 |
RequestingPermission | 复位为 Idle | 复位为 Idle |
Finalizing | 重新执行 finalize | 返回 finalize 决策(重放) |
Idle | 无 | 无 |
拍照条目入库沿用上游 onMediaCaptured:插入头部、按 id 去重、Uri 尾段解析媒体 id(ContentUris.parseId 等价),解析失败用合成负 id(-1、-2、…)。
3.2 JSON 契约(17 个 action)
会话 info / config
选择 toggle / state / selection / deselectAll
相册 buckets
分页 pageBegin / pageBatch
拍照 captureRequest / capturePermissionResult / captureLaunchFailed /
captureCameraLaunched / captureCameraResult / captureFinalize / captureRestore
调试 capturedList
记录型列表用 | 分隔字段、; 分隔记录(如 albums:"bucketId|bucketName|itemCount|coverUri|coverMime;..."),普通列表用 CSV,空值永远是空串而不是 null——扁平 JSON 不做嵌套,是 qrose / swipe 一脉相承的桥上纪律。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("MatisseCall")
fun matisseCall(request: String): String = try {
val json = MiniJson.parse(request)
when (json.str("action")) {
"info" -> info()
"toggle" -> toggle(json)
/* … 17 个 action … */
else -> err("unknown action '${json.str("action") ?: ""}'")
}
} catch (t: Throwable) {
err(t.message ?: t::class.simpleName ?: "error")
}
非法 JSON、未知 action、未初始化会话、非法配置,全部走同一个 ok:false 通道,ArkTS 侧统一 toast 渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libmatisse_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohosmatisse.so)
│ MatisseCall
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;把选择规则抄进 ArkTS 又会让"引擎没漂"失去证明,所以选择 C ABI + JSON——与 KStore / qrose / swipe 同一条路。
4.2 Kotlin/Native 导出函数
@CName("MatisseCall")
fun matisseCall(request: String): String // 17 个 action 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖引擎 |
|---|---|---|
MatisseCall | matisseCall(req) | 配置校验 + 选择状态机 + 相册聚合 + 分页窗口 + 拍照会话 FSM |
info 响应携带 fork:"2.3.2-ohos.1" 与 kotlin:"2.2.21-1.0.0",真机状态行的数据来源即此处——来自真实 Native 调用而非静态文字。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"matisseCall", nullptr, NapiMatisseCall, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohosmatisse_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohosmatisse_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohosmatisse SHARED IMPORTED)
set_target_properties(ohosmatisse PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(matisse_napi SHARED napi_init.cpp)
target_link_libraries(matisse_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohosmatisse)
OHOS_ARCH 为空或 libohosmatisse.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const matisseCall: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 权限与媒体查询
module.json5 声明 ohos.permission.READ_IMAGEVIDEO(reason 走 $string 资源),进入选择器先 abilityAccessCtrl.requestPermissionsFromUser,拒绝则进入与上游 MatissePlaceholderState.NoPermission 对应的无权限占位态。媒体查询用 photoAccessHelper.getAssets(FetchOptions):orderByDesc('DATE_ADDED')、limit(total, offset) 逐批取 40 条,喂给引擎分页窗口:
const res = this.callEngine({
'action': 'pageBatch',
'pageSize': PAGE_SIZE,
'newPage': isNewPage,
'batchFirstMediaId': firstId,
'batchIds': ids.join(',')
});
// res.acceptedIndices 决定本批哪些条目真正入网格;
// res.needMore 继续取批,res.reachedEnd 停止;Grid.onReachEnd 触发下一页
相册列表用 getAlbums 取用户相册后交给引擎 buckets 聚合排序(「全部」第一、名称升序、空桶剔除),封面取各相册最新一条。
5.2 选择、预览与拍照
网格复选框复刻上游 MatisseCheckbox:未选半透明白底圆环、选中主题色实心圆 + 序号(maxSelectable == 1 时显示 ✓);点击格子进预览、点击复选框走引擎 toggle,被拦截时按 messageKey 弹出与上游 strings.xml 逐字一致的 toast。预览层为全屏 Swiper(图片 Image、视频 Video 组件),勾选状态经引擎同步。首格拍照入口只在默认桶且 captureEnabled 时出现,走系统 CameraPicker:
import { cameraPicker, camera } from '@kit.CameraKit';
const profile: cameraPicker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
};
this.callEngine({ 'action': 'captureCameraLaunched', 'outputUri': 'pending:matisse' });
const result = await cameraPicker.pick(
context, [cameraPicker.PickerMediaType.PHOTO], profile);
if (result.resultCode === 0 && result.resultUri !== '') {
this.callEngine({ 'action': 'captureCameraResult', 'isSuccessful': true });
/* captureFinalize insert:true → 引擎把拍照条目插入头部并在默认桶排除其真实 id */
}
引擎仍跑完整的 FSM 时序(captureRequest → captureCameraLaunched → captureCameraResult → captureFinalize),uri 用占位符——鸿蒙 CameraPicker 自持输出 uri,对应上游 CaptureStrategy.createImageUri 的职责由系统承担。配置页「插入测试图片」生成渐变色 PixelMap,经 ImagePacker.packToFile(fd) 落临时文件、MediaAssetChangeRequest.createImageAssetRequest + applyChanges 入库,空相册的模拟器/新机也能验证分页。
5.3 错误边界
callEngine 统一 JSON.stringify → matisseCall → JSON.parse,ok:false 时错误原文进 toast 与 hilog(tag MatisseDemo);Native 调用本身抛错同样被 try/catch 收进日志,页面永不因桥异常崩溃。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohosmatisse)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/Bridge.kt @CName("MatisseCall") + MiniJson(继承 cmp_qrose 审核模式)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.matisse、versionName 2.3.2-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/libohosmatisse_api.h
├── src/main/cpp/types/libmatisse_napi/{index.d.ts, oh-package.json5}
├── src/main/module.json5 READ_IMAGEVIDEO 权限声明
└── src/main/ets/pages/{Index,Picker}.ets 配置页 + 选择器
6.2 构建、签名和安装
./gradlew :matisse:jvmTest # 70 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
node "E:\DE\DevEco Studio\tools\hvigor\bin\hvigorw.js" assembleHap ^
--mode module -p product=default -p buildMode=debug ^
--analyze=normal --parallel --incremental --daemon=false
命令行跑 hvigor 需要 DEVECO_SDK_HOME 指向 DevEco SDK、并把 DevEco 自带的 node 与 jbr 加入 PATH(PackageHap 是 Java 工具,spawn java ENOENT 就是缺它;改完环境后先 --stop-daemon 再跑,旧 daemon 缓存旧 PATH)。签名在 DevEco Studio 的 File → Project Structure → Signing Configs 中勾选自动生成,证书写入本机 ~/.ohos/config,不进入仓库(仓库内 build-profile.json5 的 signingConfigs 保持空数组)。
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.matisse -m entry
四、完整代码对照
4.1 整体架构
github.leavesczy.matisse.engine/**(提取的规则引擎,commonMain)
│ MatisseConfig 校验 / MediaType / 选择状态机 / 相册聚合 / 分页窗口 / 拍照 FSM
Bridge.kt + MiniJson
│ MatisseCall(17 action)
libmatisse_napi.so
│ matisseCall
Index.ets + Picker.ets
│ JSON parse + @State 更新 + photoAccessHelper / CameraPicker
▼
ArkUI 真机页面(配置页 + 选择器)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_Matisse、app/build-logic 移出 |
gradle/libs.versions.toml | 仅 Kotlin 2.2.21-1.0.0 一个插件别名 |
build.gradle.kts(根) | 版本 2.3.2-ohos.1、全局禁用 Ohos*Test* 任务 |
matisse/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64,jvmTest 带 kotlin(“test”) |
matisse/src/androidMain/** | 上游 Android 实现整体 git mv,零改动留盘 |
engine/MatisseEngineTypes.kt | MediaType / MatisseConfig(4 条校验原文)/ MediaItem / SelectState |
engine/MatisseEngine.kt | 选择状态机、相册聚合、分页窗口、拍照条目插入 |
engine/CaptureStateMachine.kt | 四相位拍照会话 + 进程重建恢复表 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt | @CName("MatisseCall")、17 action、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 | 配置页(校验、握手、测试图插入) |
harmonyApp/.../ets/pages/Picker.ets | 选择器(权限、分页、相册、多选、预览、拍照) |
README.OpenHarmony_CN.md | 鸿蒙适配主文档(20 节) |
4.3 关键 API 对照
| 层次 | API | 作用 |
|---|---|---|
| 引擎 | MatisseConfig(...) | 4 条上游校验规则(报错原文一致) |
| 引擎 | toggle(item) | 选择判定:取消 / 单选替换 / 上限 / 混选 |
| 引擎 | buildBuckets(albums, newest) | 「全部」= 总数 + 最新封面,其余名称升序 |
| 引擎 | pageBegin / pageBatch | 排除、去重、到底判定、死循环保护 |
| 引擎 | CaptureStateMachine | 拍照四相位 + 恢复表 |
| 引擎 | parseMediaId(uri) / onMediaCaptured | Uri 尾段解析、头部插入、合成负 id |
| Native | MatisseCall | C ABI JSON 导出(17 action) |
| N-API | matisseCall(req) | 向 ArkTS 暴露能力 |
| ArkTS | callEngine(...) | 统一分发与错误渲染 |
| ArkTS | getAssets / getAlbums / cameraPicker.pick | 媒体查询 / 相册 / 拍照(系统侧) |
4.4 ArkTS 与 Kotlin 的边界
const res = this.callEngine({
'action': 'toggle',
'mediaId': asset.id, 'bucketId': bucket.bucketId, 'bucketName': bucket.bucketName,
'uri': asset.uri, 'mimeType': asset.mimeType
});
// res.isSelected === true;res.blocked === 'max' 时按 res.messageKey 弹上游同款 toast
"isSelected" to state.isSelected, // 引擎是选择态唯一权威
"positionFormatted" to (state.positionFormatted ?: ""),
"blocked" to blocked.name.lowercase(), // "" | "max" | "mixed"
"messageKey" to messageKey // matisse_error_max_images / …
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:提取业务规则引擎,而不是整库编译或 ArkTS 重写
上游是纯 Android 工程,"整库编译"不存在可行性;把选择规则抄进 ArkTS 则失去"上游语义成立"的证明。提取的引擎是零依赖纯 Kotlin——不需要 kotlinx ohos 制品线,工具链直接钉社区标准线。规则在 Kotlin 侧、系统访问在 ArkTS 侧,边界清晰且每一侧都可独立验证。
决策 2:工具链钉 2.2.21 线 + Gradle 8.14.1
引擎是新提取的代码,没有 2.4 元数据包袱;社区标准线(qrose / swipe / Flippable 同款)审核路径最成熟。上游 Gradle 9.7.1 wrapper 重生成为 8.14.1,gradle.properties 钉 JDK 21 与三个 kotlin.native.* 开关。
决策 3:上游 Android 实现整体 git mv 留盘
src/main → src/androidMain 的重命名让"不编译"自解释,git 历史零断链(提交里全是 R 记录);:app、:build-logic 同样留盘。将来上游演进时,diff 语义仍然成立。
决策 4:单一 JSON 入口 17 个 action
业务状态机天然是"请求 → 状态 → 响应",一个 MatisseCall 加 action 字段足够;扁平 JSON 不嵌套、空值用空串、记录用 |/; 分隔,桥上不做 schema 演化。拍照 FSM 的七个入口逐事件暴露而不是合并成一个大调用,正是为了让"时序"可见、可测。
决策 5:jvmTest 交叉验证钉住"语义没漂"
70 项用例每条注释引用上游文件(MatisseViewModel.onMediaCheckChanged、MediaPagingSource.load、BaseCaptureActivity 恢复表…),JVM 与 ohos 跑同一份 commonMain——测试绿即引擎语义与上游对齐,这是"提取式适配"区别于"重写"的核心证据。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、JDK 21(Temurin)、DevEco Studio 26.0(含 hvigor / ohpm / jbr,安装于 E:\DE\DevEco Studio)、OpenHarmony 命令行工具(hdc 3.2.0f)与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :matisse:jvmTest
70 项用例全部通过:
| 测试类 | 用例数 | 钉住什么 |
|---|---|---|
MatisseEngineTypesTest | 14 | 4 条配置校验 + MediaType 的 includesImage/includesVideo 矩阵 |
MatisseEngineSelectionTest | 15 | 取消、单选替换、上限拦截(三种文案键)、混选拦截、序号重排、limitReached |
MatisseEnginePagingTest | 10 | 默认桶排除、跨批去重、短批到底、死循环保护、offset 推进记账 |
MatisseEngineBucketsTest | 5 | 「全部」总数 + 最新封面、名称升序、空名/零计数剔除 |
MatisseEngineCapturedTest | 8 | 头部插入、按 id 去重、合成负 id、Uri 尾段解析 |
CaptureStateMachineTest | 18 | 四相位全迁移、权限拒绝路径、恢复表全部相位(孤儿丢弃、Finalizing 重放) |
6.3 HAP 验证
:matisse:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL
llvm-nm: MatisseCall 均为全局 T 符号(双 ABI)
copyKnSoArm64 / copyKnSoX64: entry/libs 双 ABI 三件套回填
BuildNativeWithCmake / CompileArkTS / PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 7.2 MB,内含双 ABI 各三件套(HAP 内 strip 后 libohosmatisse.so arm64 约 2.0MB、x86_64 约 2.0MB,libmatisse_napi.so 约 64KB,libc++_shared.so 约 1.3MB;debugShared 未 strip 时 KN so 分别约 3.7MB / 3.4MB,含 Kotlin/Native 运行时)。
6.4 功能用例
- 启动后配置页状态行出现
Kotlin 引擎已连接 · matisse 2.3.2-ohos.1 · Kotlin 2.2.21-1.0.0(info握手,来自真实 Native 调用); - 非法组合被实时拦截:
returnOnTap仅在maxSelectable == 1可开;媒体类型切到"仅视频"时拍照开关禁用; - 空相册时点「插入测试图片」,渐变图入库后进入选择器可见分页网格;
- 权限拒绝进入无权限占位态,授予后回到网格(对齐上游
onReadMediaPermissionResult的复位语义); - 网格滚动触发
pageBatch逐页加载,到底后onReachEnd不再请求; - 相册下拉切换:默认「全部」第一、计数为各相册之和,其余按名称升序;
- 多选规则:
maxSelectable == 3时第 4 次勾选弹「最多只能选择 3 张图片」(与上游strings.xml逐字一致);开启allowMixedMedia前混选弹「不能同时选择图片和视频」;取消中间项后序号自动重排; - 预览层勾选与网格复选框双向同步,视频页可播放;
- 首格拍照入口走 CameraPicker,成功后新照片插入网格头部且重查不重复(引擎按真实 id 排除),取消则回到网格无残留;
returnOnTap开启时点击缩略图立即返回单条结果至配置页结果区。
6.5 验证结论
业务规则引擎、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,配置页展示了真实的引擎握手(见下节效果图);选择、分页、聚合、拍照时序由 70 项 JVM 用例与真机操作共同钉住。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/Matisse.git
cd Matisse
export JAVA_HOME="C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
./gradlew :matisse:jvmTest # 70 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
export DEVECO_SDK_HOME="E:\DE\DevEco Studio\sdk"
node "E:\DE\DevEco Studio\tools\hvigor\bin\hvigorw.js" assembleHap \
--mode module -p product=default -p buildMode=debug \
--analyze=normal --parallel --incremental --daemon=false
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.matisse -m entry
hdc shell hilog | grep Matisse
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算适配,必须让规则引擎真正编译成
ohosArm64/ohosX64动态库并由真机调用; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;- CameraPicker 不在
photoAccessHelper里——本 SDK(5.1.0(18))中它在@kit.CameraKit(import { cameraPicker, camera } from '@kit.CameraKit'),@kit.MultimediaKit这个 kit 名不存在,照抄旧文档必报has no exported member 'CameraPicker'; ImagePacker.packToFile只收 fd 不收路径(packToFile(source, fd, options)),要先fs.openSync再finally fs.closeSync;DataSharePredicates是limit(total, offset),没有limitAs;Circle的属性是.fill()/.stroke(),不是fillColor/strokeColor——这类"属性名差一个词"的报错都要以本机 SDK d.ts 为准;applyChanges返回Promise<void>而不是 boolean,成功判定是"未抛异常";- 命令行
hvigorw assembleHap的PackageHap报spawn java ENOENT:把 DevEco 自带 jbr 加入 PATH,且改完环境先--stop-daemon——旧 daemon 缓存旧 PATH; - 在错误目录跑 hvigor 会往源码目录里写
.hvigor/垃圾,记得删除并把example/harmonyApp/.hvigor、.clangd、.clang-tidy、entry/build、entry/libs、example/nativeApp/build逐一加进 gitignore(上游根构建的/build/是根锚定,盖不住嵌套目录); build-profile.json5的signingConfigs留空数组 + product 引用default,勾选自动签名后由 IDE 回填完整material,证书绝不进入仓库;- HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 拍照返回的
file://media/Photo/<id>/<文件名>里真实媒体 id 在第二个路径段而不是最后一段(与 AndroidContentUris.parseId不同),ArkTS 侧用Photo\/(\d+)\//提取后经captureFinalize传给引擎,默认桶排除才能命中。
8.2 已知问题
- 排序保真度:上游按
max(DATE_ADDED, DATE_MODIFIED) DESC, _ID DESC,photoAccessHelper谓词不支持多列表达式,ohos 端退化为DATE_ADDED DESC(代码内有注释标记); - 拍照:系统 CameraPicker 自持输出 uri(上游 FileProvider/MediaStore 由宿主建 uri),引擎 FSM 语义保留但 uri 为占位符;ohos 免 WRITE_EXTERNAL_STORAGE / CAMERA 权限步骤,
decision直接launchCamera; - 相册桶:ohos 用户相册以
albumUri作 bucketId,与上游目录型BUCKET_ID不同源; MediaType.MimeTypes精确查询在 ohos 端为客户端过滤;ImageEngine(Coil/Glide 插拔)在 ohos 端无对应物,缩略图由 ArkUIImage/getThumbnail承担,引擎不含图片加载;- 视频 duration / mime 部分来自 title 扩展名推断;
- 上游 Android 实现、示例 app 与 build-logic 留盘不进构建,不要拿去 sync;
- HAP 安装必须使用与 bundle name
org.terminator.ohos.matisse匹配的签名。
8.3 未来优化方向
- 补齐选择器全流程真机截图矩阵(网格 / 相册切换 / 预览 / 拍照)与 uitest 自动化截图脚本;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 在 CI 中加入 ohos 编译、ELF 符号检查和 HAP 构建;
- 引擎会话持久化(跨 Ability 重建的选择态恢复)补专用入口;
- compose.ui 的 ohos 制品出现后,再评估 UI 层的 CMP 回归路径。
九、总结
9.1 核心难点回顾
上游 Matisse(纯 Android:Activity + MediaStore + Paging3 + Compose)
→ 依赖度盘点:规则可提取 / 系统访问必须重做
→ 根构建替换(cmp_qrose 模式,Kotlin 2.2.21-1.0.0 + Gradle 8.14.1)
→ 四大状态机提取为零依赖纯 Kotlin(选择 / 聚合 / 分页窗口 / 拍照 FSM)
→ ohosArm64 / ohosX64 KLIB + 双 ABI 动态库
→ C ABI(@CName MatisseCall,17 action 扁平 JSON)
→ C++ N-API(DisposeString 纪律)
→ ArkTS 选择器(photoAccessHelper 分页 + CameraPicker 拍照)
→ ArkUI 真机验收(70 项 jvmTest + HAP + 真机效果图)
9.2 封装层次
- 引擎层:配置校验、
MediaType、选择状态机、相册聚合、分页窗口、拍照会话与恢复表; - Native 层:生成双 ABI 动态库并通过单一 C ABI 输出 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:权限、媒体查询、相册切换、网格渲染、预览、拍照触发与结果回传;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- Android-only 库的适配单位是"业务规则引擎"而不是 UI:把"选择什么、能选几个、拍照分几步"这类规则从 Activity/ViewModel 里剥出来变成纯 Kotlin,比复刻一个网格有用得多——规则可测试、可移植,UI 注定要重做;
- 零依赖是提取式适配的护城河:引擎不依赖 kotlinx 制品线,工具链直接钉社区标准线,"语义没漂"由 70 项引用上游文件的 jvmTest 钉死——提取式适配的说服力全在测试上;
- 时序型语义是跨端最容易丢的东西:拍照的权限→建 Uri→相机→回读→清理时序、进程重建恢复表,重写时几乎必然被"简化"掉;把 FSM 逐事件暴露在桥上、用 18 项状态机用例钉住每条迁移,才敢说"上游行为成立"。
9.4 适配成果
当前适配已完成:
- Matisse
2.3.2业务规则引擎的双 ABI Kotlin/Native 编译(选择 / 聚合 / 分页 / 拍照四大状态机,零第三方依赖); - 上游 Android 实现 40 文件
git mv留盘零改动(src/androidMain),根构建替换为极简 ohos 构建(cmp_qrose 模式); - 单一
@CName("MatisseCall")C ABI(17 action)与 C++ N-API 包装层(llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 两页验收 Demo(配置校验、引擎握手、权限、分页网格、相册切换、多选规则 toast、预览、CameraPicker 拍照、测试图插入);
- 70 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 7.2MB)、签名、设备安装和真机验证;
- 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)