开源鸿蒙平台 KMP 三方库「Capturable」适配全流程
本文记录
Capturable接入 OpenHarmony 的完整过程,覆盖截图类库盘点、可移植内核界定、鸿蒙协程制品线选型、真实引擎编译、ohosArm64/ohosX64目标、Kotlin/Native 动态库、C ABI/N-API 桥接、ArkUI 真机页面、HAP 构建和真机验收。
Capturable是 PatilShreyas 维护的 Android Jetpack Compose 截图库:rememberCaptureController()拿控制器,Modifier.capturable(controller)标记要截取的 Composable,captureAsync()异步拿到ImageBitmap。库只有两个生产文件,但价值密度极高——它的产品不是"取像素"这个原语,而是其上的截图请求引擎:请求进Channel(UNLIMITED)缓冲(上游 issue #202 用 SharedFlow 丢请求换来的教训)、每个请求一个CompletableDeferred、服务循环flatMapLatest逐个完成或失败且循环不死、严格 FIFO。本次适配把这套引擎原样编译成 OpenHarmony KLIB / 动态库,ArkUI 页面以componentSnapshot供给像素,演示"组件 → 队列 → PNG → 预览"的完整截图闭环。与存储类库不同,本库上游是纯 Android 工程(AGP + Jetpack Compose,无任何 commonMain),因此适配多了一步"源码集拆分":Compose 层隔离、引擎层泛型化——这是 Android 三方库走 cmp_qrose 模式的标准姿势。
项目地址: AtomGit/oh-tpc/Capturable.git
上游仓库: PatilShreyas/Capturable
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台 Android 三方库适配
Capturable 为 Android 开发者回答"怎么把 Composable 变成图片":控制器解耦 UI 与截图时机、captureAsync() 从回调安全调用、Deferred.await() 拿结果、渲染失败异常沿请求链传播。这些能力全部写在 androidx.compose.ui 之上——GraphicsLayer 记录、ImageBitmap 出帧、Modifier.Node 挂接绘制。鸿蒙应用同样需要"把组件变成图片"(分享海报、票据存图、表单快照),但 ArkUI 只给了 componentSnapshot 这个一次性原语:它不排队、不缓冲、没有 Deferred 生命周期。如果只把示例页面改成 ArkTS,页面可以显示按钮,却无法证明上游的请求引擎在 OpenHarmony ARM64 设备上完成编译、链接和执行。
| 障碍 | 具体问题 |
|---|---|
| 目标是纯 Android 工程 | 上游是 AGP + Jetpack Compose 单平台库(Kotlin 2.1.0 / AGP 8.6.1),没有 commonMain 可直接复用,必须先做源码集拆分。 |
| 渲染层强绑定 | GraphicsLayer / ImageBitmap / Modifier.Node / DrawModifierNode 全部在 androidx.compose.ui 里;社区 CMP 鸿蒙线是 Compose 1.9.2 + Kotlin 2.2,与上游 Compose BOM 2024.10 无法混编。 |
| 依赖矩阵 | 引擎依赖 kotlinx-coroutines(Channel / Deferred / StateFlow / flatMapLatest),需要 ohos 变体才能解析。 |
| 语言边界不同 | ArkTS 不能直接持有 Kotlin 对象,需要 C ABI、C++ N-API 和 JSON。 |
| 像素语义跨不了桥 | GraphicsLayer.toImageBitmap() 是 Compose 概念,鸿蒙侧像素必须由 ArkUI componentSnapshot 供给,引擎只管编排。 |
| 交付链路复杂 | .so、CMake、N-API、HAP、签名和 hdc 都要单独验收。 |
因此,本项目把边界放在:截图请求引擎的 ohos 编译(上游语义泛型化、零改写)、源码集拆分(引擎进 commonMain、Compose 层隔离 composeMain)、根构建替换(cmp_qrose 模式)和 C ABI/N-API 桥接层。排队语义完全由上游引擎负责,像素获取由 ArkTS 负责,两侧只交换 JSON。
1.2 库提供的能力
Demo 提供一个真实的截图会话,对应桥接的全部入口:
- 握手(info):返回 fork 版本、上游版本、Kotlin 与协程版本,来自真实 Native 调用而非静态文字;
- 截图(capture / await):
componentSnapshot取票据组件像素 → 引擎captureAsync()入队 → FIFO 服务循环 → PNG 编码 → Base64 回传 → 页面预览,一次调用走完上游全链路; - 连拍(capture / await:false):一次入队 3 个请求立即返回
requestId,result轮询按入队顺序依次完成——Deferred异步语义的直观演示; - 队列观测(stats):
issued / served / errored / pending四个计数实时可查; - 会话管理(create / dispose):会话销毁与惰性重建。
响应包含:
| 字段 | 作用 |
|---|---|
ok / error | 操作是否成功,失败时 error 带原文。 |
op / requestId / state | 回显本次操作、请求句柄与状态(queued / pending / done / error)。 |
width / height / pngBytes / encodeMs | 结果帧尺寸、PNG 字节数与编码耗时。 |
issued / served / errored / pending | 会话队列计数。 |
pngBase64 | PNG 帧的 Base64 文本。 |
引擎侧完整能力(Channel(UNLIMITED) 缓冲、CompletableDeferred 生命周期、flatMapLatest 服务循环、completeExceptionally 异常传播、FIFO)全部在上游语义的基础上随编译进入 .so,JVM 侧保留 5 项 jvmTest 交叉验证用例(含 #202 缓冲回归),证明 ohos .so 里执行的排队逻辑没有漂移。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 代码复用 | 上游请求队列语义(Channel 容量、trySend、Deferred 完成/异常、flatMapLatest 循环)逐行保留,仅把帧类型 ImageBitmap 泛型化为 T;上游 Compose 源码原样隔离在 composeMain,一字未改。 |
| 平台目标 | 根构建直接声明 ohosArm64 + ohosX64,保留 jvm() 作交叉验证,库模块出 KLIB,example/nativeApp 出双 ABI 动态库。 |
| 构建隔离 | 根构建替换为极简 ohos 构建(cmp_qrose 模式);上游 Android Demo :app 留盘不进构建。 |
| 桥接稳定 | 1 个 C ABI(CapturableBridge)+ UTF-8 JSON,会话式有状态桥;异常永远不跨 FFI。 |
| 依赖纪律 | 协程版本主体与上游完全一致(1.10.2),只把仓库换到 ohos 制品线(1.10.2-1.0.0,换线不换版)。 |
| UI 完整 | 单页覆盖全部桥接入口:票据卡、截图预览、连拍 ×3、队列状态、会话释放、操作日志和版本握手。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现。
Capturable的价值分层在"编排"而非"渲染":像素原语从GraphicsLayer换成componentSnapshot是平台分工,编排引擎一行不改才是适配的核心。
二、实现路线图
第 1 阶段:项目初始化 ── 盘点模块与依赖边界、界定可移植引擎、确定交付物
第 2 阶段:目标与依赖打通 ── 根构建替换、eazytec Maven 置前、Kotlin 2.2.21-1.0.0 钉版、协程 ohos 制品线换线不换版
第 3 阶段:桥协议建模 ── 会话式 JSON 契约、capture/result 两态语义、错误通道
第 4 阶段:原生桥接 ── @CName C ABI、C++ N-API、参数校验和内存释放
第 5 阶段:ArkUI 宿主 ── componentSnapshot 像素管线、票据页、连拍日志和版本握手
第 6 阶段:示例与验证 ── HAP、签名、设备安装、真机运行和效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
capturable/src/commonMain/ 新增:截图请求引擎(上游语义泛型化,唯一进构建的库源码集)
capturable/src/commonTest/ 新增:5 项 JVM 交叉验证用例
capturable/src/composeMain/ 上游 Capturable.kt + controller/CaptureController.kt(原样隔离,不编译)
capturable/src/composeTest/ 上游 JVM 单元测试(隔离)
capturable/src/composeAndroidTest/ 上游仪器测试(隔离)
example/nativeApp/ Kotlin/Native 动态库(Bridge + Pixels + PngEncoder)
example/harmonyApp/ DevEco Stage、CMake N-API 和 ArkTS 页面
README.OpenHarmony.md / _CN.md 鸿蒙适配说明(中英双份)
README.upstream.md 上游原文保存
app/ docs/ art/ spotless/ 上游 Android Demo / Dokka 站点 / 图片 / 版权头,留盘不进构建
构建拓扑走 cmp_qrose 模式(根构建即 ohos 构建):settings.gradle.kts 只 include :capturable 与 :example:nativeApp,rootProject.name = "ohos_capturable"。上游的 AGP classpath、dokka、vanniktech 发布插件、spotless 全部随根构建替换剥离——本 fork 不发 Maven Central。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| Kotlin Multiplatform | HarmonyOS Kotlin 2.2.21-1.0.0(eazytec Maven) |
| JDK | 21(Temurin,org.gradle.java.home 钉在 gradle.properties) |
| OpenHarmony 目标 | ohosArm64 + ohosX64(jvm() 保留作交叉验证) |
| Gradle | 8.14.1(上游 wrapper 8.7 随 fork 替换;2.2.21 旧线必须降 8.x,不能沿用上游) |
| kotlinx | coroutines 1.10.2-1.0.0、atomicfu 0.31.0-1.0.0(ohos 制品线) |
| DevEco product | 5.1.0(18) 兼容 |
| DevEco Studio | 5.1.0 Release |
| ABI | arm64-v8a + x86_64 |
| Bundle | org.terminator.ohos.capturable |
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.101-hotspot"
java -version
1.3 源码依赖度盘点
| 上游文件 | 依赖形态 | 处置 |
|---|---|---|
controller/CaptureController.kt | 类主体:纯 coroutines(Channel / CompletableDeferred / receiveAsFlow);类签名挂 GraphicsLayer,文件尾挂 @Composable rememberCaptureController() | 拆分:队列语义泛型化进 commonMain;GraphicsLayer 构造与 remember 部分留在 composeMain 原文件 |
Capturable.kt | Modifier.Node + DrawModifierNode + GraphicsLayer.record + drawLayer,纯 Compose | 整体隔离 composeMain;其中 observeCaptureRequestsAndServe() 循环语义抽进 commonMain 引擎 |
src/test/.../CaptureControllerTest.kt | mockk 模拟 GraphicsLayer 的 2 项 JVM 用例 | 隔离 composeTest;语义由 commonTest 5 项用例继承覆盖 |
src/androidTest/.../CapturableTest.kt | compose ui-test 仪器测试 | 隔离 composeAndroidTest |
值得单独说明:这个库的可移植面非常集中——上游"怎么安全地截"的全部答案就是 Channel(UNLIMITED) + CompletableDeferred + flatMapLatest 这约 50 行纯协程代码,其余全是渲染。泛型化只做一件事:把帧类型 ImageBitmap 换成形参 T,Channel 容量、trySend 时机、异常传播路径一字不改。#202 的教训注释也原样保留在引擎源码里。
1.4 页面边界
标题区 Capturable · OpenHarmony / 引擎握手行(Kotlin 引擎已连接 · capturable 3.0.1-ohos.1…)
票据卡 BOOKING CONFIRMED / 《Capturable 大电影》/ IMAX · F7·F8 · 128 MIN
21×21 演示图案(Canvas 确定性绘制,注明非真实二维码)/ 每秒跳动的开演时间
按钮区 📸 截图预览(capture)/ ⚡ 连拍 ×3(captureAsync)/ 📊 队列状态 / 🗑 释放会话
结果区 截取结果 PNG 预览 + 元信息(requestId · 尺寸 · 字节数 · 编码耗时)
日志区 带时间戳的操作日志(componentSnapshot 完成 / 入队 / result / stats)
页面启动时先调 {"op":"info"} 做握手,票据卡用 .id('ticket') 标记为截图目标,实时时钟证明"截的是当下状态"。
第 2 阶段:目标与依赖打通
2.1 制品线验证(本适配的成败手)
先查社区 Maven(https://maven.eazytec-cloud.com)上协程有没有与上游同版本的 ohos 制品。用 .module 元数据验证:
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2-1.0.0
variants: ohosArm64Api-published / ohosX64Api-published / jvmApi-published / jvmRuntime-published
org.jetbrains.kotlinx:atomicfu:0.31.0-1.0.0
variants: 同上(jvm + ohos 双线齐全)
两个关键结论:其一,coroutines 版本主体与上游声明逐字相同(上游 1.10.2,ohos 线 1.10.2-1.0.0),commonMain 里一行依赖坐标都不用改,这就是"换线不换版";其二,ohos 制品同时发布 jvm 变体,jvm() 交叉验证可以用同一个版本号,不需要双线隔离(Kamera 适配先例正是这对坐标)。
2.2 工具链钉版(为什么是 2.2.21-1.0.0 + Gradle 8.14.1)
coroutines 1.10.2-1.0.0 基于 Kotlin 2.2 stdlib 构建,社区此前批准的钉法 HarmonyOS Kotlin 2.2.21-1.0.0(qrose / Flippable / FlexibleBottomSheet / telephoto 用的)正好可以直接读它的元数据;上游源码本来就是 Kotlin 2.1.0 编的,2.2.21 与之同代,源码级兼容成本为零。这与存储类新线(KStore 钉 2.4.20-0.1.0-07)是两条工具链线:2.2.21 旧线必须把 Gradle 降到 8.14.1,所以上游 wrapper 8.7 → 8.14.1 随 fork 替换(直接拷自 cmp_qrose 的 wrapper,gradle-wrapper.jar 同步更新),而不是像 2.4.20 线那样沿用上游 9.x。
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_capturable"
include(":capturable")
include(":example:nativeApp")
2.3 编译真实引擎
capturable/build.gradle.kts 收敛为纯 KMP 模块:
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
applyDefaultHierarchyTemplate()
jvmToolchain(21)
jvm() // 交叉验证用
ohosArm64()
ohosX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core) // 1.10.2-1.0.0
api(libs.atomicfu) // 0.31.0-1.0.0
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
实测:
./gradlew :capturable:compileKotlinJvm # BUILD SUCCESSFUL
./gradlew :capturable:compileKotlinOhosArm64 :capturable:compileKotlinOhosX64 # BUILD SUCCESSFUL
引擎与 Compose 层的拆分靠 Gradle 源码集天然完成:composeMain / composeTest / composeAndroidTest 不是注册的源码集,默认层级模板不会把它们挂到任何目标上——上游源文件在鸿蒙构建里"存在于仓库、缺席于编译"。
2.4 测试的处置:ohos 测试任务没有运行器
与家族先例相同,处置是两条纪律的组合:语义验证统一在 JVM 跑(jvmTest),根构建全局禁用 ohos 测试任务:
subprojects {
tasks.matching { it.name.contains("Ohos", ignoreCase = true) && it.name.contains("Test") }
.configureEach { enabled = false }
}
上游原测试文件(mockk 版)零改动保留在 composeTest,语义由 commonTest 的 5 项用例继承覆盖(见 6.2),ohos 侧只承担编译与链接。
第 3 阶段:桥协议建模
3.1 JSON 契约
dev.shreyaspatil.capturable.controller/**(上游引擎语义,泛型化编译)
↓ CaptureController<EncodedFrame> / Channel(UNLIMITED) / flatMapLatest 服务循环
Bridge.kt(@CName CapturableBridge,会话式扁平 JSON 进出)
↓ UTF-8 JSON
N-API capturableBridge(request)
↓
ArkUI Index.ets(单页滚动)
请求与响应:
请求 : {"op":"info"}
{"op":"create","id":"default"}
{"op":"capture","id":"default","pixels":"<Base64>","width":724,"height":1032,
"stride":2896,"format":"bgra","scale":1,"await":true|false}
{"op":"result","id":"default","requestId":"r-2"}
{"op":"stats","id":"default"}
{"op":"dispose","id":"default"}
响应 : {"ok":true,"op":"capture","requestId":"r-2","state":"done","width":724,"height":1032,
"pngBytes":48213,"encodeMs":6,"issued":2,"served":2,"errored":0,"pending":0,"pngBase64":"..."}
{"ok":false,"error":"..."}
两个不对称设计:await:true 是同步一次一调用(页面点"截图预览"立即拿结果);await:false 立即返回 requestId,完成结果落会话内结果表、result 轮询取走——这正是上游"先 captureAsync() 拿 Deferred、稍后 await()"两段式用法在桥上的映射。响应只给扁平标量(ArkTS 类型化上下文对嵌套对象不友好,FlexibleBottomSheet 适配的教训)。
3.2 会话与帧的配对建模
桥内会话这样开:
private class Session(val id: String) {
private val controller = CaptureController<EncodedFrame>() // 上游引擎
private val controllerState = MutableStateFlow(controller)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val pendingFrames = Channel<RawFrame>(Channel.UNLIMITED) // ArkTS 供给的像素帧
init {
scope.observeCaptureRequestsAndServe(controllerState) { // 上游服务循环
PixelPipeline.render(pendingFrames.receive()) // FIFO:帧与请求按序配对
}
}
}
为什么有状态(KStore 是无状态两口进出):截图请求有生命周期——captureAsync() 入队的瞬间没有像素,像素在 ArkTS 侧 componentSnapshot 完成后才到。引擎的 flatMapLatest 循环本来就是在"等一个将来才有的帧",桥用 pendingFrames Channel 把"将来"建模出来,请求与帧严格 FIFO 配对,连拍语义因此天然正确。会话内跨线程状态全部走 Channel / StateFlow / atomicfu,会话表本身只在 ArkTS 单线程调用前提下使用(与 Flex / Kamera 桥一致)。
suspend 收口用 runBlocking 包进同步 C ABI:NAPI 线程阻塞等结果,服务循环跑在 Dispatchers.Default,两侧不同线程、互不干扰。
3.3 错误通道
每个导出整体 try/catch,Kotlin 异常永远不跨 FFI:
@CName("CapturableBridge")
fun capturableBridge(request: String): String = try {
/* op 分发 */
} catch (t: Throwable) {
"""{"ok":false,"error":${jsonString(t.message ?: t.toString())}}"""
}
非法 JSON、缺 pixels / width / height、尺寸越界、字节长度与 stride × height 不符、不支持的 op、渲染管线异常,全部走同一个 ok:false 通道;await:false 路径的异步失败落结果表为 state:"error",ArkTS 侧统一渲染。
第 4 阶段:原生桥接(技术难点)
4.1 三层桥接
ArkTS
│ JSON string
▼
C++ N-API entry(libcapturable_napi.so)
│ const char*
▼
Kotlin/Native C ABI(libohoscapturable.so)
│ CapturableBridge
▼
UTF-8 JSON + DisposeString
直接导出 Kotlin 对象会暴露 ABI 和生命周期问题;只导出整数又会让页面复制排队语义,所以选择 C ABI + JSON。
4.2 Kotlin/Native 导出函数
@CName("CapturableBridge")
fun capturableBridge(request: String): String // info / create / capture / result / stats / dispose 一口进
| C ABI 符号 | ArkTS 方法 | 覆盖内核 |
|---|---|---|
CapturableBridge | capturableBridge(req) | captureAsync() 入队 + FIFO 服务循环 + complete/completeExceptionally + 会话生命周期 |
libohoscapturable_symbols | (NAPI 内部使用) | KN 运行时符号表(DisposeString) |
版本握手不单列符号,走 {"op":"info"} 从引擎侧常量返回——与 KStore 的 kstoreVersion() 等价,少一个导出面。
4.3 C++ N-API 方法分发
napi_property_descriptor desc[] = {
{"capturableBridge", nullptr, NapiCapturableBridge, nullptr, nullptr, nullptr,
static_cast<napi_property_attributes>(napi_default | napi_enumerable), nullptr},
};
每个回调统一三步:napi_typeof 校验必须是 string、OH_LOG_INFO 打字节数、调 KN 函数并包装返回。KN 返回的字符串必须经 libohoscapturable_symbols()->DisposeString 归还 Kotlin 运行时,绝不 free()。libohoscapturable_api.h 按社区已审仓库同款手写(只声明 ExportedSymbols 结构与业务符号),不拖整份生成头。
4.4 CMake 和类型声明
add_library(ohoscapturable SHARED IMPORTED)
set_target_properties(ohoscapturable PROPERTIES
IMPORTED_LOCATION ${KN_SO}
IMPORTED_NO_SONAME TRUE)
add_library(capturable_napi SHARED napi_init.cpp)
target_link_libraries(capturable_napi PUBLIC
libace_napi.z.so
libhilog_ndk.z.so
ohoscapturable)
OHOS_ARCH 为空或 libohoscapturable.so 缺失直接 FATAL_ERROR(提示先跑 link 任务);$ORIGIN rpath 加 POST_BUILD 拷贝保证 NAPI so、KN so、libc++ 三件套同目录,dlopen 时互相可见。
export const capturableBridge: (request: string) => string;
第 5 阶段:ArkUI 宿主
5.1 组件快照与像素上送
private async snapshotTicket(): Promise<SnapshotData> {
const pm: image.PixelMap =
await this.getUIContext().getComponentSnapshot().get('ticket'); // 像素原语(平台分工)
const info: image.ImageInfo = await pm.getImageInfo();
const width = info.size.width;
const height = info.size.height;
const stride = info.stride > 0 ? info.stride : width * 4; // 行对齐步进
const buffer = new ArrayBuffer(pm.getPixelBytesNumber());
await pm.readPixelsToBuffer(buffer); // BGRA 字节流
const pixels = new util.Base64Helper().encodeToStringSync(new Uint8Array(buffer));
pm.release();
return { pixels: pixels, width: width, height: height, stride: stride };
}
像素进引擎后再出帧:
const resp = JSON.parse(capturableBridge(JSON.stringify({
op: 'capture', id: SESSION_ID, pixels: snap.pixels,
width: snap.width, height: snap.height, stride: snap.stride,
format: 'bgra', await: true
})));
const bytes = new util.Base64Helper().decodeSync(resp.pngBase64);
const frame = await image.createImageSource(bytes.buffer).createPixelMap(); // PNG → PixelMap 预览
Kotlin 侧像素管线(Pixels.kt):Base64 解码 → 尺寸/步进/字节长度校验 → 逐行按 stride 解包、BGRA/RGBA 通道序归一为 ARGB → 可选整数 box 降采样(scale 1–8)→ PngEncoder 出 PNG → Base64 回传。全程零三方图像库。
5.2 页面交互
页面自上而下:引擎握手行、票据卡(实时时钟每秒刷新)、四按钮(截图深色主按钮 / 连拍与队列状态次级 / 释放会话描边)、结果区(PNG 预览 + requestId · 尺寸 · 字节数 · 编码耗时)、日志区。连拍流程:componentSnapshot 一次 → 三次 await:false 入队(日志逐条显示 captureAsync 入队 r-N(队列 k))→ 按 r-1 → r-2 → r-3 顺序轮询 result(每条 ✅ W×Hpx · PNG xB · xms)→ 末帧上屏 → stats 刷新状态行。FIFO 顺序肉眼可见。
5.3 错误边界和防抖
ok:false 时错误原文进日志区;snapshotTicket / decodePng 全部 try/catch 收进日志;busy 状态在处理期间禁用全部按钮防连点;票据快照尺寸非法(0×0)直接抛错提示,不产生 Native 调用。
第 6 阶段:示例与验证
6.1 工程结构
example/
├── nativeApp/
│ ├── build.gradle.kts 双 ABI sharedLib(baseName=ohoscapturable)+ cxxShared + copyKnSo*
│ └── src/ohosMain/kotlin/
│ ├── Bridge.kt @CName CapturableBridge + 会话 + MiniJson(继承自 cmp_qrose 审核模式)
│ ├── Pixels.kt RawFrame 校验 / stride 解包 / BGRA-RGBA / box 降采样
│ └── PngEncoder.kt 零依赖 PNG(stored-deflate)
└── harmonyApp/
├── build-profile.json5 signingConfigs 留空(签名由 DevEco 自动生成回填)
├── AppScope/app.json5 bundleName org.terminator.ohos.capturable、versionName 3.0.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/libohoscapturable_api.h
├── src/main/cpp/types/libcapturable_napi/{index.d.ts, oh-package.json5}
└── src/main/ets/pages/Index.ets ArkUI 验收页面
6.2 构建、签名和安装
.\gradlew.bat :capturable:jvmTest # 5 项交叉验证
.\gradlew.bat :example:nativeApp:linkDebugSharedOhosArm64 `
:example:nativeApp:linkDebugSharedOhosX64 # 双 ABI so 自动回填 entry/libs
cd example\harmonyApp
hvigorw assembleHap --mode module -p product=default --no-daemon # 需 java 在 PATH
签名在 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.capturable -m entry
四、完整代码对照
4.1 整体架构
dev.shreyaspatil.capturable.controller/**(上游引擎语义,commonMain 泛型化编译)
│ CaptureController<T> / CaptureRequest<T> / observeCaptureRequestsAndServe
example/nativeApp:Bridge + Pixels + PngEncoder
│ CapturableBridge(会话 / FIFO 帧配对 / 像素管线 / PNG)
libcapturable_napi.so
│ capturableBridge
Index.ets
│ componentSnapshot 供像素 + JSON parse + @State 更新
▼
ArkUI 真机页面(单页滚动:票据 / 截图 / 连拍 / 队列 / 日志)
4.2 文件清单
| 文件 | 职责 |
|---|---|
settings.gradle.kts | 极简 ohos 构建、eazytec Maven 置前、ohos_capturable、:app 移出 |
gradle/libs.versions.toml | Kotlin 2.2.21-1.0.0 + coroutines / atomicfu ohos 制品线钉版 |
build.gradle.kts(根) | 版本 3.0.1-ohos.1、全局禁用 Ohos*Test* 任务 |
capturable/build.gradle.kts | 纯 KMP:jvm + ohosArm64 + ohosX64 |
capturable/src/commonMain/.../CaptureController.kt | 引擎(上游语义泛型化,含 #202 注释) |
capturable/src/commonTest/.../CaptureControllerTest.kt | 5 项交叉验证 |
example/nativeApp/build.gradle.kts | 双 ABI sharedLib、cxxShared 定位 libc++、copyKnSo* 回填 |
nativeApp/.../Bridge.kt / Pixels.kt / PngEncoder.kt | 会话桥 / 像素管线 / PNG 编码 |
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 | 鸿蒙适配主文档(29 节) |
4.3 关键 API 对照
| 层次 | Android(上游) | ohos 桥(本 fork) |
|---|---|---|
| 引擎 | rememberCaptureController() | {"op":"create"}(会话即控制器) |
| 引擎 | captureAsync(): Deferred<ImageBitmap> | {"op":"capture","await":false} → requestId |
| 引擎 | deferred.await() | {"op":"result","requestId":...} 轮询 |
| 引擎 | Channel(UNLIMITED) 缓冲(#202) | 同一实现,commonTest 回归钉住 |
| 引擎 | flatMapLatest 服务循环 | observeCaptureRequestsAndServe 同语义 |
| 引擎 | completeExceptionally 传播 | {"ok":false,"error":...} / state:"error" |
| 像素 | Modifier.capturable() + GraphicsLayer | ArkTS componentSnapshot.get('ticket') |
| 像素 | toImageBitmap() | 像素管线 → PngEncoder.encodeArgb |
| 结果 | ImageBitmap | pngBase64 → createPixelMap() |
| Native | —(无) | CapturableBridge C ABI JSON 导出 |
| ArkTS | —(无) | capturableBridge(req) + @State 刷新 |
4.4 ArkTS 与 Kotlin 的边界
const resp = JSON.parse(capturableBridge(JSON.stringify({
op: 'capture', id: 'default',
pixels: base64Pixels, width: 724, height: 1032, stride: 2896,
format: 'bgra', await: false
})));
// resp.requestId === 'r-2';稍后 op:'result' 取回 pngBase64
val requestId = "r-" + counter.incrementAndGet()
val deferred = controller.captureAsync() // 上游引擎:入队 + Deferred
pendingFrames.trySend(raw) // ArkTS 供来的像素帧,FIFO 配对
// 服务循环(上游语义):receive() → PixelPipeline.render → deferred.complete / completeExceptionally
两者之间只传输扁平 JSON 字符串,不传输 Kotlin 对象或未校验的动态结构。
五、关键决策说明
决策 1:移植排队引擎,而不是让 ArkTS 重写截图
componentSnapshot 只解决"取像素";上游库的真正产品是它之上的请求编排——#202 的缓冲回归、Deferred 生命周期、异常不中断循环、FIFO。用 ArkTS 手写一遍等于重新踩一遍上游踩过的坑,且无法跟随上游演进。泛型化移植让这约 50 行语义成为唯一事实源,JVM 用例钉住它。
决策 2:工具链钉 2.2.21-1.0.0 线,Gradle 替换为 8.14.1
coroutines 1.10.2-1.0.0 基于 2.2 stdlib,社区已批的 2.2.21-1.0.0 钉法(qrose / Flippable 线)直接可用;上游 Kotlin 2.1.0 同代零降级。这条线的代价是 Gradle 必须是 8.x——上游 wrapper 8.7 直接替换为 cmp_qrose 同款 8.14.1。与 KStore 的 2.4.20 线(沿用 Gradle 9.x)是两条不可互套的经验:工具链版本跟着协程制品线的 stdlib 元数据走。
决策 3:composeMain 隔离 + :app 留盘(cmp_qrose 模式)
上游是纯 Android 工程,渲染层不可能为鸿蒙编译;把两个生产文件原样移进 composeMain(未注册源码集),引擎进 commonMain——上游将来任何 Compose 层改动都可以无冲突 cherry-pick。上游 Android Demo :app 同理留盘不进 settings,README 明确警告勿 sync。
决策 4:有状态会话桥 + await:false / result 两段式
截图请求天然有生命周期:入队时像素还没到。桥用 pendingFrames Channel 建模"将来的帧",与请求 FIFO 配对;await:false + result 轮询把上游 captureAsync() / await() 的两段式语义原样暴露给 ArkTS,连拍演示因此能展示真实的队列深度变化,而不是把一切都压扁成同步调用。
决策 5:jvmTest 交叉验证 + 上游测试零改动
引擎逻辑在 commonMain,JVM 与 ohos 跑同一份代码;5 项 jvmTest(含 #202 缓冲回归)是"内核没漂"的闸门。上游 mockk 版测试文件一个字节不动地隔离保留,ohos 测试任务无运行器就全局禁用——测试零改动,交叉验证的有效性才成立。
六、测试与验证
6.1 测试环境
本次验证使用 Windows x64、JDK 21(Temurin jdk-21.0.12.101-hotspot)、DevEco Studio 26 命令行工具(hvigor / ohpm / Node)、OpenHarmony SDK 与鸿蒙真机(arm64)。
6.2 JVM 交叉验证
./gradlew :capturable:jvmTest
5 项用例全部通过(commonTest,跑在 JVM):
| 用例 | 钉住什么 |
|---|---|
captureAsync_beforeCollector_buffersRequests | 上游 issue #202 回归:无消费者时连发两请求均缓冲、Deferred 保持 active,后收集者一次拿全 |
captureRequests_canBeCollectedMultipleTimes | 上游第二测试等价物:收集器取消后再收集,新请求仍可达 |
serveCaptureRequests_completesDeferredWithRenderedFrame | 服务循环用 render 结果 complete deferred |
renderFailure_completesExceptionally_andLoopKeepsServing | render 抛异常→该请求 exceptional、循环继续服务后续 |
requests_areServedInFifoOrder | 三请求严格按序完成 |
依赖换线没有 API 漂移:coroutines 1.10.2 换成 1.10.2-1.0.0 线后版本主体不变,这正是"换线不换版"的直接证据。
6.3 HAP 验证
:capturable:compileKotlinOhosArm64 / OhosX64: BUILD SUCCESSFUL
linkDebugSharedOhosArm64 / linkDebugSharedOhosX64: BUILD SUCCESSFUL(46s,含 CAdapter 生成)
llvm-nm -D: CapturableBridge / libohoscapturable_symbols 均为全局 T 符号
copyKnSoArm64 / copyKnSoX64: 双 ABI 三件套回填 entry/libs
ohpm install: completed
BuildNativeWithNinja: finished(双 ABI 编出 libcapturable_napi.so)
CompileArkTS: finished
PackageHap: finished
BUILD SUCCESSFUL
install bundle successfully
start ability successfully
entry-default-unsigned.hap 约 7.7 MB(8,066,449 B),内含双 ABI 各三件套;HAP 内 strip 后 KN so arm64 约 2.5MB、x86_64 约 2.4MB(debugShared 未 strip 时分别约 5.2MB / 4.8MB,含 Kotlin/Native 运行时与 coroutines),NAPI 包装层仅约 64KB。
6.4 功能用例
- 启动后状态行出现
Kotlin 引擎已连接 · capturable 3.0.1-ohos.1(上游 3.0.1 · Kotlin 2.2.21-1.0.0),日志出现info → lib=capturable fork=3.0.1-ohos.1(来自真实 Native 调用,非静态文字); - 票据卡实时时钟每秒刷新;
- 点"截图预览":日志依次出现
componentSnapshot 完成:W×Hpx(stride S)与capture r-N ✅ PNG xB · 编码 xms · 端到端 xms;结果区出现 PNG 预览,内容与票面一致、时钟定格为点击瞬间; - 点"连拍 ×3":三条
captureAsync 入队 r-N(队列 k)后,r-1 → r-2 → r-3严格按序完成,末帧上屏,状态行刷新issued=3 served=3 errored=0 pending=0; - 点"队列状态":四计数与日志一致;
- 点"释放会话":日志确认销毁,再次截图自动重建会话(惰性 getOrPut)。
6.5 验证结论
请求引擎、coroutines 运行时、像素管线、PNG 编码、Native 动态库、C ABI、N-API、ArkUI 页面和 HAP 已完成链路验证。应用在鸿蒙真机上成功安装并进入前台,页面展示了真实的截图会话:版本握手、单帧截图闭环、连拍 FIFO 队列与队列观测(见下节效果图)。
七、运行效果
7.1 真机截图

7.2 命令速查
git clone https://atomgit.com/oh-tpc/Capturable.git
cd Capturable
export JAVA_HOME="/path/to/jdk-21"
./gradlew :capturable:compileKotlinOhosArm64 :capturable:compileKotlinOhosX64 # klib
./gradlew :capturable:jvmTest # 5 项交叉验证
./gradlew :example:nativeApp:linkDebugSharedOhosArm64 :example:nativeApp:linkDebugSharedOhosX64
cd example/harmonyApp
hvigorw assembleHap --mode module -p product=default --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.capturable -m entry
hdc shell hilog | grep Capturable
八、遗留问题与改进方向
8.1 踩坑复盘
- 只改 ArkTS 页面不算适配,必须让
:capturable真正编译成ohosArm64/ohosX64目标并由:example:nativeApp链出动态库; settings.gradle.kts里 eazytec 双仓必须排在pluginManagement与dependencyResolutionManagement最前,否则配置期直接Unresolved reference: ohosArm64;@CName符号不会挂到 ArkTS 模块上——ArkTS 永远导入libcapturable_napi.so,KN so 只被 CMake IMPORTED 链接,两跳缺一不可;componentSnapshot命名空间层没有create(CompileArkTS 直接报Property 'create' does not exist),已挂载组件用get(id),推荐写法this.getUIContext().getComponentSnapshot().get(id);- 命令行
hvigorw assembleHap在 PackageHap 阶段报00308018 spawn java ENOENT——把 JDK 21 的bin加进 PATH 即可,不是工程问题; - 上游
.gitignore的/build是根锚定模式,盖不住example/nativeApp/build/(KLIB 中间产物会混进提交)——嵌套 build 目录要单独追加; - DevEco 打开工程后生成的
example/harmonyApp/.idea/、.clangd、.clang-tidy同样要追加 gitignore; - HAP 缺当前 ABI 的 so 会报安装失败
9568347,abiFilters必须双 ABI,每个 ABI 三件套缺一不可; - ArkUI PixelMap 缓冲默认 BGRA 通道序,像素管线按
format字段归一,颜色 R/B 反了先查这里; - 高密度屏上快照有行对齐步进(
ImageInfo.stride可能大于width × 4),像素长度校验必须按stride × height而不是width × height × 4; - KN 返回字符串必须
DisposeString归还运行时,N-API 层不能free(); - 签名材料由 DevEco 自动写入
build-profile.json5,仓库内保持signingConfigs空数组,证书绝不进入 AtomGit。
8.2 已知问题
- Compose 层(
Modifier.capturable()/rememberCaptureController()/GraphicsLayer)不参与鸿蒙编译,鸿蒙应用不能直接使用 Compose API——这是平台边界,不是待修缺陷; - 像素必须由宿主供给:引擎不持有 UI,ArkTS 侧负责
componentSnapshot; - PNG 为 stored-deflate(无压缩 zlib)实现,大图体积大于真压缩器;
capture超时(30s)后该请求的 pending 计数不会回落(Deferred未完成,引擎语义如此);- 会话表在 ArkTS 单线程调用前提下免锁,跨 Worker 并发调用同一会话未做承诺;
- 上游全 git 历史均无 PDF / 文件输出能力,本 fork 不新增;
getContext类 API 在新 SDK 上有 deprecation 警告(已用getUIContext()系列替代)。
8.3 未来优化方向
- 补
uitest自动化截图脚本与 CI(ohos 编译、ELF 符号检查、HAP 构建); - 大图场景下用 N-API ArrayBuffer 通道替代 Base64 文本,降低数据面膨胀;
- 评估
.har形态发布,让 ArkTS 工程直接依赖而不是走 so 三件套; - 观察上游 v4 动向(GraphicsLayer API 演进),composeMain 目录隔离已保证可无冲突同步;
- 补充模拟器(x86_64)与其他设备 ABI 的验收矩阵。
九、总结
9.1 核心难点回顾
上游 Capturable(纯 Android,两个生产文件)
→ 界定可移植内核:Channel(UNLIMITED) + CompletableDeferred + flatMapLatest(#202 语义)
→ 源码集拆分:引擎 commonMain 泛型化 / Compose 层 composeMain 隔离
→ 工具链对齐 Kotlin 2.2.21-1.0.0(Gradle 8.14.1 替换 wrapper)
→ coroutines 1.10.2-1.0.0 ohos 制品线(换线不换版)
→ ohosArm64 / ohosX64 KLIB + 会话式 C ABI(CapturableBridge)
→ C++ N-API(DisposeString 纪律)
→ ArkTS:componentSnapshot 供像素 + JSON 解析
→ ArkUI 真机单页验收(截图 / 连拍 FIFO / 队列观测 / 会话释放)
9.2 封装层次
- 引擎层:上游原版请求队列语义(缓冲、Deferred、服务循环、异常传播、FIFO),泛型化编译;
- 管线层:Base64 解码、stride 解包、通道序归一、box 降采样、零依赖 PNG 编码;
- Native 层:生成双 ABI 动态库并通过 1 个 C ABI 输出会话式 JSON;
- N-API 层:完成参数检查、字符串转换和 Native 内存释放;
- ArkTS 层:组件快照、页面状态、请求分发、日志与错误文本;
- DevEco 层:完成 CMake、HAP、签名、安装和真机运行。
9.3 三条经验
- 先给库的价值分层再定路线:截图库的价值不在渲染(那是平台原语),而在编排(那是上游用真实 issue 换来的语义)。分清"平台分工"与"库的产品",可移植面自然浮现——本库 50 行引擎就是全部答案;
- 工具链线跟着协程制品走,且不能互套:coroutines
1.10.2-1.0.0对应 2.2.21 线 + Gradle 8.14.1;存储类新线对应 2.4.20 + Gradle 9.x。钉版前先看制品的 stdlib 元数据,历史结论只能参考不能照抄; - 有状态桥要诚实建模生命周期:截图请求"入队时无像素、像素稍后到"是本质异步,桥用帧 Channel + FIFO 配对把它显式化,再把
await:false/result两段式暴露出去——Demo 的连拍按钮因此演示的是真语义,不是演出来的队列。
9.4 适配成果
当前适配已完成:
- Capturable
3.0.1请求引擎的双 ABI Kotlin/Native 编译(上游队列语义零改写,仅帧类型泛型化); - HarmonyOS coroutines 制品线在截图类库上的完整落地(
1.10.2-1.0.0+ atomicfu0.31.0-1.0.0,换线不换版); - 1 个 C ABI 符号和 C++ N-API 包装层(
llvm-nm验证全局 T 符号); - DevEco Stage ArkUI 单页验收页面(截图预览、连拍 ×3 FIFO、队列观测、会话释放、版本握手、操作日志);
- 5 项 JVM 交叉验证全绿 + HAP 构建(双 ABI 六件套 so,约 7.7MB)、签名、设备安装和真机验证;
- 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)